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 @@ -30,9 +30,17 @@ written while it was being built. See [RELEASING.md](RELEASING.md).
until opened, its description.
- A team's page now shows its entry's attachments link and description, next to
the repository link.
- Participants who proposed a project find it under "My projects" in the
hackathon's menu, at any status, also after proposals have closed.

### Changed

- A proposer can now edit their project after it has been approved or rejected.
- Every project in a list, and every project page, now names who proposed it,
also when an organiser proposed it without joining the hackathon.
- The button that opens a project from a list now reads "View" instead of "More
Info".

- The repository link and the attachments link on a submission must now start
with `https://`. The box that used to take a link or free-text notes now takes
a link only, and notes go in the description.
Expand Down
42 changes: 21 additions & 21 deletions components/frontend/src/lib/components/hackathon/ProjectCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
track,
imageUrl,
moreInfoHref = '#',
moreInfoLabel = 'More Info',
moreInfoLabel = 'View',
badge,
badgeVariant = 'badge-neutral',
actions,
Expand All @@ -23,14 +23,17 @@
* by the loader — never the raw body. Absent when the project has no
* description, or has one with nothing quotable in it. */
excerpt?: string;
/** Who proposed it. Omitted when the creator is no longer a member. */
creator?: string;
/** Who proposed it. Required, so no list forgets it: every card shows
* its author. Undefined only when nobody the hackathon knows has that
* id any more — the card then says "Unknown author". */
creator: string | undefined;
/** Track name. Omitted when the project has no track, or its track was
* deleted — the row then simply carries no track. */
track?: string;
imageUrl?: string;
moreInfoHref?: string;
/** CTA text. The card is a row first and a "More Info" link second. */
/** CTA text. The card is a row first and a "View" link second. The
* prop keeps its older name; only the text it defaults to changed. */
moreInfoLabel?: string;
/** Generic chip text — kept a plain string so the card is not tied to
* any one vocabulary. Both project lists pass a status; others may not. */
Expand Down Expand Up @@ -97,23 +100,20 @@
facts read as attributes of the row rather than sentences. The
icons carry no information a sighted reader needs spelling out and
no screen reader needs repeating — the text beside them says it —
so they are aria-hidden. -->
{#if creator || track}
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-ink-3">
{#if creator}
<span class="inline-flex items-center gap-1.5">
<User class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{creator}
</span>
{/if}
{#if track}
<span class="inline-flex items-center gap-1.5">
<Tag class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{track}
</span>
{/if}
</div>
{/if}
so they are aria-hidden. The author is always there; the track only
when the project has one. -->
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-ink-3">
<span class="inline-flex items-center gap-1.5">
<User class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{creator ?? 'Unknown author'}
</span>
{#if track}
<span class="inline-flex items-center gap-1.5">
<Tag class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{track}
</span>
{/if}
</div>
</div>

<div class="flex shrink-0 items-center gap-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@
/** Omitted when the project has no track, or the hackathon has none
at all — the Track cell is then not drawn. */
track?: string;
proposer?: string;
/** Required, like ProjectCard's `creator`, so the page always names
its author. Undefined only when nobody the hackathon knows has
that id any more — the page then says "an unknown author". */
proposer: string | undefined;
createdAt?: Date;
modifiedAt?: Date;
};
Expand Down Expand Up @@ -113,8 +116,8 @@
{/if}
</div>
<p class="m-0 text-xs leading-snug text-ink-3">
{#if project.proposer}Proposed by {project.proposer}{/if}
{#if project.proposer && proposedOn}&middot;{/if}
Proposed by {project.proposer ?? 'an unknown author'}
{#if proposedOn}&middot;{/if}
{#if proposedOn}{proposedOn}{/if}
{#if editedOn}&middot; edited {editedOn}{/if}
</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
teamCount = 0,
trackCount = 0,
phaseCount = 0,
myProjectCount = 0,
stateNeedsAttention = false,
isPrivate = false,
}: {
Expand Down Expand Up @@ -82,6 +83,11 @@
* "Where the hackathon is" card on Settings.
*/
phaseCount?: number;
/**
* How many projects the viewer proposed here, zero unless they take
* part. Decides whether My projects is offered — see `memberNav`.
*/
myProjectCount?: number;
/**
* Whether the hackathon has a description at all. Gates the About entry
* — see `memberNav`. False, and there is nothing to read, so the entry
Expand Down Expand Up @@ -136,7 +142,8 @@
votingEnabled,
resultsVisible,
teamCount,
phaseCount
phaseCount,
myProjectCount
),
);
// Given the same `membership`/`isGlobalAdmin` as `badge` below, so the Manage
Expand Down
34 changes: 34 additions & 0 deletions components/frontend/src/lib/navigation/items.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,40 @@ describe("memberNav", () => {
])
})

// Only for someone who proposed here, and right under Projects, whose subtree
// its route sits in.
describe("myProjectCount", () => {
it("offers no My projects entry to a viewer who proposed nothing", () => {
expect(idsOf(memberNav("hack-1"))).not.toContain("member:my-projects")
})

it("offers My projects right under Projects once the viewer proposed one", () => {
const items = memberNav("hack-1", [], false, false, 1, 1, 1)

expectOrder(items, [
"member:projects",
"member:my-projects",
"member:teams",
])
expect(idsOf(items).indexOf("member:my-projects")).toBe(
idsOf(items).indexOf("member:projects") + 1,
)
})

// The longest match wins, so the nested route lights its own entry, and a
// project's page and its edit form still light Projects.
it("lights My projects on its page, and Projects on a project", () => {
const items = memberNav("hack-1", [], false, false, 0, 0, 1)

expect(activeNavId("/my/hackathon/hack-1/projects/mine", items)).toBe(
"member:my-projects",
)
expect(activeNavId("/my/hackathon/hack-1/projects/p1/edit", items)).toBe(
"member:projects",
)
})
})

// Phases are optional, so the entry is too — and unlike Teams, the organiser's
// half is gated on the same count, because Settings is what leads to the first
// phase.
Expand Down
20 changes: 20 additions & 0 deletions components/frontend/src/lib/navigation/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import LayoutDashboard from "lucide-svelte/icons/layout-dashboard"
import Users from "lucide-svelte/icons/users"
import UsersRound from "lucide-svelte/icons/users-round"
import Lightbulb from "lucide-svelte/icons/lightbulb"
import NotebookPen from "lucide-svelte/icons/notebook-pen"
import ClipboardCheck from "lucide-svelte/icons/clipboard-check"
import ClipboardList from "lucide-svelte/icons/clipboard-list"
import UserRoundCheck from "lucide-svelte/icons/user-round-check"
Expand Down Expand Up @@ -122,6 +123,10 @@ export interface HackathonPageRef {
* "assignments not published" — two states that want the same nav either way.
* Zero is the honest default, the same way `manageNav` treats `trackCount`.
*
* `myProjectCount` gates My projects: how many projects the viewer proposed
* here, counted by `myProjects`, which already returns zero for anyone not
* taking part. Deliberately blind to the phase and the capabilities — a
* proposer finds their projects whether or not proposals are still open.
*/
export function memberNav(
hackathonId: string,
Expand All @@ -130,6 +135,7 @@ export function memberNav(
resultsVisible = false,
teamCount = 0,
phaseCount = 0,
myProjectCount = 0,
): NavItem[] {
return [
{
Expand All @@ -155,6 +161,20 @@ export function memberNav(
icon: Lightbulb,
href: resolve(`/my/hackathon/${hackathonId}/projects`),
},
// Right under Projects, as the viewer's own slice of it. Only for someone
// who proposed something, so most participants never see it. Its route is
// nested under projects/, and the longest match lights this entry rather
// than Projects; a project's own page and its edit form stay under Projects.
...(myProjectCount > 0
? [
{
id: "member:my-projects",
label: "My projects",
icon: NotebookPen,
href: resolve(`/my/hackathon/${hackathonId}/projects/mine`),
},
]
: []),
// Between Projects and Submissions, following the hackathon's own order of
// events: projects are approved, teams form on them, teams submit.
//
Expand Down
72 changes: 72 additions & 0 deletions components/frontend/src/lib/server/hackathon/myProjects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest"
import type { Hackathon } from "$lib/server/grpc/generated/hackathon/entities/hackathon"
import type { HackathonMember } from "$lib/server/grpc/generated/hackathon/entities/hackathon_member"
import type { Project } from "$lib/server/grpc/generated/hackathon/entities/project"
import type { User } from "$lib/server/grpc/generated/user/entities/user"
import { myProjects } from "./myProjects"

// HackathonRole: OWNER=1, MEMBER=2. ProjectStatus: PROPOSED=1, APPROVED=2,
// REJECTED=3.
const OWNER = 1
const MEMBER = 2
const PROPOSED = 1
const APPROVED = 2
const REJECTED = 3

const ALICE = "user-alice"
const BOB = "user-bob"

const member = (id: string, role: number, isWaiting = false) =>
({ user: { id } as User, role, isWaiting }) as HackathonMember

const project = (
id: string,
creatorId: string,
status: number,
createdAt: string,
) => ({ id, creatorId, status, createdAt: new Date(createdAt) }) as Project

const hackathon = (members: HackathonMember[], projects: Project[]) =>
({ members, projects }) as unknown as Hackathon

const ids = (projects: Project[]) => projects.map((p) => p.id)

describe("myProjects", () => {
const projects = [
project("p-old", ALICE, APPROVED, "2026-10-01T09:00:00Z"),
project("p-bob", BOB, PROPOSED, "2026-10-02T09:00:00Z"),
project("p-new", ALICE, PROPOSED, "2026-10-03T09:00:00Z"),
project("p-rej", ALICE, REJECTED, "2026-10-02T12:00:00Z"),
]

it("lists a participant's own projects at every status, newest first", () => {
const h = hackathon([member(ALICE, MEMBER), member(BOB, MEMBER)], projects)

expect(ids(myProjects(h, ALICE))).toEqual(["p-new", "p-rej", "p-old"])
})

it("lists them for an owner who also joined as a participant", () => {
const h = hackathon([member(ALICE, OWNER)], projects)

expect(ids(myProjects(h, ALICE))).toEqual(["p-new", "p-rej", "p-old"])
})

// An owner who never joined has no participant row, whatever they proposed.
it("lists nothing for an owner who is not a participant", () => {
const h = hackathon([member(BOB, MEMBER)], projects)

expect(myProjects(h, ALICE)).toEqual([])
})

it("lists nothing for a waitlisted participant", () => {
const h = hackathon([member(ALICE, MEMBER, true)], projects)

expect(myProjects(h, ALICE)).toEqual([])
})

it("lists nothing for a viewer with no platform user", () => {
const h = hackathon([member(ALICE, MEMBER)], projects)

expect(myProjects(h, undefined)).toEqual([])
})
})
39 changes: 39 additions & 0 deletions components/frontend/src/lib/server/hackathon/myProjects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Hackathon } from "$lib/server/grpc/generated/hackathon/entities/hackathon"
import type { Project } from "$lib/server/grpc/generated/hackathon/entities/project"
import { participantRowFor } from "$lib/server/hackathon/membership"

/**
* The projects the viewer proposed in this hackathon, at every status, newest
* first — what the sidebar's My projects entry counts and its page lists. One
* function for both, so the entry is never offered for a page that comes up
* empty.
*
* **Only for a confirmed participant.** An owner can propose without taking part
* — they hold `project:propose` as a default policy, not through membership —
* and their proposals are theirs to manage from Manage Projects, so My projects
* is the participant's view of what they put forward. `role` cannot make that
* call: it reports OWNER whether or not the owner also joined. The participant
* row can, which is why this goes through `participantRowFor`. A waitlisted
* participant counts as not yet taking part, the same as everywhere else.
*
* Not gated on the phase or on `CAPABILITY_PROPOSE_PROJECTS`: closing proposals
* stops new ones, it does not take away the ones already made.
*
* Frontend-only, like every reading of `hackathon.projects`: `Get` returns every
* project to every member, so this shapes the UI rather than enforcing anything.
*
* @param platformUserId the viewer's backend user id, not their Keycloak id.
*/
export function myProjects(
hackathon: Pick<Hackathon, "members" | "projects">,
platformUserId: string | undefined,
): Project[] {
const row = participantRowFor(hackathon.members, platformUserId)
if (row === undefined || row.isWaiting) return []

return hackathon.projects
.filter((p) => p.creatorId === platformUserId)
.sort(
(a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0),
)
}
44 changes: 18 additions & 26 deletions components/frontend/src/lib/server/hackathon/projectEdit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,38 +55,30 @@ describe("projectEditData", () => {
expect(data.project.id).toBe("p1")
})

// The rule the review asked for: editing belongs to an open proposal, and an
// approved project belongs to the hackathon rather than to whoever proposed it.
it("refuses the proposer once the project is approved", () => {
const e = refusal(() =>
projectEditData(
hackathon({ status: APPROVED }),
"p1",
member(MEMBER),
user(ALICE),
),
// Review does not take the project from its proposer: they keep editing it
// once approved, and the projects page offers them Edit on its row.
it("lets the proposer edit once the project is approved", () => {
const data = projectEditData(
hackathon({ status: APPROVED }),
"p1",
member(MEMBER),
user(ALICE),
)

expect(e.status).toBe(403)
// Names the status rather than their standing — they did propose it.
expect(e.body.message).toMatch(/reviewed/)
expect(data.project.id).toBe("p1")
})

// Relied on by the projects page: a rejected proposal stays in its author's
// proposals group, so that row must offer "More Info" rather than an Edit link
// that answers 403. Reconsidering it returns it to PROPOSED and editable.
it("refuses the proposer once the project is rejected", () => {
const e = refusal(() =>
projectEditData(
hackathon({ status: REJECTED }),
"p1",
member(MEMBER),
user(ALICE),
),
// Relied on by My projects, which offers Edit on every row the viewer
// proposed, a rejected one included.
it("lets the proposer edit once the project is rejected", () => {
const data = projectEditData(
hackathon({ status: REJECTED }),
"p1",
member(MEMBER),
user(ALICE),
)

expect(e.status).toBe(403)
expect(e.body.message).toMatch(/reviewed/)
expect(data.project.id).toBe("p1")
})

it("still lets the organiser edit a rejected project", () => {
Expand Down
Loading
Loading