From ce282c28bfb9e89276ecc5fd43c3383fa0bd5754 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:14:26 +0200 Subject: [PATCH 1/4] feat(frontend): overview of every team's submissions for organizers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manage Submissions lists every team with where it stands — nothing filed, drafts only, or a final entry — teams owing an entry first. Tabs filter by standing, a search narrows by team, project or member, and a banner says whether submissions are open. Read only: owners may read every submission but cannot file or finalize one for a team. One ListSubmissions per team until the backend can list a hackathon's submissions in one call (TODO backend: submissions-hackathon-list). --- .../hackathon/SubmissionStandingTabs.svelte | 54 ++++ .../src/lib/server/hackathon/capabilities.ts | 22 ++ .../lib/server/hackathon/submissions.test.ts | 100 ++++++- .../src/lib/server/hackathon/submissions.ts | 104 +++++++ .../src/lib/utils/submissionFilter.test.ts | 31 ++ .../src/lib/utils/submissionFilter.ts | 54 ++++ .../[id]/submissions/manage/+page.server.ts | 100 +++++++ .../[id]/submissions/manage/+page.svelte | 272 ++++++++++++++++++ 8 files changed, 736 insertions(+), 1 deletion(-) create mode 100644 components/frontend/src/lib/components/hackathon/SubmissionStandingTabs.svelte create mode 100644 components/frontend/src/lib/utils/submissionFilter.test.ts create mode 100644 components/frontend/src/lib/utils/submissionFilter.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.server.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.svelte diff --git a/components/frontend/src/lib/components/hackathon/SubmissionStandingTabs.svelte b/components/frontend/src/lib/components/hackathon/SubmissionStandingTabs.svelte new file mode 100644 index 00000000..a158c54a --- /dev/null +++ b/components/frontend/src/lib/components/hackathon/SubmissionStandingTabs.svelte @@ -0,0 +1,54 @@ + + + diff --git a/components/frontend/src/lib/server/hackathon/capabilities.ts b/components/frontend/src/lib/server/hackathon/capabilities.ts index 39ac3967..6d1633d7 100644 --- a/components/frontend/src/lib/server/hackathon/capabilities.ts +++ b/components/frontend/src/lib/server/hackathon/capabilities.ts @@ -240,6 +240,28 @@ export function mayFinalizeSubmissions( return membership !== undefined && !membership.isWaiting } +/** + * Whether to offer the organiser's overview of every team's submissions. + * + * Mirrors the backend exactly: `ListSubmissions` falls back to a hackathon-wide + * `submission:read` (`team_service.go:1103-1106`), a default policy granted to + * `Owner` outright (`rbac.go:238`) and to an admin through the global escape + * hatch. Not gated on `CREATE_PROJECT_SUBMISSIONS` — that capability is about + * teams filing work, and an organiser needs the overview most once it is closed. + * + * Read only. Creating and finalizing need `submission:write` scoped to the team, + * which only `Member` holds (`rbac.go:234`); with no role inheritance an owner + * cannot finalize for a team, so the overview offers no action at all. + */ +export function mayViewAllSubmissions( + membership: HackathonMember | undefined, + isAdmin = false, +): boolean { + if (isAdmin) return true + + return membership?.role === HackathonRole.HACKATHON_ROLE_OWNER +} + /** * Whether to offer vote category management — create, edit, delete. * diff --git a/components/frontend/src/lib/server/hackathon/submissions.test.ts b/components/frontend/src/lib/server/hackathon/submissions.test.ts index 4955e50a..878d591c 100644 --- a/components/frontend/src/lib/server/hackathon/submissions.test.ts +++ b/components/frontend/src/lib/server/hackathon/submissions.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from "vitest" import type { Submission } from "$lib/server/grpc/generated/hackathon/entities/submission" -import { submissionVersions } from "./submissions" +import type { Team } from "$lib/server/grpc/generated/hackathon/entities/team" +import type { User } from "$lib/server/grpc/generated/user/entities/user" +import { + submissionOverview, + submissionStanding, + submissionVersions, +} from "./submissions" // SubmissionStatus numeric values: DRAFT=1, FINAL=2. const DRAFT = 1 @@ -150,3 +156,95 @@ describe("submissionVersions", () => { expect(r.latest?.result).toBeUndefined() }) }) + +describe("submissionStanding", () => { + it("is missing with nothing filed", () => { + expect(submissionStanding(submissionVersions([], names))).toBe("missing") + }) + + it("is draft while nothing is final", () => { + const r = submissionVersions( + [sub({ version: 1 }), sub({ version: 2 })], + names, + ) + + expect(submissionStanding(r)).toBe("draft") + }) + + it("stays final when a newer draft sits on top of the entry", () => { + const r = submissionVersions( + [sub({ version: 1, status: FINAL }), sub({ version: 2, status: DRAFT })], + names, + ) + + expect(submissionStanding(r)).toBe("final") + }) +}) + +describe("submissionOverview", () => { + const user = (id: string, displayName: string): User => + ({ id, displayName, username: id }) as User + + /** A team with only the fields the overview reads. */ + const team = (id: string, name: string, members: User[] = []): Team => + ({ id, name, projectId: "project-1", members }) as unknown as Team + + const titles = new Map([["project-1", "Flood maps"]]) + + it("puts teams with nothing first, then drafts, then finals, each by name", () => { + const teams = [ + team("t-final", "Alpha"), + team("t-draft-b", "Bravo"), + team("t-none", "Zulu"), + team("t-draft-a", "Able"), + ] + const byTeam = new Map([ + ["t-final", [sub({ version: 1, status: FINAL })]], + ["t-draft-b", [sub({ version: 1 })]], + ["t-draft-a", [sub({ version: 1 })]], + ]) + + const rows = submissionOverview(teams, byTeam, titles, names) + + expect(rows.map((r) => [r.teamName, r.standing])).toEqual([ + ["Zulu", "missing"], + ["Able", "draft"], + ["Bravo", "draft"], + ["Alpha", "final"], + ]) + }) + + it("names the project, and says so when it cannot", () => { + const rows = submissionOverview( + [ + team("t1", "Known"), + { ...team("t2", "Orphan"), projectId: "gone" } as Team, + ], + new Map(), + titles, + names, + ) + + expect(rows.map((r) => r.projectTitle)).toEqual([ + "Flood maps", + "Unknown project", + ]) + }) + + it("lists members by name, sorted, falling back to the username", () => { + const rows = submissionOverview( + [ + team("t1", "Team", [ + user("u-zed", "Zed"), + { id: "u-anon", displayName: "", username: "anon" } as User, + user("u-amy", "Amy"), + ]), + ], + new Map(), + titles, + names, + ) + + expect(rows[0]?.members).toEqual(["Amy", "anon", "Zed"]) + }) +}) diff --git a/components/frontend/src/lib/server/hackathon/submissions.ts b/components/frontend/src/lib/server/hackathon/submissions.ts index c83e42d9..78e2fcde 100644 --- a/components/frontend/src/lib/server/hackathon/submissions.ts +++ b/components/frontend/src/lib/server/hackathon/submissions.ts @@ -1,5 +1,11 @@ import type { Submission } from "$lib/server/grpc/generated/hackathon/entities/submission" import { SubmissionStatus } from "$lib/server/grpc/generated/hackathon/entities/submission_status" +import type { Team } from "$lib/server/grpc/generated/hackathon/entities/team" +import type { TeamServiceClient } from "$lib/server/grpc/generated/hackathon/team_service" +import { + SUBMISSION_STANDINGS, + type SubmissionStanding, +} from "$lib/utils/submissionFilter" /** * Server-only: reads the generated `Submission` type, so it must never be @@ -89,3 +95,101 @@ export function submissionVersions( .reverse(), } } + +/** + * Where a team stands: nothing filed, only drafts, or a final entry. + * + * Read off `latestFinal` rather than `latest`, for the reason `submissionVersions` + * keeps the two apart — a draft created after finalizing does not take the + * team's entry back, so that team is still `final`. + */ +export function submissionStanding(s: TeamSubmissions): SubmissionStanding { + if (s.latestFinal) return "final" + + return s.latest ? "draft" : "missing" +} + +/** One team on Manage Submissions — and one line of its export. */ +export interface TeamSubmissionRow extends TeamSubmissions { + teamId: string + teamName: string + projectTitle: string + /** Current members by name, sorted — who an organiser would chase. */ + members: string[] + standing: SubmissionStanding +} + +/** + * Every team's submissions, keyed by team id. + * + * One `ListSubmissions` per team, in parallel: `TeamService.List` returns teams + * without their submissions (`team_service.go:63-68`), and there is no RPC that + * lists a hackathon's submissions in one go. + * + * TODO(backend: submissions-hackathon-list): one call once the backend offers + * one — either `List` loading the submissions edge that `Get` already loads, or + * a hackathon-scoped `ListSubmissions`. + * + * `ListSubmissions` rather than the submissions nested in `team.get`, for the + * same reason the participant page gives: only this one is meant to be complete. + * Nothing is caught: an owner holds `submission:read` hackathon-wide + * (`rbac.go:238`), so a refusal here is a real fault worth surfacing. + */ +export async function submissionsByTeam( + team: TeamServiceClient, + teams: Team[], +): Promise> { + const lists = await Promise.all( + teams.map(async (t) => { + const { submissions } = await team.listSubmissions({ teamId: t.id }) + + return [t.id, submissions] as const + }), + ) + + return new Map(lists) +} + +/** + * Every team as a row of the organiser's overview, sorted the way it is shown: + * by standing, the teams with nothing first and the finished ones last, then + * by name. + * + * Problems first because the page is for finding who still owes an entry — the + * teams that are done are the ones nobody has to do anything about. Ties go by + * team name, not by activity, so a row does not move every time a team saves. + * + * @param projectTitles project id → title; a team whose project is missing from + * it shows "Unknown project" rather than dropping out of the overview. + * @param memberNames user id → name, for creators and finalizers — see + * `submissionVersions`. + */ +export function submissionOverview( + teams: Team[], + byTeam: Map, + projectTitles: Map, + memberNames: Map, +): TeamSubmissionRow[] { + const rank = (s: SubmissionStanding) => SUBMISSION_STANDINGS.indexOf(s) + + return teams + .map((t) => { + const versions = submissionVersions(byTeam.get(t.id) ?? [], memberNames) + + return { + teamId: t.id, + teamName: t.name, + projectTitle: projectTitles.get(t.projectId) ?? "Unknown project", + members: t.members + .map((m) => m.displayName || m.username) + .sort((a, b) => a.localeCompare(b)), + standing: submissionStanding(versions), + ...versions, + } + }) + .sort( + (a, b) => + rank(a.standing) - rank(b.standing) || + a.teamName.localeCompare(b.teamName), + ) +} diff --git a/components/frontend/src/lib/utils/submissionFilter.test.ts b/components/frontend/src/lib/utils/submissionFilter.test.ts new file mode 100644 index 00000000..c9bfb6e1 --- /dev/null +++ b/components/frontend/src/lib/utils/submissionFilter.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest" +import { + SUBMISSION_FILTERS, + submissionFilterFrom, + submissionFilterQuery, +} from "./submissionFilter" + +describe("submissionFilterFrom", () => { + it.each(SUBMISSION_FILTERS)("accepts %s", (filter) => { + expect(submissionFilterFrom(filter)).toBe(filter) + }) + + it.each([ + ["an absent parameter", null], + ["an empty parameter", ""], + ["a word we do not use", "pending"], + ["a numeric status", "2"], + ])("returns undefined for %s", (_name, raw) => { + expect(submissionFilterFrom(raw)).toBeUndefined() + }) +}) + +describe("submissionFilterQuery", () => { + it.each(SUBMISSION_FILTERS)("round-trips %s", (filter) => { + const raw = new URLSearchParams(submissionFilterQuery(filter)).get( + "standing", + ) + + expect(submissionFilterFrom(raw)).toBe(filter) + }) +}) diff --git a/components/frontend/src/lib/utils/submissionFilter.ts b/components/frontend/src/lib/utils/submissionFilter.ts new file mode 100644 index 00000000..56ea6044 --- /dev/null +++ b/components/frontend/src/lib/utils/submissionFilter.ts @@ -0,0 +1,54 @@ +// Where a team stands with its submission, and which of those an organiser is +// looking at on Manage Submissions. +// +// Three standings, and every team is in exactly one: nothing filed yet, only +// drafts, or a final version — the team's actual entry. A final version with a +// newer draft on top of it is still `final`: the draft does not undo what the +// team already declared (see `submissionVersions`), so the row shows the draft +// as a note rather than moving the team back a step. +// +// Words rather than numbers, like `projectFilter`: this vocabulary appears in the +// address bar, where `?standing=draft` says what it means. Nothing here imports +// the generated enums, so a component may import this file. + +/** Every standing, in the order the overview sorts them: the work first. */ +export const SUBMISSION_STANDINGS = ["missing", "draft", "final"] as const + +export type SubmissionStanding = (typeof SUBMISSION_STANDINGS)[number] + +/** The tabs: every standing, plus all of them at once, which comes first. */ +export const SUBMISSION_FILTERS = ["all", ...SUBMISSION_STANDINGS] as const + +export type SubmissionFilter = (typeof SUBMISSION_FILTERS)[number] + +/** + * Where an address that names no filter lands: every team. Unlike the project + * queue there is no one list that is "the line-up" here — the overview is the + * point, and the sort already puts the teams that need chasing on top. + */ +export const DEFAULT_SUBMISSION_FILTER: SubmissionFilter = "all" + +/** Tab labels. "Draft only", since a final team may hold drafts too. */ +export const SUBMISSION_FILTER_LABEL: Record = { + all: "All", + missing: "No submission", + draft: "Draft only", + final: "Final", +} + +/** + * The filter a query parameter asks for, or `undefined` when it asks for + * nothing recognizable. The caller applies `DEFAULT_SUBMISSION_FILTER`. + */ +export function submissionFilterFrom( + raw: string | null | undefined, +): SubmissionFilter | undefined { + return SUBMISSION_FILTERS.includes(raw as SubmissionFilter) + ? (raw as SubmissionFilter) + : undefined +} + +/** The query string that reproduces a filter, leading `?` included. */ +export function submissionFilterQuery(filter: SubmissionFilter): string { + return `?standing=${filter}` +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.server.ts new file mode 100644 index 00000000..5282a376 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.server.ts @@ -0,0 +1,100 @@ +import type { PageServerLoad } from "./$types" +import { Capability } from "$lib/server/grpc/generated/hackathon/entities/capability" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { requireGrpc } from "$lib/server/grpc/client" +import { mayViewAllSubmissions } from "$lib/server/hackathon/capabilities" +import { namesByUserId } from "$lib/server/hackathon/membership" +import { + submissionOverview, + submissionsByTeam, +} from "$lib/server/hackathon/submissions" +import { listVisibleTeams } from "$lib/server/hackathon/teams" +import { + DEFAULT_SUBMISSION_FILTER, + SUBMISSION_FILTERS, + submissionFilterFrom, + type SubmissionFilter, +} from "$lib/utils/submissionFilter" +import { error } from "@sveltejs/kit" + +// The organiser's overview: every team, where it stands with its submission, +// and what it has filed. +// +// **Read only.** An owner may read every submission but write none — creating +// and finalizing are team-scoped and granted to `Member` only (see +// `mayViewAllSubmissions`) — so the page has no actions. Chasing a team that +// has not submitted happens outside the app. +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership, hackathonState } = await event.parent() + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + // Frontend-only gate, same shape as the other manage routes; the RPCs below + // enforce it for real. + if (!mayViewAllSubmissions(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can see every team's submissions") + } + + const { team } = requireGrpc(event.locals.grpc) + + // Whether teams can file new versions right now — the banner's subject. Closing + // it does not stop a team finalizing a draft it already holds (see + // `mayFinalizeSubmissions`), which is why the banner says so. + const submissionsOpen = hackathonState.enabled.includes( + Capability.CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + ) + + // Through `listVisibleTeams` rather than a bare `List`: an owner holds + // `team:read` unconditionally, but a global admin who does not own this + // hackathon is untested (see `submissions-need-view-teams`), and a refusal + // should read as one rather than as a 500. + const teams = await listVisibleTeams(team, hackathon.id) + if (teams === undefined) { + return { + hackathonId: hackathon.id, + submissionsOpen, + teamsReadable: false, + rows: [], + counts: Object.fromEntries( + SUBMISSION_FILTERS.map((f) => [f, 0]), + ) as Record, + filter: DEFAULT_SUBMISSION_FILTER, + } + } + + // Creators and finalizers come back as bare ids. Owners included, as on the + // other manage pages: an organiser who never joined holds no member row. + const names = namesByUserId(hackathon.members, hackathon.owners) + const projectTitles = new Map(hackathon.projects.map((p) => [p.id, p.title])) + + const all = submissionOverview( + teams, + await submissionsByTeam(team, teams), + projectTitles, + names, + ) + + // Counted over every team, never over the rows on screen: the number an + // organiser wants is usually the one on the tab they are not on. + const counts = Object.fromEntries( + SUBMISSION_FILTERS.map((f) => [ + f, + f === "all" ? all.length : all.filter((r) => r.standing === f).length, + ]), + ) as Record + + // From the address bar, so a view can be bookmarked and the back button works. + const filter = + submissionFilterFrom(event.url.searchParams.get("standing")) ?? + DEFAULT_SUBMISSION_FILTER + + return { + hackathonId: hackathon.id, + submissionsOpen, + teamsReadable: true, + rows: filter === "all" ? all : all.filter((r) => r.standing === filter), + counts, + filter, + } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.svelte new file mode 100644 index 00000000..d74c38ef --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.svelte @@ -0,0 +1,272 @@ + + +{#snippet marked(text: string, segments: NameSegment[] | null)} + {#if segments}{#each segments as s, i (i)}{#if s.hit}{s.text}{:else}{s.text}{/if}{/each}{:else}{text}{/if} +{/snippet} + +{#snippet resultLine(result: string | undefined)} + {#if !result} + +

