+
+ What participants can do
+
- 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.
-
-
- {#each capabilities as capability (capability.value)}
-
-
-
-
-
- {capabilityLabel(capability.value) ?? 'Unknown'}
-
-
- {capabilityDescription(capability.value) ??
- 'No description for this capability.'}
-
- {#if capability.lockedReason}
-
- {capability.lockedReason}
-
- {/if}
- {#each capability.warnings ?? [] as warning (warning)}
- {warning}
- {/each}
-
-
- {/each}
+
+
+
+
+
+
+ {#if pending}
+ Saving…
+ {:else if dirty}
+ Unsaved changes, marked below: {summary}
+ {:else if saved && !message}
+ Saved.
+ {:else}
+ No unsaved changes
+ {/if}
+
+ {#if message && !pending}
+ {message}
+ {/if}
+
+ Discard
+
+
+ Save
+
-
+
- {#if message}
-
{message}
- {:else if saved}
-
Saved.
- {/if}
+
+
+ {#each steps as step, i (step.title)}
+
+
+ {i + 1}.
+ {step.title}
+
+ {#each step.blocks as block, j (j)}
+ {#if block.kind === 'switch'}
+ {@const capability = byValue.get(block.value)}
+ {#if capability}
+ {@render toggle(capability, block.label)}
+ {/if}
+ {:else}
+
+ {block.text}
+ {#if block.link}
+
+
+ {block.link.label} →
+
+
+ {/if}
+
+ {/if}
+ {/each}
+
+ {/each}
-
-
-
- Save changes
-
+ {#if unplaced.length > 0}
+
+ Other
+ {#each unplaced as capability (capability.value)}
+ {@render toggle(
+ capability,
+ capabilityLabel(capability.value) ?? 'Unknown',
+ capabilityDescription(capability.value),
+ )}
+ {/each}
+
+ {/if}
+
+
-
{:else}
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..4c663670
--- /dev/null
+++ b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts
@@ -0,0 +1,199 @@
+import { fireEvent, render, screen } from "@testing-library/svelte"
+import { describe, expect, it, vi } from "vitest"
+
+// The leave guard and the enhanced save need the router, which a component
+// test has none of; what they do is the browser's business, not this file's.
+vi.mock("$app/navigation", () => ({ beforeNavigate: () => {}, goto: () => {} }))
+vi.mock("$app/forms", () => ({ enhance: () => ({ destroy: () => {} }) }))
+
+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",
+ )
+ })
+
+ describe("unsaved changes", () => {
+ const status = () => screen.getAllByRole("status")[0]
+ const button = (name: string) => screen.getByRole("button", { name })
+
+ it("says nothing is unsaved, with nothing to save or discard", () => {
+ renderPanel()
+
+ expect(status()).toHaveTextContent("No unsaved changes")
+ expect(button("Save")).toBeDisabled()
+ expect(button("Discard")).toBeDisabled()
+ })
+
+ it("names what Save would write, by the organizer's labels", async () => {
+ renderPanel({
+ capabilities: all.map((c) =>
+ c.value === 5 ? { ...c, enabled: true } : c,
+ ),
+ })
+
+ await fireEvent.click(box("Open registration"))
+ await fireEvent.click(box("Participants can vote"))
+
+ expect(status()).toHaveTextContent(
+ "Unsaved changes, marked below: Open registration on, Participants can vote off",
+ )
+ expect(button("Save")).toBeEnabled()
+ })
+
+ it("is clean again once a switch is put back by hand", async () => {
+ renderPanel()
+
+ await fireEvent.click(box("Open registration"))
+ await fireEvent.click(box("Open registration"))
+
+ expect(status()).toHaveTextContent("No unsaved changes")
+ })
+
+ it("puts every switch back on Discard", async () => {
+ renderPanel()
+
+ await fireEvent.click(box("Open registration"))
+ await fireEvent.click(button("Discard"))
+
+ expect(box("Open registration")).not.toBeChecked()
+ expect(status()).toHaveTextContent("No unsaved changes")
+ })
+
+ it("confirms a save", () => {
+ renderPanel({ saved: true })
+
+ expect(status()).toHaveTextContent("Saved.")
+ })
+
+ it("shows why a save failed", () => {
+ renderPanel({ message: "Something went wrong" })
+
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Something went wrong",
+ )
+ })
+ })
+
+ // 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 @@