diff --git a/src/hooks/useFestivalPhase.ts b/src/hooks/useFestivalPhase.ts index ea7158bb..989e535b 100644 --- a/src/hooks/useFestivalPhase.ts +++ b/src/hooks/useFestivalPhase.ts @@ -2,6 +2,7 @@ import { useRouteContext } from "@tanstack/react-router"; import { type FestivalPhase, getEffectiveFestivalPhase, + phaseInputFromEdition, } from "@/lib/festivalPhase"; export function useFestivalPhase(): { phase: FestivalPhase } { @@ -9,16 +10,13 @@ export function useFestivalPhase(): { phase: FestivalPhase } { from: "/festivals/$festivalSlug/editions/$editionSlug", }); - const phase = getEffectiveFestivalPhase({ - override: edition.phase_override, - derivedInput: { - revealLevel: edition.schedule_reveal_level ?? "draft", - startDate: edition.start_date, - endDate: edition.end_date, + const phase = getEffectiveFestivalPhase( + phaseInputFromEdition({ + edition, timezone: festival.timezone, now: new Date(), - }, - }); + }), + ); return { phase }; } diff --git a/src/lib/festivalPhase.test.ts b/src/lib/festivalPhase.test.ts index cea885f3..b3ed11e7 100644 --- a/src/lib/festivalPhase.test.ts +++ b/src/lib/festivalPhase.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { getEffectiveFestivalPhase, getFestivalPhase, + phaseInputFromEdition, type FestivalPhase, type FestivalPhaseInput, } from "./festivalPhase"; @@ -161,3 +162,61 @@ describe("getEffectiveFestivalPhase", () => { }); } }); + +describe("phaseInputFromEdition", () => { + const NOW = new Date(LIVE_START); + + it("passes through a fully-populated edition", () => { + expect( + phaseInputFromEdition({ + edition: { + schedule_reveal_level: "full", + start_date: "2025-08-01", + end_date: "2025-08-03", + phase_override: "live", + }, + timezone: TZ, + now: NOW, + }), + ).toEqual({ + override: "live", + derivedInput: { + revealLevel: "full", + startDate: "2025-08-01", + endDate: "2025-08-03", + timezone: TZ, + now: NOW, + }, + }); + }); + + it("defaults a missing reveal level to draft", () => { + expect( + phaseInputFromEdition({ + edition: { start_date: null, end_date: null, phase_override: null }, + timezone: TZ, + now: NOW, + }).derivedInput.revealLevel, + ).toBe("draft"); + }); + + it("defaults missing dates to null", () => { + const { derivedInput } = phaseInputFromEdition({ + edition: { schedule_reveal_level: "full", phase_override: null }, + timezone: TZ, + now: NOW, + }); + expect(derivedInput.startDate).toBeNull(); + expect(derivedInput.endDate).toBeNull(); + }); + + it("defaults a missing override to null", () => { + expect( + phaseInputFromEdition({ + edition: { schedule_reveal_level: "full" }, + timezone: TZ, + now: NOW, + }).override, + ).toBeNull(); + }); +}); diff --git a/src/lib/festivalPhase.ts b/src/lib/festivalPhase.ts index 9b640d7f..d4dec3be 100644 --- a/src/lib/festivalPhase.ts +++ b/src/lib/festivalPhase.ts @@ -54,6 +54,44 @@ export function getFestivalPhase({ return "post-festival"; } +/** + * Minimal edition-like shape {@link phaseInputFromEdition} needs — not a full + * FestivalEdition row — so callers with a partially-loaded or differently + * shaped edition can still build phase input. + */ +export type PhaseInputEdition = { + schedule_reveal_level?: RevealLevel | null; + start_date?: string | null; + end_date?: string | null; + phase_override?: FestivalPhase | null; +}; + +/** + * The one place that owns the edition-row -> phase-input defaulting policy + * (missing reveal level -> "draft", missing dates -> null), so callers never + * re-decide it independently. + */ +export function phaseInputFromEdition({ + edition, + timezone, + now, +}: { + edition: PhaseInputEdition; + timezone: string; + now: Date; +}): GetEffectiveFestivalPhaseInput { + return { + override: edition.phase_override ?? null, + derivedInput: { + revealLevel: edition.schedule_reveal_level ?? "draft", + startDate: edition.start_date ?? null, + endDate: edition.end_date ?? null, + timezone, + now, + }, + }; +} + // Shift a yyyy-MM-dd calendar day by whole days, staying a yyyy-MM-dd string. // Returns null for an unparseable date so callers degrade instead of throwing. function shiftDayKey(dateKey: string, delta: number): string | null { diff --git a/src/lib/nowView.ts b/src/lib/nowView.ts index a4d554f4..02bcf86a 100644 --- a/src/lib/nowView.ts +++ b/src/lib/nowView.ts @@ -1,6 +1,7 @@ import { type FestivalPhase, getEffectiveFestivalPhase, + phaseInputFromEdition, } from "@/lib/festivalPhase"; import { canShowTime, type RevealLevel } from "@/lib/scheduleReveal"; @@ -23,15 +24,8 @@ export function canShowNowView( 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, - }, - }); + const phase = getEffectiveFestivalPhase( + phaseInputFromEdition({ edition, timezone, now }), + ); return phase === "live" && canShowTime(edition.schedule_reveal_level); } diff --git a/src/routes/admin/festivals/$festivalSlug/editions/$editionSlug/settings.tsx b/src/routes/admin/festivals/$festivalSlug/editions/$editionSlug/settings.tsx index c9eb7830..b1db9f97 100644 --- a/src/routes/admin/festivals/$festivalSlug/editions/$editionSlug/settings.tsx +++ b/src/routes/admin/festivals/$festivalSlug/editions/$editionSlug/settings.tsx @@ -3,7 +3,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { Card, CardContent } from "@/components/ui/card"; import { editionBySlugQuery } from "@/api/editions/useFestivalEditionBySlug"; import { festivalBySlugQuery } from "@/api/festivals/useFestivalBySlug"; -import { getFestivalPhase } from "@/lib/festivalPhase"; +import { getFestivalPhase, phaseInputFromEdition } from "@/lib/festivalPhase"; import { ScheduleRevealControl } from "@/pages/admin/festivals/ScheduleRevealControl"; import { PhaseOverrideControl } from "@/pages/admin/festivals/PhaseOverrideControl"; import { pageMeta } from "@/lib/pageHead"; @@ -29,13 +29,13 @@ function FestivalEditionSettings() { editionBySlugQuery({ festivalId: festival.id, editionSlug }), ); - const derivedPhase = getFestivalPhase({ - revealLevel: currentEdition.schedule_reveal_level ?? "draft", - startDate: currentEdition.start_date, - endDate: currentEdition.end_date, + // Deliberately derived, not override-aware: feeds the "Automatic (...)" label below, which must preview automatic derivation even when an override is active. + const { derivedInput } = phaseInputFromEdition({ + edition: currentEdition, timezone: festival.timezone, now: new Date(), }); + const derivedPhase = getFestivalPhase(derivedInput); return ( diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx index 47137922..2352bf2b 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx @@ -11,7 +11,10 @@ import ErrorBoundary from "@/components/ErrorBoundary"; import { editionBySlugQuery } from "@/api/editions/useFestivalEditionBySlug"; import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; import { stagesKeys } from "@/api/stages/types"; -import { getEffectiveFestivalPhase } from "@/lib/festivalPhase"; +import { + getEffectiveFestivalPhase, + phaseInputFromEdition, +} from "@/lib/festivalPhase"; import { getDefaultTab } from "@/pages/EditionView/TabNavigation/defaultTab"; import { tabRoutes } from "@/pages/EditionView/TabNavigation/tabRoutes"; import { pageMeta } from "@/lib/pageHead"; @@ -42,16 +45,13 @@ export const Route = createFileRoute( location.pathname === basePath || location.pathname === `${basePath}/` ) { - const phase = getEffectiveFestivalPhase({ - override: edition.phase_override, - derivedInput: { - revealLevel: edition.schedule_reveal_level, - startDate: edition.start_date, - endDate: edition.end_date, + const phase = getEffectiveFestivalPhase( + phaseInputFromEdition({ + edition, timezone: context.festival.timezone, now: new Date(), - }, - }); + }), + ); throw redirect({ to: tabRoutes[getDefaultTab(phase)],