diff --git a/src/lib/nowNext.test.ts b/src/lib/nowNext.test.ts new file mode 100644 index 00000000..1058e679 --- /dev/null +++ b/src/lib/nowNext.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from "vitest"; +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 +// 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: [], + }); + }); +}); + +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([]); + }); +}); + +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 new file mode 100644 index 00000000..85a5f5b1 --- /dev/null +++ b/src/lib/nowNext.ts @@ -0,0 +1,121 @@ +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[]; +}; + +/** + * 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, +): 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 }; +} + +/** + * 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> { + 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; +} + +/** + * 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); + return isValid(date) ? date : null; +} 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/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/ScheduleNavigation.tsx b/src/pages/EditionView/tabs/ScheduleTab/ScheduleNavigation.tsx index 48ce88c3..8ea91b4a 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/ScheduleNavigation.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/ScheduleNavigation.tsx @@ -1,16 +1,25 @@ -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 && ( + + )} - +
); diff --git a/src/pages/EditionView/tabs/ScheduleTab/ScheduleNavigationItem.tsx b/src/pages/EditionView/tabs/ScheduleTab/ScheduleNavigationItem.tsx index af2a7250..cf0206f6 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/ScheduleNavigationItem.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/ScheduleNavigationItem.tsx @@ -2,8 +2,14 @@ import { Link } from "@tanstack/react-router"; import { LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; +const VIEW_TO = { + now: "./now", + timeline: "./timeline", + list: "./list", +} as const; + interface ScheduleNavigationItemProps { - view: "timeline" | "list"; + view: keyof typeof VIEW_TO; label: string; icon: LucideIcon; } @@ -16,18 +22,18 @@ export function ScheduleNavigationItem({ return ( - + {set.stageName && ( + + )} {duration && (
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..8a2ebd6f 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule.tsx @@ -4,6 +4,7 @@ import { stripSearchParams, } from "@tanstack/react-router"; import { ScheduleTab } from "@/pages/EditionView/tabs/ScheduleTab"; +import { canShowNowView } from "@/lib/nowView"; import { timelineSearchDefaults, timelineSearchSchema, @@ -17,10 +18,18 @@ export const Route = createFileRoute( search: { middlewares: [stripSearchParams(timelineSearchDefaults)], }, - beforeLoad: ({ params, location }) => { + beforeLoad: ({ params, location, context }) => { if (location.pathname.endsWith("/schedule")) { + const liveNow = canShowNowView( + context.edition, + context.festival.timezone, + new Date(), + ); + 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..3299d8d8 --- /dev/null +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/now.tsx @@ -0,0 +1,147 @@ +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 { useNow } from "@/hooks/useNow"; +import { canShowNowView } from "@/lib/nowView"; +import { + classifyNowNextByStage, + compareNowNext, + type NowNextClassification, +} from "@/lib/nowNext"; +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", +)({ + component: ScheduleTabNow, + beforeLoad: ({ params, location, context }) => { + if ( + !canShowNowView(context.edition, context.festival.timezone, new Date()) + ) { + throw redirect({ + to: "/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline", + params, + search: location.search as Record, + }); + } + }, + loader: ({ context }) => { + void context.queryClient.ensureQueryData( + setsByEditionQuery(context.edition.id), + ); + void context.queryClient.ensureQueryData( + stagesByEditionQuery(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 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 }]; + }) + .sort((a, b) => compareNowNext(a.classification, b.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} +
+ ); +}