From 648fe329de173b5a335fd2dbbb03510dbdb42553 Mon Sep 17 00:00:00 2001
From: Sabine Maennel <5292683+sabinem@users.noreply.github.com>
Date: Mon, 24 Aug 2026 16:42:16 +0200
Subject: [PATCH] =?UTF-8?q?fix(frontend):=20a=20timeline=20that=20only=20p?=
=?UTF-8?q?romises=20what=20a=20date=20can=20keep=20With=20a=20phase=20dec?=
=?UTF-8?q?lared=20current,=20SetCurrentPhase=20is=20the=20only=20thing=20?=
=?UTF-8?q?that=20moves=20the=20hackathon=20on=20=E2=80=94=20resolvePhaseS?=
=?UTF-8?q?tatus=20stops=20reading=20the=20dates=20altogether.=20So=20the?=
=?UTF-8?q?=20next=20phase's=20start=20date=20arriving=20does=20nothing,?=
=?UTF-8?q?=20and=20the=20countdown=20built=20on=20it=20told=20participant?=
=?UTF-8?q?s=20"Hacking=20starts=20in=203=20h"=20about=20a=20phase=20that?=
=?UTF-8?q?=20starts=20when=20an=20organizer=20clicks.=20Not=20stale,=20fa?=
=?UTF-8?q?lse,=20and=20on=20the=20two=20surfaces=20a=20participant=20read?=
=?UTF-8?q?s=20to=20plan=20their=20day.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
nextBoundary now takes declared and withholds the "starts" boundary
outright; a dangling current_phase_id counts as declared, since the
pointer says a human is driving whether or not it resolves. The
participant timeline's next-phase row applies the same guard, which is
what the new declared flag in its loader is for. The "ends" boundary
keeps none: a declared phase's own end date is real, and formatCountdown
already goes quiet once it passes.
Removing a countdown leaves the disagreeing dates unexplained, so
CurrentStateCard's declared/ended block gains a participant voicing — the
organisers decide when the hackathon moves on, so the dates are a guide
rather than a deadline. Wider than the organiser's phaseEnded line beside
it, firing on a phase declared before its start as well as one held past
its end, and silent when the declaration and the dates agree. The two
organiser lines stay organiser-only, being prompts to act.
PhaseService.Create discards start and end dates, so Add Phase does not
offer them and Edit is the only place a phase can be scheduled at all —
leaving an organizer to notice a list row reading "No dates set" and click
into it. Create now redirects onto the new phase's Edit form with ?added,
which that page reads to say why someone who clicked "Add phase" is
looking at one titled "Edit Phase". Reverts to a plain redirect once the
backend can store dates on create; noted in the ticket's follow-up.
---
.../hackathon/CurrentStateCard.svelte | 36 +++++++++++++-
.../frontend/src/lib/utils/phase.test.ts | 49 ++++++++++++++++++-
components/frontend/src/lib/utils/phase.ts | 43 ++++++++++++++--
.../src/lib/utils/relativeTime.test.ts | 48 ++++++++++++++++--
.../frontend/src/lib/utils/relativeTime.ts | 30 +++++++++---
.../hackathon/[id]/timeline/+page.server.ts | 5 ++
.../my/hackathon/[id]/timeline/+page.svelte | 10 +++-
.../manage/[phaseId]/edit/+page.server.ts | 10 ++++
.../manage/[phaseId]/edit/+page.svelte | 12 ++++-
.../[id]/timeline/manage/new/+page.server.ts | 20 +++++++-
.../[id]/timeline/manage/new/+page.svelte | 5 +-
11 files changed, 243 insertions(+), 25 deletions(-)
diff --git a/components/frontend/src/lib/components/hackathon/CurrentStateCard.svelte b/components/frontend/src/lib/components/hackathon/CurrentStateCard.svelte
index 2d1c42cb..38257f45 100644
--- a/components/frontend/src/lib/components/hackathon/CurrentStateCard.svelte
+++ b/components/frontend/src/lib/components/hackathon/CurrentStateCard.svelte
@@ -76,15 +76,41 @@
// there is one at all. `new Date()` is only ever compared against, never
// rendered — the visible clock lives in `Countdown`, which is what keeps this
// from becoming a hydration mismatch.
- const boundary = $derived(nextBoundary(currentPhase, nextPhase, new Date()));
+ const boundary = $derived(nextBoundary(currentPhase, nextPhase, new Date(), declared));
// The two conditions deliberately kept out of the banner (see `stateAlerts`):
// untidy rather than blocking, so an organiser reads them here without being
// interrupted by them elsewhere.
+ //
+ // Both stay organiser-only, because both are prompts to act. A participant
+ // reading "no phase is declared current" learns nothing they can use — with no
+ // declaration the dates decide, which is what a timeline looks like anyway.
const noCurrentPhase = $derived(organiserVoice && hasState && currentPhase === null);
const phaseEnded = $derived(
organiserVoice && currentPhase?.endsAt !== undefined && currentPhase.endsAt < new Date(),
);
+
+ // The participant's version of the same fact, and the one thing manual mode
+ // owes them: with a phase declared current the dates on this card and on the
+ // timeline have stopped deciding anything, so a window that disagrees with the
+ // clock is not a mistake and not something to plan around.
+ //
+ // Both directions, unlike `phaseEnded` above: an organiser who declares a
+ // phase early leaves a "current" phase whose start is still days off, which
+ // reads just as oddly as one held past its end. Suppressing the countdown
+ // (see `nextBoundary`) removes the false statement; this is what replaces it.
+ //
+ // A declared phase whose window still covers now says nothing — the dates and
+ // the declaration agree, and there is nothing to explain.
+ const outsideWindow = $derived.by(() => {
+ if (!declared || !currentPhase) return false;
+ const now = new Date();
+ return (
+ (currentPhase.endsAt !== undefined && currentPhase.endsAt < now) ||
+ (currentPhase.startsAt !== undefined && currentPhase.startsAt > now)
+ );
+ });
+ const datesAreAGuide = $derived(!organiserVoice && outsideWindow);
+ {#if phase.id === data.nextPhaseId && phase.startsAt && !data.declared}
- Changes are visible to participants immediately. + {#if data.justAdded} + {data.phase.name} was added. + Give it a start and an end to place it on the timeline — or save it as + it is and schedule it later. + {:else} + Changes are visible to participants immediately. + {/if}
diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/manage/new/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/manage/new/+page.server.ts index 8f2ee279..edafd517 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/manage/new/+page.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/manage/new/+page.server.ts @@ -35,8 +35,9 @@ export const actions: Actions = { } const values = parsed.values + let created try { - await phase.create({ + created = await phase.create({ hackathonId: event.params.id, name: values.name, description: values.description, @@ -72,6 +73,21 @@ export const actions: Actions = { throw e } - redirect(303, resolve(`/my/hackathon/${event.params.id}/timeline/manage`)) + // Straight to the new phase's Edit page rather than back to the list, because + // `Create` discards dates (see the TODO above) and Edit is therefore the only + // place a phase can be scheduled at all. Landing an organizer on the form that + // finishes the job beats a list row reading "No dates set" that they have to + // notice and click. `?added` is what lets that page say why they are there. + // + // Falls back to the list if the RPC somehow reports no id: an organizer looking + // at their new phase in the list is a worse outcome than a broken Edit URL. + redirect( + 303, + created.phaseId + ? `${resolve( + `/my/hackathon/${event.params.id}/timeline/manage/${created.phaseId}/edit`, + )}?added` + : resolve(`/my/hackathon/${event.params.id}/timeline/manage`), + ) }, } diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/manage/new/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/manage/new/+page.svelte index bc260c89..752ca2b2 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/manage/new/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/manage/new/+page.svelte @@ -30,8 +30,9 @@- Participants see the phase as soon as it is saved. Undated phases sort to the - top of the timeline until they are scheduled. + Participants see the phase as soon as it is saved, and the next step is + scheduling it — this form hands straight over to it. Undated phases sort to + the top of the timeline until they are scheduled.