Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
fcd84b2
refactor(frontend): remove "Suggest teams" from team assignment
sabinem Oct 7, 2026
e15ca67
feat(frontend): explain the team assignment spreadsheet on the page
sabinem Oct 7, 2026
8bf954a
feat(frontend): show every registration question on team assignment
sabinem Oct 7, 2026
da94a52
feat(frontend): filter unassigned participants by registration answer
sabinem Oct 7, 2026
a2812b5
feat(frontend): style filter buttons on team assignment page
sabinem Oct 7, 2026
78f2182
refactor(frontend): move team pool filtering into a tested module
sabinem Oct 7, 2026
3847bac
feat(frontend): bring filter matches to the top instead of hiding the…
sabinem Oct 7, 2026
4267995
feat(frontend): filter unassigned participants by project preference
sabinem Oct 8, 2026
05e9ebc
feat(frontend): include free-text answers in the team assignment CSV
sabinem Oct 8, 2026
003d661
refactor(frontend): drop explanatory text from team assignment page
sabinem Oct 8, 2026
4379592
feat(frontend): move project preference filter into its own box
sabinem Oct 8, 2026
1195c73
feat(frontend): show registration answers as "A: XL" on team cards
sabinem Oct 8, 2026
33df61c
feat(frontend): let organizers name registration questions for the cards
sabinem Oct 8, 2026
260df96
feat(frontend): drop the letter from yes/no questions on team assignment
sabinem Oct 8, 2026
6465436
feat(frontend): make the team assignment upload replace the whole ass…
sabinem Oct 8, 2026
874c363
feat(frontend): rebuild every team from the uploaded assignment file
sabinem Oct 8, 2026
37d1853
feat(frontend): lock team assignment once submissions exist or teams …
sabinem Oct 8, 2026
d0d0ca7
feat(frontend): delete empty teams when saving the team assignment
sabinem Oct 8, 2026
eed5764
fix: lint errors on code changes
sabinem Oct 8, 2026
6a23a3a
fix(frontend): keep the Unassigned scrollbar visible on Manage Teams
sabinem Oct 8, 2026
9f43212
feat(frontend): use the on-screen project numbers in the team spreads…
sabinem Oct 8, 2026
4596938
feat(frontend): keep moving people by hand once teams are fixed
sabinem Oct 8, 2026
4627645
feat(frontend): accept only project numbers in the team spreadsheet
sabinem Oct 8, 2026
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
9 changes: 9 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(just nix::develop default bash -c 'just check::lint -c frontend 2>&1 | grep -E \"error TS|✖| error|Runner successful|failed\" | tail -10 && just check::test -c frontend 2>&1 | grep -E \"Test Files|Tests |FAIL|×|AssertionError\" | tail -10 && just check::build -c frontend 2>&1 | grep -E \"error|Error|Runner successful|failed\" | tail -4')",
"Bash(just nix::develop default bash -c 'just check::test -c frontend 2>&1 | grep -E \"Test Files|Tests |FAIL|×\" | tail -6')",
"Bash(just nix::develop default bash -c 'just check::lint -c frontend 2>&1 | grep -E \"error TS|✖|Runner successful|failed\" | tail -4')"
]
}
}
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,55 @@ written while it was being built. See [RELEASING.md](RELEASING.md).

### Added

- Manage Teams fixes the teams once a team has a submission or teams are
published: a notice says why, and uploading, adding and deleting teams are
disabled. People can still be moved between teams or to Unassigned by hand,
and teams renamed.
- Manage Teams can filter the Unassigned column by registration answers: click
an answer (or type into a free-text question's box) to bring the people who
gave it to the top; everyone else stays below, greyed out. Each answer shows
how many unassigned people gave it. Filters are remembered in your browser and
cleared with "Reset filters".
- Manage Teams can also filter by project preference: click a project in the
"Project preferences" box to bring the unassigned people who picked it to the
top. Its "Show on cards" checkbox hides or shows the "Prefers …" line under
each name.
- Manage Teams lets you give each registration question a short name for the
cards — "size: S" instead of "B: S" — and give a yes/no question its own words
for Yes and No, so a card says "remote" or "on site". Names are kept in your
browser.

### 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.
- Manage Teams explains itself in one place: "How assignment works" covers
dragging, saving, when the assignment locks, and the spreadsheet steps and
rules.
- Uploading the team assignment spreadsheet now replaces all teams with the ones
in the file. A row needs both a project and a team to put someone on a team;
anyone else ends up unassigned, with a warning for a row that has a mistake in
it or is missing from the file.
- The team assignment spreadsheet uses the project numbers shown on the page, in
both the project and the prefers columns; an uploaded row whose project is not
one of those numbers is not an assignment. Team names can be anything, even a
single character.
- Saving on Manage Teams now deletes every team with nobody in it.
- Manage Teams lists every registration question, not only the multiple-choice
ones, and "Show on cards" puts the question's letter and the answer itself
under each name — "A: XL" — instead of a code like "A5". A yes/no question has
no letter and shows as "Yes" or "No"; a free-text answer shows as a line of
its own.
- The team assignment spreadsheet now includes free-text registration answers,
one column per question, alongside the multiple-choice and yes/no ones.

### Fixed

- The Unassigned list on Manage Teams shows its scrollbar from the start when
there are more people than fit, instead of only on hover (Chrome, Edge and
Safari).

## [0.11.0](https://github.com/SwissDataScienceCenter/hackagon/compare/v0.10.0...v0.11.0) - 2026-09-28

### Added
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, it, expect } from "vitest"
import { numberedProjects } from "./projectNumbers"
import { ProjectStatus } from "$lib/server/grpc/generated/hackathon/entities/project_status"

