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..8fe3b49d --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/NowNextSection.tsx @@ -0,0 +1,105 @@ +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/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 && (