From 5b5cb0bb09432baacd8171b513a298ca3d4f5c3a Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:39:59 +0200 Subject: [PATCH 1/2] refactor(frontend): an overview that only carries what changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things on the member overview were present whatever the hackathon looked like and told nobody anything they could act on. "Not open yet" listed the closed capabilities — five of six for most of a hackathon, and a list nobody can do anything with. The question behind it is never "is voting closed" but "when does it open", which this card cannot answer, so the "Next" line at its foot is now a link to the timeline, which can. "No tracks have been defined, so projects are not grouped" apologised for a missing grouping to a participant who cannot create a track and an organiser who has Manage Tracks for exactly that. Without tracks the count is the whole content of that card, so it is now shown as a figure — and when there is neither a track nor an approved project the card does not render at all, rather than holding half a row with an empty box. About was a description read once, ever, sitting at the foot of the page whose job is what has changed since the last visit — and the dashboard card a member clicks to get here already shows it. It moves into the hero, with the rest of the hackathon's identity, clamped to two lines and expandable in place. --- .../hackathon/CurrentStateCard.svelte | 47 +++++++++++-------- .../components/hackathon/HeroCompact.svelte | 47 ++++++++++++++++++- .../hackathon/TrackBreakdown.svelte | 32 ++++++++++--- .../(app)/my/hackathon/[id]/+layout.svelte | 1 + .../my/hackathon/[id]/overview/+page.svelte | 42 +++++++++-------- 5 files changed, 122 insertions(+), 47 deletions(-) diff --git a/components/frontend/src/lib/components/hackathon/CurrentStateCard.svelte b/components/frontend/src/lib/components/hackathon/CurrentStateCard.svelte index 6b818ac1..2d1c42cb 100644 --- a/components/frontend/src/lib/components/hackathon/CurrentStateCard.svelte +++ b/components/frontend/src/lib/components/hackathon/CurrentStateCard.svelte @@ -1,5 +1,6 @@
{#if imageUrl}
{/if} + + {#if description} +
+

+ {description} +

+ {#if clampable} + + {/if} +
+ {/if}

Projects

- - {projectLabel}{tracks.length > 0 ? ` · ${trackLabel}` : ''} - + + {#if tracks.length > 0} + {projectLabel} · {trackLabel} + {/if}
{#if tracks.length === 0} -

- No tracks have been defined, so projects are not grouped. -

+ {#if approvedCount > 0} +
+ {approvedCount} + + approved {approvedCount === 1 ? 'project' : 'projects'} + +
+ {/if} {:else}
    {#each tracks as track (track.id)} @@ -77,7 +95,7 @@ href={resolve(`/my/hackathon/${hackathonId}/projects`)} class="text-xs font-semibold text-accent-ink no-underline hover:underline" > - View all {projectLabel} → + View all {linkLabel} → {/if}
diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.svelte index 5b38b452..9e67abfe 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.svelte @@ -119,6 +119,7 @@ {participantCount} organizers={[]} badges={heroBadges} + description={hackathon.description} /> {#if phases.length > 0} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.svelte index a09f6f37..a13783a0 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.svelte @@ -7,6 +7,11 @@ import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); + + // Whether there is a Projects card at all. With no track to group by and no + // approved project to count it would be an empty box holding half the row — + // and the way in to proposing one is already a row on the state card above. + const showProjects = $derived(data.trackCounts.length > 0 || data.approvedCount > 0); -
+ mostly-empty rectangles. One column when there is no Projects card, so the + team card takes the width rather than leaving half the row empty. + + `items-start`, so the shorter card keeps its own height instead of being + stretched to the taller one's and floating in its own whitespace. --> +
{#if data.myTeam} {/if} - -
- -
-

About

- {#if data.hackathon.description} -

{data.hackathon.description}

- {:else} -

No description provided.

+ {#if showProjects} + {/if} -
+
From e5e3f37f8722fdfe29c66b591be89af9e8c3e0d1 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:19:21 +0200 Subject: [PATCH 2/2] feat(frontend): a participant roster you can hand to a mailing tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manage Participants could approve, promote and remove people but offered no way to get their addresses out, so building a mailing list meant reading them off the screen. Hackathon.Get already returns each member's email, so this needs no new RPC — only a download endpoint that re-applies the organizer gate the page applies, since Get itself is readable by every member. That gate goes through viewerMembership rather than a plain members.find, as the vote export does: Create grants ownership on the owners edge and writes no participant row, so an organiser who never joined their own hackathon matches nobody in the member list and would be refused the file the page had just offered them. The file is the whole roster, not the searched subset: its contents must not depend on what is typed in the box beside the button. Waitlisted members are included and labelled rather than dropped, so they can be segmented instead of mailed as though they were confirmed. Members with no address are skipped — a blank one is a row a mailing tool rejects — and the header says how many, so a file shorter than the roster is not a silent one. --- .../[id]/participants/manage/+page.server.ts | 11 +- .../[id]/participants/manage/+page.svelte | 21 ++- .../participants/manage/export/+server.ts | 125 ++++++++++++++++++ 3 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/export/+server.ts diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/+page.server.ts index 03a06e86..5040f681 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/+page.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/+page.server.ts @@ -37,7 +37,16 @@ export const load: PageServerLoad = async (event) => { isMe: myUserId !== undefined && m.user!.id === myUserId, })) - return { hackathonId: hackathon.id, participants } + // The export drops members with no address, since a blank one is a row a + // mailing tool rejects (`User.email` is optional and defaults to empty). The + // count is surfaced so a file shorter than the roster is not a silent + // surprise; the addresses themselves stay out of this payload — the download + // endpoint reads them from its own `Get`. + const withoutEmail = hackathon.members.filter( + (m) => m.user !== undefined && m.user.email === "", + ).length + + return { hackathonId: hackathon.id, participants, withoutEmail } } /** The gRPC errors both write paths can return, as SvelteKit failures. */ diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/+page.svelte index 034e0e13..8136f9bc 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/+page.svelte @@ -1,6 +1,7 @@