Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions src/lib/nowNext.test.ts
Original file line number Diff line number Diff line change
@@ -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: [],
});
});
});
61 changes: 61 additions & 0 deletions src/lib/nowNext.ts
Original file line number Diff line number Diff line change
@@ -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<T> = {
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<T extends NowNextSet>(
sets: T[],
now: Date,
): NowNextClassification<T> {
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;
}
4 changes: 4 additions & 0 deletions src/pages/EditionView/PhaseBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
3 changes: 3 additions & 0 deletions src/pages/EditionView/tabs/ScheduleTab.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,6 +11,8 @@ export function ScheduleTab() {
<>
<PageTitle title="Schedule" prefix={festival?.name} />
<div className="space-y-3 md:space-y-6">
<NowNextSection />

<ScheduleNavigation />

<Outlet />
Expand Down
105 changes: 105 additions & 0 deletions src/pages/EditionView/tabs/ScheduleTab/NowNextSection.tsx
Original file line number Diff line number Diff line change
@@ -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 <LiveNowNext />;
}

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 (
<div className="space-y-4">
{nowCards.length > 0 && (
<NowNextGroup
label="On now"
live
sets={nowCards}
timezone={festival.timezone}
/>
)}
{nextCards.length > 0 && (
<NowNextGroup
label="Up next"
sets={nextCards}
timezone={festival.timezone}
/>
)}
</div>
);
}

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 (
<div>
<div className="flex items-center gap-3 mb-3 px-1">
<div className="flex items-center gap-2 bg-purple-800/50 px-3 py-1.5 rounded-full">
{live && (
<span className="h-2 w-2 rounded-full bg-red-500 animate-pulse" />
)}
<span className="text-sm font-medium text-purple-200">{label}</span>
</div>
<div className="flex-1 h-px bg-purple-400/20"></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{sets.map((set) => (
<MobileSetCard key={set.id} set={set} timezone={timezone} />
))}
</div>
</div>
);
}

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,
};
}
12 changes: 7 additions & 5 deletions src/pages/EditionView/tabs/ScheduleTab/list/MobileSetCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ export function MobileSetCard({ set, timezone }: MobileSetCardProps) {

{/* Stage and duration info */}
<div className="flex items-center gap-4 mb-3 text-sm text-purple-200">
<StageBadge
stageName={set.stageName}
stageColor={set.stageColor}
size="sm"
/>
{set.stageName && (
<StageBadge
stageName={set.stageName}
stageColor={set.stageColor}
size="sm"
/>
)}

{duration && (
<div className="flex items-center gap-1">
Expand Down
Loading