Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ written while it was being built. See [RELEASING.md](RELEASING.md).

### Added

- Organizers have a Manage Submissions page: every team and where it stands with
its submission — nothing yet, drafts only, or final — with the teams still
owing an entry first. Tabs filter by standing, a search finds a team by its
name, its project or a member — while searching, the tabs count the matches,
so a team on another tab is never missed — and each team shows its link or
notes and any earlier versions. A banner says whether submissions are open,
and "Download CSV" saves every team as a spreadsheet. The page only shows
submissions: an organizer cannot submit or finalize one for a team.
- Manage Teams can find a person by name: type part of a name to highlight
everyone who matches, on teams and in Unassigned, and press Enter to jump from
one to the next. Accents and case do not matter.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<script lang="ts">
import { resolve } from '$app/paths';
import {
SUBMISSION_FILTERS,
SUBMISSION_FILTER_LABEL,
submissionFilterQuery,
type SubmissionFilter
} from '$lib/utils/submissionFilter';

/**
* Manage Submissions, sliced by where each team stands.
*
* Links rather than buttons, the same choice `ProjectStatusTabs` makes: each
* slice is a real address that can be bookmarked and that the back button
* understands.
*
* Counts come from the caller and never from the rows on screen, so the tab
* you are not on still tells you what is there: every team normally, and
* only the teams matching a search while there is one — which is how a
* search says which tab its matches are on.
*/
let {
hackathonId,
current,
counts
}: {
hackathonId: string;
current: SubmissionFilter;
counts: Record<SubmissionFilter, number>;
} = $props();
</script>

<nav class="flex flex-wrap gap-1" aria-label="Filter teams by submission">
{#each SUBMISSION_FILTERS as filter (filter)}
{@const active = current === filter}
<!-- `resolve()` inline: svelte/no-navigation-without-resolve reads the
href expression itself. Same shape as ProjectStatusTabs. -->
<a
href="{resolve(
`/my/hackathon/${hackathonId}/submissions/manage`
)}{submissionFilterQuery(filter)}"
aria-current={active ? 'page' : undefined}
class="chip no-underline {active ? 'chip-active' : ''}"
>
{SUBMISSION_FILTER_LABEL[filter]}
<!-- Warning on the two slices that hold teams still owing an entry —
the ones an organiser came to find. Zero shows as a plain
count: nothing to chase. -->
{#if (filter === 'missing' || filter === 'draft') && counts[filter] > 0}
<span class="badge badge-warning tnum">{counts[filter]}</span>
{:else}
<span class="tnum">{counts[filter]}</span>
{/if}
</a>
{/each}
</nav>
17 changes: 17 additions & 0 deletions components/frontend/src/lib/navigation/items.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,12 +330,29 @@ describe("manageNav", () => {
"manage:projects",
"manage:tracks",
"manage:teams",
"manage:submissions",
"manage:timeline",
"manage:pages",
"manage:voting",
])
})

// Same longest-match split as Teams: the member page is where a team files,
// the manage page is where an organiser sees every team.
it("lights Submissions on the member page and Manage Submissions on its own", () => {
const items = [
...memberNav("hack-1", [], false, false, 2),
...manageNav("hack-1", owner, false),
]

expect(activeNavId("/my/hackathon/hack-1/submissions", items)).toBe(
"member:submissions",
)
expect(activeNavId("/my/hackathon/hack-1/submissions/manage", items)).toBe(
"manage:submissions",
)
})

// Same gate as the participant Timeline entry, and the reason it matters more
// here: with Manage Timeline hidden, the "Where the hackathon is" card on
// Settings is the only route to the first phase.
Expand Down
11 changes: 11 additions & 0 deletions components/frontend/src/lib/navigation/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import UserRoundCheck from "lucide-svelte/icons/user-round-check"
import UserRoundCog from "lucide-svelte/icons/user-round-cog"
import Send from "lucide-svelte/icons/send"
import Ticket from "lucide-svelte/icons/ticket"
import Inbox from "lucide-svelte/icons/inbox"
import CalendarClock from "lucide-svelte/icons/calendar-clock"
import Globe from "lucide-svelte/icons/globe"
import CalendarCog from "lucide-svelte/icons/calendar-cog"
Expand Down Expand Up @@ -476,6 +477,16 @@ export function manageNav(
icon: UserRoundCog,
href: resolve(`/my/hackathon/${hackathonId}/teams/manage`),
},
// After Teams, as in the participant entries: submissions hang off a team.
// Unconditional, like Manage Voting — whether submissions are open is a
// switch the page itself reports, and "who has not submitted" is the
// question an organiser asks most once they have closed.
{
id: "manage:submissions",
label: "Manage Submissions",
icon: Inbox,
href: resolve(`/my/hackathon/${hackathonId}/submissions/manage`),
},
// Editing, deleting and declaring a phase current — but only once there is a
// phase to do any of that to. Phases are optional, exactly like tracks, so
// the way to the *first* one is the "Where the hackathon is" card on
Expand Down
22 changes: 22 additions & 0 deletions components/frontend/src/lib/server/hackathon/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,28 @@ export function mayFinalizeSubmissions(
return membership !== undefined && !membership.isWaiting
}