describe("numberedProjects", () => {
it("numbers the approved projects from 1, in the order given", () => {
const projects = [
{ id: "a", status: ProjectStatus.PROJECT_STATUS_APPROVED },
{ id: "b", status: ProjectStatus.PROJECT_STATUS_PROPOSED },
{ id: "c", status: ProjectStatus.PROJECT_STATUS_APPROVED },
]

expect(numberedProjects(projects).map((p) => [p.id, p.number])).toEqual([
["a", 1],
["c", 2],
])
})
})
24 changes: 24 additions & 0 deletions components/frontend/src/lib/server/hackathon/projectNumbers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ProjectStatus } from "$lib/server/grpc/generated/hackathon/entities/project_status"

/**
* Server-only: reads generated types, so it must never be imported by a
* component.
*/

/**
* The approved projects, each with the number the team-assignment page shows.
*
* One definition for the page and its spreadsheet, because the spreadsheet
* carries these numbers in `project` and `prefers` and an upload reads them
* back: a number the download wrote has to name the same project the page
* shows under it. Numbered in the order `ExportPreferences` returns them —
* which is also why rejecting or approving a project shifts the numbers after
* it, and why the download writes each title beside its number.
*/
export function numberedProjects<
T extends { id: string; status: ProjectStatus },
>(projects: readonly T[]): (T & { number: number })[] {
return projects
.filter((p) => p.status === ProjectStatus.PROJECT_STATUS_APPROVED)
.map((p, i) => ({ ...p, number: i + 1 }))
}
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,7 @@ describe("answerLegend", () => {
id: "q1",
label: "Experience level",
letter: "A",
kind: "enum",
options: [
{ code: "A1", label: "Beginner" },
{ code: "A2", label: "Intermediate" },
Expand All @@ -812,6 +813,7 @@ describe("answerLegend", () => {
id: "q2",
label: "Track",
letter: "B",
kind: "enum",
options: [
{ code: "B1", label: "Data" },
{ code: "B2", label: "Web" },
Expand All @@ -820,19 +822,46 @@ describe("answerLegend", () => {
])
})

it("leaves out anything that cannot be coded", () => {
it("letters every question but a tick-box, in question order, without gaps", () => {
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", "", "bool"],
["q1", "B", "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: "Yes", label: "Yes" },
{ code: "No", 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", () => {
Expand Down Expand Up @@ -868,18 +897,53 @@ 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" },
],
)

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: "Yes", label: "Yes" } },
bob: { coc: { code: "No", 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()],
Expand Down
86 changes: 63 additions & 23 deletions components/frontend/src/lib/server/hackathon/registrationForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,21 +645,32 @@ 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. */
/**
* `A`, `B`, `C` … fixed by question order, skipping tick-boxes, which have
* none and carry `""`.
*/
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.
* For a tick-box, `Yes` or `No`.
*/
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
}

Expand Down Expand Up @@ -691,13 +702,14 @@ 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 has no
* letter and its answers are simply `Yes` and `No`, which the page lets an
* organizer reword. 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 but a tick-box, 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
Expand All @@ -718,18 +730,35 @@ export function answerLegend(
answers: readonly Answer[],
): AnswerLegend {
const legend: LegendQuestion[] = []
let lettered = 0
for (const q of questions) {
// 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)
// A tick-box takes no letter: "Yes" and "No" are their own codes, and the
// page lets an organizer give them words that say which question they
// answer. Lettering only the others keeps A, B, C free of gaps.
if (q.kind === "bool") {
legend.push({
id: q.id,
label: q.label,
letter: "",
kind: q.kind,
options: ["Yes", "No"].map((label) => ({ code: label, label })),
})
continue
}

const letter = letterAt(lettered++)
const labels = q.kind === "enum" ? q.options : []
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,
})),
Expand All @@ -741,22 +770,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 }
Expand Down
Loading
Loading