diff --git a/src/lib/festivalCountdown.test.ts b/src/lib/festivalCountdown.test.ts new file mode 100644 index 00000000..7e02ea03 --- /dev/null +++ b/src/lib/festivalCountdown.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { daysUntilStart } from "./festivalCountdown"; + +describe("daysUntilStart", () => { + it("returns null when there is no start date", () => { + expect( + daysUntilStart(null, new Date("2026-01-01T00:00:00Z"), "UTC"), + ).toBeNull(); + }); + + it("returns null for an unparseable start date", () => { + expect( + daysUntilStart("not-a-date", new Date("2026-01-01T00:00:00Z"), "UTC"), + ).toBeNull(); + }); + + it("counts whole calendar days until a future start date", () => { + expect( + daysUntilStart("2026-01-10", new Date("2026-01-01T23:00:00Z"), "UTC"), + ).toBe(9); + }); + + it("returns 0 on the start date itself", () => { + expect( + daysUntilStart("2026-01-01", new Date("2026-01-01T12:00:00Z"), "UTC"), + ).toBe(0); + }); + + it("returns a negative number once the start date has passed", () => { + expect( + daysUntilStart("2026-01-01", new Date("2026-01-05T00:00:00Z"), "UTC"), + ).toBe(-4); + }); + + it("counts today in the festival timezone, not the viewer's", () => { + // 23:30 UTC on Jan 1 is already Jan 2 in Europe/Paris (UTC+1 in winter), + // so "today" should be Jan 2 there even though it's still Jan 1 in UTC. + expect( + daysUntilStart( + "2026-01-02", + new Date("2026-01-01T23:30:00Z"), + "Europe/Paris", + ), + ).toBe(0); + }); +}); diff --git a/src/lib/festivalCountdown.ts b/src/lib/festivalCountdown.ts new file mode 100644 index 00000000..59511696 --- /dev/null +++ b/src/lib/festivalCountdown.ts @@ -0,0 +1,19 @@ +import { differenceInCalendarDays, isValid, parseISO } from "date-fns"; +import { formatInTimeZone } from "date-fns-tz"; + +export function daysUntilStart( + startDate: string | null, + now: Date, + timezone: string, +): number | null { + if (!startDate) return null; + + const start = parseISO(startDate); + if (!isValid(start)) return null; + + const todayInFestivalTz = parseISO( + formatInTimeZone(now, timezone, "yyyy-MM-dd"), + ); + + return differenceInCalendarDays(start, todayInFestivalTz); +} diff --git a/src/pages/EditionView/EditionLayout.tsx b/src/pages/EditionView/EditionLayout.tsx index 9c823e52..711e795e 100644 --- a/src/pages/EditionView/EditionLayout.tsx +++ b/src/pages/EditionView/EditionLayout.tsx @@ -1,5 +1,6 @@ import { AppHeader } from "@/components/layout/AppHeader"; import { MainTabNavigation } from "./TabNavigation/TabNavigation"; +import { PhaseBanner } from "./PhaseBanner"; import ErrorBoundary from "@/components/ErrorBoundary"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; import { Outlet } from "@tanstack/react-router"; @@ -36,6 +37,8 @@ export default function EditionView() { ticketsUrl={ticketsUrl} /> + +
diff --git a/src/pages/EditionView/PhaseBanner.tsx b/src/pages/EditionView/PhaseBanner.tsx new file mode 100644 index 00000000..c136e62f --- /dev/null +++ b/src/pages/EditionView/PhaseBanner.tsx @@ -0,0 +1,45 @@ +import { useFestivalPhase } from "@/hooks/useFestivalPhase"; +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; +import { daysUntilStart } from "@/lib/festivalCountdown"; +import type { FestivalPhase } from "@/lib/festivalPhase"; + +export function PhaseBanner() { + const { phase } = useFestivalPhase(); + const { edition, festival } = useFestivalEdition(); + + const message = bannerMessage( + phase, + edition?.start_date ?? null, + festival.timezone, + ); + if (!message) return null; + + return ( +
+ {message} +
+ ); +} + +function bannerMessage( + phase: FestivalPhase, + startDate: string | null, + timezone: string, +): string | null { + if (phase === "pre-schedule") { + return "Artists are being announced — schedule coming soon. Vote for who you want to see!"; + } + + if (phase === "planning") { + return planningCopy(daysUntilStart(startDate, new Date(), timezone)); + } + + return null; +} + +function planningCopy(days: number | null): string { + if (days === null) return "Schedule is out — start planning your visit!"; + if (days <= 0) return "Schedule is out — the festival is here!"; + if (days === 1) return "Schedule is out — 1 day to go!"; + return `Schedule is out — ${days} days to go!`; +} diff --git a/src/pages/EditionView/TabNavigation/DesktopTabButton.tsx b/src/pages/EditionView/TabNavigation/DesktopTabButton.tsx index 6b9adab2..aa22fc05 100644 --- a/src/pages/EditionView/TabNavigation/DesktopTabButton.tsx +++ b/src/pages/EditionView/TabNavigation/DesktopTabButton.tsx @@ -1,15 +1,7 @@ import { cn } from "@/lib/utils"; import { Link, useParams } from "@tanstack/react-router"; import { TabButtonProps } from "./types"; - -const tabRoutes = { - sets: "/festivals/$festivalSlug/editions/$editionSlug/sets", - schedule: "/festivals/$festivalSlug/editions/$editionSlug/schedule", - map: "/festivals/$festivalSlug/editions/$editionSlug/map", - info: "/festivals/$festivalSlug/editions/$editionSlug/info", - social: "/festivals/$festivalSlug/editions/$editionSlug/social", - explore: "/festivals/$festivalSlug/editions/$editionSlug/explore", -} as const; +import { tabRoutes } from "./tabRoutes"; export function DesktopTabButton({ config }: TabButtonProps) { const { festivalSlug, editionSlug } = useParams({ diff --git a/src/pages/EditionView/TabNavigation/MobileTabButton.tsx b/src/pages/EditionView/TabNavigation/MobileTabButton.tsx index 15fd3c71..a363d6b0 100644 --- a/src/pages/EditionView/TabNavigation/MobileTabButton.tsx +++ b/src/pages/EditionView/TabNavigation/MobileTabButton.tsx @@ -1,14 +1,6 @@ import { Link, useParams, useMatchRoute } from "@tanstack/react-router"; import { TabButtonProps } from "./types"; - -const tabRoutes = { - sets: "/festivals/$festivalSlug/editions/$editionSlug/sets", - schedule: "/festivals/$festivalSlug/editions/$editionSlug/schedule", - map: "/festivals/$festivalSlug/editions/$editionSlug/map", - info: "/festivals/$festivalSlug/editions/$editionSlug/info", - social: "/festivals/$festivalSlug/editions/$editionSlug/social", - explore: "/festivals/$festivalSlug/editions/$editionSlug/explore", -} as const; +import { tabRoutes } from "./tabRoutes"; export function MobileTabButton({ config }: TabButtonProps) { const { festivalSlug, editionSlug } = useParams({ diff --git a/src/pages/EditionView/TabNavigation/TabNavigation.tsx b/src/pages/EditionView/TabNavigation/TabNavigation.tsx index 34b9e406..ff997ed1 100644 --- a/src/pages/EditionView/TabNavigation/TabNavigation.tsx +++ b/src/pages/EditionView/TabNavigation/TabNavigation.tsx @@ -1,22 +1,37 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; import { festivalInfoQuery } from "@/api/festival-info/useFestivalInfo"; +import { useFestivalPhase } from "@/hooks/useFestivalPhase"; import { DesktopTabButton } from "./DesktopTabButton"; import { MobileTabButton } from "./MobileTabButton"; import { config } from "./config"; +const PRIMARY_TAB_LABEL = { + "pre-schedule": "Lineup", + planning: "Vote", + live: "Vote", + "post-festival": "Vote", +} as const; + export function MainTabNavigation() { const { festival } = useFestivalEdition(); const { data: festivalInfo } = useSuspenseQuery( festivalInfoQuery(festival.id), ); + const { phase } = useFestivalPhase(); - const visibleTabs = config.filter((config) => { - if (typeof config.enabled === "boolean") { - return config.enabled; - } - return config.enabled(festivalInfo); - }); + const visibleTabs = config + .filter((config) => { + if (typeof config.enabled === "boolean") { + return config.enabled; + } + return config.enabled(festivalInfo); + }) + .map((config) => { + if (config.key !== "sets") return config; + const label = PRIMARY_TAB_LABEL[phase]; + return { ...config, label, shortLabel: label }; + }); return ( <> diff --git a/src/pages/EditionView/TabNavigation/defaultTab.test.ts b/src/pages/EditionView/TabNavigation/defaultTab.test.ts new file mode 100644 index 00000000..9e6de977 --- /dev/null +++ b/src/pages/EditionView/TabNavigation/defaultTab.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { getDefaultTab } from "./defaultTab"; + +describe("getDefaultTab", () => { + it("defaults to the primary (sets) tab in Pre-Schedule", () => { + expect(getDefaultTab("pre-schedule")).toBe("sets"); + }); + + it("defaults to the primary (sets) tab in Planning", () => { + expect(getDefaultTab("planning")).toBe("sets"); + }); + + it("defaults to the schedule tab in Live", () => { + expect(getDefaultTab("live")).toBe("schedule"); + }); + + it("defaults to the primary (sets) tab in Post-Festival", () => { + expect(getDefaultTab("post-festival")).toBe("sets"); + }); +}); diff --git a/src/pages/EditionView/TabNavigation/defaultTab.ts b/src/pages/EditionView/TabNavigation/defaultTab.ts new file mode 100644 index 00000000..cd2570c2 --- /dev/null +++ b/src/pages/EditionView/TabNavigation/defaultTab.ts @@ -0,0 +1,13 @@ +import type { FestivalPhase } from "@/lib/festivalPhase"; +import type { MainTab } from "./types"; + +const DEFAULT_TAB_BY_PHASE: Record = { + "pre-schedule": "sets", + planning: "sets", + live: "schedule", + "post-festival": "sets", +}; + +export function getDefaultTab(phase: FestivalPhase): MainTab { + return DEFAULT_TAB_BY_PHASE[phase]; +} diff --git a/src/pages/EditionView/TabNavigation/tabRoutes.ts b/src/pages/EditionView/TabNavigation/tabRoutes.ts new file mode 100644 index 00000000..cd5a0980 --- /dev/null +++ b/src/pages/EditionView/TabNavigation/tabRoutes.ts @@ -0,0 +1,8 @@ +export const tabRoutes = { + sets: "/festivals/$festivalSlug/editions/$editionSlug/sets", + schedule: "/festivals/$festivalSlug/editions/$editionSlug/schedule", + map: "/festivals/$festivalSlug/editions/$editionSlug/map", + info: "/festivals/$festivalSlug/editions/$editionSlug/info", + social: "/festivals/$festivalSlug/editions/$editionSlug/social", + explore: "/festivals/$festivalSlug/editions/$editionSlug/explore", +} as const; diff --git a/src/routes/festivals/$festivalSlug.tsx b/src/routes/festivals/$festivalSlug.tsx index 7287c9b4..4a79991d 100644 --- a/src/routes/festivals/$festivalSlug.tsx +++ b/src/routes/festivals/$festivalSlug.tsx @@ -6,12 +6,19 @@ import { festivalInfoQuery } from "@/api/festival-info/useFestivalInfo"; import { customLinksQuery } from "@/api/custom-links/useCustomLinks"; export const Route = createFileRoute("/festivals/$festivalSlug")({ - loader: async ({ params, context }) => { + beforeLoad: async ({ params, context }) => { const festival = await context.queryClient.ensureQueryData( festivalBySlugQuery(params.festivalSlug), ); - void context.queryClient.ensureQueryData(festivalInfoQuery(festival.id)); - void context.queryClient.ensureQueryData(customLinksQuery(festival.id)); + return { festival }; + }, + loader: async ({ context }) => { + void context.queryClient.ensureQueryData( + festivalInfoQuery(context.festival.id), + ); + void context.queryClient.ensureQueryData( + customLinksQuery(context.festival.id), + ); }, component: FestivalLayout, }); diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx index ade6a882..78c7ec80 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx @@ -1,32 +1,40 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import EditionLayout from "@/pages/EditionView/EditionLayout"; import { editionBySlugQuery } from "@/api/editions/useFestivalEditionBySlug"; -import { festivalBySlugQuery } from "@/api/festivals/useFestivalBySlug"; import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; +import { getFestivalPhase } from "@/lib/festivalPhase"; +import { getDefaultTab } from "@/pages/EditionView/TabNavigation/defaultTab"; +import { tabRoutes } from "@/pages/EditionView/TabNavigation/tabRoutes"; export const Route = createFileRoute( "/festivals/$festivalSlug/editions/$editionSlug", )({ component: EditionLayout, beforeLoad: async ({ params, location, context }) => { - if (params?.editionSlug && location.pathname.endsWith(params.editionSlug)) { - throw redirect({ - to: "/festivals/$festivalSlug/editions/$editionSlug/sets", - params, - search: location.search as Record, - }); - } - - const festival = await context.queryClient.ensureQueryData( - festivalBySlugQuery(params.festivalSlug), - ); const edition = await context.queryClient.ensureQueryData( editionBySlugQuery({ - festivalId: festival.id, + festivalId: context.festival.id, editionSlug: params.editionSlug, }), ); + const basePath = `/festivals/${params.festivalSlug}/editions/${params.editionSlug}`; + if (location.pathname === basePath || location.pathname === `${basePath}/`) { + const phase = getFestivalPhase({ + revealLevel: edition.schedule_reveal_level, + startDate: edition.start_date, + endDate: edition.end_date, + timezone: context.festival.timezone, + now: new Date(), + }); + + throw redirect({ + to: tabRoutes[getDefaultTab(phase)], + params, + search: location.search as Record, + }); + } + return { edition }; }, loader: async ({ context }) => {