Skip to content
Merged
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
14 changes: 6 additions & 8 deletions src/hooks/useFestivalPhase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,21 @@ import { useRouteContext } from "@tanstack/react-router";
import {
type FestivalPhase,
getEffectiveFestivalPhase,
phaseInputFromEdition,
} from "@/lib/festivalPhase";

export function useFestivalPhase(): { phase: FestivalPhase } {
const { festival, edition } = useRouteContext({
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 };
}
59 changes: 59 additions & 0 deletions src/lib/festivalPhase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
getEffectiveFestivalPhase,
getFestivalPhase,
phaseInputFromEdition,
type FestivalPhase,
type FestivalPhaseInput,
} from "./festivalPhase";
Expand Down Expand Up @@ -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();
});
});
38 changes: 38 additions & 0 deletions src/lib/festivalPhase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 4 additions & 10 deletions src/lib/nowView.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type FestivalPhase,
getEffectiveFestivalPhase,
phaseInputFromEdition,
} from "@/lib/festivalPhase";
import { canShowTime, type RevealLevel } from "@/lib/scheduleReveal";

Expand All @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
<Card>
Expand Down
18 changes: 9 additions & 9 deletions src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)],
Expand Down
Loading