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} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/manage/[phaseId]/edit/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/manage/[phaseId]/edit/+page.server.ts index 56f0374e..8cf02a3a 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/manage/[phaseId]/edit/+page.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/manage/[phaseId]/edit/+page.server.ts @@ -48,6 +48,16 @@ export const load: PageServerLoad = async (event) => { return { hackathonId: hackathon.id, + // Arrived here straight from Add Phase, which redirects with `?added` because + // `Create` cannot store dates and this is where they get set. Carried in the + // URL rather than in state, so a phase legitimately left undated is not told + // it was just created when it is reached from the list. + // + // The form posts to `?/save`, which replaces the whole query string — so the + // notice is gone by the second render, failed save included. That is the + // wanted behaviour rather than a limitation: it answers "why am I on Edit", + // and after submitting once nobody is asking. + justAdded: event.url.searchParams.has("added"), phase: { id: result.phase.id, name: result.phase.name, diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/manage/[phaseId]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/manage/[phaseId]/edit/+page.svelte index 3fe26460..e3a6d748 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/manage/[phaseId]/edit/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/manage/[phaseId]/edit/+page.svelte @@ -25,8 +25,18 @@ ← Back to Manage Timeline

Edit Phase

+

- 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 @@

Add Phase

- 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.