From 32e9553bcff518e0fa3e5a56706d450f34b0976f Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:53:13 +0200 Subject: [PATCH] feat(frontend): download the project list as a CSV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manage Projects gets the Download CSV button Participants and Teams already have: every project, its proposer, status and description, with the markdown flattened for a spreadsheet. Also fixes the blank author on an organiser's own proposals — the name lookup missed owners, who hold no participant row. --- CHANGELOG.md | 10 + .../lib/server/hackathon/membership.test.ts | 46 +++++ .../src/lib/server/hackathon/membership.ts | 30 +++ .../frontend/src/lib/utils/projectCsv.test.ts | 166 ++++++++++++++++ .../frontend/src/lib/utils/projectCsv.ts | 179 ++++++++++++++++++ .../[id]/projects/manage/+page.server.ts | 20 +- .../[id]/projects/manage/+page.svelte | 33 +++- .../[id]/projects/manage/export/+server.ts | 113 +++++++++++ 8 files changed, 581 insertions(+), 16 deletions(-) create mode 100644 components/frontend/src/lib/utils/projectCsv.test.ts create mode 100644 components/frontend/src/lib/utils/projectCsv.ts create mode 100644 components/frontend/src/routes/(app)/my/hackathon/[id]/projects/manage/export/+server.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index af36eb68..6c101ea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ written while it was being built. See [RELEASING.md](RELEASING.md). ### Added +- Manage Projects now has a "Download CSV" button, next to the status tabs. The + file lists every project in the hackathon — its name, who proposed it, whether + it is awaiting review, approved or rejected, and the description in full — so + an organizer can read through the proposals in a spreadsheet or share them + with people who are not on the platform. A spreadsheet renders no markdown, so + the description arrives as prose: the headings, bold and link syntax are taken + off, while the paragraphs, list markers and link addresses stay. Manage + Participants and Manage Teams already had an export; the project list had to + be copied off the screen a card at a time. + ### Changed ### Fixed diff --git a/components/frontend/src/lib/server/hackathon/membership.test.ts b/components/frontend/src/lib/server/hackathon/membership.test.ts index 3bfe8d4a..2e771a58 100644 --- a/components/frontend/src/lib/server/hackathon/membership.test.ts +++ b/components/frontend/src/lib/server/hackathon/membership.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest" import type { HackathonMember } from "$lib/server/grpc/generated/hackathon/entities/hackathon_member" import type { User } from "$lib/server/grpc/generated/user/entities/user" import { + namesByUserId, ownerMembership, participantRowFor, viewerMembership, @@ -116,3 +117,48 @@ describe("viewerMembership", () => { ).toBeNull() }) }) + +describe("namesByUserId", () => { + const named = (id: string, displayName = "", username = "") => + ({ id, displayName, username }) as User + + it("names a participant", () => { + const names = namesByUserId( + [{ ...member(ALICE, MEMBER), user: named(ALICE, "Alice Doe", "alice") }], + [], + ) + + expect(names.get(ALICE)).toBe("Alice Doe") + }) + + it("names an owner who never joined as a participant", () => { + const names = namesByUserId([], [named(ALICE, "Alice Doe", "alice")]) + + expect(names.get(ALICE)).toBe("Alice Doe") + }) + + it("falls back to the username when there is no display name", () => { + expect(namesByUserId([], [named(ALICE, "", "alice")]).get(ALICE)).toBe( + "alice", + ) + }) + + it("prefers the member row where somebody holds both records", () => { + const names = namesByUserId( + [{ ...member(ALICE, OWNER), user: named(ALICE, "Alice Doe", "alice") }], + [named(ALICE, "A. Doe", "alice")], + ) + + expect(names.get(ALICE)).toBe("Alice Doe") + }) + + it("has nothing for somebody in neither collection", () => { + expect(namesByUserId([], [named(ALICE)]).get(BOB)).toBeUndefined() + }) + + it("skips a member row carrying no user", () => { + expect( + namesByUserId([{ ...member(ALICE, MEMBER), user: undefined }], []).size, + ).toBe(0) + }) +}) diff --git a/components/frontend/src/lib/server/hackathon/membership.ts b/components/frontend/src/lib/server/hackathon/membership.ts index a4d4957e..97606ab6 100644 --- a/components/frontend/src/lib/server/hackathon/membership.ts +++ b/components/frontend/src/lib/server/hackathon/membership.ts @@ -109,3 +109,33 @@ export function viewerMembership( return owner !== undefined ? ownerMembership(owner, createdAt) : null } + +/** + * Everybody a `Get` response can put a name to, by backend user id. + * + * Reads the owners edge as well as the member list, because those are two + * different records and `Create` writes only the first: an organiser who made a + * hackathon and never joined it as a participant is in `owners` and in no + * member row at all. That is the same asymmetry `viewerMembership` exists to + * close, and looking a project's `creatorId` up in the member list alone leaves + * every proposal that organiser made with no author against it — in the seed + * fixture, ten of the fifteen projects in "Data for Good Hackathon 2026". + * + * A member row wins where somebody holds both. The names are identical today, + * but the member row is the record the rest of the page reads. + * + * An id this returns nothing for belongs to somebody who has genuinely left, so + * every caller decides what to show in their place — never a raw uuid. + */ +export function namesByUserId( + members: HackathonMember[], + owners: User[], +): Map { + const named = new Map() + for (const o of owners) named.set(o.id, o.displayName || o.username) + for (const m of members) { + if (m.user) named.set(m.user.id, m.user.displayName || m.user.username) + } + + return named +} diff --git a/components/frontend/src/lib/utils/projectCsv.test.ts b/components/frontend/src/lib/utils/projectCsv.test.ts new file mode 100644 index 00000000..daaf0ca5 --- /dev/null +++ b/components/frontend/src/lib/utils/projectCsv.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from "vitest" +import { parseCsv } from "./csv" +import { + markdownToSheetText, + projectCsv, + type ProjectCsvRow, +} from "./projectCsv" + +const row = (over: Partial = {}): ProjectCsvRow => ({ + title: "Vision Pipeline", + creator: "Alice Doe", + status: "Approved", + description: "A camera that sorts compost.", + ...over, +}) + +describe("projectCsv", () => { + it("writes a header and one row per project, description last", () => { + const parsed = parseCsv(projectCsv([row()])) + + expect(parsed[0]).toEqual([ + "Project", + "Proposed By", + "Status", + "Description", + ]) + expect(parsed[1]).toEqual([ + "Vision Pipeline", + "Alice Doe", + "Approved", + "A camera that sorts compost.", + ]) + }) + + it("is a header on its own when there is nothing to export", () => { + expect(parseCsv(projectCsv([]))).toHaveLength(1) + }) + + it("survives a description holding commas, quotes and line breaks", () => { + const description = 'Sort "wet", then dry\nWeigh it' + const parsed = parseCsv(projectCsv([row({ description })])) + + expect(parsed).toHaveLength(2) + expect(parsed[1]?.[3]).toBe(description) + }) + + it("takes the markdown markers off the description", () => { + const parsed = parseCsv( + projectCsv([ + row({ description: "## The idea\n\nTrack **solar** output" }), + ]), + ) + + expect(parsed[1]?.[3]).toBe("The idea\n\nTrack solar output") + }) + + it("leaves the proposer blank when nobody could be named", () => { + const parsed = parseCsv(projectCsv([row({ creator: "" })])) + + expect(parsed[1]?.[1]).toBe("") + }) +}) + +describe("markdownToSheetText", () => { + it("strips heading markers and keeps the words", () => { + expect(markdownToSheetText("### The idea")).toBe("The idea") + }) + + it("strips bold, italic, strikethrough and code markers", () => { + expect(markdownToSheetText("**Very** _fast_, ~~cheap~~ and `typed`")).toBe( + "Very fast, cheap and typed", + ) + }) + + it("keeps line breaks and paragraph breaks", () => { + expect(markdownToSheetText("One\nTwo\n\nThree")).toBe("One\nTwo\n\nThree") + }) + + it("collapses a run of blank lines to one paragraph break", () => { + expect(markdownToSheetText("One\n\n\n\nTwo")).toBe("One\n\nTwo") + }) + + it("keeps list markers, normalising every bullet to a dash", () => { + expect(markdownToSheetText("- one\n* two\n+ three\n1. four")).toBe( + "- one\n- two\n- three\n1. four", + ) + }) + + it("keeps a nested bullet's indentation", () => { + expect(markdownToSheetText("- one\n * nested")).toBe("- one\n - nested") + }) + + it("writes a link as its text followed by the url", () => { + expect(markdownToSheetText("See [the docs](https://x.y/a).")).toBe( + "See the docs (https://x.y/a).", + ) + }) + + it("writes a bare link as just the url, not twice", () => { + expect(markdownToSheetText("[https://x.y](https://x.y)")).toBe( + "https://x.y", + ) + }) + + it("drops a link title, which is never shown", () => { + expect(markdownToSheetText('[docs](https://x.y "The docs")')).toBe( + "docs (https://x.y)", + ) + }) + + it("keeps an image's alt text and its url", () => { + expect(markdownToSheetText("![A schematic](https://x.y/s.png)")).toBe( + "A schematic (https://x.y/s.png)", + ) + }) + + it("leaves underscores in a url alone", () => { + expect(markdownToSheetText("See https://x.y/a_b_c now")).toBe( + "See https://x.y/a_b_c now", + ) + }) + + it("unwraps an autolink", () => { + expect(markdownToSheetText("")).toBe("https://x.y/a_b") + }) + + it("keeps an escaped marker as the character it escapes", () => { + expect(markdownToSheetText("A 5\\*5 grid, \\*not italic\\*")).toBe( + "A 5*5 grid, *not italic*", + ) + }) + + it("leaves the inside of a code span untouched", () => { + expect(markdownToSheetText("Run `make **all**` now")).toBe( + "Run make **all** now", + ) + }) + + it("keeps a fenced block verbatim and drops the fences", () => { + expect(markdownToSheetText("Try:\n\n```sh\nmake -j2 *\n```\n")).toBe( + "Try:\n\nmake -j2 *", + ) + }) + + it("drops a blockquote marker", () => { + expect(markdownToSheetText("> A quote")).toBe("A quote") + }) + + it("turns a horizontal rule into a paragraph break", () => { + expect(markdownToSheetText("One\n\n---\n\nTwo")).toBe("One\n\nTwo") + }) + + it("keeps a setext heading's words and drops its underline", () => { + expect(markdownToSheetText("The idea\n========\n\nBody")).toBe( + "The idea\n\nBody", + ) + }) + + it("trims trailing whitespace and the blank lines around the whole text", () => { + expect(markdownToSheetText("\n\n Body \n\n")).toBe("Body") + }) + + it("is empty for an empty description", () => { + expect(markdownToSheetText("")).toBe("") + }) +}) diff --git a/components/frontend/src/lib/utils/projectCsv.ts b/components/frontend/src/lib/utils/projectCsv.ts new file mode 100644 index 00000000..c662ee49 --- /dev/null +++ b/components/frontend/src/lib/utils/projectCsv.ts @@ -0,0 +1,179 @@ +import { csvRow } from "./csv" + +/** + * The project list as a CSV, for reading the proposals away from the screen. + * + * Write-only, unlike `teamAssignmentCsv` — nothing comes back in. It exists as + * its own module rather than inline in the route because two things here are + * worth pinning down in a test: the column order, since `Description` holds + * whole paragraphs and has to stay last, and what happens to the markdown those + * paragraphs are written in. + */ + +/** One proposal, as a row. */ +export interface ProjectCsvRow { + title: string + /** Who proposed it. Empty when that person has since left the hackathon. */ + creator: string + /** `Proposed`, `Approved` or `Rejected` — spelled out, never a number. */ + status: string + /** The proposal in full, as the markdown it was written in. */ + description: string +} + +/** + * What is held back from the marker-stripping below, and put back afterwards. + * + * A url is the reason this exists: `https://x.y/a_b_c` run past the emphasis + * rules comes out as `https://x.y/abc`, having lost the underscores to an + * italic that was never there. Escaped punctuation and the inside of a code + * span are held for the same reason — they are text that happens to look like + * syntax. + */ +interface Held { + values: string[] +} + +const hold = (held: Held, value: string): string => + `\ue000${held.values.push(value) - 1}\ue000` + +const release = (held: Held, text: string): string => + text.replace(/\ue000(\d+)\ue000/g, (_, i) => held.values[Number(i)] ?? "") + +/** `[the docs](https://x.y)` -> `the docs (https://x.y)`. */ +const withUrl = (held: Held, label: string, url: string): string => { + const target = url.trim() + const text = label.trim() + if (target === "") return text + if (text === "" || text === target) return hold(held, target) + + return `${text} (${hold(held, target)})` +} + +/** Everything that is a marker rather than a word, within one line. */ +function inlineText(held: Held, line: string): string { + return ( + line + // Escapes first, so `\*not bold\*` keeps its asterisks and is not read as + // an emphasis by the rules below. + .replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, (_, c: string) => hold(held, c)) + // A code span is quoted text: whatever is inside it is not markdown. + .replace(/(`+)([^`]*)\1/g, (_, __, code: string) => hold(held, code)) + // `!` and no `!` land in the same place — an image's alt text reads as + // the label, which is what it is for. + .replace( + /!?\[([^\]]*)\]\(\s*\s]*)>?(?:\s+"[^"]*")?\s*\)/g, + (_, label: string, url: string) => withUrl(held, label, url), + ) + .replace(/<((?:https?|mailto):[^>\s]+)>/gi, (_, url: string) => + hold(held, url), + ) + // A url somebody just typed into a sentence, held on the same terms as + // one that arrived inside a link. + .replace(/https?:\/\/\S+/g, (url: string) => hold(held, url)) + .replace(/(\*\*\*|___)(.+?)\1/g, "$2") + .replace(/(\*\*|__)(.+?)\1/g, "$2") + .replace(/(\*|_)(.+?)\1/g, "$2") + .replace(/~~(.+?)~~/g, "$1") + ) +} + +/** + * Markdown as a spreadsheet cell can read it. + * + * A CSV has no formatting layer at all, so `**bold**` reaches the sheet with + * its asterisks showing. This takes the markers off and leaves the words — + * while keeping **every line break**, because a newline inside a quoted field + * is real CSV and every spreadsheet renders it as a multi-line cell. Paragraphs + * and bullets keep their shape; only the syntax goes. + * + * Not `markdownToPlainText` from `$lib/utils/markdown`, which is built for a + * one-line list excerpt: it collapses all whitespace into single spaces and + * throws link targets away, so a whole proposal would arrive as one unbroken + * line with every url gone. This works on the source instead of on rendered + * html, which is what keeps `1.` numbering and list markers exactly as the + * proposer typed them. + * + * Deliberately **not a markdown parser**. The worst case is a stray marker + * surviving into a cell, which is what every cell looked like before this + * existed — no failure mode here is worse than the status quo it replaces. + */ +export function markdownToSheetText(md: string): string { + // A private-use code point stands in for a held value, and any the + // description already contains are dropped so they cannot be mistaken for + // one. Every marker is restored below, so none of this reaches the file. + const held: Held = { values: [] } + const lines = md.replaceAll("\ue000", "").replace(/\r\n?/g, "\n").split("\n") + + const out: string[] = [] + let fenced = false + for (const raw of lines) { + const line = raw.trimEnd() + + // A fenced block is code: the fence goes, the code inside it stays exactly + // as written, markers and all. + if (/^\s*(?:```|~~~)/.test(line)) { + fenced = !fenced + continue + } + if (fenced) { + out.push(line) + continue + } + + // A rule and a setext underline are both drawn with characters that mean + // nothing once there is nothing to draw with. They become the paragraph + // break they already were. + if (/^\s*([-*_])(?:\s*\1){2,}\s*$/.test(line) || /^\s*=+\s*$/.test(line)) { + out.push("") + continue + } + + const block = line + .replace(/^\s*#{1,6}\s+/, "") + .replace(/^\s*>\s?/, "") + // `*` and `+` bullets become `-`, so one file does not show three + // spellings of the same list. The emphasis rules would eat a `*` bullet + // anyway; this is what gives it back as a bullet. + .replace(/^(\s*)[*+](\s+)/, "$1-$2") + + out.push(inlineText(held, block)) + } + + return release( + held, + out + .join("\n") + .replace(/[ \t]+$/gm, "") + // Three blank lines in the source are one paragraph break in a cell. + .replace(/\n{3,}/g, "\n\n") + .trim(), + ) +} + +/** + * The file as it goes out. + * + * `Description` last, because it is the only column holding more than a few + * words: a spreadsheet shows it as one tall cell, and anything placed after it + * would sit off to the right of a paragraph nobody has scrolled past. + * + * The markdown is flattened on the way out (see `markdownToSheetText`) — a + * sheet renders none of it, so the markers would be noise in the one column + * that is meant to be read. + */ +export function projectCsv(rows: readonly ProjectCsvRow[]): string { + return ( + csvRow(["Project", "Proposed By", "Status", "Description"]) + + rows + .map((r) => + csvRow([ + r.title, + r.creator, + r.status, + markdownToSheetText(r.description), + ]), + ) + .join("") + ) +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/manage/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/manage/+page.server.ts index 5a747fca..7d686be9 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/manage/+page.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/manage/+page.server.ts @@ -1,6 +1,7 @@ import type { PageServerLoad } from "./$types" import { ProjectStatus } from "$lib/server/grpc/generated/hackathon/entities/project_status" import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { namesByUserId } from "$lib/server/hackathon/membership" import { mayReviewProjects } from "$lib/server/hackathon/capabilities" import { DEFAULT_PROJECT_FILTER, @@ -78,15 +79,14 @@ export const load: PageServerLoad = async (event) => { DEFAULT_PROJECT_FILTER const shown = ordered.filter((p) => matches[filter](p.status)) - // `Project` carries only `creatorId`, so the name comes from the membership - // list that arrived in the same response. A creator who has since left the - // hackathon resolves to nothing and the card omits the line — better than - // printing a raw uuid at someone. - const memberNames = new Map( - hackathon.members - .filter((m) => m.user !== undefined) - .map((m) => [m.user!.id, m.user!.displayName || m.user!.username]), - ) + // `Project` carries only `creatorId`, so the name comes from the people the + // same response already named — the member list **and the owners edge**. The + // second half matters most here: an organiser who never joined their own + // hackathon holds no member row, so their own proposals are exactly the ones + // whose cards used to show no author at all. A creator who has genuinely left + // resolves to nothing and the card omits the line — better than printing a + // raw uuid at someone. + const names = namesByUserId(hackathon.members, hackathon.owners) const trackNames = new Map(hackathon.tracks.map((t) => [t.id, t.name])) @@ -100,7 +100,7 @@ export const load: PageServerLoad = async (event) => { num: shown.length - i, title: p.title, excerpt: markdownExcerpt(p.description, PROJECT_EXCERPT_CHARS), - creator: memberNames.get(p.creatorId), + creator: names.get(p.creatorId), track: p.trackId ? trackNames.get(p.trackId) : undefined, imageUrl: p.image, status: p.status, diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/manage/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/manage/+page.svelte index 67d630d9..8e8ed759 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/manage/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/manage/+page.svelte @@ -1,5 +1,5 @@