/**
* Whether to offer the organiser's overview of every team's submissions.
*
* Mirrors the backend exactly: `ListSubmissions` falls back to a hackathon-wide
* `submission:read` (`team_service.go:1103-1106`), a default policy granted to
* `Owner` outright (`rbac.go:238`) and to an admin through the global escape
* hatch. Not gated on `CREATE_PROJECT_SUBMISSIONS` — that capability is about
* teams filing work, and an organiser needs the overview most once it is closed.
*
* Read only. Creating and finalizing need `submission:write` scoped to the team,
* which only `Member` holds (`rbac.go:234`); with no role inheritance an owner
* cannot finalize for a team, so the overview offers no action at all.
*/
export function mayViewAllSubmissions(
membership: HackathonMember | undefined,
isAdmin = false,
): boolean {
if (isAdmin) return true

return membership?.role === HackathonRole.HACKATHON_ROLE_OWNER
}

/**
* Whether to offer vote category management — create, edit, delete.
*
Expand Down
100 changes: 99 additions & 1 deletion components/frontend/src/lib/server/hackathon/submissions.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, it, expect } from "vitest"
import type { Submission } from "$lib/server/grpc/generated/hackathon/entities/submission"
import { submissionVersions } from "./submissions"
import type { Team } from "$lib/server/grpc/generated/hackathon/entities/team"
import type { User } from "$lib/server/grpc/generated/user/entities/user"
import {
submissionOverview,
submissionStanding,
submissionVersions,
} from "./submissions"

// SubmissionStatus numeric values: DRAFT=1, FINAL=2.
const DRAFT = 1
Expand Down Expand Up @@ -150,3 +156,95 @@ describe("submissionVersions", () => {
expect(r.latest?.result).toBeUndefined()
})
})

describe("submissionStanding", () => {
it("is missing with nothing filed", () => {
expect(submissionStanding(submissionVersions([], names))).toBe("missing")
})

it("is draft while nothing is final", () => {
const r = submissionVersions(
[sub({ version: 1 }), sub({ version: 2 })],
names,
)

expect(submissionStanding(r)).toBe("draft")
})

it("stays final when a newer draft sits on top of the entry", () => {
const r = submissionVersions(
[sub({ version: 1, status: FINAL }), sub({ version: 2, status: DRAFT })],
names,
)

expect(submissionStanding(r)).toBe("final")
})
})

describe("submissionOverview", () => {
const user = (id: string, displayName: string): User =>
({ id, displayName, username: id }) as User

/** A team with only the fields the overview reads. */
const team = (id: string, name: string, members: User[] = []): Team =>
({ id, name, projectId: "project-1", members }) as unknown as Team

const titles = new Map([["project-1", "Flood maps"]])

it("puts teams with nothing first, then drafts, then finals, each by name", () => {
const teams = [
team("t-final", "Alpha"),
team("t-draft-b", "Bravo"),
team("t-none", "Zulu"),
team("t-draft-a", "Able"),
]
const byTeam = new Map([
["t-final", [sub({ version: 1, status: FINAL })]],
["t-draft-b", [sub({ version: 1 })]],
["t-draft-a", [sub({ version: 1 })]],
])

const rows = submissionOverview(teams, byTeam, titles, names)

expect(rows.map((r) => [r.teamName, r.standing])).toEqual([
["Zulu", "missing"],
["Able", "draft"],
["Bravo", "draft"],
["Alpha", "final"],
])
})

it("names the project, and says so when it cannot", () => {
const rows = submissionOverview(
[
team("t1", "Known"),
{ ...team("t2", "Orphan"), projectId: "gone" } as Team,
],
new Map(),
titles,
names,
)

expect(rows.map((r) => r.projectTitle)).toEqual([
"Flood maps",
"Unknown project",
])
})

