From 1375c9030adf117bf5b40a5efe22eaafcf004dc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 12:26:07 +0000 Subject: [PATCH 1/4] feat(phases): Pre-Schedule/Planning edition experience (#137) Adds the first phase-driven UI behavior on top of getFestivalPhase/useFestivalPhase (#136): - Compact PhaseBanner in EditionLayout with phase-aware copy (lineup-is-live in Pre-Schedule, schedule-out countdown in Planning), backed by a small pure daysUntilStart helper. - Phase-driven default landing tab: the $editionSlug beforeLoad redirect now resolves via a phase-to-tab map (getDefaultTab) instead of a hardcoded /sets redirect, so Live/Post can slot in later without reshaping the mechanism. - Primary tab label is phase-aware ("Lineup" in Pre-Schedule, "Vote" otherwise). - Deduplicated the tab-key-to-route map (tabRoutes) shared by the tab buttons and the route's default-tab redirect. No tab is added or hidden by phase; the tab set stays stable. --- src/lib/festivalCountdown.test.ts | 32 +++++++++++++++ src/lib/festivalCountdown.ts | 13 ++++++ src/pages/EditionView/EditionLayout.tsx | 3 ++ src/pages/EditionView/PhaseBanner.tsx | 40 +++++++++++++++++++ .../TabNavigation/DesktopTabButton.tsx | 10 +---- .../TabNavigation/MobileTabButton.tsx | 10 +---- .../TabNavigation/TabNavigation.tsx | 27 ++++++++++--- .../TabNavigation/defaultTab.test.ts | 17 ++++++++ .../EditionView/TabNavigation/defaultTab.ts | 13 ++++++ .../EditionView/TabNavigation/tabRoutes.ts | 8 ++++ .../$festivalSlug/editions/$editionSlug.tsx | 26 +++++++++++- 11 files changed, 174 insertions(+), 25 deletions(-) create mode 100644 src/lib/festivalCountdown.test.ts create mode 100644 src/lib/festivalCountdown.ts create mode 100644 src/pages/EditionView/PhaseBanner.tsx create mode 100644 src/pages/EditionView/TabNavigation/defaultTab.test.ts create mode 100644 src/pages/EditionView/TabNavigation/defaultTab.ts create mode 100644 src/pages/EditionView/TabNavigation/tabRoutes.ts diff --git a/src/lib/festivalCountdown.test.ts b/src/lib/festivalCountdown.test.ts new file mode 100644 index 00000000..58a5bad6 --- /dev/null +++ b/src/lib/festivalCountdown.test.ts @@ -0,0 +1,32 @@ +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"))).toBeNull(); + }); + + it("returns null for an unparseable start date", () => { + expect( + daysUntilStart("not-a-date", new Date("2026-01-01T00:00:00Z")), + ).toBeNull(); + }); + + it("counts whole calendar days until a future start date", () => { + expect( + daysUntilStart("2026-01-10", new Date("2026-01-01T23:00:00Z")), + ).toBe(9); + }); + + it("returns 0 on the start date itself", () => { + expect( + daysUntilStart("2026-01-01", new Date("2026-01-01T12:00:00Z")), + ).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")), + ).toBe(-4); + }); +}); diff --git a/src/lib/festivalCountdown.ts b/src/lib/festivalCountdown.ts new file mode 100644 index 00000000..26685b52 --- /dev/null +++ b/src/lib/festivalCountdown.ts @@ -0,0 +1,13 @@ +import { differenceInCalendarDays, isValid, parseISO } from "date-fns"; + +export function daysUntilStart( + startDate: string | null, + now: Date, +): number | null { + if (!startDate) return null; + + const start = parseISO(startDate); + if (!isValid(start)) return null; + + return differenceInCalendarDays(start, now); +} 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..e9f2f4b2 --- /dev/null +++ b/src/pages/EditionView/PhaseBanner.tsx @@ -0,0 +1,40 @@ +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 } = useFestivalEdition(); + + const message = bannerMessage(phase, edition?.start_date ?? null); + if (!message) return null; + + return ( +
+ {message} +
+ ); +} + +function bannerMessage( + phase: FestivalPhase, + startDate: string | null, +): string | null { + if (phase === "pre-schedule") { + return "The lineup is live — schedule coming soon. Vote for who you want to see!"; + } + + if (phase === "planning") { + return planningCopy(daysUntilStart(startDate, new Date())); + } + + 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..dbee97a3 --- /dev/null +++ b/src/pages/EditionView/TabNavigation/defaultTab.test.ts @@ -0,0 +1,17 @@ +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("has a default tab defined for every phase", () => { + expect(getDefaultTab("live")).toBe("sets"); + 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..5af1ee83 --- /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: "sets", + "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/editions/$editionSlug.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx index ade6a882..035870c2 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx @@ -3,6 +3,10 @@ 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 { festivalBySlugQuery } from "@/api/festivals/useFestivalBySlug"; +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", @@ -10,8 +14,28 @@ export const Route = createFileRoute( component: EditionLayout, beforeLoad: async ({ params, location, context }) => { if (params?.editionSlug && location.pathname.endsWith(params.editionSlug)) { + const [festival, edition] = await Promise.all([ + context.queryClient.ensureQueryData( + festivalBySlugQuery(params.festivalSlug), + ), + context.queryClient.ensureQueryData( + editionBySlugQuery({ + festivalSlug: params.festivalSlug, + editionSlug: params.editionSlug, + }), + ), + ]); + + const phase = getFestivalPhase({ + revealLevel: edition.schedule_reveal_level, + startDate: edition.start_date, + endDate: edition.end_date, + timezone: festival.timezone, + now: new Date(), + }); + throw redirect({ - to: "/festivals/$festivalSlug/editions/$editionSlug/sets", + to: tabRoutes[getDefaultTab(phase)], params, search: location.search as Record, }); From 5c4793145ab429db740db37ecbd360a4175e98cb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 13:21:38 +0000 Subject: [PATCH 2/4] fix(phases): compute planning countdown in festival timezone daysUntilStart compared the festival's start_date against the viewer's local calendar day, so the countdown could be off by one for viewers outside the festival timezone. Convert "now" to the festival's calendar day first. --- src/lib/festivalCountdown.test.ts | 24 +++++++++++++++++++----- src/lib/festivalCountdown.ts | 8 +++++++- src/pages/EditionView/PhaseBanner.tsx | 11 ++++++++--- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/lib/festivalCountdown.test.ts b/src/lib/festivalCountdown.test.ts index 58a5bad6..7e02ea03 100644 --- a/src/lib/festivalCountdown.test.ts +++ b/src/lib/festivalCountdown.test.ts @@ -3,30 +3,44 @@ 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"))).toBeNull(); + 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")), + 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")), + 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")), + 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")), + 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 index 26685b52..59511696 100644 --- a/src/lib/festivalCountdown.ts +++ b/src/lib/festivalCountdown.ts @@ -1,13 +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; - return differenceInCalendarDays(start, now); + const todayInFestivalTz = parseISO( + formatInTimeZone(now, timezone, "yyyy-MM-dd"), + ); + + return differenceInCalendarDays(start, todayInFestivalTz); } diff --git a/src/pages/EditionView/PhaseBanner.tsx b/src/pages/EditionView/PhaseBanner.tsx index e9f2f4b2..df9dbc30 100644 --- a/src/pages/EditionView/PhaseBanner.tsx +++ b/src/pages/EditionView/PhaseBanner.tsx @@ -5,9 +5,13 @@ import type { FestivalPhase } from "@/lib/festivalPhase"; export function PhaseBanner() { const { phase } = useFestivalPhase(); - const { edition } = useFestivalEdition(); + const { edition, festival } = useFestivalEdition(); - const message = bannerMessage(phase, edition?.start_date ?? null); + const message = bannerMessage( + phase, + edition?.start_date ?? null, + festival.timezone, + ); if (!message) return null; return ( @@ -20,13 +24,14 @@ export function PhaseBanner() { function bannerMessage( phase: FestivalPhase, startDate: string | null, + timezone: string, ): string | null { if (phase === "pre-schedule") { return "The lineup is live — schedule coming soon. Vote for who you want to see!"; } if (phase === "planning") { - return planningCopy(daysUntilStart(startDate, new Date())); + return planningCopy(daysUntilStart(startDate, new Date(), timezone)); } return null; From 18c6b9d8b68246d5b118abc0d6b224e1a5f944d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 14:12:53 +0000 Subject: [PATCH 3/4] fix(phases): address review feedback on route and default tab - Fetch the festival once in the parent /festivals/$festivalSlug beforeLoad and expose it via router context instead of re-fetching it in the child edition route. - Fetch the edition once per beforeLoad invocation instead of once inside the redirect branch and again in the fallthrough branch. - Default to the schedule tab (which itself lands on timeline) in the Live phase, instead of sets. - Soften the Pre-Schedule banner copy: artists can be announced incrementally before the schedule drops, so "the lineup is live" overstated completeness. --- src/pages/EditionView/PhaseBanner.tsx | 2 +- .../TabNavigation/defaultTab.test.ts | 7 ++-- .../EditionView/TabNavigation/defaultTab.ts | 2 +- src/routes/festivals/$festivalSlug.tsx | 13 ++++++-- .../$festivalSlug/editions/$editionSlug.tsx | 33 +++++-------------- 5 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/pages/EditionView/PhaseBanner.tsx b/src/pages/EditionView/PhaseBanner.tsx index df9dbc30..c136e62f 100644 --- a/src/pages/EditionView/PhaseBanner.tsx +++ b/src/pages/EditionView/PhaseBanner.tsx @@ -27,7 +27,7 @@ function bannerMessage( timezone: string, ): string | null { if (phase === "pre-schedule") { - return "The lineup is live — schedule coming soon. Vote for who you want to see!"; + return "Artists are being announced — schedule coming soon. Vote for who you want to see!"; } if (phase === "planning") { diff --git a/src/pages/EditionView/TabNavigation/defaultTab.test.ts b/src/pages/EditionView/TabNavigation/defaultTab.test.ts index dbee97a3..9e6de977 100644 --- a/src/pages/EditionView/TabNavigation/defaultTab.test.ts +++ b/src/pages/EditionView/TabNavigation/defaultTab.test.ts @@ -10,8 +10,11 @@ describe("getDefaultTab", () => { expect(getDefaultTab("planning")).toBe("sets"); }); - it("has a default tab defined for every phase", () => { - expect(getDefaultTab("live")).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 index 5af1ee83..cd2570c2 100644 --- a/src/pages/EditionView/TabNavigation/defaultTab.ts +++ b/src/pages/EditionView/TabNavigation/defaultTab.ts @@ -4,7 +4,7 @@ import type { MainTab } from "./types"; const DEFAULT_TAB_BY_PHASE: Record = { "pre-schedule": "sets", planning: "sets", - live: "sets", + live: "schedule", "post-festival": "sets", }; 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 035870c2..88c2c979 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx @@ -1,9 +1,7 @@ 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 { festivalBySlugQuery } from "@/api/festivals/useFestivalBySlug"; import { getFestivalPhase } from "@/lib/festivalPhase"; import { getDefaultTab } from "@/pages/EditionView/TabNavigation/defaultTab"; import { tabRoutes } from "@/pages/EditionView/TabNavigation/tabRoutes"; @@ -13,24 +11,19 @@ export const Route = createFileRoute( )({ component: EditionLayout, beforeLoad: async ({ params, location, context }) => { - if (params?.editionSlug && location.pathname.endsWith(params.editionSlug)) { - const [festival, edition] = await Promise.all([ - context.queryClient.ensureQueryData( - festivalBySlugQuery(params.festivalSlug), - ), - context.queryClient.ensureQueryData( - editionBySlugQuery({ - festivalSlug: params.festivalSlug, - editionSlug: params.editionSlug, - }), - ), - ]); + const edition = await context.queryClient.ensureQueryData( + editionBySlugQuery({ + festivalId: context.festival.id, + editionSlug: params.editionSlug, + }), + ); + if (params?.editionSlug && location.pathname.endsWith(params.editionSlug)) { const phase = getFestivalPhase({ revealLevel: edition.schedule_reveal_level, startDate: edition.start_date, endDate: edition.end_date, - timezone: festival.timezone, + timezone: context.festival.timezone, now: new Date(), }); @@ -41,16 +34,6 @@ export const Route = createFileRoute( }); } - const festival = await context.queryClient.ensureQueryData( - festivalBySlugQuery(params.festivalSlug), - ); - const edition = await context.queryClient.ensureQueryData( - editionBySlugQuery({ - festivalId: festival.id, - editionSlug: params.editionSlug, - }), - ); - return { edition }; }, loader: async ({ context }) => { From 72ac6603f93701d9d9bfa896770928f97347a513 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 14:27:59 +0000 Subject: [PATCH 4/4] fix(phases): match base edition route by exact path, not endsWith location.pathname.endsWith(params.editionSlug) misfires whenever an edition slug happens to match a tab segment (e.g. slug "sets" against .../editions/sets/sets), sending users on a tab back to the default tab. Compare against the exact base path instead. --- src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx index 88c2c979..78c7ec80 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx @@ -18,7 +18,8 @@ export const Route = createFileRoute( }), ); - if (params?.editionSlug && location.pathname.endsWith(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,