From 82544bb4adf1e439b053885132fb3151ff609b4b Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:46:56 +0200 Subject: [PATCH] feat(frontend): rebuild team management as a save-once workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manage-teams page could not cope with a hundred participants: people were chips in a wrap, preferences were truncated titles, and every drag was a server round trip. It is now two columns — projects with their teams on the left, unassigned on the right, both scrollable — and people are full-width rows wherever they sit. Each project carries a number, and a participant's preferences show as those numbers rather than titles, with the matching one highlighted inside a team so it is obvious whether someone got what they asked for. "Suggest teams" builds a whole distribution from everyone's preferences. The rule is deliberately singular: everyone goes to a project they picked, spread evenly across their options, and no team exceeds six. There is no minimum team size. A minimum sounds reasonable and generates every hard case — projects dissolved, people redistributed, totals that satisfy no split, teams emptied when the number is raised — for a judgement that belongs to the organizer anyway. A project two people want is a team of two, and dragging is the tool for deciding otherwise. The algorithm lives in $lib/utils/teamDistribution so it can be tested without a page around it. The page is now a workspace rather than a series of instructions: dragging, adding, renaming, deleting and clearing all happen in the browser, the header shows what is pending, and one Save writes it. That collapses move, createTeam, renameTeam, deleteTeam and clearAssignments into a single `save` action which takes the complete desired state and reconciles — deletes first, then creates and renames, then the membership difference. Applying is slow by construction: there is no bulk RPC, so a hundred people is a few hundred sequential calls. --- .../src/lib/utils/teamDistribution.test.ts | 285 ++++++++ .../src/lib/utils/teamDistribution.ts | 195 ++++++ .../[id]/teams/manage/+page.server.ts | 300 +++++---- .../hackathon/[id]/teams/manage/+page.svelte | 613 ++++++++++++------ 4 files changed, 1047 insertions(+), 346 deletions(-) create mode 100644 components/frontend/src/lib/utils/teamDistribution.test.ts create mode 100644 components/frontend/src/lib/utils/teamDistribution.ts diff --git a/components/frontend/src/lib/utils/teamDistribution.test.ts b/components/frontend/src/lib/utils/teamDistribution.test.ts new file mode 100644 index 00000000..5bef0c55 --- /dev/null +++ b/components/frontend/src/lib/utils/teamDistribution.test.ts @@ -0,0 +1,285 @@ +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) + }) +}) + +describe("initialsOf", () => { + it("takes the initial of each word", () => { + expect(initialsOf("AutoML Pipeline Builder")).toBe("APB") + }) + + it("falls back rather than returning nothing", () => { + expect(initialsOf(" ")).toBe("?") + }) +}) diff --git a/components/frontend/src/lib/utils/teamDistribution.ts b/components/frontend/src/lib/utils/teamDistribution.ts new file mode 100644 index 00000000..567c6af1 --- /dev/null +++ b/components/frontend/src/lib/utils/teamDistribution.ts @@ -0,0 +1,195 @@ +/** + * 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. + */ + +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. */ + 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 ( + text + .split(/\s+/) + .filter(Boolean) + .map((w) => w[0]?.toUpperCase()) + .join("") || "?" + ) +} 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 6bf0526e..0453b58a 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 @@ -30,22 +30,56 @@ export const load: PageServerLoad = async (event) => { hackathonId: event.params.id, }) - // Inverted: which project(s) a given user prefers, shown on their chip + // The projects that get a row, in the order they get it. A project's + // position is the number shown on its row and on every preference that names + // it — with fifteen projects and a hundred people, full titles on every + // participant row are unreadable, and a number that matches the row above is + // not. + // + // One row per approved project, so its team(s) can be created and staffed + // here. A project that already has a team but isn't approved (edge case: seed + // data has one) still gets a row — otherwise that team would have no drop + // zone anywhere on this page — tagged so it doesn't read as a mistake. + const projectIdsWithTeams = new Set(teams.map((t) => t.projectId)) + const rowProjects = preferences.filter( + (p) => + p.status === ProjectStatus.PROJECT_STATUS_APPROVED || + projectIdsWithTeams.has(p.id), + ) + const numberByProjectId = new Map(rowProjects.map((p, i) => [p.id, i + 1])) + + // Inverted: which project(s) a given user prefers, shown on their row // rather than on the project row. - const preferredTitlesByUser = new Map() + const preferredByUser = new Map() for (const p of preferences) { for (const u of p.preferences) { - const titles = preferredTitlesByUser.get(u.id) ?? [] - titles.push(p.title) - preferredTitlesByUser.set(u.id, titles) + const list = preferredByUser.get(u.id) ?? [] + list.push({ id: p.id, title: p.title }) + preferredByUser.set(u.id, list) } } - const toPerson = (id: string, name: string) => ({ - id, - name, - preferredTitles: preferredTitlesByUser.get(id) ?? [], - }) + // Three views of the same preference set, each earning its place: numbers are + // what the row shows, titles are what its tooltip spells out, and ids are what + // the suggested distribution matches on — nothing guarantees two projects + // cannot share a title. + // + // A preference for a project with no row has no number, and is dropped from + // that list rather than shown as a gap: it cannot be assigned here either. + const toPerson = (id: string, name: string) => { + const preferred = preferredByUser.get(id) ?? [] + + return { + id, + name, + preferredTitles: preferred.map((p) => p.title), + preferredProjectIds: preferred.map((p) => p.id), + preferredNumbers: preferred + .map((p) => numberByProjectId.get(p.id)) + .filter((n) => n !== undefined) + .sort((a, b) => a - b), + } + } const teamsById = teams.map((t) => ({ id: t.id, @@ -68,22 +102,14 @@ export const load: PageServerLoad = async (event) => { teamsByProject.set(t.projectId, list) } - // One row per approved project, so its team(s) can be created and staffed - // here. A project that already has a team but isn't approved (edge case: - // seed data has one) still gets a row — otherwise that team would have no - // drop zone anywhere on this page — tagged so it doesn't read as a mistake. - const projectRows = preferences - .filter( - (p) => - p.status === ProjectStatus.PROJECT_STATUS_APPROVED || - teamsByProject.has(p.id), - ) - .map((p) => ({ - id: p.id, - title: p.title, - isApproved: p.status === ProjectStatus.PROJECT_STATUS_APPROVED, - teams: teamsByProject.get(p.id) ?? [], - })) + const projectRows = rowProjects.map((p, i) => ({ + id: p.id, + number: i + 1, + title: p.title, + isApproved: p.status === ProjectStatus.PROJECT_STATUS_APPROVED, + interested: p.preferences.length, + teams: teamsByProject.get(p.id) ?? [], + })) return { hackathonId: event.params.id, @@ -93,148 +119,108 @@ export const load: PageServerLoad = async (event) => { } export const actions: Actions = { - createTeam: async (event) => { - // No `parent()` here — actions get a plain `RequestEvent`, not a load - // event — so the project lookup this needs re-fetches via `hackathon.get`. - const { team, hackathon: hackathonClient } = requireGrpc(event.locals.grpc) + // The page is a workspace: every edit — dragging someone, adding a team, + // renaming, deleting, accepting a suggestion — happens in the browser and + // nothing reaches the backend until this runs. One action, because there is + // one gesture: Save. + // + // What arrives is the **complete** desired state of every team in this + // hackathon, so a team the plan does not mention has been deleted. This works + // out the difference against what is stored and writes only that. + // + // There is no bulk RPC, so the cost is one round trip per team created, + // deleted or renamed, plus one or two per person who actually moves. + // 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 } = requireGrpc(event.locals.grpc) const form = await event.request.formData() - const projectId = form.get("projectId") - if (typeof projectId !== "string" || projectId === "") { - return fail(400, { message: "Missing project" }) - } - - const { hackathon } = await hackathonClient.get({ - hackathonId: event.params.id, - }) - const proj = hackathon?.projects.find((p) => p.id === projectId) - if (!hackathon || !proj) { - return fail(404, { message: "Project not found" }) + const raw = form.get("teams") + if (typeof raw !== "string" || raw === "") { + return fail(400, { message: "Nothing to save" }) } - const base = `Team ${initialsOf(proj.title)}` - - const { teams: existing } = await team.list({ - hackathonId: event.params.id, - }) - const teamCount = existing.filter((t) => t.projectId === projectId).length - const name = teamCount === 0 ? base : `${base} ${teamCount + 1}` + let plan: PlannedTeam[] try { - await team.create({ projectId, name, description: "" }) - } catch (e) { - if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { - return fail(400, { message: e.details }) - } - if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { - return fail(403, { - message: "You don't have permission to create teams here", - }) - } - if (e instanceof ClientError && e.code === Status.NOT_FOUND) { - return fail(404, { message: "Project not found" }) - } - throw e + plan = JSON.parse(raw) + } catch { + return fail(400, { message: "Could not read the changes" }) } - - return { success: true } - }, - - renameTeam: async (event) => { - const { team } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - - const teamId = form.get("teamId") - const name = form.get("name") - if (typeof teamId !== "string" || teamId === "") { - return fail(400, { message: "Missing team" }) + if (!Array.isArray(plan) || !plan.every(isPlannedTeam)) { + return fail(400, { message: "Could not read the changes" }) } - if (typeof name !== "string" || name.trim().length < 3) { - return fail(400, { message: "Team name must be at least 3 characters" }) + if (plan.some((t) => t.name.trim().length < 3)) { + return fail(400, { + message: "Every team needs a name of at least 3 characters", + }) } try { - await team.edit({ id: teamId, name }) - } catch (e) { - if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { - return fail(400, { message: e.details }) - } - if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { - return fail(403, { - message: "You don't have permission to rename this team", - }) - } - if (e instanceof ClientError && e.code === Status.NOT_FOUND) { - return fail(404, { message: "Team not found" }) - } - throw e - } - - return { success: true } - }, + const { teams: before } = await team.list({ + hackathonId: event.params.id, + }) + const keep = new Set( + plan.map((t) => t.id).filter((id): id is string => id !== null), + ) - deleteTeam: async (event) => { - const { team } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() + // 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) { + if (!keep.has(t.id)) await team.delete({ id: t.id }) + } - const teamId = form.get("teamId") - if (typeof teamId !== "string" || teamId === "") { - return fail(400, { message: "Missing team" }) - } + // Then creations and renames, so every team the plan names exists with + // the name it should have before members are placed into it. + const nameBefore = new Map(before.map((t) => [t.id, t.name])) + const wanted = new Map() + for (const t of plan) { + let teamId = t.id + if (teamId === null) { + const created = await team.create({ + projectId: t.projectId, + name: t.name, + description: "", + }) + teamId = created.teamId + } else if (nameBefore.get(teamId) !== t.name) { + await team.edit({ id: teamId, name: t.name }) + } + wanted.set(teamId, t.memberIds) + } - try { - await team.delete({ id: teamId }) - } catch (e) { - if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { - return fail(403, { - message: "You don't have permission to delete this team", - }) + // Finally the membership difference. A deleted team's members count as + // already unassigned, which is why `keep` filters them out here. + const current = new Map() + for (const t of before) { + if (!keep.has(t.id)) continue + for (const m of t.members) current.set(m.id, t.id) } - if (e instanceof ClientError && e.code === Status.NOT_FOUND) { - return fail(404, { message: "Team not found" }) + const target = new Map() + for (const [teamId, memberIds] of wanted) { + for (const userId of memberIds) target.set(userId, teamId) } - throw e - } - return { success: true } - }, - - // Moves a participant to `toTeamId`, or unassigns them when it is empty. The - // backend allows a user on several teams, so the single-team rule is - // enforced here: every other team membership in this hackathon is removed - // first. - move: async (event) => { - const { team } = requireGrpc(event.locals.grpc) - const form = await event.request.formData() - - const userId = form.get("userId") - const toTeamId = form.get("toTeamId") - if (typeof userId !== "string" || userId === "") { - return fail(400, { message: "Select a participant to move" }) - } - if (typeof toTeamId !== "string") { - return fail(400, { message: "Missing target team" }) - } - - try { - const current = await team.list({ hackathonId: event.params.id }) - const leaving = current.teams.filter( - (t) => t.id !== toTeamId && t.members.some((m) => m.id === userId), - ) - - for (const t of leaving) { - await team.removeUser({ teamId: t.id, userId }) - } - if (toTeamId !== "") { - await team.assignUser({ teamId: toTeamId, userId }) + for (const userId of new Set([...current.keys(), ...target.keys()])) { + const from = current.get(userId) + const to = target.get(userId) + if (from === to) continue + // Leave before joining: the one-team-per-person rule is the frontend's + // to keep, because the backend allows somebody on several at once. + if (from !== undefined) await team.removeUser({ teamId: from, userId }) + if (to !== undefined) await team.assignUser({ teamId: to, userId }) } } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { return fail(403, { message: "You don't have permission to manage these teams", }) } if (e instanceof ClientError && e.code === Status.NOT_FOUND) { - return fail(404, { message: "Team or participant not found" }) + return fail(404, { message: "A team or participant no longer exists" }) } throw e } @@ -243,13 +229,25 @@ export const actions: Actions = { }, } -/** "AutoML Pipeline Builder" -> "APB". */ -function initialsOf(text: string): string { +/** One team as the page's workspace wants it to end up. */ +type PlannedTeam = { + /** `null` for a team added in the browser that must still be created. */ + id: string | null + projectId: string + name: string + memberIds: string[] +} + +function isPlannedTeam(value: unknown): value is PlannedTeam { + if (typeof value !== "object" || value === null) return false + const t = value as Record + return ( - text - .split(/\s+/) - .filter(Boolean) - .map((w) => w[0]?.toUpperCase()) - .join("") || "?" + (t.id === null || typeof t.id === "string") && + typeof t.projectId === "string" && + t.projectId !== "" && + typeof t.name === "string" && + Array.isArray(t.memberIds) && + t.memberIds.every((m) => typeof m === "string" && m !== "") ) } 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 82837e1c..ca393134 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 @@ -1,49 +1,164 @@ -