From 9102b25a96af4e6eed5761e3de9951f861c020e6 Mon Sep 17 00:00:00 2001
From: Sabine Maennel <5292683+sabinem@users.noreply.github.com>
Date: Thu, 22 Oct 2026 08:23:07 +0200
Subject: [PATCH 1/2] feat(settings): turn the participant switches into a
guide for organizers
- Eight steps in the order a hackathon runs: register, projects,
preferences, team assignments, hacking, voting setup, voting, results
- Each switch sits at its step, labelled for the organizer
("Open registration", "Publish results")
- Links to the pages in between: registration form, approving
participants, invitation links (private only), Manage Projects,
Manage Teams, Manage Voting
- "Set team preferences" is now "Choose preferred projects" on the
participant pages too
- A switch no step names still shows, under "Other"
---
CHANGELOG.md | 7 +
.../hackathon/CapabilitiesPanel.svelte | 289 +++++++++++++++---
.../hackathon/CapabilitiesPanel.test.ts | 132 ++++++++
components/frontend/src/lib/utils/phase.ts | 10 +-
.../my/hackathon/[id]/manage/+page.svelte | 2 +
5 files changed, 387 insertions(+), 53 deletions(-)
create mode 100644 components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a4e04eb..e71a0487 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -77,6 +77,13 @@ written while it was being built. See [RELEASING.md](RELEASING.md).
- For organizers, the "Participant View" section of a hackathon's sidebar is now
folded every time you open the hackathon. Open it with its arrow when you need
it.
+- A hackathon's Settings page now walks organizers through the event step by
+ step: registration, projects, preferences, teams, hacking, voting and results.
+ Each switch sits at the step where it is turned on, with links to the pages in
+ between, such as approving participants, forming teams and setting up voting.
+ A private hackathon links to its invitation links.
+- "Set team preferences" is now called "Choose preferred projects", since
+ participants pick projects, not teammates.
- 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.
diff --git a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte
index 5b2f6612..db925a0f 100644
--- a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte
+++ b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte
@@ -1,12 +1,18 @@
+{#snippet toggle(capability: Capability, label: string, description?: string)}
+
+
+{/snippet}
+
- Each one is a permission held by every participant, for the whole
- hackathon. Moving between phases never changes them.
+ These switches decide what participants can do, and this is the only
+ place to change them. Moving between phases never does.
What participants can do
diff --git a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts
new file mode 100644
index 00000000..90549e3c
--- /dev/null
+++ b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts
@@ -0,0 +1,132 @@
+import { render, screen } from "@testing-library/svelte"
+import { describe, expect, it } from "vitest"
+
+import CapabilitiesPanel from "./CapabilitiesPanel.svelte"
+
+/*
+ * The Settings switches as the steps of running a hackathon, with the screens
+ * between them. Raw enum numbers: REGISTER=1, PROPOSE_PROJECTS=2,
+ * SET_TEAM_PREFERENCES=3, CREATE_PROJECT_SUBMISSIONS=4, VOTE=5,
+ * VIEW_RESULTS=6, VIEW_TEAMS=7.
+ */
+
+const all = [1, 2, 3, 7, 4, 5, 6].map((value) => ({ value, enabled: false }))
+
+function renderPanel(overrides = {}) {
+ return render(CapabilitiesPanel, {
+ hackathonId: "h1",
+ isPrivate: false,
+ hasState: true,
+ capabilities: all,
+ ...overrides,
+ })
+}
+
+const link = (name: string) => screen.queryByRole("link", { name })
+const box = (name: string) => screen.getByRole("checkbox", { name })
+
+describe("CapabilitiesPanel", () => {
+ it("lists the steps in the order a hackathon runs", () => {
+ renderPanel()
+
+ const titles = screen
+ .getAllByRole("heading", { level: 4 })
+ .map((h) => h.textContent?.replace(/^\s*\d+\.\s*/, "").trim())
+ expect(titles).toEqual([
+ "Register",
+ "Projects",
+ "Project preferences",
+ "Team assignments",
+ "Hacking starts",
+ "Set up voting",
+ "Voting starts",
+ "Publish results",
+ ])
+ expect(screen.getAllByRole("checkbox")).toHaveLength(7)
+ })
+
+ it("names each switch for the organizer, on the right capability", () => {
+ renderPanel()
+
+ expect(box("Open registration")).toHaveAttribute("value", "1")
+ expect(box("Participants can propose projects")).toHaveAttribute(
+ "value",
+ "2",
+ )
+ expect(box("Participants can voice preferences")).toHaveAttribute(
+ "value",
+ "3",
+ )
+ expect(box("Participants can see teams")).toHaveAttribute("value", "7")
+ expect(box("Participants can submit for their team")).toHaveAttribute(
+ "value",
+ "4",
+ )
+ expect(box("Participants can vote")).toHaveAttribute("value", "5")
+ expect(box("Publish results")).toHaveAttribute("value", "6")
+ })
+
+ it("links the steps that are screens, not switches", () => {
+ renderPanel()
+
+ expect(link("Registration form →")).toHaveAttribute(
+ "href",
+ "/my/hackathon/h1/manage/forms/registration",
+ )
+ expect(link("Manage Projects →")).toHaveAttribute(
+ "href",
+ "/my/hackathon/h1/projects/manage",
+ )
+ expect(link("Manage Teams →")).toHaveAttribute(
+ "href",
+ "/my/hackathon/h1/teams/manage",
+ )
+ expect(link("Set up voting →")).toHaveAttribute(
+ "href",
+ "/my/hackathon/h1/voting/manage",
+ )
+ // Publishing is locked until the results are final, made so there.
+ expect(link("Manage Voting →")).toHaveAttribute(
+ "href",
+ "/my/hackathon/h1/voting/manage",
+ )
+ })
+
+ // Every join waits for approval, public or private.
+ it("sends registration to approving people, either way", () => {
+ for (const isPrivate of [false, true]) {
+ const { unmount } = renderPanel({ isPrivate })
+
+ expect(link("Manage Participants →")).toHaveAttribute(
+ "href",
+ "/my/hackathon/h1/participants/manage",
+ )
+ unmount()
+ }
+ })
+
+ it("says anyone can join a public hackathon, with no invitation link", () => {
+ renderPanel()
+
+ expect(screen.getByText(/anyone can find it and join/)).toBeInTheDocument()
+ expect(link("Invitation links →")).toBeNull()
+ })
+
+ it("points a private hackathon's registration at its invitation links", () => {
+ renderPanel({ isPrivate: true })
+
+ expect(screen.getByText(/send invitation links/)).toBeInTheDocument()
+ expect(link("Invitation links →")).toHaveAttribute(
+ "href",
+ "/my/hackathon/h1/manage/invites",
+ )
+ })
+
+ // A switch the backend adds before a step names it must still be settable.
+ it("keeps a capability no step names, under Other", () => {
+ renderPanel({ capabilities: [...all, { value: 99, enabled: false }] })
+
+ expect(screen.getByText("Other")).toBeInTheDocument()
+ expect(screen.getAllByRole("checkbox")).toHaveLength(8)
+ })
+})
diff --git a/components/frontend/src/lib/utils/phase.ts b/components/frontend/src/lib/utils/phase.ts
index afcaca6d..2726df21 100644
--- a/components/frontend/src/lib/utils/phase.ts
+++ b/components/frontend/src/lib/utils/phase.ts
@@ -209,8 +209,10 @@ export function currentAndNextPhase<
* the frontend no longer reads or writes it. See
* `mydocs/docs/backend-tickets/phase-capabilities-unused.md`.
*
- * The description is what the label cannot carry: "Set team preferences" reads
- * as a setting rather than as a permission handed to everyone in the hackathon.
+ * The description is what the label cannot carry: "Choose preferred projects"
+ * reads as a setting rather than as a permission handed to everyone in the
+ * hackathon. Its label used to be "Set team preferences", which organizers read
+ * as choosing teammates; the preference is for a project.
*
* **Pronoun-free throughout**, which is stronger than third person and is what
* lets one string serve two surfaces. Capabilities grant to the `Member` role
@@ -239,8 +241,8 @@ const CAPABILITIES: {
},
{
value: 3,
- label: "Set team preferences",
- description: "Mark preferred projects on the project list.",
+ label: "Choose preferred projects",
+ description: "Mark preferred projects on the project list, not teammates.",
},
// Out of enum order, and deliberately: this is what the preferences above it
// lead to and what the submissions below it are filed against, so it reads
diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte
index f41d477d..271ddfab 100644
--- a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte
+++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte
@@ -238,6 +238,8 @@
Date: Thu, 22 Oct 2026 08:41:54 +0200
Subject: [PATCH 2/2] feat(settings): warn about unsaved switch changes, as on
Manage Teams
- A bar at the top lists what is unsaved, with Save and Discard, and
sticks below the nav bar while scrolling
- Changed switches are tinted until saved
- Leaving through a link asks first; closing the tab, or pressing a
phase button, gets the browser's own prompt
- Saving stays on the page; a failed save keeps the ticks
---
CHANGELOG.md | 3 +
.../hackathon/CapabilitiesPanel.svelte | 184 +++++++++++++++---
.../hackathon/CapabilitiesPanel.test.ts | 71 ++++++-
3 files changed, 231 insertions(+), 27 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e71a0487..26dd35f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,9 @@ written while it was being built. See [RELEASING.md](RELEASING.md).
### Added
+- The Settings page shows when you have changed a switch without saving, as
+ Manage Teams does: a bar at the top lists what is unsaved, with Save and
+ Discard, and leaving the page asks first. Saving keeps you on the page.
- Changing your registration answers is easier to find. On a hackathon's
Participants page your own entry is now at the top, marked "You", and its
button reads "View & edit". Your profile there has an "Edit answers" button
diff --git a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte
index db925a0f..587230e7 100644
--- a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte
+++ b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte
@@ -1,5 +1,8 @@
+ {
+ if (dirty && !leaving) e.preventDefault();
+ }}
+/>
+
+
+
{#snippet toggle(capability: Capability, label: string, description?: string)}
+
+ {@const unsaved = draft[capability.value] !== capability.enabled}