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 }) => {