it("lists members by name, sorted, falling back to the username", () => {
const rows = submissionOverview(
[
team("t1", "Team", [
user("u-zed", "Zed"),
{ id: "u-anon", displayName: "", username: "anon" } as User,
user("u-amy", "Amy"),
]),
],
new Map(),
titles,
names,
)

expect(rows[0]?.members).toEqual(["Amy", "anon", "Zed"])
})
})
104 changes: 104 additions & 0 deletions components/frontend/src/lib/server/hackathon/submissions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { Submission } from "$lib/server/grpc/generated/hackathon/entities/submission"
import { SubmissionStatus } from "$lib/server/grpc/generated/hackathon/entities/submission_status"
import type { Team } from "$lib/server/grpc/generated/hackathon/entities/team"
import type { TeamServiceClient } from "$lib/server/grpc/generated/hackathon/team_service"
import {
SUBMISSION_STANDINGS,
type SubmissionStanding,
} from "$lib/utils/submissionFilter"

/**
* Server-only: reads the generated `Submission` type, so it must never be
Expand Down Expand Up @@ -89,3 +95,101 @@ export function submissionVersions(
.reverse(),
}
}

/**
* Where a team stands: nothing filed, only drafts, or a final entry.
*
* Read off `latestFinal` rather than `latest`, for the reason `submissionVersions`
* keeps the two apart — a draft created after finalizing does not take the
* team's entry back, so that team is still `final`.
*/
export function submissionStanding(s: TeamSubmissions): SubmissionStanding {
if (s.latestFinal) return "final"

return s.latest ? "draft" : "missing"
}

/** One team on Manage Submissions — and one line of its export. */
export interface TeamSubmissionRow extends TeamSubmissions {
teamId: string
teamName: string
projectTitle: string
/** Current members by name, sorted — who an organiser would chase. */
members: string[]
standing: SubmissionStanding
}

/**
* Every team's submissions, keyed by team id.
*
* One `ListSubmissions` per team, in parallel: `TeamService.List` returns teams
* without their submissions (`team_service.go:63-68`), and there is no RPC that
* lists a hackathon's submissions in one go.
*
* TODO(backend: submissions-hackathon-list): one call once the backend offers
* one — either `List` loading the submissions edge that `Get` already loads, or
* a hackathon-scoped `ListSubmissions`.
*
* `ListSubmissions` rather than the submissions nested in `team.get`, for the
* same reason the participant page gives: only this one is meant to be complete.
* Nothing is caught: an owner holds `submission:read` hackathon-wide
* (`rbac.go:238`), so a refusal here is a real fault worth surfacing.
*/
export async function submissionsByTeam(
team: TeamServiceClient,
teams: Team[],
): Promise<Map<string, Submission[]>> {
const lists = await Promise.all(
teams.map(async (t) => {
const { submissions } = await team.listSubmissions({ teamId: t.id })

return [t.id, submissions] as const
}),
)

return new Map(lists)
}

/**
* Every team as a row of the organiser's overview, sorted the way it is shown:
* by standing, the teams with nothing first and the finished ones last, then
* by name.
*
* Problems first because the page is for finding who still owes an entry — the
* teams that are done are the ones nobody has to do anything about. Ties go by
* team name, not by activity, so a row does not move every time a team saves.
*
* @param projectTitles project id → title; a team whose project is missing from
* it shows "Unknown project" rather than dropping out of the overview.
* @param memberNames user id → name, for creators and finalizers — see
* `submissionVersions`.
*/
export function submissionOverview(
teams: Team[],
byTeam: Map<string, Submission[]>,
projectTitles: Map<string, string>,
memberNames: Map<string, string>,
): TeamSubmissionRow[] {
const rank = (s: SubmissionStanding) => SUBMISSION_STANDINGS.indexOf(s)

return teams
.map((t) => {
const versions = submissionVersions(byTeam.get(t.id) ?? [], memberNames)

return {
teamId: t.id,
teamName: t.name,
projectTitle: projectTitles.get(t.projectId) ?? "Unknown project",
members: t.members
.map((m) => m.displayName || m.username)
.sort((a, b) => a.localeCompare(b)),
standing: submissionStanding(versions),
...versions,
}
})
.sort(
(a, b) =>
rank(a.standing) - rank(b.standing) ||
a.teamName.localeCompare(b.teamName),
)
}
Loading
Loading