No link or notes.

+ {:else if isHttpUrl(result)} + + + {result} + + + {:else} +

{result}

+ {/if} +{/snippet} + +{#snippet versionMeta(v: Version)} + + Version {v.version} · submitted {formatDate(v.createdAt)}{#if v.creator} + by {v.creator}{/if}{#if v.finalizedAt} + · finalized {formatDate(v.finalizedAt)}{#if v.finalizedBy} by {v.finalizedBy}{/if}{/if} + +{/snippet} + + +
+
+ +

Manage Submissions

+
+ + +
+
+ + {#if !data.teamsReadable} +

+ The teams of this hackathon cannot be read with your account, so there are no + submissions to show. +

+ {:else} +
+ + +
+ +
+ {#if data.counts.all === 0} +

+ There are no teams yet, so nobody can submit. + + Set up teams + +

+ {:else if data.rows.length === 0} +

+ {#if data.filter === 'missing'} + Every team has submitted something. + {:else if data.filter === 'draft'} + No team is left with only drafts. + {:else} + No team has a final entry yet. + {/if} +

+ {:else if shown.length === 0} +

+ No team, project or person here matches “{query.trim()}”. +

+ {:else} + {#each shown as { row, team, project } (row.teamId)} + {@const entry = entryOf(row)} + {@const badge = STANDING_BADGE[row.standing]} +
+
+
+

+ {@render marked(row.teamName, team)} +

+ + {@render marked(row.projectTitle, project)} + + + {#if row.members.length > 0} + {#each row.members as m, i (i)}{#if i > 0}, + {/if}{@render marked(m, nameSegments(m, query))}{/each} + {:else} + No members + {/if} + +
+ {badge.label} +
+ + {#if entry} +
+ {@render resultLine(entry.result)} + {@render versionMeta(entry)} +
+ {/if} + + {#if row.latest && row.latestFinal && row.latest.id !== row.latestFinal.id} + +
+ + Newer draft, not finalized + + {@render resultLine(row.latest.result)} + {@render versionMeta(row.latest)} +
+ {/if} + + {#if row.earlier.length > 0} +
+ + {row.earlier.length === 1 + ? '1 earlier version' + : `${row.earlier.length} earlier versions`} + +
+ {#each row.earlier as v (v.id)} +
+ + {submissionStatusLabel(v.status) ?? 'Unknown'} + + {@render resultLine(v.result)} + {@render versionMeta(v)} +
+ {/each} +
+
+ {/if} +
+ {/each} + {/if} +
+ {/if} +
From 54e17b4857c2f626da3c2e5141301372d3bdff45 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:53:48 +0200 Subject: [PATCH 2/4] feat(frontend): download Manage Submissions as a CSV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every team with its standing and the entry that counts — version, who submitted and finalized it and when (UTC), a newer unfinalized draft if there is one, and the link or notes. Always the whole hackathon, whichever tab or search is on screen. --- .../src/lib/utils/submissionCsv.test.ts | 112 ++++++++++++++++++ .../frontend/src/lib/utils/submissionCsv.ts | 82 +++++++++++++ .../[id]/submissions/manage/+page.svelte | 44 ++++--- .../[id]/submissions/manage/export/+server.ts | 109 +++++++++++++++++ 4 files changed, 333 insertions(+), 14 deletions(-) create mode 100644 components/frontend/src/lib/utils/submissionCsv.test.ts create mode 100644 components/frontend/src/lib/utils/submissionCsv.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/export/+server.ts diff --git a/components/frontend/src/lib/utils/submissionCsv.test.ts b/components/frontend/src/lib/utils/submissionCsv.test.ts new file mode 100644 index 00000000..b0b655ce --- /dev/null +++ b/components/frontend/src/lib/utils/submissionCsv.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest" +import { parseCsv } from "./csv" +import { + sheetTime, + submissionCsv, + type SubmissionCsvRow, +} from "./submissionCsv" + +const row = (over: Partial = {}): SubmissionCsvRow => ({ + team: "Byte Me", + project: "Flood maps", + members: ["Alice", "Bob"], + standing: "Final", + version: 3, + submittedAt: new Date("2026-08-01T10:05:42Z"), + submittedBy: "Bob", + finalizedAt: new Date("2026-08-02T09:00:00Z"), + finalizedBy: "Alice", + newerDraft: undefined, + result: "https://github.com/byte-me/floods", + ...over, +}) + +describe("submissionCsv", () => { + it("writes a header and one row per team, result last", () => { + const parsed = parseCsv(submissionCsv([row()])) + + expect(parsed[0]).toEqual([ + "Team", + "Project", + "Members", + "Standing", + "Version", + "Submitted (UTC)", + "Submitted By", + "Finalized (UTC)", + "Finalized By", + "Newer Draft", + "Result", + ]) + expect(parsed[1]).toEqual([ + "Byte Me", + "Flood maps", + "Alice, Bob", + "Final", + "3", + "2026-08-01 10:05", + "Bob", + "2026-08-02 09:00", + "Alice", + "", + "https://github.com/byte-me/floods", + ]) + }) + + it("is a header on its own when there are no teams", () => { + expect(parseCsv(submissionCsv([]))).toHaveLength(1) + }) + + it("leaves every submission column blank for a team with nothing filed", () => { + const parsed = parseCsv( + submissionCsv([ + row({ + standing: "No submission", + version: undefined, + submittedAt: undefined, + submittedBy: undefined, + finalizedAt: undefined, + finalizedBy: undefined, + result: undefined, + }), + ]), + ) + + expect(parsed[1]?.slice(3)).toEqual([ + "No submission", + "", + "", + "", + "", + "", + "", + "", + ]) + }) + + it("names a newer draft by its version", () => { + const parsed = parseCsv(submissionCsv([row({ newerDraft: 4 })])) + + expect(parsed[1]?.[9]).toBe("4") + }) + + it("survives notes holding commas, quotes and line breaks", () => { + const result = 'Repo is "private", ask us\nDemo on Friday' + const parsed = parseCsv(submissionCsv([row({ result })])) + + expect(parsed).toHaveLength(2) + expect(parsed[1]?.[10]).toBe(result) + }) +}) + +describe("sheetTime", () => { + it("is blank without a time", () => { + expect(sheetTime(undefined)).toBe("") + }) + + it("drops seconds and writes UTC whatever the local zone", () => { + expect(sheetTime(new Date("2026-12-31T23:59:59.999Z"))).toBe( + "2026-12-31 23:59", + ) + }) +}) diff --git a/components/frontend/src/lib/utils/submissionCsv.ts b/components/frontend/src/lib/utils/submissionCsv.ts new file mode 100644 index 00000000..9a58ed32 --- /dev/null +++ b/components/frontend/src/lib/utils/submissionCsv.ts @@ -0,0 +1,82 @@ +import { csvRow } from "./csv" + +/** + * Manage Submissions as a CSV, for checking entries off away from the screen. + * + * Write-only, like `projectCsv`. Its own module so the column order and the + * date format are pinned down in a test rather than inline in the route. + */ + +/** One team, as a row. Every field is already the text the cell shows. */ +export interface SubmissionCsvRow { + team: string + project: string + /** Current members by name, already sorted. */ + members: string[] + /** "No submission", "Draft only" or "Final" — as the page's tabs say it. */ + standing: string + /** The version that counts: the final entry if there is one. */ + version: number | undefined + submittedAt: Date | undefined + submittedBy: string | undefined + finalizedAt: Date | undefined + finalizedBy: string | undefined + /** A draft newer than the final entry, which does not count unless finalized. */ + newerDraft: number | undefined + result: string | undefined +} + +/** + * A time as a spreadsheet reads it: `2026-08-01 10:00`, in UTC. + * + * UTC because the file is built on the server, which knows nothing of the + * reader's time zone — the header says so rather than leaving a local-looking + * time to be misread. Without seconds or a `T`, which is the shape every + * spreadsheet parses as a date on its own. + */ +export function sheetTime(d: Date | undefined): string { + return d ? d.toISOString().slice(0, 16).replace("T", " ") : "" +} + +/** + * The file as it goes out. + * + * `Result` last, because it is the one column that may hold a paragraph of + * notes rather than a link, and anything after it would sit beyond a tall cell. + */ +export function submissionCsv(rows: readonly SubmissionCsvRow[]): string { + const num = (n: number | undefined) => (n === undefined ? "" : String(n)) + + return ( + csvRow([ + "Team", + "Project", + "Members", + "Standing", + "Version", + "Submitted (UTC)", + "Submitted By", + "Finalized (UTC)", + "Finalized By", + "Newer Draft", + "Result", + ]) + + rows + .map((r) => + csvRow([ + r.team, + r.project, + r.members.join(", "), + r.standing, + num(r.version), + sheetTime(r.submittedAt), + r.submittedBy ?? "", + sheetTime(r.finalizedAt), + r.finalizedBy ?? "", + num(r.newerDraft), + r.result ?? "", + ]), + ) + .join("") + ) +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.svelte index d74c38ef..ffbe7a32 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.svelte @@ -1,5 +1,5 @@