From 536548aa9abc2862349e455c41d99f788502d047 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 15:38:32 +0000 Subject: [PATCH 1/9] feat(phases): live now/next schedule experience Adds a pure now/next set classifier, a Live-only Now/Next section leading the Schedule tab (reusing MobileSetCard), and Live copy in the shared phase banner. Skipped below reveal level full; no background timer. Closes #138. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016AXf1EyBWLa985JwjYwvKX --- src/lib/nowNext.test.ts | 149 ++++++++++++++++++ src/lib/nowNext.ts | 61 +++++++ src/pages/EditionView/PhaseBanner.tsx | 4 + src/pages/EditionView/tabs/ScheduleTab.tsx | 3 + .../tabs/ScheduleTab/NowNextSection.tsx | 105 ++++++++++++ 5 files changed, 322 insertions(+) create mode 100644 src/lib/nowNext.test.ts create mode 100644 src/lib/nowNext.ts create mode 100644 src/pages/EditionView/tabs/ScheduleTab/NowNextSection.tsx diff --git a/src/lib/nowNext.test.ts b/src/lib/nowNext.test.ts new file mode 100644 index 00000000..10cb805c --- /dev/null +++ b/src/lib/nowNext.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import { classifyNowNext } from "./nowNext"; + +// Europe/Lisbon is UTC+1 (WEST) in August, so a set playing after midnight +// festival time still sits on the previous UTC calendar day. Classification +// compares instants, so the viewer-vs-festival day mismatch must not matter. +// +// Festival night of 2025-08-01 → 2025-08-02 (Lisbon wall clock): +// 23:00–00:00 Lisbon = 22:00Z–23:00Z +// 00:00–01:00 Lisbon (Aug 2) = 23:00Z–00:00Z (still Aug 1 in UTC) +const LATE_SET = { + id: "late", + time_start: "2025-08-01T22:00:00.000Z", + time_end: "2025-08-01T23:00:00.000Z", +}; +const MIDNIGHT_SET = { + id: "midnight", + time_start: "2025-08-01T23:00:00.000Z", + time_end: "2025-08-02T00:00:00.000Z", +}; + +function at(iso: string): Date { + return new Date(iso); +} + +describe("classifyNowNext", () => { + it("marks a set now-playing for now in [time_start, time_end)", () => { + const { nowPlaying, next } = classifyNowNext( + [LATE_SET, MIDNIGHT_SET], + at("2025-08-01T22:30:00Z"), + ); + expect(nowPlaying.map((s) => s.id)).toEqual(["late"]); + expect(next.map((s) => s.id)).toEqual(["midnight"]); + }); + + it("is now-playing at exactly time_start (inclusive)", () => { + const { nowPlaying } = classifyNowNext( + [LATE_SET], + at("2025-08-01T22:00:00Z"), + ); + expect(nowPlaying.map((s) => s.id)).toEqual(["late"]); + }); + + it("is no longer now-playing at exactly time_end (exclusive)", () => { + const { nowPlaying } = classifyNowNext( + [LATE_SET, MIDNIGHT_SET], + at("2025-08-01T23:00:00Z"), + ); + expect(nowPlaying.map((s) => s.id)).toEqual(["midnight"]); + }); + + it("classifies across the festival midnight even when the UTC day differs", () => { + // 00:30 Lisbon on Aug 2 is still Aug 1 in UTC — instant comparison only. + const { nowPlaying, next } = classifyNowNext( + [LATE_SET, MIDNIGHT_SET], + at("2025-08-01T23:30:00Z"), + ); + expect(nowPlaying.map((s) => s.id)).toEqual(["midnight"]); + expect(next).toEqual([]); + }); + + it("selects only the nearest upcoming start as next, not everything upcoming", () => { + const later = { + id: "later", + time_start: "2025-08-02T01:00:00.000Z", + time_end: "2025-08-02T02:00:00.000Z", + }; + const { next, laterPast } = classifyNowNext( + [later, MIDNIGHT_SET, LATE_SET], + at("2025-08-01T21:00:00Z"), + ); + expect(next.map((s) => s.id)).toEqual(["late"]); + expect(laterPast.map((s) => s.id)).toEqual(["midnight", "later"]); + }); + + it("includes all sets tied on the nearest start (multi-stage concurrency), ordered by id", () => { + const stageB = { ...MIDNIGHT_SET, id: "a-other-stage" }; + const { next } = classifyNowNext( + [MIDNIGHT_SET, stageB], + at("2025-08-01T22:30:00Z"), + ); + expect(next.map((s) => s.id)).toEqual(["a-other-stage", "midnight"]); + }); + + it("orders concurrent now-playing sets deterministically by start then id", () => { + const overlapping = { + id: "b-overlap", + time_start: "2025-08-01T22:30:00.000Z", + time_end: "2025-08-01T23:30:00.000Z", + }; + const sameStart = { ...LATE_SET, id: "a-same-start" }; + const { nowPlaying } = classifyNowNext( + [overlapping, LATE_SET, sameStart], + at("2025-08-01T22:45:00Z"), + ); + expect(nowPlaying.map((s) => s.id)).toEqual([ + "a-same-start", + "late", + "b-overlap", + ]); + }); + + it("excludes sets with masked or missing times without error", () => { + const masked = { id: "masked", time_start: null, time_end: null }; + const noEnd = { + id: "no-end", + time_start: "2025-08-01T22:00:00.000Z", + time_end: null, + }; + const { nowPlaying, next } = classifyNowNext( + [masked, noEnd, MIDNIGHT_SET], + at("2025-08-01T22:30:00Z"), + ); + expect(nowPlaying).toEqual([]); + expect(next.map((s) => s.id)).toEqual(["midnight"]); + }); + + it("excludes sets with unparseable times without error", () => { + const broken = { + id: "broken", + time_start: "not-a-date", + time_end: "also-not-a-date", + }; + const { nowPlaying, next } = classifyNowNext( + [broken], + at("2025-08-01T22:30:00Z"), + ); + expect(nowPlaying).toEqual([]); + expect(next).toEqual([]); + }); + + it("classifies everything as later-past when the festival night is over", () => { + const { nowPlaying, next, laterPast } = classifyNowNext( + [LATE_SET, MIDNIGHT_SET], + at("2025-08-02T03:00:00Z"), + ); + expect(nowPlaying).toEqual([]); + expect(next).toEqual([]); + expect(laterPast.map((s) => s.id)).toEqual(["late", "midnight"]); + }); + + it("returns empty groups for an empty list", () => { + expect(classifyNowNext([], at("2025-08-01T22:30:00Z"))).toEqual({ + nowPlaying: [], + next: [], + laterPast: [], + }); + }); +}); diff --git a/src/lib/nowNext.ts b/src/lib/nowNext.ts new file mode 100644 index 00000000..ea209c7c --- /dev/null +++ b/src/lib/nowNext.ts @@ -0,0 +1,61 @@ +import { isValid, parseISO } from "date-fns"; + +export type NowNextSet = { + id: string; + time_start: string | null; + time_end: string | null; +}; + +export type NowNextClassification = { + nowPlaying: T[]; + next: T[]; + laterPast: T[]; +}; + +// Pure now/next classifier for Live mode. Compares UTC instants, so it is +// festival-timezone-correct by construction; `now` is injected like in +// getFestivalPhase. Sets missing either time (masked below reveal level +// `full`, or unparseable) never classify — they are silently excluded. +export function classifyNowNext( + sets: T[], + now: Date, +): NowNextClassification { + const timed = sets + .flatMap((set) => { + const start = parseInstant(set.time_start); + const end = parseInstant(set.time_end); + return start && end ? [{ set, start, end }] : []; + }) + .sort( + (a, b) => + a.start.getTime() - b.start.getTime() || + a.set.id.localeCompare(b.set.id), + ); + + const nowMs = now.getTime(); + const nextStartMs = timed.find( + (s) => s.start.getTime() > nowMs, + )?.start.getTime(); + + const nowPlaying: T[] = []; + const next: T[] = []; + const laterPast: T[] = []; + + for (const { set, start, end } of timed) { + if (start.getTime() <= nowMs && nowMs < end.getTime()) { + nowPlaying.push(set); + } else if (start.getTime() === nextStartMs) { + next.push(set); + } else { + laterPast.push(set); + } + } + + return { nowPlaying, next, laterPast }; +} + +function parseInstant(iso: string | null): Date | null { + if (!iso) return null; + const date = parseISO(iso); + return isValid(date) ? date : null; +} diff --git a/src/pages/EditionView/PhaseBanner.tsx b/src/pages/EditionView/PhaseBanner.tsx index c136e62f..e0faa7a1 100644 --- a/src/pages/EditionView/PhaseBanner.tsx +++ b/src/pages/EditionView/PhaseBanner.tsx @@ -34,6 +34,10 @@ function bannerMessage( return planningCopy(daysUntilStart(startDate, new Date(), timezone)); } + if (phase === "live") { + return "The festival is on — see what's playing now and next!"; + } + return null; } diff --git a/src/pages/EditionView/tabs/ScheduleTab.tsx b/src/pages/EditionView/tabs/ScheduleTab.tsx index 184af590..a89e9725 100644 --- a/src/pages/EditionView/tabs/ScheduleTab.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab.tsx @@ -1,4 +1,5 @@ import { ScheduleNavigation } from "./ScheduleTab/ScheduleNavigation"; +import { NowNextSection } from "./ScheduleTab/NowNextSection"; import { Outlet } from "@tanstack/react-router"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; import { PageTitle } from "@/components/PageTitle/PageTitle"; @@ -10,6 +11,8 @@ export function ScheduleTab() { <>
+ + diff --git a/src/pages/EditionView/tabs/ScheduleTab/NowNextSection.tsx b/src/pages/EditionView/tabs/ScheduleTab/NowNextSection.tsx new file mode 100644 index 00000000..768c1827 --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/NowNextSection.tsx @@ -0,0 +1,105 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useRouteContext } from "@tanstack/react-router"; +import { classifyNowNext } from "@/lib/nowNext"; +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; +import { useFestivalPhase } from "@/hooks/useFestivalPhase"; +import { useScheduleReveal } from "@/hooks/useScheduleReveal"; +import { useSetsByEditionQuery } from "@/api/sets/useSetsByEdition"; +import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; +import { MobileSetCard } from "./list/MobileSetCard"; +import type { FestivalSet } from "@/api/sets/types"; +import type { Stage } from "@/api/stages/types"; +import type { ScheduleSet } from "@/hooks/useScheduleData"; + +export function NowNextSection() { + const { phase } = useFestivalPhase(); + const { canShowTime } = useScheduleReveal(); + + if (phase !== "live" || !canShowTime) return null; + + return ; +} + +function LiveNowNext() { + const { festival } = useFestivalEdition(); + const { edition } = useRouteContext({ + from: "/festivals/$festivalSlug/editions/$editionSlug/schedule", + }); + const { data: sets = [] } = useSetsByEditionQuery(edition.id); + const { data: stages } = useSuspenseQuery(stagesByEditionQuery(edition.id)); + + const { nowPlaying, next } = classifyNowNext(sets, new Date()); + if (!nowPlaying.length && !next.length) return null; + + const nowCards = nowPlaying.map((set) => toCardSet(set, stages)); + const nextCards = next.map((set) => toCardSet(set, stages)); + + return ( +
+ {nowCards.length > 0 && ( + + )} + {nextCards.length > 0 && ( + + )} +
+ ); +} + +type CardSet = ScheduleSet & { stageName: string; stageColor?: string }; + +interface NowNextGroupProps { + label: string; + live?: boolean; + sets: CardSet[]; + timezone: string; +} + +function NowNextGroup({ label, live = false, sets, timezone }: NowNextGroupProps) { + return ( +
+
+
+ {live && ( + + )} + {label} +
+
+
+
+ {sets.map((set) => ( + + ))} +
+
+ ); +} + +function toCardSet(set: FestivalSet, stages: Stage[]): CardSet { + const stage = stages.find((s) => s.id === set.stage_id); + return { + id: set.id, + name: set.name, + slug: set.slug ?? undefined, + stageId: set.stage_id ?? undefined, + startTime: set.time_start ? new Date(set.time_start) : undefined, + endTime: set.time_end ? new Date(set.time_end) : undefined, + votes: set.votes ?? [], + artists: (set.artists ?? []).map((artist) => ({ + id: artist.id, + name: artist.name, + })), + stageName: stage?.name ?? "", + stageColor: stage?.color ?? undefined, + }; +} From 883f083443b96434c64eaa26a2919d755d368fbb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 15:52:25 +0000 Subject: [PATCH 2/9] fix(schedule): harden now/next stage badges and loading Falls back to the sets query's stage_name when the stage lookup misses, hides the stage badge entirely for stage-less sets, and loads stages non-blocking so the Now/Next section never suspends the Schedule tab. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016AXf1EyBWLa985JwjYwvKX --- .../EditionView/tabs/ScheduleTab/NowNextSection.tsx | 6 +++--- .../tabs/ScheduleTab/list/MobileSetCard.tsx | 12 +++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/pages/EditionView/tabs/ScheduleTab/NowNextSection.tsx b/src/pages/EditionView/tabs/ScheduleTab/NowNextSection.tsx index 768c1827..8fe3b49d 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/NowNextSection.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/NowNextSection.tsx @@ -1,4 +1,4 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useRouteContext } from "@tanstack/react-router"; import { classifyNowNext } from "@/lib/nowNext"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; @@ -26,7 +26,7 @@ function LiveNowNext() { from: "/festivals/$festivalSlug/editions/$editionSlug/schedule", }); const { data: sets = [] } = useSetsByEditionQuery(edition.id); - const { data: stages } = useSuspenseQuery(stagesByEditionQuery(edition.id)); + const { data: stages = [] } = useQuery(stagesByEditionQuery(edition.id)); const { nowPlaying, next } = classifyNowNext(sets, new Date()); if (!nowPlaying.length && !next.length) return null; @@ -99,7 +99,7 @@ function toCardSet(set: FestivalSet, stages: Stage[]): CardSet { id: artist.id, name: artist.name, })), - stageName: stage?.name ?? "", + stageName: stage?.name ?? set.stage_name ?? "", stageColor: stage?.color ?? undefined, }; } diff --git a/src/pages/EditionView/tabs/ScheduleTab/list/MobileSetCard.tsx b/src/pages/EditionView/tabs/ScheduleTab/list/MobileSetCard.tsx index add98fff..fa9082ad 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/list/MobileSetCard.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/list/MobileSetCard.tsx @@ -37,11 +37,13 @@ export function MobileSetCard({ set, timezone }: MobileSetCardProps) { {/* Stage and duration info */}
- + {set.stageName && ( + + )} {duration && (
From 8aba4821cbe871193dce30c193e74de42b6d1eef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 20:24:14 +0000 Subject: [PATCH 3/9] feat(schedule): dedicated per-stage now/next view for live phase Replaces the Now/Next section that squeezed the timeline with a third schedule view at /schedule/now: one compact row per stage showing the current set and that stage's own next set. Live editions land there by default; the view is guarded (and hidden from the switcher) outside live or below reveal level full. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016AXf1EyBWLa985JwjYwvKX --- src/lib/nowNext.test.ts | 54 ++++++++- src/lib/nowNext.ts | 21 ++++ src/pages/EditionView/tabs/ScheduleTab.tsx | 3 - .../tabs/ScheduleTab/NowNextSection.tsx | 105 ------------------ .../tabs/ScheduleTab/ScheduleNavigation.tsx | 11 +- .../ScheduleTab/ScheduleNavigationItem.tsx | 14 ++- .../tabs/ScheduleTab/now/NowBoard.tsx | 56 ++++++++++ .../tabs/ScheduleTab/now/NowStageRow.tsx | 69 ++++++++++++ src/routeTree.gen.ts | 23 ++++ .../editions/$editionSlug/schedule.tsx | 18 ++- .../editions/$editionSlug/schedule/now.tsx | 30 +++++ 11 files changed, 288 insertions(+), 116 deletions(-) delete mode 100644 src/pages/EditionView/tabs/ScheduleTab/NowNextSection.tsx create mode 100644 src/pages/EditionView/tabs/ScheduleTab/now/NowBoard.tsx create mode 100644 src/pages/EditionView/tabs/ScheduleTab/now/NowStageRow.tsx create mode 100644 src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx diff --git a/src/lib/nowNext.test.ts b/src/lib/nowNext.test.ts index 10cb805c..213e0df9 100644 --- a/src/lib/nowNext.test.ts +++ b/src/lib/nowNext.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { classifyNowNext } from "./nowNext"; +import { classifyNowNext, classifyNowNextByStage } from "./nowNext"; // Europe/Lisbon is UTC+1 (WEST) in August, so a set playing after midnight // festival time still sits on the previous UTC calendar day. Classification @@ -147,3 +147,55 @@ describe("classifyNowNext", () => { }); }); }); + +describe("classifyNowNextByStage", () => { + const MAIN_NOW = { ...LATE_SET, id: "main-now", stage_id: "main" }; + const MAIN_NEXT = { ...MIDNIGHT_SET, id: "main-next", stage_id: "main" }; + const BEACH_SOON = { + id: "beach-soon", + stage_id: "beach", + time_start: "2025-08-01T22:35:00.000Z", + time_end: "2025-08-01T23:30:00.000Z", + }; + + it("classifies each stage independently — next is per-stage, not global", () => { + // At 22:30Z the globally nearest upcoming start is beach-soon (22:35Z), + // but main's own next must still be main-next (23:00Z). + const byStage = classifyNowNextByStage( + [MAIN_NOW, MAIN_NEXT, BEACH_SOON], + at("2025-08-01T22:30:00Z"), + ); + expect(byStage.get("main")?.nowPlaying.map((s) => s.id)).toEqual([ + "main-now", + ]); + expect(byStage.get("main")?.next.map((s) => s.id)).toEqual(["main-next"]); + expect(byStage.get("beach")?.nowPlaying).toEqual([]); + expect(byStage.get("beach")?.next.map((s) => s.id)).toEqual(["beach-soon"]); + }); + + it("excludes stage-less sets entirely", () => { + const stageless = { ...LATE_SET, id: "stageless", stage_id: null }; + const byStage = classifyNowNextByStage( + [stageless], + at("2025-08-01T22:30:00Z"), + ); + expect(byStage.size).toBe(0); + }); + + it("keeps masked-time exclusion within each stage", () => { + const masked = { + id: "masked", + stage_id: "main", + time_start: null, + time_end: null, + }; + const byStage = classifyNowNextByStage( + [masked, MAIN_NOW], + at("2025-08-01T22:30:00Z"), + ); + expect(byStage.get("main")?.nowPlaying.map((s) => s.id)).toEqual([ + "main-now", + ]); + expect(byStage.get("main")?.laterPast).toEqual([]); + }); +}); diff --git a/src/lib/nowNext.ts b/src/lib/nowNext.ts index ea209c7c..1e92ac2d 100644 --- a/src/lib/nowNext.ts +++ b/src/lib/nowNext.ts @@ -54,6 +54,27 @@ export function classifyNowNext( return { nowPlaying, next, laterPast }; } +// Per-stage variant for the Now board: each stage's "next" is its own nearest +// upcoming set, not the globally nearest one (which turns into noise once many +// stages run concurrently). Stage-less sets are excluded. +export function classifyNowNextByStage< + T extends NowNextSet & { stage_id: string | null }, +>(sets: T[], now: Date): Map> { + const byStage = new Map(); + for (const set of sets) { + if (!set.stage_id) continue; + const group = byStage.get(set.stage_id) ?? []; + group.push(set); + byStage.set(set.stage_id, group); + } + + const classified = new Map>(); + for (const [stageId, stageSets] of byStage) { + classified.set(stageId, classifyNowNext(stageSets, now)); + } + return classified; +} + function parseInstant(iso: string | null): Date | null { if (!iso) return null; const date = parseISO(iso); diff --git a/src/pages/EditionView/tabs/ScheduleTab.tsx b/src/pages/EditionView/tabs/ScheduleTab.tsx index a89e9725..184af590 100644 --- a/src/pages/EditionView/tabs/ScheduleTab.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab.tsx @@ -1,5 +1,4 @@ import { ScheduleNavigation } from "./ScheduleTab/ScheduleNavigation"; -import { NowNextSection } from "./ScheduleTab/NowNextSection"; import { Outlet } from "@tanstack/react-router"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; import { PageTitle } from "@/components/PageTitle/PageTitle"; @@ -11,8 +10,6 @@ export function ScheduleTab() { <>
- - diff --git a/src/pages/EditionView/tabs/ScheduleTab/NowNextSection.tsx b/src/pages/EditionView/tabs/ScheduleTab/NowNextSection.tsx deleted file mode 100644 index 8fe3b49d..00000000 --- a/src/pages/EditionView/tabs/ScheduleTab/NowNextSection.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useRouteContext } from "@tanstack/react-router"; -import { classifyNowNext } from "@/lib/nowNext"; -import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; -import { useFestivalPhase } from "@/hooks/useFestivalPhase"; -import { useScheduleReveal } from "@/hooks/useScheduleReveal"; -import { useSetsByEditionQuery } from "@/api/sets/useSetsByEdition"; -import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; -import { MobileSetCard } from "./list/MobileSetCard"; -import type { FestivalSet } from "@/api/sets/types"; -import type { Stage } from "@/api/stages/types"; -import type { ScheduleSet } from "@/hooks/useScheduleData"; - -export function NowNextSection() { - const { phase } = useFestivalPhase(); - const { canShowTime } = useScheduleReveal(); - - if (phase !== "live" || !canShowTime) return null; - - return ; -} - -function LiveNowNext() { - const { festival } = useFestivalEdition(); - const { edition } = useRouteContext({ - from: "/festivals/$festivalSlug/editions/$editionSlug/schedule", - }); - const { data: sets = [] } = useSetsByEditionQuery(edition.id); - const { data: stages = [] } = useQuery(stagesByEditionQuery(edition.id)); - - const { nowPlaying, next } = classifyNowNext(sets, new Date()); - if (!nowPlaying.length && !next.length) return null; - - const nowCards = nowPlaying.map((set) => toCardSet(set, stages)); - const nextCards = next.map((set) => toCardSet(set, stages)); - - return ( -
- {nowCards.length > 0 && ( - - )} - {nextCards.length > 0 && ( - - )} -
- ); -} - -type CardSet = ScheduleSet & { stageName: string; stageColor?: string }; - -interface NowNextGroupProps { - label: string; - live?: boolean; - sets: CardSet[]; - timezone: string; -} - -function NowNextGroup({ label, live = false, sets, timezone }: NowNextGroupProps) { - return ( -
-
-
- {live && ( - - )} - {label} -
-
-
-
- {sets.map((set) => ( - - ))} -
-
- ); -} - -function toCardSet(set: FestivalSet, stages: Stage[]): CardSet { - const stage = stages.find((s) => s.id === set.stage_id); - return { - id: set.id, - name: set.name, - slug: set.slug ?? undefined, - stageId: set.stage_id ?? undefined, - startTime: set.time_start ? new Date(set.time_start) : undefined, - endTime: set.time_end ? new Date(set.time_end) : undefined, - votes: set.votes ?? [], - artists: (set.artists ?? []).map((artist) => ({ - id: artist.id, - name: artist.name, - })), - stageName: stage?.name ?? set.stage_name ?? "", - stageColor: stage?.color ?? undefined, - }; -} diff --git a/src/pages/EditionView/tabs/ScheduleTab/ScheduleNavigation.tsx b/src/pages/EditionView/tabs/ScheduleTab/ScheduleNavigation.tsx index 48ce88c3..51cfe684 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/ScheduleNavigation.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/ScheduleNavigation.tsx @@ -1,10 +1,19 @@ -import { Calendar, List } from "lucide-react"; +import { Calendar, List, Radio } from "lucide-react"; +import { useFestivalPhase } from "@/hooks/useFestivalPhase"; +import { useScheduleReveal } from "@/hooks/useScheduleReveal"; import { ScheduleNavigationItem } from "./ScheduleNavigationItem"; export function ScheduleNavigation() { + const { phase } = useFestivalPhase(); + const { canShowTime } = useScheduleReveal(); + const showNow = phase === "live" && canShowTime; + return (
+ {showNow && ( + + )} +

Loading schedule...

+
+ ); + } + + const byStage = classifyNowNextByStage(sets, new Date()); + const rows = sortStagesByOrder([...stages]).flatMap((stage) => { + const classification = byStage.get(stage.id); + if (!classification) return []; + if (!classification.nowPlaying.length && !classification.next.length) { + return []; + } + return [{ stage, classification }]; + }); + + if (!rows.length) { + return ( +
+

Nothing on right now — see the timeline for the full schedule.

+
+ ); + } + + return ( +
+ {rows.map(({ stage, classification }) => ( + + ))} +
+ ); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/now/NowStageRow.tsx b/src/pages/EditionView/tabs/ScheduleTab/now/NowStageRow.tsx new file mode 100644 index 00000000..5c54f76c --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/now/NowStageRow.tsx @@ -0,0 +1,69 @@ +import { Link, useParams } from "@tanstack/react-router"; +import { StageBadge } from "@/components/StageBadge"; +import { formatTimeOnly } from "@/lib/timeUtils"; +import type { NowNextClassification } from "@/lib/nowNext"; +import type { FestivalSet } from "@/api/sets/types"; +import type { Stage } from "@/api/stages/types"; + +interface NowStageRowProps { + stage: Stage; + classification: NowNextClassification; + timezone: string; +} + +export function NowStageRow({ + stage, + classification, + timezone, +}: NowStageRowProps) { + return ( +
+ + + {classification.nowPlaying.map((set) => ( + + ))} + {classification.next.map((set) => ( + + ))} +
+ ); +} + +interface SetLineProps { + set: FestivalSet; + timezone: string; + live?: boolean; +} + +function SetLine({ set, timezone, live = false }: SetLineProps) { + const { festivalSlug, editionSlug } = useParams({ + from: "/festivals/$festivalSlug/editions/$editionSlug", + }); + + const time = live + ? `until ${formatTimeOnly(set.time_end, null, true, timezone)}` + : `at ${formatTimeOnly(set.time_start, null, true, timezone)}`; + + return ( +
+ {live ? ( + + ) : ( + Next: + )} + + {set.name} + + {time} +
+ ); +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 47dc86ce..b7ff53a5 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -35,6 +35,7 @@ import { Route as AdminFestivalsFestivalSlugEditionsEditionSlugRouteImport } fro import { Route as FestivalsFestivalSlugEditionsEditionSlugSetsIndexRouteImport } from './routes/festivals/$festivalSlug/editions/$editionSlug/sets/index' import { Route as FestivalsFestivalSlugEditionsEditionSlugSetsSetSlugRouteImport } from './routes/festivals/$festivalSlug/editions/$editionSlug/sets/$setSlug' import { Route as FestivalsFestivalSlugEditionsEditionSlugScheduleTimelineRouteImport } from './routes/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline' +import { Route as FestivalsFestivalSlugEditionsEditionSlugScheduleNowRouteImport } from './routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now' import { Route as FestivalsFestivalSlugEditionsEditionSlugScheduleListRouteImport } from './routes/festivals/$festivalSlug/editions/$editionSlug/schedule/list' import { Route as AdminFestivalsFestivalSlugEditionsEditionSlugStagesRouteImport } from './routes/admin/festivals/$festivalSlug/editions/$editionSlug/stages' import { Route as AdminFestivalsFestivalSlugEditionsEditionSlugSetsRouteImport } from './routes/admin/festivals/$festivalSlug/editions/$editionSlug/sets' @@ -183,6 +184,12 @@ const FestivalsFestivalSlugEditionsEditionSlugScheduleTimelineRoute = path: '/timeline', getParentRoute: () => FestivalsFestivalSlugEditionsEditionSlugScheduleRoute, } as any) +const FestivalsFestivalSlugEditionsEditionSlugScheduleNowRoute = + FestivalsFestivalSlugEditionsEditionSlugScheduleNowRouteImport.update({ + id: '/now', + path: '/now', + getParentRoute: () => FestivalsFestivalSlugEditionsEditionSlugScheduleRoute, + } as any) const FestivalsFestivalSlugEditionsEditionSlugScheduleListRoute = FestivalsFestivalSlugEditionsEditionSlugScheduleListRouteImport.update({ id: '/list', @@ -236,6 +243,7 @@ export interface FileRoutesByFullPath { '/admin/festivals/$festivalSlug/editions/$editionSlug/sets': typeof AdminFestivalsFestivalSlugEditionsEditionSlugSetsRoute '/admin/festivals/$festivalSlug/editions/$editionSlug/stages': typeof AdminFestivalsFestivalSlugEditionsEditionSlugStagesRoute '/festivals/$festivalSlug/editions/$editionSlug/schedule/list': typeof FestivalsFestivalSlugEditionsEditionSlugScheduleListRoute + '/festivals/$festivalSlug/editions/$editionSlug/schedule/now': typeof FestivalsFestivalSlugEditionsEditionSlugScheduleNowRoute '/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline': typeof FestivalsFestivalSlugEditionsEditionSlugScheduleTimelineRoute '/festivals/$festivalSlug/editions/$editionSlug/sets/$setSlug': typeof FestivalsFestivalSlugEditionsEditionSlugSetsSetSlugRoute '/festivals/$festivalSlug/editions/$editionSlug/sets/': typeof FestivalsFestivalSlugEditionsEditionSlugSetsIndexRoute @@ -266,6 +274,7 @@ export interface FileRoutesByTo { '/admin/festivals/$festivalSlug/editions/$editionSlug/sets': typeof AdminFestivalsFestivalSlugEditionsEditionSlugSetsRoute '/admin/festivals/$festivalSlug/editions/$editionSlug/stages': typeof AdminFestivalsFestivalSlugEditionsEditionSlugStagesRoute '/festivals/$festivalSlug/editions/$editionSlug/schedule/list': typeof FestivalsFestivalSlugEditionsEditionSlugScheduleListRoute + '/festivals/$festivalSlug/editions/$editionSlug/schedule/now': typeof FestivalsFestivalSlugEditionsEditionSlugScheduleNowRoute '/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline': typeof FestivalsFestivalSlugEditionsEditionSlugScheduleTimelineRoute '/festivals/$festivalSlug/editions/$editionSlug/sets/$setSlug': typeof FestivalsFestivalSlugEditionsEditionSlugSetsSetSlugRoute '/festivals/$festivalSlug/editions/$editionSlug/sets': typeof FestivalsFestivalSlugEditionsEditionSlugSetsIndexRoute @@ -299,6 +308,7 @@ export interface FileRoutesById { '/admin/festivals/$festivalSlug/editions/$editionSlug/sets': typeof AdminFestivalsFestivalSlugEditionsEditionSlugSetsRoute '/admin/festivals/$festivalSlug/editions/$editionSlug/stages': typeof AdminFestivalsFestivalSlugEditionsEditionSlugStagesRoute '/festivals/$festivalSlug/editions/$editionSlug/schedule/list': typeof FestivalsFestivalSlugEditionsEditionSlugScheduleListRoute + '/festivals/$festivalSlug/editions/$editionSlug/schedule/now': typeof FestivalsFestivalSlugEditionsEditionSlugScheduleNowRoute '/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline': typeof FestivalsFestivalSlugEditionsEditionSlugScheduleTimelineRoute '/festivals/$festivalSlug/editions/$editionSlug/sets/$setSlug': typeof FestivalsFestivalSlugEditionsEditionSlugSetsSetSlugRoute '/festivals/$festivalSlug/editions/$editionSlug/sets/': typeof FestivalsFestivalSlugEditionsEditionSlugSetsIndexRoute @@ -333,6 +343,7 @@ export interface FileRouteTypes { | '/admin/festivals/$festivalSlug/editions/$editionSlug/sets' | '/admin/festivals/$festivalSlug/editions/$editionSlug/stages' | '/festivals/$festivalSlug/editions/$editionSlug/schedule/list' + | '/festivals/$festivalSlug/editions/$editionSlug/schedule/now' | '/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline' | '/festivals/$festivalSlug/editions/$editionSlug/sets/$setSlug' | '/festivals/$festivalSlug/editions/$editionSlug/sets/' @@ -363,6 +374,7 @@ export interface FileRouteTypes { | '/admin/festivals/$festivalSlug/editions/$editionSlug/sets' | '/admin/festivals/$festivalSlug/editions/$editionSlug/stages' | '/festivals/$festivalSlug/editions/$editionSlug/schedule/list' + | '/festivals/$festivalSlug/editions/$editionSlug/schedule/now' | '/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline' | '/festivals/$festivalSlug/editions/$editionSlug/sets/$setSlug' | '/festivals/$festivalSlug/editions/$editionSlug/sets' @@ -395,6 +407,7 @@ export interface FileRouteTypes { | '/admin/festivals/$festivalSlug/editions/$editionSlug/sets' | '/admin/festivals/$festivalSlug/editions/$editionSlug/stages' | '/festivals/$festivalSlug/editions/$editionSlug/schedule/list' + | '/festivals/$festivalSlug/editions/$editionSlug/schedule/now' | '/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline' | '/festivals/$festivalSlug/editions/$editionSlug/sets/$setSlug' | '/festivals/$festivalSlug/editions/$editionSlug/sets/' @@ -595,6 +608,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof FestivalsFestivalSlugEditionsEditionSlugScheduleTimelineRouteImport parentRoute: typeof FestivalsFestivalSlugEditionsEditionSlugScheduleRoute } + '/festivals/$festivalSlug/editions/$editionSlug/schedule/now': { + id: '/festivals/$festivalSlug/editions/$editionSlug/schedule/now' + path: '/now' + fullPath: '/festivals/$festivalSlug/editions/$editionSlug/schedule/now' + preLoaderRoute: typeof FestivalsFestivalSlugEditionsEditionSlugScheduleNowRouteImport + parentRoute: typeof FestivalsFestivalSlugEditionsEditionSlugScheduleRoute + } '/festivals/$festivalSlug/editions/$editionSlug/schedule/list': { id: '/festivals/$festivalSlug/editions/$editionSlug/schedule/list' path: '/list' @@ -704,6 +724,7 @@ const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren) interface FestivalsFestivalSlugEditionsEditionSlugScheduleRouteChildren { FestivalsFestivalSlugEditionsEditionSlugScheduleListRoute: typeof FestivalsFestivalSlugEditionsEditionSlugScheduleListRoute + FestivalsFestivalSlugEditionsEditionSlugScheduleNowRoute: typeof FestivalsFestivalSlugEditionsEditionSlugScheduleNowRoute FestivalsFestivalSlugEditionsEditionSlugScheduleTimelineRoute: typeof FestivalsFestivalSlugEditionsEditionSlugScheduleTimelineRoute } @@ -711,6 +732,8 @@ const FestivalsFestivalSlugEditionsEditionSlugScheduleRouteChildren: FestivalsFe { FestivalsFestivalSlugEditionsEditionSlugScheduleListRoute: FestivalsFestivalSlugEditionsEditionSlugScheduleListRoute, + FestivalsFestivalSlugEditionsEditionSlugScheduleNowRoute: + FestivalsFestivalSlugEditionsEditionSlugScheduleNowRoute, FestivalsFestivalSlugEditionsEditionSlugScheduleTimelineRoute: FestivalsFestivalSlugEditionsEditionSlugScheduleTimelineRoute, } diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule.tsx index 817b9248..b80de9c3 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule.tsx @@ -4,6 +4,8 @@ import { stripSearchParams, } from "@tanstack/react-router"; import { ScheduleTab } from "@/pages/EditionView/tabs/ScheduleTab"; +import { getFestivalPhase } from "@/lib/festivalPhase"; +import { canShowTime } from "@/lib/scheduleReveal"; import { timelineSearchDefaults, timelineSearchSchema, @@ -17,10 +19,22 @@ export const Route = createFileRoute( search: { middlewares: [stripSearchParams(timelineSearchDefaults)], }, - beforeLoad: ({ params, location }) => { + beforeLoad: ({ params, location, context }) => { if (location.pathname.endsWith("/schedule")) { + const phase = getFestivalPhase({ + revealLevel: context.edition.schedule_reveal_level, + startDate: context.edition.start_date, + endDate: context.edition.end_date, + timezone: context.festival.timezone, + now: new Date(), + }); + const liveNow = + phase === "live" && canShowTime(context.edition.schedule_reveal_level); + throw redirect({ - to: "/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline", + to: liveNow + ? "/festivals/$festivalSlug/editions/$editionSlug/schedule/now" + : "/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline", params, search: location.search as Record, }); diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx new file mode 100644 index 00000000..6a2b80df --- /dev/null +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx @@ -0,0 +1,30 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { ScheduleTabNow } from "@/pages/EditionView/tabs/ScheduleTab/now/NowBoard"; +import { getFestivalPhase } from "@/lib/festivalPhase"; +import { canShowTime } from "@/lib/scheduleReveal"; + +export const Route = createFileRoute( + "/festivals/$festivalSlug/editions/$editionSlug/schedule/now", +)({ + component: ScheduleTabNow, + beforeLoad: ({ params, location, context }) => { + const phase = getFestivalPhase({ + revealLevel: context.edition.schedule_reveal_level, + startDate: context.edition.start_date, + endDate: context.edition.end_date, + timezone: context.festival.timezone, + now: new Date(), + }); + + if ( + phase !== "live" || + !canShowTime(context.edition.schedule_reveal_level) + ) { + throw redirect({ + to: "/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline", + params, + search: location.search as Record, + }); + } + }, +}); From 468413b05881805d8940dafb3bc6eb30c1974806 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 19:32:46 +0000 Subject: [PATCH 4/9] refactor(schedule): address now-view review comments Colocates the Now board in its route file, switches it to suspense queries with a sets loader prefetch, and rewrites the classifier comments as what/why JSDoc. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016AXf1EyBWLa985JwjYwvKX --- src/lib/nowNext.ts | 24 +++- .../tabs/ScheduleTab/now/NowBoard.tsx | 56 -------- .../tabs/ScheduleTab/now/NowStageRow.tsx | 69 ---------- .../editions/$editionSlug/schedule/now.tsx | 123 +++++++++++++++++- 4 files changed, 138 insertions(+), 134 deletions(-) delete mode 100644 src/pages/EditionView/tabs/ScheduleTab/now/NowBoard.tsx delete mode 100644 src/pages/EditionView/tabs/ScheduleTab/now/NowStageRow.tsx diff --git a/src/lib/nowNext.ts b/src/lib/nowNext.ts index 1e92ac2d..27b18413 100644 --- a/src/lib/nowNext.ts +++ b/src/lib/nowNext.ts @@ -12,10 +12,17 @@ export type NowNextClassification = { laterPast: T[]; }; -// Pure now/next classifier for Live mode. Compares UTC instants, so it is -// festival-timezone-correct by construction; `now` is injected like in -// getFestivalPhase. Sets missing either time (masked below reveal level -// `full`, or unparseable) never classify — they are silently excluded. +/** + * Answers "what's on right now, and what starts next?" for a list of sets — + * the data behind the Live phase's "Now" schedule view. + * + * Splits the sets into three groups relative to `now`: `nowPlaying` (on + * stage at this moment), `next` (the set or sets sharing the nearest + * upcoming start), and `laterPast` (everything else — already over or + * further out). Sets without both times — e.g. masked below reveal level + * `full` — are left out entirely. `now` is injected, keeping this a pure + * function like getFestivalPhase. + */ export function classifyNowNext( sets: T[], now: Date, @@ -54,9 +61,12 @@ export function classifyNowNext( return { nowPlaying, next, laterPast }; } -// Per-stage variant for the Now board: each stage's "next" is its own nearest -// upcoming set, not the globally nearest one (which turns into noise once many -// stages run concurrently). Stage-less sets are excluded. +/** + * classifyNowNext applied per stage, keyed by stage_id — the "Now" board + * renders one row per stage, and each stage's `next` must be its own nearest + * upcoming set (with many stages, the globally nearest start is noise). + * Stage-less sets are excluded. + */ export function classifyNowNextByStage< T extends NowNextSet & { stage_id: string | null }, >(sets: T[], now: Date): Map> { diff --git a/src/pages/EditionView/tabs/ScheduleTab/now/NowBoard.tsx b/src/pages/EditionView/tabs/ScheduleTab/now/NowBoard.tsx deleted file mode 100644 index 2170f1a0..00000000 --- a/src/pages/EditionView/tabs/ScheduleTab/now/NowBoard.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; -import { useRouteContext } from "@tanstack/react-router"; -import { classifyNowNextByStage } from "@/lib/nowNext"; -import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; -import { useSetsByEditionQuery } from "@/api/sets/useSetsByEdition"; -import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; -import { sortStagesByOrder } from "@/lib/stageUtils"; -import { NowStageRow } from "./NowStageRow"; - -export function ScheduleTabNow() { - const { festival } = useFestivalEdition(); - const { edition } = useRouteContext({ - from: "/festivals/$festivalSlug/editions/$editionSlug/schedule/now", - }); - const { data: sets = [], isLoading } = useSetsByEditionQuery(edition.id); - const { data: stages } = useSuspenseQuery(stagesByEditionQuery(edition.id)); - - if (isLoading) { - return ( -
-

Loading schedule...

-
- ); - } - - const byStage = classifyNowNextByStage(sets, new Date()); - const rows = sortStagesByOrder([...stages]).flatMap((stage) => { - const classification = byStage.get(stage.id); - if (!classification) return []; - if (!classification.nowPlaying.length && !classification.next.length) { - return []; - } - return [{ stage, classification }]; - }); - - if (!rows.length) { - return ( -
-

Nothing on right now — see the timeline for the full schedule.

-
- ); - } - - return ( -
- {rows.map(({ stage, classification }) => ( - - ))} -
- ); -} diff --git a/src/pages/EditionView/tabs/ScheduleTab/now/NowStageRow.tsx b/src/pages/EditionView/tabs/ScheduleTab/now/NowStageRow.tsx deleted file mode 100644 index 5c54f76c..00000000 --- a/src/pages/EditionView/tabs/ScheduleTab/now/NowStageRow.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Link, useParams } from "@tanstack/react-router"; -import { StageBadge } from "@/components/StageBadge"; -import { formatTimeOnly } from "@/lib/timeUtils"; -import type { NowNextClassification } from "@/lib/nowNext"; -import type { FestivalSet } from "@/api/sets/types"; -import type { Stage } from "@/api/stages/types"; - -interface NowStageRowProps { - stage: Stage; - classification: NowNextClassification; - timezone: string; -} - -export function NowStageRow({ - stage, - classification, - timezone, -}: NowStageRowProps) { - return ( -
- - - {classification.nowPlaying.map((set) => ( - - ))} - {classification.next.map((set) => ( - - ))} -
- ); -} - -interface SetLineProps { - set: FestivalSet; - timezone: string; - live?: boolean; -} - -function SetLine({ set, timezone, live = false }: SetLineProps) { - const { festivalSlug, editionSlug } = useParams({ - from: "/festivals/$festivalSlug/editions/$editionSlug", - }); - - const time = live - ? `until ${formatTimeOnly(set.time_end, null, true, timezone)}` - : `at ${formatTimeOnly(set.time_start, null, true, timezone)}`; - - return ( -
- {live ? ( - - ) : ( - Next: - )} - - {set.name} - - {time} -
- ); -} diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx index 6a2b80df..9167994c 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx @@ -1,7 +1,24 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; -import { ScheduleTabNow } from "@/pages/EditionView/tabs/ScheduleTab/now/NowBoard"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { + createFileRoute, + Link, + redirect, + useParams, +} from "@tanstack/react-router"; +import { StageBadge } from "@/components/StageBadge"; +import { setsByEditionQuery } from "@/api/sets/useSetsByEdition"; +import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; import { getFestivalPhase } from "@/lib/festivalPhase"; +import { + classifyNowNextByStage, + type NowNextClassification, +} from "@/lib/nowNext"; import { canShowTime } from "@/lib/scheduleReveal"; +import { sortStagesByOrder } from "@/lib/stageUtils"; +import { formatTimeOnly } from "@/lib/timeUtils"; +import type { FestivalSet } from "@/api/sets/types"; +import type { Stage } from "@/api/stages/types"; export const Route = createFileRoute( "/festivals/$festivalSlug/editions/$editionSlug/schedule/now", @@ -27,4 +44,106 @@ export const Route = createFileRoute( }); } }, + loader: ({ context }) => { + void context.queryClient.ensureQueryData( + setsByEditionQuery(context.edition.id), + ); + }, }); + +function ScheduleTabNow() { + const { festival } = useFestivalEdition(); + const { edition } = Route.useRouteContext(); + const { data: sets } = useSuspenseQuery(setsByEditionQuery(edition.id)); + const { data: stages } = useSuspenseQuery(stagesByEditionQuery(edition.id)); + + const byStage = classifyNowNextByStage(sets, new Date()); + const rows = sortStagesByOrder([...stages]).flatMap((stage) => { + const classification = byStage.get(stage.id); + if (!classification) return []; + if (!classification.nowPlaying.length && !classification.next.length) { + return []; + } + return [{ stage, classification }]; + }); + + if (!rows.length) { + return ( +
+

Nothing on right now — see the timeline for the full schedule.

+
+ ); + } + + return ( +
+ {rows.map(({ stage, classification }) => ( + + ))} +
+ ); +} + +interface NowStageRowProps { + stage: Stage; + classification: NowNextClassification; + timezone: string; +} + +function NowStageRow({ stage, classification, timezone }: NowStageRowProps) { + return ( +
+ + + {classification.nowPlaying.map((set) => ( + + ))} + {classification.next.map((set) => ( + + ))} +
+ ); +} + +interface SetLineProps { + set: FestivalSet; + timezone: string; + live?: boolean; +} + +function SetLine({ set, timezone, live = false }: SetLineProps) { + const { festivalSlug, editionSlug } = useParams({ + from: "/festivals/$festivalSlug/editions/$editionSlug", + }); + + const time = live + ? `until ${formatTimeOnly(set.time_end, null, true, timezone)}` + : `at ${formatTimeOnly(set.time_start, null, true, timezone)}`; + + return ( +
+ {live ? ( + + ) : ( + Next: + )} + + {set.name} + + {time} +
+ ); +} From ab37718570b1188aa7c7e707723c6fc25a574364 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 18:36:06 +0000 Subject: [PATCH 5/9] feat(schedule): refresh now board every minute Add a useNow hook so the now/next classification tracks the clock while the board is mounted, instead of freezing at mount-time. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016AXf1EyBWLa985JwjYwvKX --- .../$festivalSlug/editions/$editionSlug/schedule/now.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx index 9167994c..a514ac2d 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx @@ -9,6 +9,7 @@ import { StageBadge } from "@/components/StageBadge"; import { setsByEditionQuery } from "@/api/sets/useSetsByEdition"; import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; +import { useNow } from "@/hooks/useNow"; import { getFestivalPhase } from "@/lib/festivalPhase"; import { classifyNowNextByStage, @@ -56,8 +57,9 @@ function ScheduleTabNow() { const { edition } = Route.useRouteContext(); const { data: sets } = useSuspenseQuery(setsByEditionQuery(edition.id)); const { data: stages } = useSuspenseQuery(stagesByEditionQuery(edition.id)); + const now = useNow(); - const byStage = classifyNowNextByStage(sets, new Date()); + const byStage = classifyNowNextByStage(sets, now); const rows = sortStagesByOrder([...stages]).flatMap((stage) => { const classification = byStage.get(stage.id); if (!classification) return []; From 0f6ff896c2bac26c780732e75eced9d6d4ced131 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 18:41:33 +0000 Subject: [PATCH 6/9] feat(schedule): lay out now board as responsive grid Stack stage cards in 2 columns from md and 3 from xl so many stages read as a single glanceable board instead of full-width rows; mobile stays a single column. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016AXf1EyBWLa985JwjYwvKX --- .../$festivalSlug/editions/$editionSlug/schedule/now.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx index a514ac2d..af5b3d7e 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx @@ -78,7 +78,7 @@ function ScheduleTabNow() { } return ( -
+
{rows.map(({ stage, classification }) => ( Date: Thu, 23 Jul 2026 19:42:03 +0000 Subject: [PATCH 7/9] feat(schedule): order now board by live then soonest next Sort board rows so stages with a set playing come first, followed by next-only stages by upcoming start time, instead of plain stage order. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016AXf1EyBWLa985JwjYwvKX --- src/lib/nowNext.test.ts | 43 ++++++++++++++++++- src/lib/nowNext.ts | 35 +++++++++++++-- .../editions/$editionSlug/schedule/now.tsx | 19 ++++---- 3 files changed, 85 insertions(+), 12 deletions(-) diff --git a/src/lib/nowNext.test.ts b/src/lib/nowNext.test.ts index 213e0df9..1058e679 100644 --- a/src/lib/nowNext.test.ts +++ b/src/lib/nowNext.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { classifyNowNext, classifyNowNextByStage } from "./nowNext"; +import { + classifyNowNext, + classifyNowNextByStage, + compareNowNext, +} from "./nowNext"; // Europe/Lisbon is UTC+1 (WEST) in August, so a set playing after midnight // festival time still sits on the previous UTC calendar day. Classification @@ -199,3 +203,40 @@ describe("classifyNowNextByStage", () => { expect(byStage.get("main")?.laterPast).toEqual([]); }); }); + +describe("compareNowNext", () => { + function classification( + nowPlaying: (typeof LATE_SET)[], + next: (typeof LATE_SET)[], + ) { + return { nowPlaying, next, laterPast: [] }; + } + + const LIVE = classification([LATE_SET], [MIDNIGHT_SET]); + const NEXT_SOON = classification([], [LATE_SET]); + const NEXT_LATER = classification([], [MIDNIGHT_SET]); + const NOTHING = classification([], []); + + it("orders a live stage before a next-only stage", () => { + expect(compareNowNext(LIVE, NEXT_SOON)).toBeLessThan(0); + expect(compareNowNext(NEXT_SOON, LIVE)).toBeGreaterThan(0); + }); + + it("orders next-only stages by soonest upcoming start", () => { + expect(compareNowNext(NEXT_SOON, NEXT_LATER)).toBeLessThan(0); + expect(compareNowNext(NEXT_LATER, NEXT_SOON)).toBeGreaterThan(0); + }); + + it("keeps live stages tied so a stable sort preserves stage order", () => { + expect(compareNowNext(LIVE, LIVE)).toBe(0); + }); + + it("orders a stage with no upcoming set last", () => { + expect(compareNowNext(NOTHING, NEXT_LATER)).toBeGreaterThan(0); + }); + + it("sorts a mixed board live-first then by next start", () => { + const sorted = [NEXT_LATER, NOTHING, LIVE, NEXT_SOON].sort(compareNowNext); + expect(sorted).toEqual([LIVE, NEXT_SOON, NEXT_LATER, NOTHING]); + }); +}); diff --git a/src/lib/nowNext.ts b/src/lib/nowNext.ts index 27b18413..85a5f5b1 100644 --- a/src/lib/nowNext.ts +++ b/src/lib/nowNext.ts @@ -40,9 +40,9 @@ export function classifyNowNext( ); const nowMs = now.getTime(); - const nextStartMs = timed.find( - (s) => s.start.getTime() > nowMs, - )?.start.getTime(); + const nextStartMs = timed + .find((s) => s.start.getTime() > nowMs) + ?.start.getTime(); const nowPlaying: T[] = []; const next: T[] = []; @@ -85,6 +85,35 @@ export function classifyNowNextByStage< return classified; } +/** + * Orders stage classifications for the "Now" board: stages with a set + * playing right now come first, then stages by soonest upcoming start, + * so the board reads "what's on, then what's next" instead of stage + * order. Ties (e.g. two live stages) compare equal — sort is stable, so + * the caller's stage ordering is kept within each group. + */ +export function compareNowNext( + a: NowNextClassification, + b: NowNextClassification, +): number { + const aLive = a.nowPlaying.length > 0; + const bLive = b.nowPlaying.length > 0; + if (aLive !== bLive) { + return aLive ? -1 : 1; + } + if (aLive) { + return 0; + } + return nextStartMs(a) - nextStartMs(b); +} + +function nextStartMs( + classification: NowNextClassification, +): number { + const start = parseInstant(classification.next[0]?.time_start ?? null); + return start ? start.getTime() : Number.MAX_SAFE_INTEGER; +} + function parseInstant(iso: string | null): Date | null { if (!iso) return null; const date = parseISO(iso); diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx index af5b3d7e..0059a438 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx @@ -13,6 +13,7 @@ import { useNow } from "@/hooks/useNow"; import { getFestivalPhase } from "@/lib/festivalPhase"; import { classifyNowNextByStage, + compareNowNext, type NowNextClassification, } from "@/lib/nowNext"; import { canShowTime } from "@/lib/scheduleReveal"; @@ -60,14 +61,16 @@ function ScheduleTabNow() { const now = useNow(); const byStage = classifyNowNextByStage(sets, now); - const rows = sortStagesByOrder([...stages]).flatMap((stage) => { - const classification = byStage.get(stage.id); - if (!classification) return []; - if (!classification.nowPlaying.length && !classification.next.length) { - return []; - } - return [{ stage, classification }]; - }); + const rows = sortStagesByOrder([...stages]) + .flatMap((stage) => { + const classification = byStage.get(stage.id); + if (!classification) return []; + if (!classification.nowPlaying.length && !classification.next.length) { + return []; + } + return [{ stage, classification }]; + }) + .sort((a, b) => compareNowNext(a.classification, b.classification)); if (!rows.length) { return ( From aeea19f334d60349d2a7876915d0ac68ab823f4f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 05:20:03 +0000 Subject: [PATCH 8/9] refactor(schedule): shorten view tab labels to timeline and list Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016AXf1EyBWLa985JwjYwvKX --- src/pages/EditionView/tabs/ScheduleTab/ScheduleNavigation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/EditionView/tabs/ScheduleTab/ScheduleNavigation.tsx b/src/pages/EditionView/tabs/ScheduleTab/ScheduleNavigation.tsx index 51cfe684..8ea91b4a 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/ScheduleNavigation.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/ScheduleNavigation.tsx @@ -16,10 +16,10 @@ export function ScheduleNavigation() { )} - +
); From 9b641974c579e52512a50c60f3a4205fb757a0ce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 09:39:01 +0000 Subject: [PATCH 9/9] fix(schedule): gate now view on effective phase via shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /schedule redirect and /schedule/now guard derived the phase directly, ignoring phase_override, while the nav tab used the override-aware hook — an override could show a tab whose route redirects away (or vice versa). Extract the compound rule into canShowNowView, built on getEffectiveFestivalPhase, and use it in both routes. Also prefetch stages alongside sets in the now route loader to avoid a second suspense waterfall. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016AXf1EyBWLa985JwjYwvKX --- src/lib/nowView.test.ts | 51 +++++++++++++++++++ src/lib/nowView.ts | 37 ++++++++++++++ .../editions/$editionSlug/schedule.tsx | 17 +++---- .../editions/$editionSlug/schedule/now.tsx | 17 ++----- 4 files changed, 99 insertions(+), 23 deletions(-) create mode 100644 src/lib/nowView.test.ts create mode 100644 src/lib/nowView.ts diff --git a/src/lib/nowView.test.ts b/src/lib/nowView.test.ts new file mode 100644 index 00000000..9eed983a --- /dev/null +++ b/src/lib/nowView.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { canShowNowView, type NowViewEdition } from "./nowView"; + +// Festival runs 2025-08-01..2025-08-03 in Europe/Lisbon (UTC+1 in August). +const LIVE_EDITION: NowViewEdition = { + schedule_reveal_level: "full", + start_date: "2025-08-01", + end_date: "2025-08-03", + phase_override: null, +}; + +const TZ = "Europe/Lisbon"; +const DURING = new Date("2025-08-02T12:00:00Z"); +const BEFORE = new Date("2025-07-01T12:00:00Z"); + +describe("canShowNowView", () => { + it("is available while derived-live at reveal level full", () => { + expect(canShowNowView(LIVE_EDITION, TZ, DURING)).toBe(true); + }); + + it("is unavailable outside the festival dates", () => { + expect(canShowNowView(LIVE_EDITION, TZ, BEFORE)).toBe(false); + }); + + it("honours a live override outside the festival dates", () => { + const edition = { ...LIVE_EDITION, phase_override: "live" as const }; + expect(canShowNowView(edition, TZ, BEFORE)).toBe(true); + }); + + it("honours an override away from live during the festival", () => { + const edition = { ...LIVE_EDITION, phase_override: "planning" as const }; + expect(canShowNowView(edition, TZ, DURING)).toBe(false); + }); + + it("is unavailable below reveal level full even when live", () => { + const edition = { + ...LIVE_EDITION, + schedule_reveal_level: "stages" as const, + }; + expect(canShowNowView(edition, TZ, DURING)).toBe(false); + }); + + it("never shows for a live override below reveal level full", () => { + const edition = { + ...LIVE_EDITION, + schedule_reveal_level: "days" as const, + phase_override: "live" as const, + }; + expect(canShowNowView(edition, TZ, BEFORE)).toBe(false); + }); +}); diff --git a/src/lib/nowView.ts b/src/lib/nowView.ts new file mode 100644 index 00000000..a4d554f4 --- /dev/null +++ b/src/lib/nowView.ts @@ -0,0 +1,37 @@ +import { + type FestivalPhase, + getEffectiveFestivalPhase, +} from "@/lib/festivalPhase"; +import { canShowTime, type RevealLevel } from "@/lib/scheduleReveal"; + +export type NowViewEdition = { + schedule_reveal_level: RevealLevel; + start_date: string | null; + end_date: string | null; + phase_override: FestivalPhase | null; +}; + +/** + * Single gate for the "Now" schedule view: the edition is effectively + * live (override-aware, per festivalPhase's rule that consumers read the + * effective phase) AND set times are revealed. Shared by the /schedule + * default redirect and the /schedule/now guard so they can never + * disagree with the nav tab. + */ +export function canShowNowView( + edition: NowViewEdition, + timezone: string, + now: Date, +): boolean { + const phase = getEffectiveFestivalPhase({ + override: edition.phase_override, + derivedInput: { + revealLevel: edition.schedule_reveal_level, + startDate: edition.start_date, + endDate: edition.end_date, + timezone, + now, + }, + }); + return phase === "live" && canShowTime(edition.schedule_reveal_level); +} diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule.tsx index b80de9c3..8a2ebd6f 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule.tsx @@ -4,8 +4,7 @@ import { stripSearchParams, } from "@tanstack/react-router"; import { ScheduleTab } from "@/pages/EditionView/tabs/ScheduleTab"; -import { getFestivalPhase } from "@/lib/festivalPhase"; -import { canShowTime } from "@/lib/scheduleReveal"; +import { canShowNowView } from "@/lib/nowView"; import { timelineSearchDefaults, timelineSearchSchema, @@ -21,15 +20,11 @@ export const Route = createFileRoute( }, beforeLoad: ({ params, location, context }) => { if (location.pathname.endsWith("/schedule")) { - const phase = getFestivalPhase({ - revealLevel: context.edition.schedule_reveal_level, - startDate: context.edition.start_date, - endDate: context.edition.end_date, - timezone: context.festival.timezone, - now: new Date(), - }); - const liveNow = - phase === "live" && canShowTime(context.edition.schedule_reveal_level); + const liveNow = canShowNowView( + context.edition, + context.festival.timezone, + new Date(), + ); throw redirect({ to: liveNow diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx index 0059a438..3299d8d8 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx @@ -10,13 +10,12 @@ import { setsByEditionQuery } from "@/api/sets/useSetsByEdition"; import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; import { useNow } from "@/hooks/useNow"; -import { getFestivalPhase } from "@/lib/festivalPhase"; +import { canShowNowView } from "@/lib/nowView"; import { classifyNowNextByStage, compareNowNext, type NowNextClassification, } from "@/lib/nowNext"; -import { canShowTime } from "@/lib/scheduleReveal"; import { sortStagesByOrder } from "@/lib/stageUtils"; import { formatTimeOnly } from "@/lib/timeUtils"; import type { FestivalSet } from "@/api/sets/types"; @@ -27,17 +26,8 @@ export const Route = createFileRoute( )({ component: ScheduleTabNow, beforeLoad: ({ params, location, context }) => { - const phase = getFestivalPhase({ - revealLevel: context.edition.schedule_reveal_level, - startDate: context.edition.start_date, - endDate: context.edition.end_date, - timezone: context.festival.timezone, - now: new Date(), - }); - if ( - phase !== "live" || - !canShowTime(context.edition.schedule_reveal_level) + !canShowNowView(context.edition, context.festival.timezone, new Date()) ) { throw redirect({ to: "/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline", @@ -50,6 +40,9 @@ export const Route = createFileRoute( void context.queryClient.ensureQueryData( setsByEditionQuery(context.edition.id), ); + void context.queryClient.ensureQueryData( + stagesByEditionQuery(context.edition.id), + ); }, });