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
46 changes: 46 additions & 0 deletions src/lib/festivalCountdown.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
19 changes: 19 additions & 0 deletions src/lib/festivalCountdown.ts
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 3 additions & 0 deletions src/pages/EditionView/EditionLayout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -36,6 +37,8 @@ export default function EditionView() {
ticketsUrl={ticketsUrl}
/>

<PhaseBanner />

<MainTabNavigation />

<div className="mt-4 md:mt-8">
Expand Down
45 changes: 45 additions & 0 deletions src/pages/EditionView/PhaseBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mb-4 rounded-lg bg-white/10 backdrop-blur-md px-4 py-2 text-center text-sm text-purple-100">
{message}
</div>
);
}

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!`;
}
10 changes: 1 addition & 9 deletions src/pages/EditionView/TabNavigation/DesktopTabButton.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
10 changes: 1 addition & 9 deletions src/pages/EditionView/TabNavigation/MobileTabButton.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
27 changes: 21 additions & 6 deletions src/pages/EditionView/TabNavigation/TabNavigation.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
Expand Down
20 changes: 20 additions & 0 deletions src/pages/EditionView/TabNavigation/defaultTab.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
13 changes: 13 additions & 0 deletions src/pages/EditionView/TabNavigation/defaultTab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { FestivalPhase } from "@/lib/festivalPhase";
import type { MainTab } from "./types";

const DEFAULT_TAB_BY_PHASE: Record<FestivalPhase, MainTab> = {
"pre-schedule": "sets",
planning: "sets",
live: "schedule",
"post-festival": "sets",
};

export function getDefaultTab(phase: FestivalPhase): MainTab {
return DEFAULT_TAB_BY_PHASE[phase];
}
8 changes: 8 additions & 0 deletions src/pages/EditionView/TabNavigation/tabRoutes.ts
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 10 additions & 3 deletions src/routes/festivals/$festivalSlug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
34 changes: 21 additions & 13 deletions src/routes/festivals/$festivalSlug/editions/$editionSlug.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
});
}

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<string, unknown>,
});
}

return { edition };
},
loader: async ({ context }) => {
Expand Down
Loading