diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..8cd2936a --- /dev/null +++ b/.claude/settings.json @@ -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')" + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 51728e5a..066e6266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/components/frontend/src/lib/server/hackathon/projectNumbers.test.ts b/components/frontend/src/lib/server/hackathon/projectNumbers.test.ts new file mode 100644 index 00000000..287bf7b6 --- /dev/null +++ b/components/frontend/src/lib/server/hackathon/projectNumbers.test.ts @@ -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], + ]) + }) +}) diff --git a/components/frontend/src/lib/server/hackathon/projectNumbers.ts b/components/frontend/src/lib/server/hackathon/projectNumbers.ts new file mode 100644 index 00000000..aa73e250 --- /dev/null +++ b/components/frontend/src/lib/server/hackathon/projectNumbers.ts @@ -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 })) +} diff --git a/components/frontend/src/lib/server/hackathon/registrationForm.test.ts b/components/frontend/src/lib/server/hackathon/registrationForm.test.ts index dd004f56..423f6b7f 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 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", () => { @@ -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: "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()], diff --git a/components/frontend/src/lib/server/hackathon/registrationForm.ts b/components/frontend/src/lib/server/hackathon/registrationForm.ts index 28c7e498..30a0ec3c 100644 --- a/components/frontend/src/lib/server/hackathon/registrationForm.ts +++ b/components/frontend/src/lib/server/hackathon/registrationForm.ts @@ -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 } @@ -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 @@ -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, })), @@ -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 } diff --git a/components/frontend/src/lib/server/hackathon/teamAssignmentLock.test.ts b/components/frontend/src/lib/server/hackathon/teamAssignmentLock.test.ts new file mode 100644 index 00000000..facab551 --- /dev/null +++ b/components/frontend/src/lib/server/hackathon/teamAssignmentLock.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest" +import { assignmentLockReasons, lockReasons } from "./teamAssignmentLock" +import { Capability } from "$lib/server/grpc/generated/hackathon/entities/capability" +import type { HackathonState } from "$lib/server/grpc/generated/hackathon/entities/hackathon_state" +import type { TeamServiceClient } from "$lib/server/grpc/generated/hackathon/team_service" +import { ClientError, Status } from "nice-grpc-common" + +/** A client whose `listSubmissions` answers per team id. Nothing else is called. */ +function client(perTeam: Record): TeamServiceClient { + return { + listSubmissions: async ({ teamId }: { teamId: string }) => { + const answer = perTeam[teamId] + if (answer === "refuse") { + throw new ClientError( + "/hackathon.TeamService/ListSubmissions", + Status.PERMISSION_DENIED, + "nope", + ) + } + + return { submissions: Array.from({ length: answer ?? 0 }, () => ({})) } + }, + } as unknown as TeamServiceClient +} + +function state(viewTeams: boolean): HackathonState { + return { + capabilities: [ + { capability: Capability.CAPABILITY_VIEW_TEAMS, enabled: viewTeams }, + ], + } as unknown as HackathonState +} + +describe("assignmentLockReasons", () => { + it("is open with no submissions and teams not published", async () => { + expect( + await assignmentLockReasons( + client({ t1: 0, t2: 0 }), + ["t1", "t2"], + state(false), + ), + ).toEqual([]) + }) + + it("counts submissions across every team", async () => { + expect( + await assignmentLockReasons( + client({ t1: 2, t2: 1 }), + ["t1", "t2"], + state(false), + ), + ).toEqual(["3 submissions exist"]) + }) + + it("locks on published teams alone", async () => { + expect(await assignmentLockReasons(client({}), [], state(true))).toEqual([ + "teams are published", + ]) + }) + + it("locks when submissions cannot be checked, rather than guess open", async () => { + expect( + await assignmentLockReasons( + client({ t1: "refuse" }), + ["t1"], + state(false), + ), + ).toEqual(["submissions could not be checked"]) + }) + + it("reads a hackathon with no state row as not published", async () => { + expect(await assignmentLockReasons(client({}), [], undefined)).toEqual([]) + }) +}) + +describe("lockReasons", () => { + it("names both reasons, submissions first", () => { + expect(lockReasons({ submissions: 1, published: true })).toEqual([ + "1 submission exists", + "teams are published", + ]) + }) +}) diff --git a/components/frontend/src/lib/server/hackathon/teamAssignmentLock.ts b/components/frontend/src/lib/server/hackathon/teamAssignmentLock.ts new file mode 100644 index 00000000..595cfb08 --- /dev/null +++ b/components/frontend/src/lib/server/hackathon/teamAssignmentLock.ts @@ -0,0 +1,82 @@ +import { Capability } from "$lib/server/grpc/generated/hackathon/entities/capability" +import type { HackathonState } from "$lib/server/grpc/generated/hackathon/entities/hackathon_state" +import type { TeamServiceClient } from "$lib/server/grpc/generated/hackathon/team_service" +import { enabledCapabilities } from "./phaseForm" + +/** + * Server-only: reads generated types, so it must never be imported by a + * component. + */ + +/** + * Why the teams themselves are fixed — empty while they are not. + * + * Fixed means no team may be added or deleted, and so no upload, which + * replaces every team. People may still be moved by hand and teams renamed: + * that is how an organizer handles someone dropping out late. Two things fix + * the teams, each because rebuilding them would pull the ground from under + * somebody: + * + * - **a submission exists.** A deleted team takes its submissions with it. + * - **teams are published** (`CAPABILITY_VIEW_TEAMS`). Participants have been + * told which team they are on; replacing the teams changes that behind their + * backs. + * + * Either one is enough, and the reasons are listed so the page can say which. + * + * TODO(backend: team-assignment-lock): this is a frontend rule only. The page + * stops offering the controls and its save action refuses a plan that adds or + * deletes a team, but `TeamService` still accepts that from any other caller. + * Planned as a backend check in a follow-up pull request; the frontend can + * then keep this for the explanation and rely on the error for the rule. + */ +export async function assignmentLockReasons( + team: TeamServiceClient, + teamIds: readonly string[], + state: HackathonState | undefined, +): Promise { + const published = enabledCapabilities(state).includes( + Capability.CAPABILITY_VIEW_TEAMS, + ) + + // `TeamService.List` does not load submissions, so each team is asked. A + // refusal or failure counts as "cannot tell", which locks: unlocking wrongly + // can delete a submission, locking wrongly costs a reload. + let submissions: number | null = 0 + try { + const counts = await Promise.all( + teamIds.map(async (teamId) => { + const { submissions } = await team.listSubmissions({ teamId }) + + return submissions.length + }), + ) + submissions = counts.reduce((n, c) => n + c, 0) + } catch { + submissions = null + } + + return lockReasons({ submissions, published }) +} + +/** The reasons as the page shows them, most decisive first. */ +export function lockReasons({ + submissions, + published, +}: { + /** How many submissions exist, or `null` when that could not be checked. */ + submissions: number | null + published: boolean +}): string[] { + const reasons: string[] = [] + if (submissions === null) { + reasons.push("submissions could not be checked") + } else if (submissions > 0) { + reasons.push( + `${submissions} ${submissions === 1 ? "submission exists" : "submissions exist"}`, + ) + } + if (published) reasons.push("teams are published") + + return reasons +} diff --git a/components/frontend/src/lib/utils/teamAnswerLabels.test.ts b/components/frontend/src/lib/utils/teamAnswerLabels.test.ts new file mode 100644 index 00000000..defbd83d --- /dev/null +++ b/components/frontend/src/lib/utils/teamAnswerLabels.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from "vitest" +import { + LABEL_MAX, + answerText, + optionText, + restoreLabels, + setLabels, + shortName, + type LabeledQuestion, +} from "./teamAnswerLabels" + +const SIZE: LabeledQuestion = { id: "size", letter: "B", kind: "enum" } +// A tick-box carries no letter; see `answerLegend`. +const REMOTE: LabeledQuestion = { id: "remote", letter: "", kind: "bool" } +const UNI: LabeledQuestion = { id: "uni", letter: "A", kind: "text" } +const QUESTIONS = [UNI, SIZE, REMOTE] + +describe("answerText", () => { + it("uses the letter until a question has a short name", () => { + expect(answerText(SIZE, "S", {})).toBe("B: S") + expect(answerText(SIZE, "S", { size: { short: "size" } })).toBe("size: S") + }) + + it("reads a tick-box as plain Yes or No until it has its own words", () => { + expect(answerText(REMOTE, "Yes", {})).toBe("Yes") + expect(answerText(REMOTE, "No", {})).toBe("No") + }) + + it("shows a tick-box's own word alone", () => { + const named = { remote: { yes: "remote", no: "on site" } } + + expect(answerText(REMOTE, "Yes", named)).toBe("remote") + expect(answerText(REMOTE, "No", named)).toBe("on site") + }) + + it("falls back for the side of a tick-box that has no word", () => { + const named = { remote: { yes: "remote" } } + + expect(answerText(REMOTE, "No", named)).toBe("No") + }) + + it("prefixes free text like any other answer", () => { + expect(answerText(UNI, "ETH Zurich", { uni: { short: "uni" } })).toBe( + "uni: ETH Zurich", + ) + }) +}) + +describe("optionText", () => { + it("renames only a tick-box's answers", () => { + const named = { + remote: { yes: "remote", no: "on site" }, + size: { short: "size" }, + } + + expect(optionText(REMOTE, "No", named)).toBe("on site") + expect(optionText(SIZE, "S", named)).toBe("S") + }) +}) + +describe("setLabels", () => { + it("trims, caps, and drops what is blank", () => { + const next = setLabels({}, REMOTE, { + yes: " remote ", + no: " ", + }) + + expect(next).toEqual({ remote: { yes: "remote" } }) + expect( + setLabels({}, SIZE, { short: "x".repeat(LABEL_MAX + 5) }).size?.short, + ).toBe("x".repeat(LABEL_MAX)) + }) + + it("clears a question whose names are all blank", () => { + const before = { size: { short: "size" } } + + expect(setLabels(before, SIZE, { short: " " })).toEqual({}) + expect(shortName(SIZE, {})).toBe("B") + }) + + it("keeps Yes and No words for tick-boxes only", () => { + expect(setLabels({}, SIZE, { short: "size", yes: "big" })).toEqual({ + size: { short: "size" }, + }) + }) + + it("gives a tick-box no short name — its words already say which question", () => { + expect( + setLabels({}, REMOTE, { short: "remote", yes: "remote", no: "on site" }), + ).toEqual({ remote: { yes: "remote", no: "on site" } }) + }) + + it("does not change what it was given", () => { + const before = { size: { short: "size" } } + setLabels(before, SIZE, { short: "tee" }) + + expect(before).toEqual({ size: { short: "size" } }) + }) +}) + +describe("restoreLabels", () => { + it("keeps names for questions that still exist, tidied", () => { + const stored = { + size: { short: " size " }, + remote: { short: "remote", yes: "remote", no: "on site" }, + gone: { short: "old" }, + } + + expect(restoreLabels(stored, QUESTIONS)).toEqual({ + size: { short: "size" }, + remote: { yes: "remote", no: "on site" }, + }) + }) + + it.each([null, "nonsense", 42, [], { size: "size" }, { size: { short: 7 } }])( + "reads %j as nothing named", + (stored) => { + expect(restoreLabels(stored, QUESTIONS)).toEqual({}) + }, + ) +}) diff --git a/components/frontend/src/lib/utils/teamAnswerLabels.ts b/components/frontend/src/lib/utils/teamAnswerLabels.ts new file mode 100644 index 00000000..68b4507d --- /dev/null +++ b/components/frontend/src/lib/utils/teamAnswerLabels.ts @@ -0,0 +1,147 @@ +import type { QuestionKind } from "./question" + +/** + * Short names an organizer gives registration questions on the team-assignment + * page, so a card can say `size: S` or `remote` instead of `B: S` or `D: Yes`. + * + * A view preference of one organizer at one screen, kept in their browser like + * the "Show on cards" ticks: a co-organizer sees letters until they name the + * questions themselves, and the spreadsheet download keeps the full questions. + * + * Purely presentation. Filters are kept against the answer as stored, so + * renaming a question or its Yes and No never changes what a filter matches. + */ + +/** What an organizer has named one question. Anything unset is absent. */ +export type QuestionLabels = { + /** + * Stands in for the question's letter. Never set on a tick-box: its own + * words for Yes and No say which question they answer, so a short name + * would be a third box for the same thing. + */ + short?: string + /** Shown instead of a tick-box's Yes. */ + yes?: string + /** Shown instead of a tick-box's No. */ + no?: string +} + +/** By question id. A question with nothing named is absent. */ +export type AnswerLabels = Record + +/** Long enough for "experience", short enough to leave a card readable. */ +export const LABEL_MAX = 12 + +/** A question as far as naming it needs. */ +export interface LabeledQuestion { + id: string + letter: string + kind: QuestionKind +} + +/** Trimmed and capped; blank reads as unset. */ +function clean(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const text = value.trim().slice(0, LABEL_MAX).trim() + + return text === "" ? undefined : text +} + +/** + * One question's names, tidied: trimmed, capped, blanks dropped. A tick-box + * keeps only its Yes and No words, every other kind only its short name. + */ +function tidy(raw: unknown, kind: QuestionKind): QuestionLabels { + const r = + typeof raw === "object" && raw !== null + ? (raw as Record) + : {} + const out: QuestionLabels = {} + if (kind === "bool") { + const yes = clean(r.yes) + const no = clean(r.no) + if (yes) out.yes = yes + if (no) out.no = no + } else { + const short = clean(r.short) + if (short) out.short = short + } + + return out +} + +/** + * Names as read back from storage, keeping only those for questions that still + * exist. Anything that is not ours reads as nothing named. + */ +export function restoreLabels( + stored: unknown, + questions: readonly LabeledQuestion[], +): AnswerLabels { + if (typeof stored !== "object" || stored === null || Array.isArray(stored)) { + return {} + } + const raw = stored as Record + const out: AnswerLabels = {} + for (const q of questions) { + const named = tidy(raw[q.id], q.kind) + if (Object.keys(named).length > 0) out[q.id] = named + } + + return out +} + +/** Replaces one question's names; all blank clears them. */ +export function setLabels( + labels: AnswerLabels, + question: LabeledQuestion, + next: QuestionLabels, +): AnswerLabels { + const named = tidy(next, question.kind) + const out = { ...labels, [question.id]: named } + if (Object.keys(named).length === 0) delete out[question.id] + + return out +} + +/** The question's short name, or its letter until it has one. */ +export function shortName( + question: LabeledQuestion, + labels: AnswerLabels, +): string { + return labels[question.id]?.short ?? question.letter +} + +/** + * An answer as an answer pill reads it: the answer itself, or a tick-box's own + * word for it once one is set. + */ +export function optionText( + question: LabeledQuestion, + answer: string, + labels: AnswerLabels, +): string { + if (question.kind !== "bool") return answer + const named = labels[question.id] + const word = + answer === "Yes" ? named?.yes : answer === "No" ? named?.no : undefined + + return word ?? answer +} + +/** + * An answer as a card or a filter tag reads it. + * + * `size: S` — the short name (or letter) and the answer. A tick-box has no + * letter, so it reads as its own word for that answer — `remote` — or as + * plain `Yes` or `No` until it has one. + */ +export function answerText( + question: LabeledQuestion, + answer: string, + labels: AnswerLabels, +): string { + if (question.kind === "bool") return optionText(question, answer, labels) + + return `${shortName(question, labels)}: ${answer}` +} diff --git a/components/frontend/src/lib/utils/teamAssignmentCsv.test.ts b/components/frontend/src/lib/utils/teamAssignmentCsv.test.ts index d8d288ef..4a8f1c4c 100644 --- a/components/frontend/src/lib/utils/teamAssignmentCsv.test.ts +++ b/components/frontend/src/lib/utils/teamAssignmentCsv.test.ts @@ -15,9 +15,9 @@ const QUESTIONS = [ const row = (over: Partial = {}): AssignmentRow => ({ userId: "u1", name: "Alice Doe", - project: "Vision Pipeline", + project: "1", team: "Team VP", - prefers: ["Vision Pipeline"], + prefers: ["1"], answers: { experience: "Many", tshirt: "M" }, ...over, }) @@ -37,8 +37,8 @@ const world = (over: Partial = {}): ImportWorld => ({ { id: "u2", name: "Bob Smith" }, ], projects: [ - { id: "p1", title: "Vision Pipeline" }, - { id: "p2", title: "Chat Agent" }, + { id: "p1", title: "Vision Pipeline", number: 1 }, + { id: "p2", title: "Chat Agent", number: 2 }, ], teams: [team({ memberIds: ["u1"] })], ...over, @@ -55,15 +55,13 @@ describe("assignmentCsv", () => { it("writes a person's row, preferences joined and answers in column order", () => { const [, first] = assignmentCsv( - [row({ prefers: ["Vision Pipeline", "Chat Agent"] })], + [row({ prefers: ["1", "2"] })], QUESTIONS, ).split("\r\n") // Unquoted: a semicolon is an ordinary character in a comma-separated // file, and the header is what a reader sniffs the delimiter from. - expect(first).toBe( - "u1,Alice Doe,Vision Pipeline,Team VP,Vision Pipeline; Chat Agent,Many,M", - ) + expect(first).toBe("u1,Alice Doe,1,Team VP,1; 2,Many,M") }) it("leaves an unanswered question's cell empty", () => { @@ -79,238 +77,248 @@ describe("assignmentCsv", () => { "\r\n", ) - expect(first).toBe("u1,Alice Doe,,,Vision Pipeline") + expect(first).toBe("u1,Alice Doe,,,1") }) }) describe("applyAssignmentCsv", () => { const file = (...lines: string[]) => ["user_id,name,project,team", ...lines].join("\r\n") + "\r\n" + const opts = { max: 6 } + + // Bob as the download writes him while he has no team. + const BOB_FREE = "u2,Bob Smith,," + + const shape = (teams: PlannedTeam[]) => + teams.map((t) => [t.projectId, t.name, t.memberIds]) - it("is a no-op on a file that says what the workspace already says", () => { + it("reads the project as the number the page shows", () => { const result = applyAssignmentCsv( - file("u1,Alice Doe,Vision Pipeline,Team VP"), + file("u1,Alice Doe,2,Team CA", "u2,Bob Smith,02,Team CA"), world(), - { max: 6 }, + opts, ) - expect(result.moved).toBe(0) - expect(result.problems).toEqual([]) - expect(result.teams).toEqual(world().teams) + expect(result.warnings).toEqual([]) + expect(shape(result.teams)).toEqual([["p2", "Team CA", ["u1", "u2"]]]) }) - it("moves somebody onto a team that already exists", () => { + it("takes any team name, even a single character", () => { const result = applyAssignmentCsv( - file("u2,Bob Smith,Vision Pipeline,Team VP"), + file("u1,Alice Doe,1,A", "u2,Bob Smith,1,7"), world(), - { max: 6 }, + opts, ) - expect(result.teams[0]?.memberIds).toEqual(["u1", "u2"]) - expect(result.moved).toBe(1) - expect(result.read).toBe(1) + expect(result.warnings).toEqual([]) + expect(shape(result.teams)).toEqual([ + ["p1", "A", ["u1"]], + ["p1", "7", ["u2"]], + ]) }) - it("unassigns on a blank team, whatever the project says", () => { + it("rebuilds the download, unedited, without a warning", () => { const result = applyAssignmentCsv( - file("u1,Alice Doe,Vision Pipeline,"), + file("u1,Alice Doe,1,Team VP", BOB_FREE), world(), - { max: 6 }, + opts, ) - expect(result.teams[0]?.memberIds).toEqual([]) - expect(result.moved).toBe(1) + expect(result.refused).toBe(false) + expect(result.warnings).toEqual([]) + expect(shape(result.teams)).toEqual([["p1", "Team VP", ["u1"]]]) + expect(result.assigned).toBe(1) + expect(result.unassigned).toBe(1) }) - it("creates a team the file names but the workspace does not hold", () => { + it("replaces every team with a new one, even under the same name", () => { const result = applyAssignmentCsv( - file("u2,Bob Smith,Chat Agent,Team CA"), + file("u1,Alice Doe,1,Team VP", BOB_FREE), world(), - { max: 6 }, + opts, ) - expect(result.created).toEqual(["Team CA"]) - expect(result.teams[1]).toEqual({ - key: "csv-0", - id: null, - projectId: "p2", - name: "Team CA", - memberIds: ["u2"], - }) + expect(result.teams).toEqual([ + { + key: "csv-0", + id: null, + projectId: "p1", + name: "Team VP", + memberIds: ["u1"], + }, + ]) }) - it("hands out a key no second import can collide with", () => { - const once = applyAssignmentCsv( - file("u2,Bob Smith,Chat Agent,Team CA"), + it("puts people with the same project and team together, however cased", () => { + const result = applyAssignmentCsv( + file("u1,Alice Doe,1,Team VP", "u2,Bob Smith,1,TEAM VP"), world(), - { max: 6 }, - ) - const twice = applyAssignmentCsv( - file("u1,Alice Doe,Chat Agent,Team CA 2"), - world({ teams: once.teams }), - { max: 6 }, + opts, ) - expect(twice.teams.map((t) => t.key)).toEqual(["t1", "csv-0", "csv-1"]) + expect(shape(result.teams)).toEqual([["p1", "Team VP", ["u1", "u2"]]]) }) - it("matches a project and a team however they are cased", () => { + it("keeps one name on two projects as two teams", () => { const result = applyAssignmentCsv( - file("u2,Bob Smith,vision pipeline,TEAM VP"), + file("u1,Alice Doe,1,Blue", "u2,Bob Smith,2,Blue"), world(), - { max: 6 }, + opts, ) - expect(result.created).toEqual([]) - expect(result.teams[0]?.memberIds).toEqual(["u1", "u2"]) + expect(shape(result.teams)).toEqual([ + ["p1", "Blue", ["u1"]], + ["p2", "Blue", ["u2"]], + ]) }) - it("leaves anybody the file does not mention exactly as they are", () => { + it("leaves nobody on a team the file does not name", () => { const result = applyAssignmentCsv( - file("u2,Bob Smith,Chat Agent,Team CA"), + file("u1,Alice Doe,,", BOB_FREE), world(), - { max: 6 }, + opts, ) - expect(result.teams[0]?.memberIds).toEqual(["u1"]) - expect(result.absent).toBe(1) - }) - - it("never deletes a team, even one the file empties", () => { - const result = applyAssignmentCsv(file("u1,Alice Doe,,"), world(), { - max: 6, - }) - - expect(result.teams).toHaveLength(1) - expect(result.teams[0]?.memberIds).toEqual([]) + expect(result.teams).toEqual([]) + expect(result.warnings).toEqual([]) + expect(result.unassigned).toBe(2) }) it("reads the columns by name, not by position", () => { const result = applyAssignmentCsv( - "team,notes,USER_ID,project\r\nTeam VP,anything,u2,Vision Pipeline\r\n", + "team,notes,USER_ID,project\r\n" + + "Team VP,anything,u1,1\r\n" + + "Team VP,,u2,1\r\n", world(), - { max: 6 }, + opts, ) - expect(result.problems).toEqual([]) - expect(result.teams[0]?.memberIds).toEqual(["u1", "u2"]) + expect(result.warnings).toEqual([]) + expect(shape(result.teams)).toEqual([["p1", "Team VP", ["u1", "u2"]]]) }) - it("says which teams are now too big without refusing them", () => { + it("says which teams are too big without refusing them", () => { const crowd = Array.from({ length: 7 }, (_, i) => ({ id: `x${i}`, name: `Person ${i}`, })) const result = applyAssignmentCsv( ["user_id,name,project,team"] - .concat(crowd.map((p) => `${p.id},${p.name},Vision Pipeline,Team VP`)) + .concat(crowd.map((p) => `${p.id},${p.name},1,Team VP`)) .join("\r\n"), - world({ people: crowd, teams: [team()] }), - { max: 6 }, + world({ people: crowd, teams: [] }), + opts, ) expect(result.oversized).toEqual(["Team VP"]) - expect(result.teams[0]?.memberIds).toHaveLength(7) }) - describe("what it refuses", () => { - it("an empty file", () => { - const result = applyAssignmentCsv("", world(), { max: 6 }) + describe("warns, and leaves the person unassigned, over", () => { + // Alice is on Team VP before every upload here, so "unassigned" is a + // change, not a coincidence. + const warned = (...lines: string[]) => { + const result = applyAssignmentCsv(file(...lines), world(), opts) - expect(result.problems).toEqual(["That file is empty."]) - expect(result.teams).toEqual(world().teams) - }) + expect(result.refused).toBe(false) + expect(result.teams.flatMap((t) => t.memberIds)).not.toContain("u1") - it("a file with none of the columns it reads", () => { - const result = applyAssignmentCsv( - "name,email\r\nAlice,a@example.com\r\n", - world(), - { max: 6 }, - ) + return result.warnings + } - expect(result.problems[0]).toContain("needs a user_id") - expect(result.read).toBe(0) + it("a project with no team", () => { + expect(warned("u1,Alice Doe,1,", BOB_FREE)).toEqual([ + "Row 2: Alice Doe has a project but no team, and is left unassigned.", + ]) }) - it("a row for somebody this page cannot place, naming them", () => { - const result = applyAssignmentCsv( - file("u9,Carol Jones,Vision Pipeline,Team VP"), - world(), - { max: 6 }, - ) - - expect(result.problems).toEqual([ - "Row 2: Carol Jones is not somebody this page can place.", + it("a team with no project", () => { + expect(warned("u1,Alice Doe,,Team VP", BOB_FREE)).toEqual([ + "Row 2: Alice Doe has a team but no project, and is left unassigned.", ]) - expect(result.moved).toBe(0) }) - it("a team on a project that is not on this page", () => { - const result = applyAssignmentCsv( - file("u2,Bob Smith,Weather Bot,Team WB"), - world(), - { max: 6 }, - ) - - expect(result.problems).toEqual([ - 'Row 2: no project on this page is called "Weather Bot".', + it("a project number that is not on this page", () => { + expect(warned("u1,Alice Doe,7,Team X", BOB_FREE)).toEqual([ + 'Row 2: "7" is not a project number on this page, so Alice Doe is left unassigned.', ]) - expect(result.created).toEqual([]) }) - it("a team with no project to put it on", () => { - const result = applyAssignmentCsv( - file("u2,Bob Smith,,Team CA"), - world(), - { max: 6 }, - ) + it("a project title instead of its number", () => { + expect(warned("u1,Alice Doe,Vision Pipeline,Team VP", BOB_FREE)).toEqual([ + 'Row 2: "Vision Pipeline" is not a project number on this page, so Alice Doe is left unassigned.', + ]) + }) - expect(result.problems).toEqual([ - 'Row 2: Bob Smith is on "Team CA", but no project says which.', + it("a second row for the same person, using neither", () => { + expect( + warned("u1,Alice Doe,1,Team VP", BOB_FREE, "u1,Alice Doe,2,Team CA"), + ).toEqual([ + "Row 4: Alice Doe appears more than once, and is left unassigned.", ]) }) - it("a second row for the same person, keeping the first", () => { - const result = applyAssignmentCsv( - file( - "u2,Bob Smith,Vision Pipeline,Team VP", - "u2,Bob Smith,Chat Agent,Team CA", - ), - world(), - { max: 6 }, - ) + it("no row at all, in one line for everyone missing", () => { + expect(warned(BOB_FREE)).toEqual([ + "1 person has no row in the file and is left unassigned: Alice Doe.", + ]) + }) - expect(result.problems).toEqual([ - "Row 3: Bob Smith appears more than once; the first won.", + it("a truncated row, whose person then counts as missing", () => { + expect(warned("u1,Alice Doe", BOB_FREE)).toEqual([ + "Row 2: too few columns to read.", + "1 person has no row in the file and is left unassigned: Alice Doe.", ]) - expect(result.teams[0]?.memberIds).toEqual(["u1", "u2"]) - expect(result.created).toEqual([]) }) + }) - it("a truncated row, rather than reading it as an unassignment", () => { - const result = applyAssignmentCsv( - "user_id,name,project,team\r\nu1,Alice Doe\r\n", - world(), - { max: 6 }, - ) + it("names a row for somebody it cannot place, and applies the rest", () => { + const result = applyAssignmentCsv( + file("u1,Alice Doe,1,Team VP", BOB_FREE, "u9,Carol Jones,1,Team VP"), + world(), + opts, + ) + + expect(result.warnings).toEqual([ + "Row 4: Carol Jones is not somebody this page can place.", + ]) + expect(shape(result.teams)).toEqual([["p1", "Team VP", ["u1"]]]) + }) - expect(result.problems).toEqual(["Row 2: too few columns to read."]) - expect(result.teams[0]?.memberIds).toEqual(["u1"]) + it("lists many missing people in one line", () => { + const crowd = Array.from({ length: 5 }, (_, i) => ({ + id: `x${i}`, + name: `Person ${i}`, + })) + const result = applyAssignmentCsv( + file("x0,Person 0,,"), + world({ people: crowd, teams: [] }), + opts, + ) + + expect(result.warnings).toEqual([ + "4 people have no row in the file and are left unassigned: Person 1, " + + "Person 2, Person 3 and 1 more.", + ]) + }) + + describe("refuses, changing nothing, only a file it cannot read", () => { + const refused = (text: string) => { + const result = applyAssignmentCsv(text, world(), opts) + + expect(result.refused).toBe(true) + expect(result.teams).toEqual(world().teams) + + return result.problems + } + + it("an empty file", () => { + expect(refused("")).toEqual(["That file is empty."]) }) - it("one bad row without losing the good ones around it", () => { - const result = applyAssignmentCsv( - file( - "u9,Carol Jones,Vision Pipeline,Team VP", - "u2,Bob Smith,Vision Pipeline,Team VP", - ), - world(), - { max: 6 }, + it("a file without the columns it reads", () => { + expect(refused("name,email\r\nAlice,a@example.com\r\n")[0]).toContain( + "needs a user_id", ) - - expect(result.problems).toHaveLength(1) - expect(result.read).toBe(1) - expect(result.teams[0]?.memberIds).toEqual(["u1", "u2"]) }) }) }) diff --git a/components/frontend/src/lib/utils/teamAssignmentCsv.ts b/components/frontend/src/lib/utils/teamAssignmentCsv.ts index ca300f69..37da359a 100644 --- a/components/frontend/src/lib/utils/teamAssignmentCsv.ts +++ b/components/frontend/src/lib/utils/teamAssignmentCsv.ts @@ -38,11 +38,14 @@ export interface AssignmentQuestion { export interface AssignmentRow { userId: string name: string - /** The project their team belongs to; empty when they are unassigned. */ + /** + * The number of the project their team belongs to, as the page shows it; + * empty when they are unassigned. + */ project: string /** Their team's name; empty when they are unassigned. */ team: string - /** The projects they said they wanted, as titles. */ + /** The projects they said they wanted, as the page's numbers. */ prefers: string[] /** Their answer, by question key. A question they skipped is absent. */ answers: Record @@ -56,6 +59,10 @@ export interface AssignmentRow { * is what there is least of and an answer is a two-character code. Here there is * a whole column, so spelling it out costs nothing and reads better. * + * Projects go out as the numbers the page shows — in `project` and in + * `prefers` alike — because that is what an organizer types and compares + * against the screen. + * * `prefers` joins with `; ` rather than `, ` so the cell stays legible in a * spreadsheet that has just been told the file is comma-separated. */ @@ -93,71 +100,74 @@ export function assignmentCsv( export interface ImportWorld { /** Everybody this page can put on a team. */ people: readonly { id: string; name: string }[] - /** The projects with a row on the page — approved ones, by title. */ - projects: readonly { id: string; title: string }[] + /** The projects with a row on the page, with the number each row shows. */ + projects: readonly { id: string; title: string; number: number }[] /** The workspace as it stands. Copied, never mutated. */ teams: readonly PlannedTeam[] } export interface ImportResult { - /** The workspace the file asks for. */ + /** The teams the file describes — the ones it was given, if refused. */ teams: PlannedTeam[] - /** Rows naming somebody this page can place. */ - read: number - /** People whose team the file changed. */ - moved: number - /** People the file said nothing about, and who were therefore left alone. */ - absent: number - /** Teams the file named that did not exist yet. */ - created: string[] + /** True when the file could not be read at all, so nothing was applied. */ + refused: boolean + /** People the file put on a team. */ + assigned: number + /** People who end up on no team. */ + unassigned: number /** Teams left holding more than `max`. */ oversized: string[] - /** What could not be read, one line each, in the order it was met. */ + /** Why the file could not be read. Empty unless `refused`. */ problems: string[] + /** + * Rows that could not be used, and people with no row — each of them ends up + * unassigned. One line each, in the order met; missing people last, together. + */ + warnings: string[] } /** - * Read an edited file back onto the workspace. + * Read an edited file back as the whole team assignment. + * + * **The file replaces every team.** The teams it names are the teams there + * are afterwards, each a new one; whatever was on the page before is gone once + * saved. An organizer who wants a name changed renames the team on the page. * - * Four rules, and they are all about doing as little as the file actually asks: + * **An assignment is a person, a project and a team.** The project is the + * number the page shows — nothing else counts; the team is any name at all, + * even one character, and the same name on the same project is the same team. * - * 1. **A blank `team` unassigns.** That is how somebody is taken off a team, - * and it is why `project` is only consulted when `team` says something. - * 2. **A team named but not found is created**, exactly as `+ Add Team` would. - * Which is also why a *rename* cannot be expressed here: the old name is - * simply a team nobody is on any more. Rename on screen. - * 3. **A row that is not in the file changes nothing.** The file speaks about - * the rows it contains and about no others. - * 4. **Nothing is deleted.** A team everybody left stays, empty, for the - * organizer to delete on screen if they meant to. + * Anything short of that is not an assignment, and the person **ends up + * unassigned** — never left wherever they were, because the file replaces + * everything: * - * Together those make the import strictly non-destructive, which is what an - * escape hatch should be: worst case it moves people, and the workspace it - * returns is unsaved like every other edit on that page. + * - a row with neither project nor team: that is simply how the download writes + * somebody without a team, so it is not worth a warning; + * - a row with a mistake in it — one of the two missing, a project that is not + * one of the page's numbers, a second row for the same person — is warned + * about; + * - somebody with no row at all is warned about too: most likely they joined + * after the download, but a trimmed file looks the same. * - * A row this cannot read is reported and skipped — one bad line does not cost - * the other ninety-nine. Row numbers are the spreadsheet's, counting the header - * as row 1. + * Only a file that cannot be read as an assignment at all — empty, or without + * the columns it needs — is refused. Everything else is applied and left + * unsaved, so Discard undoes it. Row numbers are the spreadsheet's, counting + * the header as row 1. */ export function applyAssignmentCsv( text: string, world: ImportWorld, { max }: { max: number }, ): ImportResult { - const teams: PlannedTeam[] = world.teams.map((t) => ({ - ...t, - memberIds: [...t.memberIds], - })) const problems: string[] = [] - const created: string[] = [] const refuse = (): ImportResult => ({ - teams, - read: 0, - moved: 0, - absent: world.people.length, - created, + teams: world.teams.map((t) => ({ ...t, memberIds: [...t.memberIds] })), + refused: true, + assigned: 0, + unassigned: 0, oversized: [], problems, + warnings: [], }) const rows = parseCsv(text) @@ -185,119 +195,120 @@ export function applyAssignmentCsv( const widthNeeded = Math.max(idAt, projectAt, teamAt) const nameById = new Map(world.people.map((p) => [p.id, p.name])) - const projectByTitle = new Map( - world.projects.map((p) => [p.title.trim().toLowerCase(), p]), + const projectByNumber = new Map( + world.projects.map((p) => [String(p.number), p]), ) - const usedKeys = new Set(teams.map((t) => t.key)) - const teamOf = (list: readonly PlannedTeam[]) => { - const where = new Map() - for (const t of list) for (const m of t.memberIds) where.set(m, t.key) - - return where - } - const before = teamOf(teams) + const warnings: string[] = [] + /** Who goes where, by user id, for every row that is a usable assignment. */ + const plan = new Map() + /** Everyone with a row, usable or not — so not also reported as missing. */ const seen = new Set() - let read = 0 for (let i = 1; i < rows.length; i++) { const row = rows[i] ?? [] const line = i + 1 const cell = (n: number) => (row[n] ?? "").trim() - // Genuinely truncated, not merely blank at the end: without this a row that - // lost its team column would read as "unassign them", silently. + // Genuinely truncated, not merely blank at the end. Whoever it was meant + // for has no usable row, and is reported as missing below. if (row.length <= widthNeeded) { - problems.push(`Row ${line}: too few columns to read.`) + warnings.push(`Row ${line}: too few columns to read.`) continue } const userId = cell(idAt) - if (userId === "") { - problems.push(`Row ${line}: no ${COLUMNS.userId}.`) - continue - } - const who = nameById.get(userId) if (who === undefined) { const named = (nameAt === -1 ? "" : cell(nameAt)) || userId - problems.push( - `Row ${line}: ${named} is not somebody this page can place.`, + warnings.push( + named === "" + ? `Row ${line}: no ${COLUMNS.userId}.` + : `Row ${line}: ${named} is not somebody this page can place.`, ) continue } if (seen.has(userId)) { - problems.push( - `Row ${line}: ${who} appears more than once; the first won.`, + // Which of the two was meant is a guess, so neither is used. + plan.delete(userId) + warnings.push( + `Row ${line}: ${who} appears more than once, and is left unassigned.`, ) continue } seen.add(userId) - let target: PlannedTeam | undefined + const named = cell(projectAt) const teamName = cell(teamAt) - if (teamName !== "") { - const title = cell(projectAt) - if (title === "") { - problems.push( - `Row ${line}: ${who} is on "${teamName}", but no ${COLUMNS.project} says which.`, - ) - continue - } - const project = projectByTitle.get(title.toLowerCase()) - if (project === undefined) { - problems.push( - `Row ${line}: no project on this page is called "${title}".`, - ) - continue - } - - target = teams.find( - (t) => - t.projectId === project.id && - t.name.toLowerCase() === teamName.toLowerCase(), + if (named === "" && teamName === "") continue + if (teamName === "") { + warnings.push( + `Row ${line}: ${who} has a ${COLUMNS.project} but no ${COLUMNS.team}, and is left unassigned.`, ) - if (target === undefined) { - // Past any key the workspace already holds, so a second import onto the - // result of a first cannot hand out a key that is in use. - let n = 0 - while (usedKeys.has(`csv-${n}`)) n++ - usedKeys.add(`csv-${n}`) - target = { - key: `csv-${n}`, - id: null, - projectId: project.id, - name: teamName, - memberIds: [], - } - teams.push(target) - created.push(teamName) - } + continue + } + if (named === "") { + warnings.push( + `Row ${line}: ${who} has a ${COLUMNS.team} but no ${COLUMNS.project}, and is left unassigned.`, + ) + continue } - read++ - // Leave before joining, so nobody is briefly on two teams and an unchanged - // row is a no-op rather than a duplicate. - for (const t of teams) { - const held = t.memberIds.indexOf(userId) - if (held !== -1) t.memberIds.splice(held, 1) + // A project number as the page shows it, and nothing else: a title, a + // typo or a number no project has is not an assignment. + const project = /^\d+$/.test(named) + ? projectByNumber.get(String(Number(named))) + : undefined + if (project === undefined) { + warnings.push( + `Row ${line}: "${named}" is not a project number on this page, so ${who} is left unassigned.`, + ) + continue } - target?.memberIds.push(userId) + + plan.set(userId, { projectId: project.id, team: teamName }) } - const after = teamOf(teams) - let moved = 0 - for (const id of new Set([...before.keys(), ...after.keys()])) { - if (before.get(id) !== after.get(id)) moved++ + // One line for everyone missing: a cut-down file is missing dozens, not one. + const missing = world.people.filter((p) => !seen.has(p.id)) + if (missing.length > 0) { + const shown = missing.slice(0, 3).map((p) => p.name) + const rest = missing.length - shown.length + warnings.push( + `${missing.length} ${missing.length === 1 ? "person has" : "people have"} ` + + `no row in the file and ${missing.length === 1 ? "is" : "are"} left ` + + `unassigned: ${shown.join(", ")}${rest > 0 ? ` and ${rest} more` : ""}.`, + ) + } + + // Every team is new: the file replaces what was there, it does not edit it. + const teams: PlannedTeam[] = [] + for (const [userId, assignment] of plan) { + let target = teams.find( + (t) => + t.projectId === assignment.projectId && + t.name.toLowerCase() === assignment.team.toLowerCase(), + ) + if (target === undefined) { + target = { + key: `csv-${teams.length}`, + id: null, + projectId: assignment.projectId, + name: assignment.team, + memberIds: [], + } + teams.push(target) + } + target.memberIds.push(userId) } return { teams, - read, - moved, - absent: world.people.filter((p) => !seen.has(p.id)).length, - created, + refused: false, + assigned: plan.size, + unassigned: world.people.length - plan.size, oversized: teams.filter((t) => t.memberIds.length > max).map((t) => t.name), problems, + warnings, } } 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/lib/utils/teamPoolFilter.test.ts b/components/frontend/src/lib/utils/teamPoolFilter.test.ts new file mode 100644 index 00000000..f52e32c5 --- /dev/null +++ b/components/frontend/src/lib/utils/teamPoolFilter.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect } from "vitest" +import { + NO_FILTERS, + countAnswers, + countPreferences, + isFiltering, + matches, + matchesFirst, + restoreFilters, + setText, + toggleAnswer, + toggleProject, + type FilterQuestion, + type PoolFilters, +} from "./teamPoolFilter" + +const QUESTIONS: FilterQuestion[] = [ + { + id: "exp", + kind: "enum", + options: [{ label: "First time" }, { label: "A few" }, { label: "Many" }], + }, + { id: "remote", kind: "bool", options: [{ label: "Yes" }, { label: "No" }] }, + { id: "skills", kind: "text", options: [] }, +] + +const PROJECTS = ["p1", "p2"] + +const person = ( + id: string, + codes: Record = {}, + preferredProjectIds: string[] = [], +) => ({ + id, + codes: Object.fromEntries( + Object.entries(codes).map(([q, label]) => [q, { label }]), + ), + preferredProjectIds, +}) + +const filters = (over: Partial = {}): PoolFilters => ({ + ...NO_FILTERS, + ...over, +}) + +describe("matches", () => { + it("lets everyone through when nothing is picked", () => { + expect(matches(person("a"), NO_FILTERS)).toBe(true) + }) + + it("widens within one question", () => { + const f = filters({ answers: { exp: ["First time", "A few"] } }) + + expect(matches(person("a", { exp: "First time" }), f)).toBe(true) + expect(matches(person("b", { exp: "A few" }), f)).toBe(true) + expect(matches(person("c", { exp: "Many" }), f)).toBe(false) + }) + + it("narrows across questions", () => { + const f = filters({ + answers: { exp: ["A few"], remote: ["Yes"] }, + }) + + expect(matches(person("a", { exp: "A few", remote: "Yes" }), f)).toBe(true) + expect(matches(person("b", { exp: "A few", remote: "No" }), f)).toBe(false) + }) + + it("does not let through someone who skipped a filtered question", () => { + const f = filters({ answers: { exp: ["Many"] } }) + + expect(matches(person("a"), f)).toBe(false) + }) + + it("widens within the picked projects", () => { + const f = filters({ projects: ["p1", "p2"] }) + + expect(matches(person("a", {}, ["p1"]), f)).toBe(true) + expect(matches(person("b", {}, ["p2", "p3"]), f)).toBe(true) + expect(matches(person("c", {}, ["p3"]), f)).toBe(false) + expect(matches(person("d"), f)).toBe(false) + }) + + it("narrows projects against answers", () => { + const f = filters({ projects: ["p1"], answers: { exp: ["Many"] } }) + + expect(matches(person("a", { exp: "Many" }, ["p1"]), f)).toBe(true) + expect(matches(person("b", { exp: "A few" }, ["p1"]), f)).toBe(false) + expect(matches(person("c", { exp: "Many" }, ["p2"]), f)).toBe(false) + }) + + it("matches free text ignoring case and the spaces around it", () => { + const f = filters({ texts: { skills: " PYTHON " } }) + + expect(matches(person("a", { skills: "Python, design" }), f)).toBe(true) + expect(matches(person("b", { skills: "Design" }), f)).toBe(false) + expect(matches(person("c"), f)).toBe(false) + }) +}) + +describe("matchesFirst", () => { + it("splits the pool and keeps each part in its order", () => { + const pool = [ + person("a", { exp: "Many" }), + person("b", { exp: "First time" }), + person("c", { exp: "Many" }), + person("d"), + ] + const { matching, rest } = matchesFirst( + pool, + filters({ answers: { exp: ["Many"] } }), + ) + + expect(matching.map((p) => p.id)).toEqual(["a", "c"]) + expect(rest.map((p) => p.id)).toEqual(["b", "d"]) + }) +}) + +describe("toggleAnswer", () => { + it("picks, then unpicks, and leaves no empty question behind", () => { + const once = toggleAnswer(NO_FILTERS, "exp", "Many") + expect(once.answers).toEqual({ exp: ["Many"] }) + + const twice = toggleAnswer(once, "exp", "Many") + expect(twice.answers).toEqual({}) + expect(isFiltering(twice)).toBe(false) + }) + + it("does not change what it was given", () => { + const before = filters({ answers: { exp: ["Many"] } }) + toggleAnswer(before, "exp", "A few") + + expect(before.answers).toEqual({ exp: ["Many"] }) + }) +}) + +describe("setText", () => { + it("removes a filter whose text is blank", () => { + const on = setText(NO_FILTERS, "skills", "py") + expect(on.texts).toEqual({ skills: "py" }) + + expect(setText(on, "skills", " ").texts).toEqual({}) + }) +}) + +describe("restoreFilters", () => { + it("keeps what still means something", () => { + const stored = { + answers: { exp: ["Many"], remote: ["Yes"] }, + texts: { skills: "py" }, + projects: ["p1"], + } + + expect(restoreFilters(stored, QUESTIONS, PROJECTS)).toEqual(stored) + }) + + it("drops questions, answers and projects that no longer exist", () => { + const stored = { + answers: { exp: ["Many", "Wizard"], gone: ["x"], remote: ["Maybe"] }, + texts: { skills: " ", alsoGone: "py" }, + projects: ["p2", "unapproved", 7], + } + + expect(restoreFilters(stored, QUESTIONS, PROJECTS)).toEqual({ + answers: { exp: ["Many"] }, + texts: {}, + projects: ["p2"], + }) + }) + + it("does not take a free-text question's answers as picks, or the reverse", () => { + const stored = { + answers: { skills: ["py"] }, + texts: { exp: "Many" }, + } + + expect(restoreFilters(stored, QUESTIONS, PROJECTS)).toEqual(NO_FILTERS) + }) + + it("reads filters saved before projects could be picked", () => { + const stored = { answers: { exp: ["Many"] }, texts: {} } + + expect(restoreFilters(stored, QUESTIONS, PROJECTS)).toEqual( + filters({ answers: { exp: ["Many"] } }), + ) + }) + + it.each([ + null, + "nonsense", + 42, + [], + { answers: "x", texts: [], projects: "p1" }, + ])("reads %j as no filter", (stored) => { + expect(restoreFilters(stored, QUESTIONS, PROJECTS)).toEqual(NO_FILTERS) + }) +}) + +describe("toggleProject", () => { + it("picks, then unpicks", () => { + const once = toggleProject(NO_FILTERS, "p1") + expect(once.projects).toEqual(["p1"]) + expect(isFiltering(once)).toBe(true) + + expect(toggleProject(once, "p1").projects).toEqual([]) + }) +}) + +describe("countPreferences", () => { + it("counts each person once per project they picked", () => { + const pool = [ + person("a", {}, ["p1", "p2"]), + person("b", {}, ["p1", "p1"]), + person("c"), + ] + + expect(countPreferences(pool)).toEqual({ p1: 2, p2: 1 }) + }) +}) + +describe("countAnswers", () => { + it("counts each answer by question", () => { + const pool = [ + person("a", { exp: "Many", remote: "Yes" }), + person("b", { exp: "Many" }), + person("c", { exp: "A few" }), + ] + + expect(countAnswers(pool)).toEqual({ + exp: { Many: 2, "A few": 1 }, + remote: { Yes: 1 }, + }) + }) +}) diff --git a/components/frontend/src/lib/utils/teamPoolFilter.ts b/components/frontend/src/lib/utils/teamPoolFilter.ts new file mode 100644 index 00000000..1dacb9e7 --- /dev/null +++ b/components/frontend/src/lib/utils/teamPoolFilter.ts @@ -0,0 +1,224 @@ +import type { QuestionKind } from "./question" + +/** + * Narrowing the team-assignment pool by what people answered at registration. + * + * Kept out of the page so it can be tested directly: the page only renders + * what this decides. + * + * **One rule for combining:** answers to one question widen (B1 or B2), + * different questions narrow (B2 and "python"). That is what "people who said + * Beginner or Intermediate, and who are remote" means when said aloud. + * + * A filter reorders rather than hides — see `matchesFirst`. + */ + +export type PoolFilters = { + /** Picked answers, by question id. A question with none picked is absent. */ + answers: Record + /** "Contains" text for free-text questions, by question id. Never blank. */ + texts: Record + /** + * Picked projects, by id: people who prefer any of them match. Treated as + * one more question — "which projects do you want" — so it widens within + * itself and narrows against the answers. + */ + projects: string[] +} + +export const NO_FILTERS: PoolFilters = { answers: {}, texts: {}, projects: [] } + +/** A question as far as filtering needs it. */ +export interface FilterQuestion { + id: string + kind: QuestionKind + options: readonly { label: string }[] +} + +/** A person as far as filtering needs it. */ +export interface FilterablePerson { + /** Their answers, by question id. */ + codes: Record + /** The projects they said they want. */ + preferredProjectIds: readonly string[] +} + +/** + * Filters as read back from storage, keeping only what still means something. + * + * 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. Anything naming a question, an answer or a project that no longer + * exists is dropped, and anything that is not ours at all reads as no filter. + */ +export function restoreFilters( + stored: unknown, + questions: readonly FilterQuestion[], + projectIds: readonly string[], +): PoolFilters { + const byId = new Map(questions.map((q) => [q.id, q])) + const record = (v: unknown): Record => + typeof v === "object" && v !== null && !Array.isArray(v) + ? (v as Record) + : {} + const s = record(stored) + + const answers: PoolFilters["answers"] = {} + for (const [id, labels] of Object.entries(record(s.answers))) { + const q = byId.get(id) + if (!q || q.kind === "text" || !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] = [...new Set(known)] + } + + const texts: PoolFilters["texts"] = {} + for (const [id, text] of Object.entries(record(s.texts))) { + if ( + byId.get(id)?.kind === "text" && + typeof text === "string" && + text.trim() !== "" + ) { + texts[id] = text + } + } + + const known = new Set(projectIds) + const projects = Array.isArray(s.projects) + ? [ + ...new Set( + s.projects.filter( + (id): id is string => typeof id === "string" && known.has(id), + ), + ), + ] + : [] + + return { answers, texts, projects } +} + +/** Picks a project if it is not picked, and unpicks it if it is. */ +export function toggleProject( + filters: PoolFilters, + projectId: string, +): PoolFilters { + return { + ...filters, + projects: filters.projects.includes(projectId) + ? filters.projects.filter((id) => id !== projectId) + : [...filters.projects, projectId], + } +} + +/** Picks an answer if it is not picked, and unpicks it if it is. */ +export function toggleAnswer( + filters: PoolFilters, + questionId: string, + label: string, +): PoolFilters { + const picked = filters.answers[questionId] ?? [] + const next = picked.includes(label) + ? picked.filter((l) => l !== label) + : [...picked, label] + const answers = { ...filters.answers, [questionId]: next } + if (next.length === 0) delete answers[questionId] + + return { ...filters, answers } +} + +/** Sets a free-text question's "contains" text; blank removes it. */ +export function setText( + filters: PoolFilters, + questionId: string, + text: string, +): PoolFilters { + const texts = { ...filters.texts, [questionId]: text } + if (text.trim() === "") delete texts[questionId] + + return { ...filters, texts } +} + +export function isFiltering(filters: PoolFilters): boolean { + return ( + Object.keys(filters.answers).length > 0 || + Object.keys(filters.texts).length > 0 || + filters.projects.length > 0 + ) +} + +/** + * Whether a person passes every filter. Free text matches ignoring case and + * the spaces around what was typed. + */ +export function matches( + person: FilterablePerson, + filters: PoolFilters, +): boolean { + if ( + filters.projects.length > 0 && + !filters.projects.some((id) => person.preferredProjectIds.includes(id)) + ) { + return false + } + 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 pool split into who matches and who does not, each in its original + * order. + * + * Reordering rather than hiding is deliberate. A hidden person is one that + * dragging back into the pool appears to lose, and a filter left on from + * yesterday reads as people having vanished. Everyone stays in reach; the + * filter only decides who is on top. + */ +export function matchesFirst( + people: readonly T[], + filters: PoolFilters, +): { matching: T[]; rest: T[] } { + const matching: T[] = [] + const rest: T[] = [] + for (const p of people) (matches(p, filters) ? matching : rest).push(p) + + return { matching, rest } +} + +/** How many people picked each project, by project id. */ +export function countPreferences( + people: readonly FilterablePerson[], +): Record { + const counts: Record = {} + for (const p of people) { + for (const id of new Set(p.preferredProjectIds)) { + counts[id] = (counts[id] ?? 0) + 1 + } + } + + return counts +} + +/** How many people gave each answer, by question id then answer text. */ +export function countAnswers( + people: readonly FilterablePerson[], +): Record> { + const counts: Record> = {} + for (const p of people) { + for (const [id, answer] of Object.entries(p.codes)) { + const byAnswer = (counts[id] ??= {}) + byAnswer[answer.label] = (byAnswer[answer.label] ?? 0) + 1 + } + } + + return counts +} 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..e0b86ab8 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 @@ -3,8 +3,9 @@ import { requireGrpc } from "$lib/server/grpc/client" import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" import { HackathonRole } from "$lib/server/grpc/generated/hackathon/entities/hackathon_role" import { ProjectStatus } from "$lib/server/grpc/generated/hackathon/entities/project_status" -import { participantRowFor } from "$lib/server/hackathon/membership" +import { numberedProjects } from "$lib/server/hackathon/projectNumbers" import { listAnswers } from "$lib/server/hackathon/questions" +import { assignmentLockReasons } from "$lib/server/hackathon/teamAssignmentLock" import { answerLegend, questionRows, @@ -35,6 +36,14 @@ export const load: PageServerLoad = async (event) => { const { teams } = await team.list({ hackathonId: event.params.id }) + // Every team, not only those on approved projects: a submission anywhere in + // the hackathon is one a save could take with it. + const lockReasons = await assignmentLockReasons( + team, + teams.map((t) => t.id), + hackathon.state, + ) + // Who prefers what, per project — same `Project:Write` permission this page // is already gated on, so no separate check is needed here. const { projects: preferences } = await project.exportPreferences({ @@ -59,13 +68,14 @@ export const load: PageServerLoad = async (event) => { // deleting it, which is the part that actually mattered. Nothing here can // create one — TODO(backend: team-create-requires-approved-project) is what // stops the API from doing so. - const rowProjects = preferences.filter( - (p) => p.status === ProjectStatus.PROJECT_STATUS_APPROVED, - ) - const numberByProjectId = new Map(rowProjects.map((p, i) => [p.id, i + 1])) + // + // Numbered by `numberedProjects`, which the spreadsheet download shares: the + // file carries these numbers and an upload reads them back. + const rowProjects = numberedProjects(preferences) + const numberByProjectId = new Map(rowProjects.map((p) => [p.id, p.number])) // 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 +117,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), @@ -139,9 +149,9 @@ export const load: PageServerLoad = async (event) => { teamsByProject.set(t.projectId, list) } - const projectRows = rowProjects.map((p, i) => ({ + const projectRows = rowProjects.map((p) => ({ id: p.id, - number: i + 1, + number: p.number, title: p.title, interested: p.preferences.length, teams: teamsByProject.get(p.id) ?? [], @@ -151,20 +161,12 @@ 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, - // Whether to explain the organiser's own absence from the pool. They hold no - // participant row unless they joined the hackathon the ordinary way, and - // `unassigned` is built from participant rows — so an organiser looking for - // their own name finds nothing, which reads as this page having lost them - // rather than as a state they are in. Stated, not offered: taking part is - // joining, and joining does not belong on a team-assignment screen. - ownerMissingFromPool: - isHackathonOwner && - participantRowFor(hackathon.members, event.locals.platformUser?.id) === - undefined, + // Why the assignment can no longer be changed; empty while it can. + lockReasons, } } @@ -183,7 +185,11 @@ export const actions: Actions = { // Distributing a hundred people is a few hundred sequential calls and takes a // noticeable moment; that is a backend gap, not a client one. save: async (event) => { - const { team, project } = requireGrpc(event.locals.grpc) + const { + team, + project, + hackathon: hackathonClient, + } = requireGrpc(event.locals.grpc) const form = await event.request.formData() const raw = form.get("teams") @@ -200,18 +206,26 @@ export const actions: Actions = { if (!Array.isArray(plan) || !plan.every(isPlannedTeam)) { return fail(400, { message: "Could not read the changes" }) } - if (plan.some((t) => t.name.trim().length < 3)) { - return fail(400, { - message: "Every team needs a name of at least 3 characters", - }) + // Any name will do, even "1" — the backend asks only that it is not empty. + if (plan.some((t) => t.name.trim() === "")) { + return fail(400, { message: "Every team needs a name" }) } try { const { teams: all } = await team.list({ hackathonId: event.params.id, }) - const keep = new Set( - plan.map((t) => t.id).filter((id): id is string => id !== null), + + // Checked again here, not only on load: a page opened before the first + // submission or before teams were published still offers what is now + // locked. + const { hackathon: latest } = await hackathonClient.get({ + hackathonId: event.params.id, + }) + const locked = await assignmentLockReasons( + team, + all.map((t) => t.id), + latest?.state, ) // Only the teams this page could actually show are in scope. The load gives @@ -234,6 +248,31 @@ export const actions: Actions = { ) const before = all.filter((t) => approved.has(t.projectId)) + if (locked.length > 0) { + // Locked, the teams themselves stand: people may move between them or + // to Unassigned and a team may be renamed, but none may be added or + // deleted — deleting one takes its submissions with it, and published + // teams are what participants have been told. An emptied team is kept. + const planned = new Set(plan.map((t) => t.id)) + if ( + plan.some((t) => t.id === null) || + before.some((t) => !planned.has(t.id)) + ) { + return fail(409, { + message: `Teams cannot be added or deleted now: ${locked.join(" and ")}.`, + }) + } + } else { + // A team with nobody in it is not part of the assignment: dropped, so + // a new one is never created and a saved one is deleted below like any + // team the plan leaves out. + plan = plan.filter((t) => t.memberIds.length > 0) + } + + const keep = new Set( + plan.map((t) => t.id).filter((id): id is string => id !== null), + ) + // Deletions first, so their members are free before anything is assigned // and a name being reused is no longer taken. for (const t of before) { 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..a48e1635 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 @@ -3,24 +3,46 @@ import { resolve } from '$app/paths'; import { Check, + CircleHelp, Download, Eraser, GripVertical, + Lock as LockIcon, 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 { + LABEL_MAX, + answerText, + optionText, + restoreLabels, + setLabels, + shortName, + type AnswerLabels + } from '$lib/utils/teamAnswerLabels'; + import { + NO_FILTERS, + countAnswers, + countPreferences, + isFiltering, + matchesFirst, + restoreFilters, + setText, + toggleAnswer, + toggleProject, + type PoolFilters + } from '$lib/utils/teamPoolFilter'; import type { ActionData, PageData } from './$types'; 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 +104,165 @@ } } - /** The ticked questions this person answered, in question order. */ - function codesFor(person: Person): { code: string; title: string }[] { - return shownQuestions.flatMap((q) => { + // Whether the "Prefers 3, 7" line rides along beside a name, kept like the + // question ticks. Shown unless turned off, which is how the page always + // looked; the "?" for a team off someone's preferences stays regardless — + // that is a warning about the assignment, not a preference on display. + let showPreferences = $state(true); + + const preferencesKey = $derived(`hackagon:team-show-preferences:${hackathonId}`); + + $effect(() => { + let stored: unknown = null; + try { + stored = JSON.parse(localStorage.getItem(preferencesKey) ?? 'null'); + } catch { + // No storage, or something in it that is not ours. Shown. + } + showPreferences = stored !== false; + }); + + function toggleShowPreferences() { + showPreferences = !showPreferences; + try { + localStorage.setItem(preferencesKey, JSON.stringify(showPreferences)); + } catch { + // Private browsing, or a full quota. The tick still holds for this visit. + } + } + + /** + * The ticked questions this person answered, in question order, as the + * question's letter and the answer itself — `A: XL` — with the question in + * full on hover. Fixed answers as tags, free text as lines of its own: a + * sentence does not fit in a tag. + */ + function answersFor(person: Person) { + const tags: { key: string; text: string; title: string }[] = []; + const texts: { key: string; text: string; title: string }[] = []; + for (const q of shownQuestions) { const answer = person.codes[q.id]; + if (!answer) continue; + const entry = { + key: q.id, + text: answerText(q, answer.label, labels), + title: `${q.label}: ${answer.label}` + }; + (q.kind === 'text' ? texts : tags).push(entry); + } - return answer ? [{ code: answer.code, title: `${q.label}: ${answer.label}` }] : []; - }); + return { tags, texts }; + } + + const KIND_NOTE: Partial> = { bool: 'yes / no', text: 'free text' }; + + // What the organizer has named each question — `size` for B, `remote` and + // `on site` for a tick-box's Yes and No. Kept in the browser like the + // ticks; see `teamAnswerLabels`. + let labels: AnswerLabels = $state({}); + + const labelsKey = $derived(`hackagon:team-answer-labels:${hackathonId}`); + + // Restored the way `shownIds` is, and for the same reason it only reads. + $effect(() => { + let stored: unknown = null; + try { + stored = JSON.parse(localStorage.getItem(labelsKey) ?? 'null'); + } catch { + // No storage, or something in it that is not ours. Letters it is. + } + labels = restoreLabels(stored, answerQuestions); + }); + + // One question's names are editable at a time, like a team's name. + let namingId: string | null = $state(null); + let draft = $state({ short: '', yes: '', no: '' }); + + function startNaming(q: (typeof answerQuestions)[number]) { + const named = labels[q.id]; + namingId = q.id; + draft = { short: named?.short ?? '', yes: named?.yes ?? '', no: named?.no ?? '' }; + } + + function commitNaming(q: (typeof answerQuestions)[number]) { + labels = setLabels(labels, q, draft); + namingId = null; + try { + localStorage.setItem(labelsKey, JSON.stringify(labels)); + } catch { + // Private browsing, or a full quota. The names still hold for this visit. + } + } + + function namingKeys(e: KeyboardEvent, q: (typeof answerQuestions)[number]) { + if (e.key === 'Enter') commitNaming(q); + if (e.key === 'Escape') namingId = null; + } + + // Which answers bring people to the top of the Unassigned column. The rules + // live in `teamPoolFilter`; this keeps them, stores them and draws them. + // + // Kept in the browser like the ticks above, so a reload keeps the filter. + // That is safe because a filter only reorders, the pool says how many + // match, and a reset is one click away. + let filters: PoolFilters = $state(NO_FILTERS); + + const filterKey = $derived(`hackagon:team-filters:${hackathonId}`); + + // Restored the way `shownIds` is, and for the same reason it only reads. + $effect(() => { + 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. + } + filters = restoreFilters( + stored, + answerQuestions, + projectRows.map((p) => p.id) + ); + }); + + function setFilters(next: PoolFilters) { + filters = next; + try { + localStorage.setItem(filterKey, JSON.stringify(next)); + } catch { + // Private browsing, or a full quota. The filter still holds for this visit. + } } + /** The filters as removable tags: projects in row order, then questions. */ + const activeFilters = $derived([ + ...projectRows + .filter((p) => filters.projects.includes(p.id)) + .map((p) => ({ + key: `project:${p.id}`, + text: `Prefers ${p.number}`, + title: `Prefers ${p.title}`, + remove: () => setFilters(toggleProject(filters, p.id)) + })), + ...answerQuestions.flatMap((q) => [ + ...(filters.answers[q.id] ?? []).map((label) => ({ + key: `${q.id}:${label}`, + text: answerText(q, label, labels), + title: `${q.label}: ${label}`, + remove: () => setFilters(toggleAnswer(filters, q.id, label)) + })), + ...(filters.texts[q.id] !== undefined + ? [ + { + key: `${q.id}:text`, + text: `${shortName(q, labels)}: "${filters.texts[q.id]?.trim()}"`, + title: `${q.label} contains "${filters.texts[q.id]?.trim()}"`, + remove: () => setFilters(setText(filters, q.id, '')) + } + ] + : []) + ]) + ]); + // Drop target id for the unassigned pool; team keys are used as-is. const POOL = 'pool'; @@ -138,6 +310,10 @@ /** What the last uploaded file did, until it is dismissed or superseded. */ let importResult: ImportResult | null = $state(null); + // "How assignment works". Closed by default: whoever needs the rules asks + // for them, and everyone else should not have to read past them. + let helpOpen = $state(false); + // Only one team's name is editable at a time. let editingKey: string | null = $state(null); let editName = $state(''); @@ -169,17 +345,35 @@ return [...peopleById.values()].filter((p) => !placed.has(p.id)); }); + /** The pool with whoever matches the filters on top. */ + const pool = $derived(matchesFirst(unassigned, filters)); + const filtering = $derived(isFiltering(filters)); + + // How many unassigned people gave each answer, over the whole pool rather + // than the matching part, so a number beside an answer does not change as + // other answers are picked. + const poolCounts = $derived(countAnswers(unassigned)); + const poolPreferenceCounts = $derived(countPreferences(unassigned)); + + 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. */ const changes = $derived.by(() => { const base = fromServer(data.projectRows); const nameBefore = new Map(base.map((t) => [t.key, t.name])); - const alive = new Set(teams.map((t) => t.key)); + // Save drops a team with nobody in it, so an empty one counts as + // deleted, or as not added — the summary says what Save will do. Not + // while the teams are fixed: then an emptied team is kept. + const kept = locked ? teams : teams.filter((t) => t.memberIds.length > 0); + const alive = new Set(kept.map((t) => t.key)); - const added = teams.filter((t) => t.id === null).length; + const added = kept.filter((t) => t.id === null).length; const removed = base.filter((t) => !alive.has(t.key)).length; - const renamed = teams.filter( + const renamed = kept.filter( (t) => t.id !== null && nameBefore.get(t.key) !== t.name ).length; @@ -208,7 +402,7 @@ function commitEdit(key: string) { const name = editName.trim(); - if (name.length < 3) return; + if (name === '') return; const team = teams.find((t) => t.key === key); if (team) team.name = name; editingKey = null; @@ -244,6 +438,16 @@ dropTarget = null; } + // Once a submission exists or teams are published, the teams themselves + // stand: no upload (it replaces every team), no adding, deleting or Clear + // all. People can still be moved by hand — between teams or to Unassigned — + // and teams renamed, and the save action enforces the same line. The + // reason is said once at the top and again on hover over what is off. See + // `assignmentLockReasons`. + const lockReasons = $derived(data.lockReasons); + const locked = $derived(lockReasons.length > 0); + const lockedTitle = $derived(locked ? `Locked: ${lockReasons.join(' and ')}` : undefined); + function canDrop(target: string) { return draggedId !== null && draggedFrom !== target; } @@ -291,28 +495,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; @@ -320,12 +502,9 @@ } /** - * Read an edited assignment back in. - * - * It lands on the workspace like any other edit — nothing is written until - * Save, so the change summary and Discard both still apply to it. The file - * is applied to the workspace **as it stands**, not to the state it was - * downloaded from, which is what the confirmation is about. + * Read an edited assignment back in. The file replaces every team — see + * `applyAssignmentCsv` — and lands on the workspace like any other edit: + * nothing is written until Save, and Discard undoes it. */ async function importFile(event: Event) { const input = event.currentTarget as HTMLInputElement; @@ -334,10 +513,7 @@ input.value = ''; if (file === undefined || pending) return; - if ( - changes.total > 0 && - !confirm('Apply this file on top of your unsaved changes?') - ) { + if (teams.length > 0 && !confirm('Uploading replaces all current teams. Continue?')) { return; } @@ -345,14 +521,18 @@ await file.text(), { people: [...peopleById.values()], - projects: projectRows.map((p) => ({ id: p.id, title: p.title })), + projects: projectRows.map((p) => ({ + id: p.id, + title: p.title, + number: p.number + })), teams }, { max: TEAM_MAX } ); importResult = result; - if (result.read > 0 || result.created.length > 0) { + if (!result.refused) { teams = result.teams; editingKey = null; } @@ -362,27 +542,15 @@ const importSummary = $derived.by(() => { const r = importResult; if (r === null) return []; + if (r.refused) return ['Nothing was applied.']; - const lines: string[] = []; - if (r.read === 0) { - lines.push('Nothing was applied.'); - } else { - const did: string[] = []; - if (r.moved > 0) did.push(`${r.moved} ${r.moved === 1 ? 'move' : 'moves'}`); - if (r.created.length > 0) did.push(`${r.created.length} new`); - lines.push( - `Read ${r.read} ${r.read === 1 ? 'row' : 'rows'}` + - (did.length > 0 ? `: ${did.join(', ')}.` : ', changing nothing.') - ); - } - if (r.absent > 0) { - lines.push( - `${r.absent} ${r.absent === 1 ? 'person was' : 'people were'} not in the file, ` + - 'and were left as they are.' - ); - } + const lines = [ + `${r.teams.length} ${r.teams.length === 1 ? 'team' : 'teams'} with ` + + `${r.assigned} ${r.assigned === 1 ? 'person' : 'people'}; ` + + `${r.unassigned} unassigned.` + ]; if (r.oversized.length > 0) { - lines.push(`Now over ${TEAM_MAX}: ${r.oversized.join(', ')}.`); + lines.push(`Over ${TEAM_MAX}: ${r.oversized.join(', ')}.`); } return lines; @@ -430,7 +598,7 @@ projectNumber: number | null )} {@const matches = projectId !== null && person.preferredProjectIds.includes(projectId)} - {@const answerCodes = codesFor(person)} + {@const answers = answersFor(person)}
{person.name} - {#if person.preferredNumbers.length > 0} + {#if !showPreferences} + + {:else if person.preferredNumbers.length > 0} {/if} - {#if answerCodes.length > 0} + {#if answers.tags.length > 0} + - {#each answerCodes as c (c.code)} - {c.code} + {#each answers.tags as a (a.key)} + + {a.text} + {/each} {/if} + {#each answers.texts as t (t.key)} + + {t.text} + + {/each}
{#if projectId !== null && !matches}

Manage Teams

-

- Drag a participant onto a team to assign them. Everyone belongs to at most one team, - and no team holds more than {TEAM_MAX}. Nothing is written until you save. -

+ {#if locked} +
+ +

+ Teams are fixed: + {lockReasons.join(' and ')}. Upload and adding or deleting teams are off; you can + still move people between teams or to Unassigned, and rename teams. +

+
+ {/if} + {#if form?.message}

- - - - - Download CSV - - -

+ Spreadsheet + + + Download CSV + + + +
+ + +
{#if changes.total > 0} @@ -591,8 +793,77 @@
+ + {#if helpOpen} +
+
+

How team assignment works

+ +
+ +
+

+ Drag people from Unassigned onto a team, or plan in the spreadsheet. +

+

+ Nothing changes until you Save; Save deletes teams with nobody in them. +

+

+ Once a team has a submission or teams are published, teams are fixed: no + upload, no adding or deleting teams. Moving people and renaming still work. +

+
+ +

Spreadsheet

+
    +
  1. + Download CSV. One row per participant, with + their current project and team, or empty if they have no team yet. +
  2. +
  3. + Correct the project and team + columns. + Keep every row and leave user_id as it is; other columns are ignored. +
  4. +
  5. + Upload CSV. Replaces all teams. Check, then + Save. +
  6. +
+ +
+
project
+
+ a project number as shown here and in the prefers column; anything else is + an incomplete assignment +
+ +
team
+
any name, even a single character; can be renamed after upload
+ +
both empty
+
the person ends up unassigned
+ +
incomplete assignment
+
a warning, and the person ends up unassigned
+ +
a row missing
+
a warning, and the person ends up unassigned
+
+
+ {/if} + {#if importResult} - {@const bad = importResult.problems.length > 0} + {@const bad = importResult.refused || importResult.warnings.length > 0}
(helpOpen = true)} + > + How assignment works + + {/if} + {/if} + {#if importResult.warnings.length > 0} + +
    + {#each importResult.warnings.slice(0, 5) as warning (warning)} +
  • {warning}
  • + {/each} + {#if importResult.warnings.length > 5} +
  • and {importResult.warnings.length - 5} more.
  • + {/if} +
{/if}
{/if} - + {#if answerQuestions.length > 0}
-
-

Registration answers

+

Registration questions

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

- Tick a question to mark everyone's answer beside their name. -

- {:else} -
- {#each shownQuestions as q (q.id)} -
-
{q.letter} · {q.label}
-
+ {#if q.kind === 'text'} + + + setFilters(setText(filters, q.id, e.currentTarget.value))} + /> + + {answeredInPool(q.id)} of {unassigned.length} answered + + + {:else} + {#each q.options as o (o.code)} - - {o.code} - {o.label} - + {@const picked = + filters.answers[q.id]?.includes(o.label) ?? false} + + {/each} -
-
- {/each} -
- {/if} + + {/if} + + + {/each} + +
+ {/if} + + + {#if projectRows.length > 0} +
+
+

Project preferences

+ +
+
+ {#each projectRows as p (p.id)} + {@const picked = filters.projects.includes(p.id)} + + {/each} +
{/if} @@ -711,7 +1145,7 @@ - + {#if !locked} + + {/if} {/if}
@@ -795,16 +1231,18 @@

{/if} - + {#if !locked} + + {/if}
{/each} @@ -821,23 +1259,57 @@ lg:top-4" class:border-accent={dropTarget === POOL} > -

Unassigned ({unassigned.length})

- - {#if data.ownerMissingFromPool} -

- You are not here: you run this hackathon without taking part in it. -

+

+ Unassigned ({unassigned.length}){filtering + ? ` · ${pool.matching.length} match` + : ''} +

+ {#if filtering} +
+ {#each activeFilters as f (f.key)} + + {/each} + +
{/if} {#if unassigned.length === 0}

Every confirmed participant is on a team.

{:else} -
- {#each unassigned as person (person.id)} + +
+ {#if filtering && pool.matching.length === 0} +

+ Nobody unassigned matches these filters. +

+ {/if} + {#each pool.matching as person (person.id)} {@render personRow(person, POOL, null, null)} {/each} + + {#if pool.rest.length > 0} +

Not matching ({pool.rest.length})

+ {#each pool.rest as person (person.id)} +
+ {@render personRow(person, POOL, null, null)} +
+ {/each} + {/if}
{/if} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/export/+server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/export/+server.ts index e4931216..4021d9cc 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/export/+server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/export/+server.ts @@ -1,9 +1,9 @@ import type { RequestHandler } from "./$types" import { HackathonRole } from "$lib/server/grpc/generated/hackathon/entities/hackathon_role" -import { ProjectStatus } from "$lib/server/grpc/generated/hackathon/entities/project_status" import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" import { requireGrpc } from "$lib/server/grpc/client" import { viewerMembership } from "$lib/server/hackathon/membership" +import { numberedProjects } from "$lib/server/hackathon/projectNumbers" import { listAnswers } from "$lib/server/hackathon/questions" import { answersByParticipant, @@ -77,40 +77,38 @@ export const GET: RequestHandler = async (event) => { listAnswers(hackathon, event.params.id), ]) - const titleById = new Map(projects.map((p) => [p.id, p.title])) - const approved = new Set( - projects - .filter((p) => p.status === ProjectStatus.PROJECT_STATUS_APPROVED) - .map((p) => p.id), - ) + // The page's own numbering, from the same helper, so a number in this file + // names the project the page shows under it. + const numbered = numberedProjects(projects) + const byId = new Map(numbered.map((p) => [p.id, p])) - // Every preference, including ones naming a project that is not on offer. A - // title in this column is context for a decision, not a value to copy across - // — and the import names the project it cannot find, so a misuse is loud. + // As the page's "Prefers 3, 7": numbers, in project order, and only for + // projects that have one — a preference for a project not on offer has no + // row to be placed on, here as there. const prefersByUser = new Map() - for (const p of projects) { + for (const p of numbered) { for (const u of p.preferences) { - prefersByUser.set(u.id, [...(prefersByUser.get(u.id) ?? []), p.title]) + prefersByUser.set(u.id, [ + ...(prefersByUser.get(u.id) ?? []), + String(p.number), + ]) } } - // A column per question that has a summarisable answer. Free text is left out: - // a paragraph per cell is what makes a sheet unreadable, and the answer is on - // the participant's own page. A tick-box reads as Yes or No, which is what a + // A column per question, free text included: the spreadsheet is where teams + // are planned, and "which university" or "what can you do" is often exactly + // what decides who goes together. A long answer stays in its one cell — the + // writer quotes line breaks. A tick-box reads as Yes or No, which is what a // person editing a spreadsheet expects to see in a column. - const columns = questionRows(questions.questions).filter( - (q) => q.kind === "enum" || q.kind === "bool", - ) - const coded = new Set(columns.map((q) => q.key)) - const answersByUser = answersByParticipant( - questionRows(questions.questions), - answers, - ) + // + // Written as stored, with no apostrophe in front of an answer starting `=`, + // the same choice `csvRow` makes for names; see there. + const columns = questionRows(questions.questions) + const answersByUser = answersByParticipant(columns, answers) const answersFor = (userId: string): Record => { const filed = answersByUser[userId] ?? [] const out: Record = {} for (const a of filed) { - if (!coded.has(a.key)) continue out[a.key] = typeof a.value === "boolean" ? (a.value ? "Yes" : "No") : a.value } @@ -121,37 +119,38 @@ export const GET: RequestHandler = async (event) => { const person = ( id: string, name: string, - projectTitle: string, + project: { number: number; title: string } | undefined, teamName: string, ): AssignmentRow => ({ userId: id, name, - project: projectTitle, + project: project ? String(project.number) : "", team: teamName, prefers: prefersByUser.get(id) ?? [], answers: answersFor(id), }) - // Grouped the way the screen is — by project, then by team — because the file - // is for reading a team as a block and moving somebody out of it, not for - // looking one person up. Unassigned last, which is where the pool sits. + // Grouped the way the screen is — by project number, then by team — because + // the file is for reading a team as a block and moving somebody out of it, + // not for looking one person up. Unassigned last, which is where the pool + // sits. const rows: AssignmentRow[] = [] const placed = teams - .filter((t) => approved.has(t.projectId)) - .map((t) => ({ ...t, projectTitle: titleById.get(t.projectId) ?? "" })) + .map((t) => ({ ...t, project: byId.get(t.projectId) })) + .filter( + (t): t is typeof t & { project: NonNullable } => + t.project !== undefined, + ) .sort( (a, b) => - a.projectTitle.localeCompare(b.projectTitle) || - a.name.localeCompare(b.name), + a.project.number - b.project.number || a.name.localeCompare(b.name), ) for (const t of placed) { const members = [...t.members].sort((a, b) => (a.displayName || a.username).localeCompare(b.displayName || b.username), ) for (const m of members) { - rows.push( - person(m.id, m.displayName || m.username, t.projectTitle, t.name), - ) + rows.push(person(m.id, m.displayName || m.username, t.project, t.name)) } } @@ -165,7 +164,7 @@ export const GET: RequestHandler = async (event) => { name: m.user!.displayName || m.user!.username, })) .sort((a, b) => a.name.localeCompare(b.name)) - for (const p of pool) rows.push(person(p.id, p.name, "", "")) + for (const p of pool) rows.push(person(p.id, p.name, undefined, "")) return new Response( assignmentCsv( diff --git a/components/frontend/src/themes/hackagon.css b/components/frontend/src/themes/hackagon.css index e89b5073..567fe377 100644 --- a/components/frontend/src/themes/hackagon.css +++ b/components/frontend/src/themes/hackagon.css @@ -640,6 +640,36 @@ white-space: nowrap; } + /* A scrolling list whose scrollbar is there before anyone scrolls. + * + * macOS hides scrollbars until the pointer moves over them, so a list taller + * than its box looks like it simply ends. A styled `::-webkit-scrollbar` is + * never an overlay, which keeps it visible in Chrome, Edge and Safari. + * + * The standard properties are for Firefox only, and only there: Chrome + * ignores every `::-webkit-scrollbar` rule once `scrollbar-color` or + * `scrollbar-width` is set. Firefox on macOS still follows the system + * setting — thin and themed is as far as it goes. */ + .scroll-visible { + overflow-y: auto; + } + .scroll-visible::-webkit-scrollbar { + width: --spacing(2); + } + .scroll-visible::-webkit-scrollbar-track { + background-color: transparent; + } + .scroll-visible::-webkit-scrollbar-thumb { + background-color: var(--color-line-strong); + border-radius: var(--radius-control); + } + @supports not selector(::-webkit-scrollbar) { + .scroll-visible { + scrollbar-width: thin; + scrollbar-color: var(--color-line-strong) transparent; + } + } + /* --------------------------------------------------------------- * Rendered markdown. *