Skip to content
242 changes: 242 additions & 0 deletions src/lib/nowNext.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
121 changes: 121 additions & 0 deletions src/lib/nowNext.ts
Original file line number Diff line number Diff line change
@@ -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<T> = {
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<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 };
}

/**
* 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<string, NowNextClassification<T>> {
const byStage = new Map<string, T[]>();
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<string, NowNextClassification<T>>();
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<T extends NowNextSet>(
a: NowNextClassification<T>,
b: NowNextClassification<T>,
): 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<T extends NowNextSet>(
classification: NowNextClassification<T>,
): 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;
}
Loading
Loading