diff --git a/src/app/components/ProjectCard.tsx b/src/app/components/ProjectCard.tsx
index 5d7f5eb..efae0f9 100644
--- a/src/app/components/ProjectCard.tsx
+++ b/src/app/components/ProjectCard.tsx
@@ -129,7 +129,7 @@ function ProjectTags({project, className}: {project: ProjectSummary; className:
);
}
-function MemberStack({
+export function MemberStack({
members,
emptyLabel = 'up for grabs',
}: {
diff --git a/src/app/queries/projects.ts b/src/app/queries/projects.ts
index 10f4261..f7f3a3a 100644
--- a/src/app/queries/projects.ts
+++ b/src/app/queries/projects.ts
@@ -118,6 +118,7 @@ export function useSaveProject(projectId?: string, claim = false) {
onSuccess: ({project}) => {
cache.setQueryData(['project', project.id], {project});
void cache.invalidateQueries({queryKey: ['projects', project.yearId]});
+ void cache.invalidateQueries({queryKey: ['year', project.yearId]});
void cache.invalidateQueries({queryKey: ['years']});
},
});
@@ -128,7 +129,10 @@ export function useDeleteProject() {
return useMutation({
mutationFn: (projectId: string) =>
apiRequest(`/projects/${encodeURIComponent(projectId)}`, {method: 'DELETE'}),
- onSuccess: () => void cache.invalidateQueries({queryKey: ['projects']}),
+ onSuccess: () => {
+ void cache.invalidateQueries({queryKey: ['projects']});
+ void cache.invalidateQueries({queryKey: ['year']});
+ },
});
}
diff --git a/src/app/routes/ProjectsPage.tsx b/src/app/routes/ProjectsPage.tsx
index f10208c..b782ea4 100644
--- a/src/app/routes/ProjectsPage.tsx
+++ b/src/app/routes/ProjectsPage.tsx
@@ -2,9 +2,10 @@ import {useEffect, useRef, useState} from 'react';
import {Link, useParams, useSearchParams} from 'wouter';
import type {BallotStatusResponse} from '../../shared/administration';
+import type {ProjectSummary} from '../../shared/projects';
import {getAwardCategoryDescription} from '../awardCategories';
import {GroupManager} from '../components/GroupManager';
-import {ProjectCard} from '../components/ProjectCard';
+import {MemberStack, ProjectCard} from '../components/ProjectCard';
import {PageState, QueryState} from '../components/AppLayout';
import {useBallotStatus} from '../queries/administration';
import {useProjects, useYear} from '../queries/projects';
@@ -130,6 +131,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
? 'browse the finished projects, teams, and award winners.'
: 'see what everyone is building, join a team, or share an idea.'}
+
{(isAdmin || year.data.year.submissionsClosed) && (
@@ -348,6 +350,32 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
);
}
+function MyProjectsStrip({projects}: {projects: ProjectSummary[]}) {
+ if (!projects.length) return null;
+ return (
+
+ yours
+
+ {projects.map((project) => (
+
+
+ {project.kind === 'idea'
+ ? 'open idea'
+ : (project.group?.name ?? 'ungrouped')}
+
+ {project.name}
+
+
+ ))}
+
+
+ );
+}
+
function selectedCategoriesByProject(ballot?: BallotStatusResponse) {
const result = new Map
();
if (!ballot) return result;
diff --git a/src/app/styles.css b/src/app/styles.css
index 0676b1e..a78587e 100644
--- a/src/app/styles.css
+++ b/src/app/styles.css
@@ -292,7 +292,7 @@ main {
letter-spacing: -0.055em;
}
.archiveHero p:last-child,
-.projectsHero > div > p:last-child,
+.projectsHero > div > p:last-of-type,
.editorPage > header > p:last-child,
.operationsHero > p {
max-width: 34rem;
@@ -301,6 +301,64 @@ main {
font-size: 1rem;
line-height: 1.75;
}
+.myProjects {
+ margin-top: 1.5rem;
+}
+.myProjects .kicker {
+ margin-bottom: 0.75rem;
+}
+.myProjectsTiles {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
+ gap: 0.65rem;
+}
+.myProjectTile {
+ display: grid;
+ gap: 0.4rem;
+ min-width: 0;
+ padding: 0.85rem 0.95rem;
+ color: inherit;
+ text-decoration: none;
+ border: 1px solid var(--line);
+ border-radius: 0.65rem;
+ background: #fff;
+ transition:
+ transform 120ms ease,
+ box-shadow 120ms ease,
+ border-color 120ms ease;
+}
+.myProjectTile:hover {
+ color: inherit;
+ border-color: var(--blurple);
+ transform: translateY(-2px);
+ box-shadow: 0 8px 20px rgba(78, 42, 154, 0.1);
+}
+.myProjectTile--idea {
+ background: #fcfaff;
+}
+.myProjectTile .tag {
+ overflow: hidden;
+ max-width: 100%;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.myProjectTile strong {
+ overflow: hidden;
+ font-size: 0.95rem;
+ font-weight: 600;
+ line-height: 1.25;
+ letter-spacing: -0.02em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.myProjectTile .memberStack,
+.myProjectTile .openSeat {
+ margin-top: 0.15rem;
+}
+.myProjectTile .memberStack > span {
+ width: 1.65rem;
+ height: 1.65rem;
+}
.currentYearHero {
animation: rise 0.35s ease-out both;
@@ -923,6 +981,7 @@ main {
}
.projectRow {
display: grid;
+ position: relative;
grid-template-columns: minmax(12rem, 1fr) auto auto;
gap: 1rem;
align-items: center;
@@ -931,6 +990,14 @@ main {
border-bottom: 1px solid var(--line);
animation: rise 0.3s ease-out both;
}
+.projectRow:has(h2 a) {
+ cursor: pointer;
+}
+.projectRow h2 a::after {
+ position: absolute;
+ inset: 0;
+ content: '';
+}
.projectRow h2 {
min-width: 0;
margin: 0;
@@ -995,6 +1062,7 @@ main {
}
.projectCard {
display: flex;
+ position: relative;
min-width: 0;
min-height: 18rem;
padding: 1.25rem;
@@ -1004,6 +1072,7 @@ main {
background: #fff;
box-shadow: 0 1px 0 rgba(29, 17, 39, 0.03);
animation: rise 0.3s ease-out both;
+ cursor: pointer;
transition:
box-shadow 120ms ease,
transform 120ms ease;
@@ -1052,6 +1121,15 @@ main {
.projectCard h2 a {
text-decoration: none;
}
+.projectCard h2 a::after {
+ position: absolute;
+ inset: 0;
+ content: '';
+}
+.projectCard > .markdown a {
+ position: relative;
+ z-index: 1;
+}
.projectCard > .markdown {
display: -webkit-box;
overflow: hidden;
@@ -3420,6 +3498,9 @@ kbd {
align-items: stretch;
flex-direction: column;
}
+ .myProjectsTiles {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
.ballotOverview {
grid-template-columns: 1fr;
gap: 1.75rem;
diff --git a/src/shared/projects.ts b/src/shared/projects.ts
index 296a8d5..0ce930d 100644
--- a/src/shared/projects.ts
+++ b/src/shared/projects.ts
@@ -72,6 +72,7 @@ export interface YearResponse {
year: YearSummary;
groups: GroupSummary[];
awards: AwardSummary[];
+ myProjects: ProjectSummary[];
}
export interface ProjectsResponse {
diff --git a/src/worker/repositories/projects.ts b/src/worker/repositories/projects.ts
index 4d7af9b..b6c8da0 100644
--- a/src/worker/repositories/projects.ts
+++ b/src/worker/repositories/projects.ts
@@ -164,6 +164,26 @@ export async function listProjectOptions(db: D1Database, yearId: string) {
};
}
+export async function listMyProjects(db: D1Database, yearId: string, userId: string) {
+ const {results} = await db
+ .prepare(
+ `${projectSelect()} WHERE p.year_id = ? AND p.status = 'active'
+ AND (p.creator_id = ? OR EXISTS (
+ SELECT 1 FROM project_members pm
+ WHERE pm.project_id = p.id AND pm.user_id = ?
+ ))
+ ORDER BY CASE p.kind WHEN 'project' THEN 0 ELSE 1 END,
+ p.name COLLATE NOCASE, p.id`,
+ )
+ .bind(yearId, userId, userId)
+ .all();
+ const members = await membersByProjectIds(
+ db,
+ results.map(({id}) => id),
+ );
+ return results.map((row) => mapProject(row, members.get(row.id) ?? []));
+}
+
export async function listProjects(
db: D1Database,
options: {
diff --git a/src/worker/routes/years.ts b/src/worker/routes/years.ts
index b49d02b..df344fc 100644
--- a/src/worker/routes/years.ts
+++ b/src/worker/routes/years.ts
@@ -7,6 +7,7 @@ import {createGroup} from '../repositories/groups';
import {
getYear,
listGroups,
+ listMyProjects,
listProjectOptions,
listYears,
} from '../repositories/projects';
@@ -23,9 +24,10 @@ yearsRoutes.get('/', async (c) => {
yearsRoutes.get('/:yearId', async (c) => {
try {
const yearId = c.req.param('yearId');
- const [year, groups, awardResult] = await Promise.all([
+ const [year, groups, myProjects, awardResult] = await Promise.all([
getYear(c.env.DB, yearId),
listGroups(c.env.DB, yearId),
+ listMyProjects(c.env.DB, yearId, c.get('user').id),
c.env.DB.prepare(
`SELECT a.id, a.year_id, a.project_id, p.name project_name,
a.category_id, category.name category_name, a.name
@@ -47,6 +49,7 @@ yearsRoutes.get('/:yearId', async (c) => {
const response: YearResponse = {
year,
groups,
+ myProjects,
awards: awardResult.results.map((award) => ({
id: award.id,
yearId: award.year_id,
diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx
index 84434e4..0e512a1 100644
--- a/test/app/routes.test.tsx
+++ b/test/app/routes.test.tsx
@@ -206,6 +206,7 @@ describe('clickable project routes', () => {
},
groups: [],
awards: [],
+ myProjects: [],
});
}
if (url.includes('/api/votes?')) {
@@ -471,6 +472,58 @@ describe('clickable project routes', () => {
expect(listBadge.closest('.projectRow')).toBeTruthy();
});
+ it('tiles owned and attached projects under the page title', async () => {
+ const ownedIdea = {
+ ...projectFixture,
+ id: 'owned-idea',
+ name: 'Postcard idea',
+ kind: 'idea' as const,
+ group: null,
+ members: [],
+ };
+ const attachedProject = {
+ ...projectFixture,
+ id: 'attached-project',
+ name: 'Shared machine',
+ creator: {
+ ...projectFixture.creator,
+ id: 'someone-else',
+ displayName: 'Someone Else',
+ },
+ };
+ mockProjectsOverview({
+ votingEnabled: false,
+ projects: [projectFixture, ownedIdea, attachedProject],
+ myProjects: [attachedProject, ownedIdea],
+ });
+
+ renderRoute(, '/years/2026/projects', '/years/:yearId/projects');
+
+ const heading = await screen.findByRole('heading', {name: 'projects & ideas'});
+ const mine = screen.getByRole('region', {name: 'your projects'});
+ expect(
+ heading.compareDocumentPosition(mine) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(within(mine).getByText('yours')).toBeTruthy();
+ const attached = within(mine).getByRole('link', {name: /Shared machine/});
+ expect(attached.getAttribute('href')).toBe('/years/2026/projects/attached-project');
+ expect(within(attached).getByLabelText('Member One')).toBeTruthy();
+ expect(
+ within(mine)
+ .getByRole('link', {name: /Postcard idea/})
+ .getAttribute('href'),
+ ).toBe('/years/2026/projects/owned-idea');
+ expect(within(mine).queryByRole('link', {name: /A small machine/})).toBeNull();
+ });
+
+ it('hides the personal project tiles when none belong to you', async () => {
+ mockProjectsOverview({votingEnabled: false, projects: [projectFixture]});
+ renderRoute(, '/years/2026/projects', '/years/:yearId/projects');
+
+ expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy();
+ expect(screen.queryByRole('region', {name: 'your projects'})).toBeNull();
+ });
+
it('keeps closed-year browsing and ballot read failures local', async () => {
mockProjectsOverview({votingEnabled: false, projects: [projectFixture]});
const closed = renderRoute(
@@ -514,6 +567,7 @@ describe('clickable project routes', () => {
},
groups: [{id: 'group', yearId: '2026', name: 'Orbital', projectCount: 1}],
awards: [],
+ myProjects: [],
});
}
return json({
@@ -592,6 +646,7 @@ describe('clickable project routes', () => {
},
groups: [{id: 'group', yearId: '2026', name: 'Orbital', projectCount: 1}],
awards: [],
+ myProjects: [],
});
}
return json({
@@ -654,6 +709,7 @@ describe('clickable project routes', () => {
{id: 'group-b', yearId: '2026', name: 'Lunar', projectCount: 1},
],
awards: [],
+ myProjects: [],
});
}
@@ -738,6 +794,7 @@ describe('clickable project routes', () => {
},
groups: [{id: 'group', yearId: '2026', name: 'Orbital', projectCount: 1}],
awards: [],
+ myProjects: [],
});
}
return json({
@@ -782,6 +839,7 @@ describe('clickable project routes', () => {
},
groups: [{id: 'group', yearId: '2026', name: 'Orbital', projectCount: 1}],
awards: [],
+ myProjects: [],
});
}
expect(new URL(url, 'https://hackweek.test').searchParams.get('group')).toBe(
@@ -837,6 +895,7 @@ describe('clickable project routes', () => {
},
groups: [],
awards: [],
+ myProjects: [],
});
}
@@ -941,6 +1000,7 @@ describe('clickable project routes', () => {
},
groups: [],
awards: [],
+ myProjects: [],
});
}
@@ -1012,6 +1072,7 @@ describe('clickable project routes', () => {
},
groups: [],
awards: [],
+ myProjects: [],
});
}
@@ -1064,6 +1125,7 @@ describe('clickable project routes', () => {
},
groups: [],
awards: [],
+ myProjects: [],
});
}
@@ -1120,6 +1182,7 @@ describe('clickable project routes', () => {
},
groups: [{id: 'group', yearId: '2026', name: 'Orbital', projectCount: 1}],
awards: [],
+ myProjects: [],
streamMode: 'disabled',
});
}
@@ -1483,8 +1546,17 @@ describe('clickable project routes', () => {
renderRoute(, '/');
- const link = screen.getByRole('link', {name: 'link'});
- const description = link.closest('.markdown');
+ const card = screen
+ .getByRole('heading', {name: 'A small machine'})
+ .closest('.projectCard');
+ expect(card).toBeTruthy();
+ const projectLink = screen.getByRole('link', {name: 'A small machine'});
+ const markdownLink = screen.getByRole('link', {name: 'link'});
+ expect(projectLink.getAttribute('href')).toBe('/years/2026/projects/project');
+ expect(markdownLink.getAttribute('href')).toBe('https://example.com');
+ expect(markdownLink.closest('.projectCard')).toBe(card);
+ expect(projectLink.contains(markdownLink)).toBe(false);
+ const description = markdownLink.closest('.markdown');
expect(description?.classList.contains('markdown--compact')).toBe(true);
expect(description?.getAttribute('title')).toBe(summary);
expect(screen.getByText('detail').tagName).toBe('STRONG');
@@ -1714,12 +1786,14 @@ function mockProjectsOverview({
categories = [],
votes = [],
projects = [],
+ myProjects = [],
ballotError = false,
}: {
votingEnabled?: boolean;
categories?: Array<{id: string; yearId: string; name: string}>;
votes?: BallotStatusResponse['votes'];
projects?: ProjectDetail[];
+ myProjects?: ProjectDetail[];
ballotError?: boolean;
}) {
fetchMock.mockImplementation(async (input) => {
@@ -1738,6 +1812,7 @@ function mockProjectsOverview({
},
groups: [],
awards: [],
+ myProjects,
});
}
if (url.includes('/api/votes?')) {
diff --git a/test/projects/projects.test.ts b/test/projects/projects.test.ts
index 9fe97e2..453371b 100644
--- a/test/projects/projects.test.ts
+++ b/test/projects/projects.test.ts
@@ -95,6 +95,39 @@ describe('project and history APIs', () => {
expect(page.body.projects[0].members).toBeInstanceOf(Array);
});
+ it('includes owned and attached projects on the year payload', async () => {
+ const owned = await createProject(memberToken, {name: 'Owned engine'});
+ const idea = await createProject(memberToken, {
+ name: 'Owned postcard',
+ kind: 'idea',
+ groupId: null,
+ });
+ await session(outsiderToken);
+ const outsiderProject = await createProject(outsiderToken, {name: 'Outsider engine'});
+ const attached = await createProject(outsiderToken, {name: 'Attached engine'});
+ const member = await env.DB.prepare('SELECT id FROM users WHERE google_subject = ?')
+ .bind(`project-member-${suffix}`)
+ .first<{id: string}>();
+ await env.DB.prepare(
+ 'INSERT INTO project_members (project_id, user_id) VALUES (?, ?)',
+ )
+ .bind(attached.id, member!.id)
+ .run();
+
+ const year = await api(`/years/${yearId}`, memberToken);
+ const outsiderYear = await api(`/years/${yearId}`, outsiderToken);
+
+ expect(year.status).toBe(200);
+ expect(year.body.myProjects.map((project: {id: string}) => project.id)).toEqual([
+ attached.id,
+ owned.id,
+ idea.id,
+ ]);
+ expect(
+ outsiderYear.body.myProjects.map((project: {id: string}) => project.id),
+ ).toEqual([attached.id, outsiderProject.id]);
+ });
+
it('returns ordered year categories and persists ordered project nominations', async () => {
const options = await api(`/years/${yearId}/options`, memberToken);
const allCategories = await createProject(memberToken, {