From fcd84b2e6f555b34f1ea989776232e33e1b76b72 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:04:02 +0200 Subject: [PATCH 01/23] refactor(frontend): remove "Suggest teams" from team assignment Teams are now built from the downloaded spreadsheet or by dragging, so the automatic suggestion only competed with that for attention. Drop the button, its handler, and the distribution algorithm nothing else used. PlannedTeam and initialsOf stay; the CSV import and "+ Add Team" still need them. --- CHANGELOG.md | 4 + .../src/lib/utils/teamDistribution.test.ts | 275 +----------------- .../src/lib/utils/teamDistribution.ts | 176 +---------- .../hackathon/[id]/teams/manage/+page.svelte | 34 +-- 4 files changed, 9 insertions(+), 480 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51728e5a..757e2526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ written while it was being built. See [RELEASING.md](RELEASING.md). ### Changed +- Manage Teams no longer has a "Suggest teams" button. Build teams by dragging + people onto them, or by downloading the spreadsheet, filling in the team + column and uploading it again. + ### Fixed ## [0.11.0](https://github.com/SwissDataScienceCenter/hackagon/compare/v0.10.0...v0.11.0) - 2026-09-28 diff --git a/components/frontend/src/lib/utils/teamDistribution.test.ts b/components/frontend/src/lib/utils/teamDistribution.test.ts index 5bef0c55..af476d4c 100644 --- a/components/frontend/src/lib/utils/teamDistribution.test.ts +++ b/components/frontend/src/lib/utils/teamDistribution.test.ts @@ -1,278 +1,5 @@ import { describe, it, expect } from "vitest" -import { - initialsOf, - suggestDistribution, - type DistributablePerson, - type DistributableProject, - type PlannedTeam, -} from "./teamDistribution" - -const SIZES = { max: 6 } - -/** n people, all preferring the same list of projects. */ -function people( - n: number, - prefs: string[], - prefix = "u", -): DistributablePerson[] { - return Array.from({ length: n }, (_, i) => ({ - id: `${prefix}${String(i).padStart(3, "0")}`, - preferredProjectIds: prefs, - })) -} - -function project( - id: string, - title: string, - teams: DistributableProject["teams"] = [], -) { - return { id, title, teams } -} - -const placed = (plan: PlannedTeam[]) => plan.flatMap((t) => t.memberIds) -const forProject = (plan: PlannedTeam[], id: string) => - plan.filter((t) => t.projectId === id) -const sizesOf = (plan: PlannedTeam[]) => plan.map((t) => t.memberIds.length) - -describe("suggestDistribution", () => { - it("puts everyone on a project they actually asked for", () => { - const projects = [project("a", "Alpha"), project("b", "Beta")] - const pool = [...people(5, ["a"], "x"), ...people(5, ["b"], "y")] - - const plan = suggestDistribution(projects, pool, SIZES) - const projectOf = new Map( - plan.flatMap((t) => t.memberIds.map((m) => [m, t.projectId] as const)), - ) - - for (const person of pool) { - expect(person.preferredProjectIds).toContain(projectOf.get(person.id)) - } - }) - - it("never seats more than the maximum on one team", () => { - const plan = suggestDistribution( - [project("a", "Alpha")], - people(20, ["a"]), - SIZES, - ) - - expect(plan.length).toBeGreaterThan(1) - for (const team of plan) - expect(team.memberIds.length).toBeLessThanOrEqual(SIZES.max) - }) - - it("stays at or under the maximum for every total", () => { - for (let n = 1; n <= 60; n++) { - const sizes = sizesOf( - suggestDistribution([project("a", "Alpha")], people(n, ["a"]), SIZES), - ) - - expect({ n, over: sizes.filter((s) => s > SIZES.max) }).toEqual({ - n, - over: [], - }) - expect(sizes.reduce((a, b) => a + b, 0)).toBe(n) - } - }) - - it("balances a remainder instead of leaving a team of one", () => { - // 7 people, max 6: two teams of 4 and 3, not 6 and 1. - expect( - sizesOf( - suggestDistribution([project("a", "Alpha")], people(7, ["a"]), SIZES), - ), - ).toEqual([4, 3]) - }) - - it("gives a project its team however few people want it", () => { - // The whole reason there is no minimum. Two people who want a project get a - // team of two; whether that is viable is the organizer's call, not a rule's. - const projects = [project("big", "Big"), project("tiny", "Tiny")] - const pool = [...people(6, ["big"], "b"), ...people(2, ["tiny"], "t")] - - const plan = suggestDistribution(projects, pool, SIZES) - - expect(sizesOf(forProject(plan, "tiny"))).toEqual([2]) - expect(placed(plan)).toHaveLength(8) - }) - - it("leaves nobody unassigned who picked something on offer", () => { - const projects = ["a", "b", "c"].map((id) => project(id, id.toUpperCase())) - const pool = [ - ...people(9, ["a"], "x"), - ...people(2, ["b"], "y"), - ...people(1, ["c"], "z"), - ] - - expect(placed(suggestDistribution(projects, pool, SIZES))).toHaveLength(12) - }) - - it("leaves someone with no preferences unassigned", () => { - const pool = [ - ...people(4, ["a"], "p"), - { id: "z", preferredProjectIds: [] }, - ] - - const plan = suggestDistribution([project("a", "Alpha")], pool, SIZES) - - expect(placed(plan)).not.toContain("z") - expect(placed(plan)).toHaveLength(4) - }) - - it("ignores a preference for a project that is not on offer", () => { - const pool = people(3, ["a", "gone"]) - - const plan = suggestDistribution([project("a", "Alpha")], pool, SIZES) - - expect(forProject(plan, "gone")).toHaveLength(0) - expect(placed(plan)).toHaveLength(3) - }) - - it("keeps an existing team and the people already on it", () => { - const projects = [ - project("a", "Alpha", [{ id: "t1", name: "Team A", memberIds: ["old"] }]), - ] - - const plan = suggestDistribution(projects, people(2, ["a"]), SIZES) - - expect(plan).toHaveLength(1) - expect(plan[0]?.id).toBe("t1") - expect(plan[0]?.memberIds).toContain("old") - expect(plan[0]?.memberIds).toHaveLength(3) - }) - - it("opens a second team and balances it against the existing one", () => { - // 1 already there + 9 assigned = 10, which is two teams of 5. Filling the - // existing team to the maximum first would give 6 and 4 instead. - const projects = [ - project("a", "Alpha Project", [ - { id: "t1", name: "Team AP", memberIds: ["old"] }, - ]), - ] - - const [existing, opened] = suggestDistribution( - projects, - people(9, ["a"]), - SIZES, - ) - - expect(existing?.id).toBe("t1") - expect(existing?.memberIds).toContain("old") - expect(existing?.memberIds).toHaveLength(5) - expect(opened?.id).toBeNull() - expect(opened?.name).toBe("Team AP 2") - expect(opened?.memberIds).toHaveLength(5) - }) - - it("never empties a team that already exists", () => { - // Three teams, only four takers. Every team keeps somebody rather than one - // being hollowed out — deleting a team is the organizer's call. - const projects = [ - project("a", "Alpha", [ - { id: "t1", name: "One", memberIds: [] }, - { id: "t2", name: "Two", memberIds: [] }, - { id: "t3", name: "Three", memberIds: [] }, - ]), - ] - - const plan = suggestDistribution(projects, people(4, ["a"]), SIZES) - - expect(plan).toHaveLength(3) - expect(sizesOf(plan)).toEqual([2, 1, 1]) - expect(plan.every((t) => t.id !== null)).toBe(true) - }) - - it("leaves a project alone when nobody new is joining it", () => { - const projects = [ - project("a", "Alpha", [ - { id: "t1", name: "Team A", memberIds: ["x", "y", "z"] }, - ]), - project("b", "Beta"), - ] - - const plan = suggestDistribution(projects, people(4, ["b"]), SIZES) - - expect(forProject(plan, "a")).toHaveLength(1) - expect(forProject(plan, "a")[0]?.memberIds).toEqual(["x", "y", "z"]) - }) - - it("never places one person twice", () => { - const projects = ["a", "b", "c"].map((id) => project(id, id.toUpperCase())) - const all = placed( - suggestDistribution(projects, people(30, ["a", "b", "c"]), SIZES), - ) - - expect(new Set(all).size).toBe(all.length) - }) - - it("is deterministic", () => { - const projects = ["a", "b", "c"].map((id) => project(id, id.toUpperCase())) - const pool = people(40, ["a", "b", "c"]) - - expect(suggestDistribution(projects, pool, SIZES)).toEqual( - suggestDistribution(projects, pool, SIZES), - ) - }) - - it("spreads across preferences rather than piling onto one project", () => { - const projects = [project("a", "Alpha"), project("b", "Beta")] - - const plan = suggestDistribution(projects, people(12, ["a", "b"]), SIZES) - - expect(forProject(plan, "a").length).toBeGreaterThan(0) - expect(forProject(plan, "b").length).toBeGreaterThan(0) - }) - - it("handles a hackathon the size of the Data for Good fixture", () => { - // 15 projects, 104 participants, weighted the way cmd/seed weights them. - const weights = [12, 6, 4, 3, 1, 11, 7, 5, 3, 1, 10, 8, 5, 2, 1] - const projects = weights.map((_, i) => project(`p${i}`, `Project ${i}`)) - - let seed = 1 - const next = (n: number) => { - seed = (seed * 1103515245 + 12345) % 2147483648 - - return seed % n - } - const total = weights.reduce((a, b) => a + b, 0) - const pool: DistributablePerson[] = Array.from({ length: 104 }, (_, i) => { - const picks = new Set() - const wanted = 1 + next(4) - while (picks.size < wanted) { - let r = next(total) - picks.add(`p${weights.findIndex((w) => (r -= w) < 0)}`) - } - - return { - id: `u${String(i).padStart(3, "0")}`, - preferredProjectIds: [...picks], - } - }) - - const plan = suggestDistribution(projects, pool, SIZES) - const all = placed(plan) - - // Everybody placed exactly once, on something they asked for, in a team no - // bigger than the maximum. - expect(new Set(all).size).toBe(all.length) - expect(all).toHaveLength(pool.length) - - const prefs = new Map(pool.map((p) => [p.id, p.preferredProjectIds])) - for (const team of plan) { - expect(team.memberIds.length).toBeGreaterThan(0) - expect(team.memberIds.length).toBeLessThanOrEqual(SIZES.max) - for (const m of team.memberIds) - expect(prefs.get(m)).toContain(team.projectId) - } - - // Without a minimum, every project somebody picked gets to run. (The - // weighted draw can leave a project with no takers at all; that one does - // not, and should not.) - const wanted = new Set(pool.flatMap((p) => p.preferredProjectIds)) - - expect(new Set(plan.map((t) => t.projectId))).toEqual(wanted) - }) -}) +import { initialsOf } from "./teamDistribution" describe("initialsOf", () => { it("takes the initial of each word", () => { diff --git a/components/frontend/src/lib/utils/teamDistribution.ts b/components/frontend/src/lib/utils/teamDistribution.ts index 567c6af1..9ec345b8 100644 --- a/components/frontend/src/lib/utils/teamDistribution.ts +++ b/components/frontend/src/lib/utils/teamDistribution.ts @@ -1,188 +1,18 @@ /** - * Suggests how to split a hackathon's participants into teams, from the - * projects they said they were interested in. - * - * Kept out of the page component so it can be tested directly: the interesting - * behaviour is the distribution, not the drag-and-drop around it. + * The shape of a team on the assignment page, shared by the page's workspace + * and the spreadsheet import that edits it. */ -export type DistributablePerson = { - id: string - /** Ids of the projects this person marked as preferred. */ - preferredProjectIds: string[] -} - -export type DistributableProject = { - id: string - title: string - /** Teams that already exist for this project, with their current members. */ - teams: { id: string; name: string; memberIds: string[] }[] -} - export type PlannedTeam = { /** Stable key for rendering; equals `id` for a team that already exists. */ key: string - /** `null` for a team the plan invented and that still has to be created. */ + /** `null` for a team added on the page that still has to be created. */ id: string | null projectId: string name: string memberIds: string[] } -export type DistributionOptions = { - /** Above this a team stops being one. The only size rule there is. */ - max: number -} - -/** - * Builds a distribution from what people asked for. One rule: - * - * everyone goes to a project they picked, spread evenly, no team over `max`. - * - * There is deliberately **no minimum**. A minimum sounds reasonable and is the - * source of every hard case: projects have to be dissolved, the people on them - * redistributed, some totals cannot satisfy both bounds at once, and raising it - * can leave a team with nobody in it. Without one, a project two people want is - * simply a team of two — which an organizer can look at and fix by dragging, a - * judgement no arithmetic was going to make correctly anyway. - * - * Preferences are an **unranked set** — the schema has no first or second - * choice — so "according to their preferences" can only mean "on a project they - * picked". Given that, this spreads people across their options rather than - * piling everyone onto the popular ones, which is also what keeps team sizes - * even. - * - * Teams that already exist are kept and their members left where they are - * wherever the sizes allow. - * - * Deterministic: the same input always yields the same plan, so pressing the - * button twice cannot quietly produce two different answers. - */ -export function suggestDistribution( - projects: DistributableProject[], - unassigned: DistributablePerson[], - { max }: DistributionOptions, -): PlannedTeam[] { - const existingTeams: PlannedTeam[] = projects.flatMap((p) => - p.teams.map((t) => ({ - key: t.id, - id: t.id, - projectId: p.id, - name: t.name, - memberIds: [...t.memberIds], - })), - ) - - const byId = new Map(projects.map((p) => [p.id, p])) - - // Headcount per project, starting from whoever is already on a team there so - // a half-staffed project is not filled twice over. - const load: Record = {} - for (const t of existingTeams) { - load[t.projectId] = (load[t.projectId] ?? 0) + t.memberIds.length - } - - // Only a project that is actually on offer can take anyone. - const optionsFor = (person: DistributablePerson) => - person.preferredProjectIds.filter((id) => byId.has(id)) - - const emptiest = (ids: string[]) => - ids.reduce((a, b) => ((load[a] ?? 0) <= (load[b] ?? 0) ? a : b)) - - // Fewest options first — whoever is hardest to place gets the pick of the - // room. Ties break on id, which is what makes the result reproducible. - const pool = [...unassigned].sort( - (a, b) => - optionsFor(a).length - optionsFor(b).length || a.id.localeCompare(b.id), - ) - - const chosen: Record = {} - for (const person of pool) { - const options = optionsFor(person) - if (options.length === 0) continue - const best = emptiest(options) - chosen[person.id] = best - load[best] = (load[best] ?? 0) + 1 - } - - const byProject: Record = {} - for (const [userId, projectId] of Object.entries(chosen)) { - ;(byProject[projectId] ??= []).push(userId) - } - - const result: PlannedTeam[] = [] - let invented = 0 - - for (const p of projects) { - const existing = existingTeams.filter((t) => t.projectId === p.id) - const incoming = [...(byProject[p.id] ?? [])].sort() - - // Nobody new: leave the project exactly as it stands. Re-cutting teams - // nothing has changed about would move people for no reason. - if (incoming.length === 0) { - result.push(...existing) - continue - } - - const held = existing.reduce((n, t) => n + t.memberIds.length, 0) - const total = held + incoming.length - - // Enough teams to keep every one of them at or under `max`, and never fewer - // than already exist — emptying a team the organizer built is not this - // function's call to make. - const count = Math.max(existing.length, Math.ceil(total / max)) - const targets = balancedSizes(total, count) - - // Existing members keep their own team where it still has room for them, so - // the plan moves as few of them as it can; whoever spills over — and - // everyone new — is placed below. - const floating: string[] = [] - const planned: PlannedTeam[] = [] - - for (let i = 0; i < count; i++) { - const was = existing[i] - if (was === undefined) { - const base = `Team ${initialsOf(p.title)}` - planned.push({ - key: `new-${invented++}`, - id: null, - projectId: p.id, - name: i === 0 ? base : `${base} ${i + 1}`, - memberIds: [], - }) - continue - } - const target = targets[i] ?? 0 - floating.push(...was.memberIds.slice(target)) - planned.push({ ...was, memberIds: was.memberIds.slice(0, target) }) - } - - floating.push(...incoming) - for (let i = 0; i < count; i++) { - const team = planned[i] - if (team === undefined) continue - const room = (targets[i] ?? 0) - team.memberIds.length - if (room > 0) team.memberIds.push(...floating.splice(0, room)) - } - // Belt and braces: the targets sum to `total`, so nothing should be left. - for (let i = 0; floating.length > 0; i++) { - planned[i % count]?.memberIds.push(...floating.splice(0, 1)) - } - - result.push(...planned) - } - - return result -} - -/** Splits `total` into `count` parts differing by at most one, largest first. */ -function balancedSizes(total: number, count: number): number[] { - const base = Math.floor(total / count) - const over = total % count - - return Array.from({ length: count }, (_, i) => base + (i < over ? 1 : 0)) -} - /** "AutoML Pipeline Builder" -> "APB". Mirrors the server's team naming. */ export function initialsOf(text: string): string { return ( diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte index cd16a04f..9fa378ce 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte @@ -7,14 +7,13 @@ Eraser, GripVertical, Pencil, - Sparkles, Trash2, Upload, X } from 'lucide-svelte'; import ManageHubBackLink from '$lib/components/hackathon/ManageHubBackLink.svelte'; import { applyAssignmentCsv, type ImportResult } from '$lib/utils/teamAssignmentCsv'; - import { initialsOf, suggestDistribution } from '$lib/utils/teamDistribution'; + import { initialsOf } from '$lib/utils/teamDistribution'; import type { ActionData, PageData } from './$types'; type Person = { @@ -291,28 +290,6 @@ teams = teams.map((t) => ({ ...t, memberIds: [] })); } - function suggest() { - const plan = suggestDistribution( - projectRows.map((p) => ({ - id: p.id, - title: p.title, - teams: p.teams.map((t) => ({ - id: t.id, - name: t.name, - memberIds: t.members.map((m) => m.id) - })) - })), - data.unassigned, - { max: TEAM_MAX } - ); - - // Keep inventing keys past the ones the plan handed out, so a team added - // afterwards cannot collide with one of them. - invented = plan.filter((t) => t.id === null).length; - editingKey = null; - teams = plan; - } - function discard() { teams = fromServer(data.projectRows); editingKey = null; @@ -515,15 +492,6 @@ {/if}
-
@@ -502,37 +508,46 @@ Clear all - - - - Download CSV - - - + +
+ Spreadsheet + + + Download CSV + + + +
{#if changes.total > 0} @@ -559,6 +574,81 @@
+ + {#if fileHelpOpen} +
+
+

How the spreadsheet works

+ +
+ +
    +
  1. + Download CSV. One row per participant: people on + a team first, grouped by project and team, then everyone unassigned. +
  2. +
  3. + Fill in the project and + team columns in any spreadsheet. Leave + user_id as it is — it is how each row is matched to a person. +
  4. +
  5. + Upload CSV. The changes appear on this page as + unsaved edits, with a summary of what was read. Check them, then Save. +
  6. +
+ +
+
team left empty
+
the person becomes unassigned
+ +
team not found
+
+ a new team with that name is created under the row's project +
+ +
project
+
+ must be a project title shown on this page (capitals don't matter); needed + whenever team is filled in +
+ +
a row removed
+
that person stays where they are
+ +
renaming a team
+
+ reads as a new team, so rename it on this page instead +
+ +
deleting
+
+ never happens — a team everyone left stays, empty, until you delete it here +
+ +
more than {TEAM_MAX} on a team
+
allowed by the upload, but flagged so you can fix it
+ +
every other column
+
+ is there to read and is ignored on upload — reorder, add or delete columns + freely +
+
+
+ {/if} + {#if importResult} {@const bad = importResult.problems.length > 0}
and {importResult.problems.length - 5} more. {/if} + {#if !fileHelpOpen} + + {/if} {/if}
{/if} From 8bf954a44a139e49b967b0b4ccf2c04c7274f39a Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:49:04 +0200 Subject: [PATCH 03/23] feat(frontend): show every registration question on team assignment Only multiple-choice questions reached the Manage Teams legend. Letter all of them: a tick-box codes as Yes/No like a two-option list, and a free-text answer is carried whole under the question's letter and shown as a truncated line under the name, full text on hover. The panel becomes one row per question with its codes and a "Show on cards" toggle. --- CHANGELOG.md | 3 + .../server/hackathon/registrationForm.test.ts | 78 +++++++++++-- .../lib/server/hackathon/registrationForm.ts | 63 +++++++---- .../[id]/teams/manage/+page.server.ts | 6 +- .../hackathon/[id]/teams/manage/+page.svelte | 105 +++++++++++------- 5 files changed, 182 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac0aa416..2ee0d8f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ written while it was being built. See [RELEASING.md](RELEASING.md). column and uploading it again. - Manage Teams explains the spreadsheet upload: "How the file works", next to Download and Upload, lists the steps and what each kind of edit does. +- Manage Teams lists every registration question, not only the multiple-choice + ones: yes/no answers get codes like the others, and a free-text answer can be + shown as a line under each name. Question letters may shift as a result. ### Fixed diff --git a/components/frontend/src/lib/server/hackathon/registrationForm.test.ts b/components/frontend/src/lib/server/hackathon/registrationForm.test.ts index dd004f56..0b2a6583 100644 --- a/components/frontend/src/lib/server/hackathon/registrationForm.test.ts +++ b/components/frontend/src/lib/server/hackathon/registrationForm.test.ts @@ -802,6 +802,7 @@ describe("answerLegend", () => { id: "q1", label: "Experience level", letter: "A", + kind: "enum", options: [ { code: "A1", label: "Beginner" }, { code: "A2", label: "Intermediate" }, @@ -812,6 +813,7 @@ describe("answerLegend", () => { id: "q2", label: "Track", letter: "B", + kind: "enum", options: [ { code: "B1", label: "Data" }, { code: "B2", label: "Web" }, @@ -820,19 +822,46 @@ describe("answerLegend", () => { ]) }) - it("leaves out anything that cannot be coded", () => { + it("letters every kind of question, in question order", () => { const { questions } = answerLegend( [ row({ id: "text", kind: "text", options: [] }), row({ id: "coc", kind: "bool", options: [] }), - row({ id: "empty", options: [] }), row({ id: "q1" }), ], [], ) - expect(questions.map((q) => q.id)).toEqual(["q1"]) - expect(questions.map((q) => q.letter)).toEqual(["A"]) + expect(questions.map((q) => [q.id, q.letter, q.kind])).toEqual([ + ["text", "A", "text"], + ["coc", "B", "bool"], + ["q1", "C", "enum"], + ]) + }) + + it("reads a tick-box as Yes and No, and free text as having no options", () => { + const { questions } = answerLegend( + [ + row({ id: "coc", kind: "bool", options: [] }), + row({ id: "skills", kind: "text", options: [] }), + ], + [], + ) + + expect(questions[0]?.options).toEqual([ + { code: "A1", label: "Yes" }, + { code: "A2", label: "No" }, + ]) + expect(questions[1]?.options).toEqual([]) + }) + + it("leaves out a fixed-list question nobody can answer", () => { + const { questions } = answerLegend( + [row({ id: "empty", options: [] }), row({ id: "q1" })], + [], + ) + + expect(questions.map((q) => [q.id, q.letter])).toEqual([["q1", "A"]]) }) it("keeps a letter with its question when an earlier one is not shown", () => { @@ -868,11 +897,11 @@ describe("answerLegend", () => { expect(codesByParticipant).toEqual({}) }) - it("ignores answers to questions that carry no code", () => { + it("ignores answers to questions that are not in the legend", () => { const { codesByParticipant } = answerLegend( - [row(), row({ id: "coc", kind: "bool", options: [] })], + [row(), row({ id: "empty", options: [] })], [ - { questionId: "coc", participantId: "alice", boolValue: true }, + { questionId: "empty", participantId: "alice", textValue: "whatever" }, { questionId: "gone", participantId: "alice", textValue: "whatever" }, ], ) @@ -880,6 +909,41 @@ describe("answerLegend", () => { expect(codesByParticipant).toEqual({}) }) + it("codes a tick-box answer from its bool, either way", () => { + const { codesByParticipant } = answerLegend( + [row({ id: "coc", kind: "bool", options: [] })], + [ + { questionId: "coc", participantId: "alice", boolValue: true }, + { questionId: "coc", participantId: "bob", boolValue: false }, + // The wrong arm for a tick-box: not guessed at. + { questionId: "coc", participantId: "carol", textValue: "Yes" }, + ], + ) + + expect(codesByParticipant).toEqual({ + alice: { coc: { code: "A1", label: "Yes" } }, + bob: { coc: { code: "A2", label: "No" } }, + }) + }) + + it("carries a free-text answer whole, under the bare letter", () => { + const { codesByParticipant } = answerLegend( + [row({ id: "skills", kind: "text", options: [] })], + [ + { + questionId: "skills", + participantId: "alice", + textValue: " Python, some design ", + }, + { questionId: "skills", participantId: "bob", textValue: " " }, + ], + ) + + expect(codesByParticipant).toEqual({ + alice: { skills: { code: "A", label: "Python, some design" } }, + }) + }) + it("marks an answer that is no longer one of the options", () => { const { codesByParticipant } = answerLegend( [row()], diff --git a/components/frontend/src/lib/server/hackathon/registrationForm.ts b/components/frontend/src/lib/server/hackathon/registrationForm.ts index 28c7e498..308035f7 100644 --- a/components/frontend/src/lib/server/hackathon/registrationForm.ts +++ b/components/frontend/src/lib/server/hackathon/registrationForm.ts @@ -645,21 +645,28 @@ export function answeredParticipantIds( return new Set(answers.map((a) => a.participantId)) } -/** One fixed-list question, as the team-assignment legend spells it out. */ +/** One registration question, as the team-assignment legend spells it out. */ export interface LegendQuestion { id: string label: string /** `A`, `B`, `C` … fixed by question order. */ letter: string - /** The question's own options, in its own order, each with its code. */ + kind: QuestionKind + /** + * Each possible answer with its code: the question's own options in its own + * order, `Yes` then `No` for a tick-box, and none for free text. + */ options: AnswerCode[] } /** A code as it appears beside a name, and what it stands for. */ export interface AnswerCode { - /** The letter of the question and the position of the option: `A2`. */ + /** + * The letter of the question and the position of the option: `A2`. For a + * free-text answer, the letter alone — it names the question, not a choice. + */ code: string - /** The option as it was written, for the tooltip. */ + /** The option as it was written, or the free-text answer in full. */ label: string } @@ -691,13 +698,13 @@ function letterAt(index: number): string { * argument the page already makes for numbering the projects and printing the * numbers rather than the titles on every row. * - * **Fixed-list questions only.** A code is a position in a list of options, so a - * question without one has nothing to number: free text is a sentence, and a - * tick-box would need a second code shape ("the letter alone means yes") for two - * values. Questions the organizer cannot code are absent from the legend rather - * than present and empty, and the page offers no tick for them. + * **Every kind of question is in it, each coded as far as it can be.** A code is + * a position in a list of options. A fixed list has its own; a tick-box is read + * as the two-option list `Yes`, `No`, so it codes like any other. Free text has + * nothing to number, so its answer is carried whole under the question's bare + * letter, and the page decides how much of it fits beside a name. * - * Letters go to **every** enum question in question order, whether or not the + * Letters go to **every** question in question order, whether or not the * organizer has chosen to show it. Assigning them to the shown ones instead * would renumber the rest each time one is ticked, so a screenshot — or an * organizer's memory of what A meant — would stop being true. The cost is that @@ -722,14 +729,17 @@ export function answerLegend( // An enum with no options is answerable by nobody — the builder refuses to // save one, but the backend will store it — so it would be a tick that can // never mark anything. - if (q.kind !== "enum" || q.options.length === 0) continue + if (q.kind === "enum" && q.options.length === 0) continue const letter = letterAt(legend.length) + const labels = + q.kind === "enum" ? q.options : q.kind === "bool" ? ["Yes", "No"] : [] legend.push({ id: q.id, label: q.label, letter, - options: q.options.map((label, i) => ({ + kind: q.kind, + options: labels.map((label, i) => ({ code: `${letter}${i + 1}`, label, })), @@ -741,22 +751,33 @@ export function answerLegend( for (const a of answers) { const q = byId.get(a.questionId) - // Text and tick-box answers land here too — they have no legend entry, so - // they have no code. + // An answer to a question that is gone, or to an enum with no options. if (!q) continue - // `textValue` only: an enum answer stores the option's text, and reading a - // stray bool arm would be guessing at which option it meant. - const value = a.textValue + // One arm per kind, the one the backend files it under: a tick-box as + // `boolValue`, text and enum as `textValue`. Reading the other arm as well + // would be guessing at what a stray value meant. + let value: string | undefined + if (q.kind === "bool") { + if (a.boolValue !== undefined) value = a.boolValue ? "Yes" : "No" + } else if (q.kind === "text") { + value = a.textValue?.trim() + } else { + // Not trimmed: it is matched against the option exactly as stored. + value = a.textValue + } if (value === undefined || value === "") continue const person = codesByParticipant[a.participantId] ?? (codesByParticipant[a.participantId] = {}) - person[q.id] = q.options.find((o) => o.label === value) ?? { - code: `${q.letter}?`, - label: value, - } + person[q.id] = + q.kind === "text" + ? { code: q.letter, label: value } + : (q.options.find((o) => o.label === value) ?? { + code: `${q.letter}?`, + label: value, + }) } return { questions: legend, codesByParticipant } diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.server.ts index 29212176..0cdbdd92 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.server.ts @@ -65,7 +65,7 @@ export const load: PageServerLoad = async (event) => { const numberByProjectId = new Map(rowProjects.map((p, i) => [p.id, i + 1])) // What people said about themselves on the way in, as codes short enough to - // sit beside a name. Fixed-list questions only — see `answerLegend`. + // sit beside a name — and free text whole. See `answerLegend`. // // Decoration, and fetched as such: the page assigns teams with or without it, // so a refusal leaves an empty legend and no ticks rather than a failed page. @@ -107,7 +107,7 @@ export const load: PageServerLoad = async (event) => { id, name, // Keyed by question so the page can show the ticked ones and nothing - // else. Empty for anyone who answered no fixed-list question. + // else. Empty for anyone who answered nothing. codes: legend.codesByParticipant[id] ?? {}, preferredTitles: preferred.map((p) => p.title), preferredProjectIds: preferred.map((p) => p.id), @@ -151,7 +151,7 @@ export const load: PageServerLoad = async (event) => { hackathonId: event.params.id, unassigned, projectRows, - // Every fixed-list question, lettered — not only the ones an organizer has + // Every question, lettered — not only the ones an organizer has // chosen to show. Which of them to show is a preference of one person at // one screen, so it is kept in their browser and never reaches here. answerQuestions: legend.questions, diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte index ade3888b..3c031264 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte @@ -20,7 +20,7 @@ type Person = { id: string; name: string; - /** Their fixed-list registration answers as codes, by question id. */ + /** Their registration answers by question id: a code, or free text whole. */ codes: Record; preferredTitles: string[]; preferredProjectIds: string[]; @@ -82,15 +82,27 @@ } } - /** The ticked questions this person answered, in question order. */ - function codesFor(person: Person): { code: string; title: string }[] { - return shownQuestions.flatMap((q) => { + /** + * The ticked questions this person answered, in question order: coded + * answers as badges, free text as lines of its own — a sentence does not + * fit in a badge. + */ + function answersFor(person: Person) { + const coded: { code: string; title: string }[] = []; + const texts: { letter: string; text: string; title: string }[] = []; + for (const q of shownQuestions) { const answer = person.codes[q.id]; + if (!answer) continue; + const title = `${q.label}: ${answer.label}`; + if (q.kind === 'text') texts.push({ letter: q.letter, text: answer.label, title }); + else coded.push({ code: answer.code, title }); + } - return answer ? [{ code: answer.code, title: `${q.label}: ${answer.label}` }] : []; - }); + return { coded, texts }; } + const KIND_NOTE: Partial> = { bool: 'yes / no', text: 'free text' }; + // Drop target id for the unassigned pool; team keys are used as-is. const POOL = 'pool'; @@ -412,7 +424,7 @@ projectNumber: number | null )} {@const matches = projectId !== null && person.preferredProjectIds.includes(projectId)} - {@const answerCodes = codesFor(person)} + {@const answers = answersFor(person)}
{/if} - {#if answerCodes.length > 0} + {#if answers.coded.length > 0} - {#each answerCodes as c (c.code)} + {#each answers.coded as c (c.code)} {c.code} {/each} {/if} + {#each answers.texts as t (t.letter)} + + {t.letter} + {t.text} + + {/each}
{#if projectId !== null && !matches} {/if} - + {#if answerQuestions.length > 0}
-
-

Registration answers

- {#each answerQuestions as q (q.id)} - - {/each} -
- - {#if shownQuestions.length === 0} +
+

Registration questions

- Tick a question to mark everyone's answer beside their name. + Tick "Show on cards" to see that answer beside every name.

- {:else} -
- {#each shownQuestions as q (q.id)} -
-
{q.letter} · {q.label}
-
+
+
    + {#each answerQuestions as q (q.id)} +
  • + + {q.letter} + {q.label} + {#if KIND_NOTE[q.kind]} + {KIND_NOTE[q.kind]} + {/if} + + {#if q.options.length > 0} + {#each q.options as o (o.code)} {o.code} {o.label} {/each} - -
- {/each} - - {/if} + + {:else} + + Shown as a line under the name; hover for the full answer. + + {/if} + + + {/each} +
{/if} From da94a52ee52ea130d84ae1553453fb02fc4d52b5 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:56:37 +0200 Subject: [PATCH 04/23] feat(frontend): filter unassigned participants by registration answer Clicking an answer in the questions panel narrows the Unassigned column to the people who gave it; a free-text question gets a "contains" box. Answers to one question widen the filter, different questions narrow it. Each answer shows how many unassigned people gave it. Active filters show as removable tags above the pool with a count and "Reset filters", and are kept per hackathon in the browser, keyed by question id and answer text so a shifted letter cannot redirect them. --- CHANGELOG.md | 5 + .../hackathon/[id]/teams/manage/+page.svelte | 216 ++++++++++++++++-- 2 files changed, 208 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee0d8f7..db6a983a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ written while it was being built. See [RELEASING.md](RELEASING.md). ### Added +- Manage Teams can filter the Unassigned column by registration answers: click + an answer (or type into a free-text question's box) to see only the people who + gave it. Each answer shows how many unassigned people gave it. Filters are + remembered in your browser and cleared with "Reset filters". + ### Changed - Manage Teams no longer has a "Suggest teams" button. Build teams by dragging diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte index 3c031264..a649e6e1 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte @@ -103,6 +103,124 @@ const KIND_NOTE: Partial> = { bool: 'yes / no', text: 'free text' }; + // Which answers narrow the Unassigned column. Answers to one question widen + // it (B1 or B2), different questions narrow it (B2 and "python"). + // + // Kept in the browser like the ticks above, so a reload keeps the filter. + // That is only safe because the pool always says it is filtered and offers + // a reset — a forgotten filter must never read as people having vanished. + // Keyed by question id and answer text rather than by code, so a letter + // that shifts when a question is added cannot point a saved filter at a + // different question. + type Filters = { + /** Picked answers, by question id. */ + answers: Record; + /** "Contains" text for free-text questions, by question id. */ + texts: Record; + }; + let filters: Filters = $state({ answers: {}, texts: {} }); + + const filterKey = $derived(`hackagon:team-filters:${hackathonId}`); + + // Restored the way `shownIds` is, and for the same reason it only reads. + // Anything naming a question or an answer that no longer exists is dropped. + $effect(() => { + const byId = new Map(answerQuestions.map((q) => [q.id, q])); + let stored: unknown = null; + try { + stored = JSON.parse(localStorage.getItem(filterKey) ?? 'null'); + } catch { + // No storage, or something in it that is not ours. No filter. + } + const s = (typeof stored === 'object' && stored !== null ? stored : {}) as Record< + string, + unknown + >; + const entries = (v: unknown) => + typeof v === 'object' && v !== null ? Object.entries(v) : []; + + const answers: Filters['answers'] = {}; + for (const [id, labels] of entries(s.answers)) { + const q = byId.get(id); + if (!q || !Array.isArray(labels)) continue; + const known = labels.filter( + (l): l is string => typeof l === 'string' && q.options.some((o) => o.label === l) + ); + if (known.length > 0) answers[id] = known; + } + const texts: Filters['texts'] = {}; + for (const [id, text] of entries(s.texts)) { + if (byId.get(id)?.kind === 'text' && typeof text === 'string' && text.trim() !== '') { + texts[id] = text; + } + } + filters = { answers, texts }; + }); + + function setFilters(next: Filters) { + filters = next; + try { + localStorage.setItem(filterKey, JSON.stringify(next)); + } catch { + // Private browsing, or a full quota. The filter still holds for this visit. + } + } + + function toggleAnswer(questionId: string, label: string) { + const picked = filters.answers[questionId] ?? []; + const rest = { ...filters.answers }; + const next = picked.includes(label) + ? picked.filter((l) => l !== label) + : [...picked, label]; + if (next.length > 0) rest[questionId] = next; + else delete rest[questionId]; + setFilters({ ...filters, answers: rest }); + } + + function setText(questionId: string, text: string) { + const rest = { ...filters.texts }; + if (text.trim() !== '') rest[questionId] = text; + else delete rest[questionId]; + setFilters({ ...filters, texts: rest }); + } + + function resetFilters() { + setFilters({ answers: {}, texts: {} }); + } + + function matchesFilters(person: Person): boolean { + for (const [id, labels] of Object.entries(filters.answers)) { + const answer = person.codes[id]; + if (!answer || !labels.includes(answer.label)) return false; + } + for (const [id, text] of Object.entries(filters.texts)) { + const needle = text.trim().toLowerCase(); + if (!person.codes[id]?.label.toLowerCase().includes(needle)) return false; + } + + return true; + } + + /** The filters as removable tags, in question order. */ + const activeFilters = $derived( + answerQuestions.flatMap((q) => [ + ...(filters.answers[q.id] ?? []).map((label) => ({ + key: `${q.id}:${label}`, + text: `${q.options.find((o) => o.label === label)?.code ?? q.letter} ${label}`, + remove: () => toggleAnswer(q.id, label) + })), + ...(filters.texts[q.id] !== undefined + ? [ + { + key: `${q.id}:text`, + text: `${q.letter} "${filters.texts[q.id]?.trim()}"`, + remove: () => setText(q.id, '') + } + ] + : []) + ]) + ); + // Drop target id for the unassigned pool; team keys are used as-is. const POOL = 'pool'; @@ -185,6 +303,28 @@ return [...peopleById.values()].filter((p) => !placed.has(p.id)); }); + /** The part of the pool the filters let through. */ + const shownPool = $derived(unassigned.filter(matchesFilters)); + + // How many unassigned people gave each answer, by question id then answer. + // Counted over the whole pool, not the filtered part, so a number beside an + // answer does not change as other answers are picked. + const poolCounts = $derived.by(() => { + const counts: Record> = {}; + for (const p of unassigned) { + for (const [id, answer] of Object.entries(p.codes)) { + const byAnswer = (counts[id] ??= {}); + byAnswer[answer.label] = (byAnswer[answer.label] ?? 0) + 1; + } + } + + return counts; + }); + + function answeredInPool(questionId: string): number { + return Object.values(poolCounts[questionId] ?? {}).reduce((n, c) => n + c, 0); + } + const assignedCount = $derived(teams.reduce((n, t) => n + t.memberIds.length, 0)); /** What Save would write, compared against what the server last reported. */ @@ -720,7 +860,8 @@

Registration questions

- Tick "Show on cards" to see that answer beside every name. + Click an answer to filter Unassigned; the number is how many unassigned people + gave it. Tick "Show on cards" to see that answer beside every name.

    @@ -733,18 +874,43 @@ {KIND_NOTE[q.kind]} {/if} - {#if q.options.length > 0} - - {#each q.options as o (o.code)} - - {o.code} - {o.label} - - {/each} + {#if q.kind === 'text'} + + setText(q.id, e.currentTarget.value)} + /> + + {answeredInPool(q.id)} of {unassigned.length} answered + {:else} - - Shown as a line under the name; hover for the full answer. + + {#each q.options as o (o.code)} + {@const picked = + filters.answers[q.id]?.includes(o.label) ?? false} + + {/each} {/if}