Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions components/frontend/src/lib/server/hackathon/membership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
})
})
30 changes: 30 additions & 0 deletions components/frontend/src/lib/server/hackathon/membership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
const named = new Map<string, string>()
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
}
166 changes: 166 additions & 0 deletions components/frontend/src/lib/utils/projectCsv.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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("<https://x.y/a_b>")).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("")
})
})
Loading
Loading