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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
</script>

<!--
Expand Down Expand Up @@ -241,7 +267,7 @@
</a>
{/if}

{#if noCurrentPhase || phaseEnded}
{#if noCurrentPhase || phaseEnded || datesAreAGuide}
<div class="flex flex-col gap-1 border-t border-line pt-3">
{#if noCurrentPhase}
<span class="text-xs text-ink-3">
Expand All @@ -253,6 +279,12 @@
This phase's dates have passed — it stays current until you change it.
</span>
{/if}
{#if datesAreAGuide}
<span class="text-xs text-ink-3">
The organisers decide when the hackathon moves on, so the dates on the
timeline are a guide rather than a deadline.
</span>
{/if}
</div>
{/if}
</section>
49 changes: 47 additions & 2 deletions components/frontend/src/lib/utils/phase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,17 +256,62 @@ describe("formatPhaseRange", () => {
new Date("2026-08-20T09:00"),
new Date("2026-08-25T18:00"),
),
).toBe("Aug 20, 2026, 09:00 – Aug 25, 2026, 18:00")
})

// The case the date-only format could not tell apart: three phases of one
// hackathon day all read "Aug 25, 2026 – Aug 25, 2026" before.
it("collapses the repeated date on a single-day range", () => {
expect(
formatPhaseRange(
new Date("2026-08-25T09:00"),
new Date("2026-08-25T13:00"),
),
).toBe("Aug 25, 2026, 09:00 – 13:00")
})

// Midnight on both bounds is an organizer who left the time fields alone, so
// the range is the whole day and there is no time worth printing.
it("prints dates alone when both bounds are at midnight", () => {
expect(
formatPhaseRange(
new Date("2026-08-20T00:00"),
new Date("2026-08-25T00:00"),
),
).toBe("Aug 20, 2026 – Aug 25, 2026")
})

it("collapses an all-day range to the one date", () => {
expect(
formatPhaseRange(
new Date("2026-08-25T00:00"),
new Date("2026-08-25T00:00"),
),
).toBe("Aug 25, 2026")
})

// One decision for the range, not per bound: an end at 00:00 keeps its time
// rather than looking like a range with no end stated.
it("shows both times when only one bound has one", () => {
expect(
formatPhaseRange(
new Date("2026-08-25T09:00"),
new Date("2026-08-26T00:00"),
),
).toBe("Aug 25, 2026, 09:00 – Aug 26, 2026, 00:00")
})

// Both-or-neither is enforced on write by a CEL rule, but rows predating it can
// still carry one alone, so neither half may render as "Invalid Date".
it("handles one bound alone", () => {
expect(formatPhaseRange(new Date("2026-08-20T09:00"), undefined)).toBe(
"From Aug 20, 2026",
"From Aug 20, 2026, 09:00",
)
expect(formatPhaseRange(undefined, new Date("2026-08-25T18:00"))).toBe(
"Until Aug 25, 2026",
"Until Aug 25, 2026, 18:00",
)
expect(formatPhaseRange(new Date("2026-08-20T00:00"), undefined)).toBe(
"From Aug 20, 2026",
)
})

Expand Down
43 changes: 40 additions & 3 deletions components/frontend/src/lib/utils/phase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,24 @@ export function sortPhasesByStart<T extends { startsAt?: Date | undefined }>(
* Both dates are optional in the schema, and the CEL rule only forbids one
* *without* the other on write — rows predating it can still carry either alone,
* so all four combinations are handled.
*
* **Times are shown when there are times to show.** The organizer's form fields
* are `datetime-local`, so a phase carries an hour and a minute whether or not
* anyone meant it to; printing the date alone made every phase of a two-day
* hackathon read "Aug 25, 2026 – Aug 25, 2026" and threw away the half of the
* schedule that actually paces a hackathon day. Midnight on both bounds is read
* as "the whole day" and prints as before — that is what an organizer who left
* the time fields alone meant, and it is the only reading under which a
* suppressed time loses nothing.
*
* One decision for the whole range, never per bound: a start at 09:00 against an
* end at midnight prints both, because "Aug 25, 2026, 09:00 – Aug 26, 2026" would
* read as a range whose end is unspecified rather than as one ending at 00:00.
*
* 24-hour, and the repeated date collapsed on a single-day range — "Aug 25, 2026,
* 09:00 – 13:00". Both keep the line short enough to sit beside a phase name in
* the `tnum` spans that render it, which is the whole reason the date is not
* simply printed twice.
*/
export function formatPhaseRange(
startsAt: Date | undefined,
Expand All @@ -87,11 +105,30 @@ export function formatPhaseRange(
day: "numeric",
year: "numeric",
})
// Local, like `toDateTimeLocal` and for the same reason: an organizer's 09:00
// is their own 09:00, and `toLocaleTimeString` with no zone honours that.
const time = (d: Date) =>
d.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
const midnight = (d: Date) => d.getHours() === 0 && d.getMinutes() === 0
const timed = [startsAt, endsAt].some((d) => d !== undefined && !midnight(d))
const stamp = (d: Date) => (timed ? `${fmt(d)}, ${time(d)}` : fmt(d))

if (!startsAt && !endsAt) return "No dates set"
if (!startsAt) return `Until ${fmt(endsAt as Date)}`
if (!endsAt) return `From ${fmt(startsAt)}`
if (!startsAt) return `Until ${stamp(endsAt as Date)}`
if (!endsAt) return `From ${stamp(startsAt)}`

// Compared as rendered, so "same day" means the same thing the reader sees.
if (fmt(startsAt) === fmt(endsAt)) {
return timed
? `${fmt(startsAt)}, ${time(startsAt)} – ${time(endsAt)}`
: fmt(startsAt)
}

return `${fmt(startsAt)} – ${fmt(endsAt)}`
return `${stamp(startsAt)} – ${stamp(endsAt)}`
}

/**
Expand Down
48 changes: 44 additions & 4 deletions components/frontend/src/lib/utils/relativeTime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,28 +52,67 @@ describe("nextBoundary", () => {
{ endsAt: at("2026-08-13T12:00:00Z") },
{ startsAt: at("2026-08-13T12:00:00Z") },
NOW,
false,
)
expect(b).toEqual({ verb: "ends", target: at("2026-08-13T12:00:00Z") })
})

it("counts down to the end of a declared phase too", () => {
// A declared phase's own end date is still worth naming — it is what the
// organizer scheduled. Only the *next* phase's start is a promise the dates
// cannot keep.
const b = nextBoundary(
{ endsAt: at("2026-08-13T12:00:00Z") },
{ startsAt: at("2026-08-14T09:00:00Z") },
NOW,
true,
)
expect(b).toEqual({ verb: "ends", target: at("2026-08-13T12:00:00Z") })
})

it("falls through to the next phase starting once the current one has ended", () => {
// Routine, not an edge case: a declared phase stays current until an
// organizer moves the pointer, so its end date passing is normal.
const b = nextBoundary(
{ endsAt: at("2026-08-10T12:00:00Z") },
{ startsAt: at("2026-08-15T09:00:00Z") },
NOW,
false,
)
expect(b).toEqual({ verb: "starts", target: at("2026-08-15T09:00:00Z") })
})

it("names no start while a phase is declared current", () => {
// The lie this guard exists to stop: nothing starts the next phase but
// SetCurrentPhase, so "starts in 3 days" would be false, not just stale.
expect(
nextBoundary(
{ endsAt: at("2026-08-10T12:00:00Z") },
{ startsAt: at("2026-08-15T09:00:00Z") },
NOW,
true,
),
).toBeNull()
})

it("names no start for a declaration that resolves to no phase", () => {
// A `current_phase_id` naming a phase that is not in the list. The pointer
// still says an organizer is driving, so the dates still decide nothing.
expect(
nextBoundary(null, { startsAt: at("2026-08-15T09:00:00Z") }, NOW, true),
).toBeNull()
})

it("uses the next phase when there is no current one", () => {
const b = nextBoundary(null, { startsAt: at("2026-08-15T09:00:00Z") }, NOW)
const b = nextBoundary(
null,
{ startsAt: at("2026-08-15T09:00:00Z") },
NOW,
false,
)
expect(b).toEqual({ verb: "starts", target: at("2026-08-15T09:00:00Z") })
})

it("is null when the current phase has no end date and nothing is next", () => {
expect(nextBoundary({ endsAt: undefined }, null, NOW)).toBeNull()
expect(nextBoundary({ endsAt: undefined }, null, NOW, false)).toBeNull()
})

it("is null when every date is in the past", () => {
Expand All @@ -82,6 +121,7 @@ describe("nextBoundary", () => {
{ endsAt: at("2026-08-01T12:00:00Z") },
{ startsAt: at("2026-08-02T12:00:00Z") },
NOW,
false,
),
).toBeNull()
})
Expand Down
30 changes: 22 additions & 8 deletions components/frontend/src/lib/utils/relativeTime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,24 +45,38 @@ export type Boundary = { verb: "ends" | "starts"; target: Date }
* The next moment the hackathon changes shape, from the current and next phase.
*
* The current phase ending is the boundary that matters while it is running,
* because that is when what participants may do can change. Once it has ended —
* which happens routinely, since a declared phase stays current until an
* organizer moves the pointer — the next phase starting is the only boundary
* left to name, so this falls through to it rather than reporting nothing.
* because that is when what participants may do can change. Once it has ended,
* the next phase starting is the only boundary left to name — but only where a
* date is what will start it.
*
* Null when neither date is in the future: a hackathon with no dates set, or one
* whose last phase has run out. Both are states the card renders without a
* countdown rather than with an invented one.
* **`declared` suppresses the "starts" boundary entirely.** With a phase declared
* current, `SetCurrentPhase` is the only thing that moves the hackathon on:
* `resolvePhaseStatus` stops reading the dates, the declared phase stays current
* after its window runs out, and the next phase's start date arriving does
* nothing at all. Counting down to it told a participant "Hacking starts in 3 h"
* about a phase that starts when an organizer clicks — the one statement on the
* page that could be flatly false rather than merely stale. A dangling
* `current_phase_id` counts as declared too: the pointer says an organizer is
* driving, whether or not it resolves.
*
* The "ends" boundary needs no such guard. A declared phase's end date is not a
* promise about what happens next, and `formatCountdown` returns null once it has
* passed, so a phase held past its window simply stops counting.
*
* Null when there is no boundary left to name: no dates set, the last phase run
* out, or a declaration with its end behind us. All are states the caller renders
* without a countdown rather than with an invented one.
*/
export function nextBoundary(
current: { endsAt?: Date | undefined } | null,
next: { startsAt?: Date | undefined } | null,
now: Date,
declared: boolean,
): Boundary | null {
if (current?.endsAt && current.endsAt > now) {
return { verb: "ends", target: current.endsAt }
}
if (next?.startsAt && next.startsAt > now) {
if (!declared && next?.startsAt && next.startsAt > now) {
return { verb: "starts", target: next.startsAt }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export const load: PageServerLoad = async (event) => {
// Which row gets the "starts in …" countdown. Named rather than derived
// here so it is the same "next" the overview's card names.
nextPhaseId: hackathonState.nextPhase?.id ?? "",
// Whether an organizer declared the current phase, in which case no date
// starts anything and the countdown above is withheld — same rule
// `nextBoundary` applies to the overview's card. The page needs the flag
// rather than deriving it from `livePhaseId`, which is set either way.
declared: hackathonState.declared,
enabled: hackathonState.enabled,
hasState: hackathonState.hasState,
// Every capability check also requires a confirmed membership, so "open now"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,15 @@
</a>
{/if}
<span class="ms-auto flex shrink-0 items-baseline gap-3">
{#if phase.id === data.nextPhaseId && phase.startsAt}
<!-- Withheld while a phase is declared current:
nothing but SetCurrentPhase starts the next
one then, so its start date arriving does
nothing and "starts in 3 h" would be a
promise no clock keeps. Same guard
`nextBoundary` applies for the overview's
card; the date beside it still says when it
was scheduled for. -->
{#if phase.id === data.nextPhaseId && phase.startsAt && !data.declared}
<Countdown
boundary={{ verb: 'starts', target: phase.startsAt }}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,18 @@
&larr; Back to Manage Timeline
</a>
<h1 class="m-0 text-title text-ink">Edit Phase</h1>
<!-- Says why they are here when they did not choose to be: Add Phase
redirects onto this form because creating a phase cannot store its
dates. Without the line, an organizer who clicked "Add phase" lands on
a page titled "Edit Phase" and has to work out what happened. -->
<p class="m-0 text-xs text-ink-3">
Changes are visible to participants immediately.
{#if data.justAdded}
<strong class="text-ink">{data.phase.name} was added.</strong>
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}
</p>
</div>

Expand Down
Loading
Loading