feat: persistent Coaster Mode toggle in header top-right
All checks were successful
Build and Deploy / Build & Push (push) Successful in 53s

- Moves the coaster toggle out of WeekNav and into the homepage header
  top-right as "🎢 Coaster Mode", alongside the parks open badge
- State is stored in localStorage ("coasterMode") so the preference
  persists across sessions and page refreshes
- Dropped the ?coasters=1 URL param entirely; the server always fetches
  both rideCounts and coasterCounts, and HomePageClient picks which to
  display client-side — no flash or server round-trip on toggle
- Individual park pages: LiveRidePanel reads localStorage on mount and
  pre-selects the Coasters Only filter when Coaster Mode is active
- Extracted HomePageClient (client component) to own the full homepage
  UI; page.tsx is now pure data-fetching

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Josh Wright
2026-04-05 08:36:38 -04:00
parent 8c3841d9a5
commit 5b575f962e
4 changed files with 207 additions and 146 deletions

View File

@@ -1,9 +1,5 @@
import { WeekCalendar } from "@/components/WeekCalendar";
import { MobileCardList } from "@/components/MobileCardList";
import { WeekNav } from "@/components/WeekNav";
import { Legend } from "@/components/Legend";
import { EmptyState } from "@/components/EmptyState";
import { PARKS, groupByRegion } from "@/lib/parks";
import { HomePageClient } from "@/components/HomePageClient";
import { PARKS } from "@/lib/parks";
import { openDb, getDateRange } from "@/lib/db";
import { getTodayLocal, isWithinOperatingWindow } from "@/lib/env";
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
@@ -11,7 +7,7 @@ import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
import { readParkMeta, getCoasterSet } from "@/lib/park-meta";
interface PageProps {
searchParams: Promise<{ week?: string; coasters?: string }>;
searchParams: Promise<{ week?: string }>;
}
function getWeekStart(param: string | undefined): string {
@@ -46,7 +42,6 @@ function getCurrentWeekStart(): string {
export default async function HomePage({ searchParams }: PageProps) {
const params = await searchParams;
const weekStart = getWeekStart(params.week);
const coastersOnly = params.coasters === "1";
const weekDates = getWeekDates(weekStart);
const endDate = weekDates[6];
const today = getTodayLocal();
@@ -61,8 +56,7 @@ export default async function HomePage({ searchParams }: PageProps) {
0
);
// Fetch live ride counts for parks open today (cached 5 min via Queue-Times).
// Only shown when the current time is within 1h before open to 1h after close.
// Always fetch both ride and coaster counts — the client decides which to display.
const parkMeta = readParkMeta();
const hasCoasterData = PARKS.some((p) => (parkMeta[p.id]?.coasters.length ?? 0) > 0);
@@ -91,108 +85,17 @@ export default async function HomePage({ searchParams }: PageProps) {
);
}
const activeCounts = coastersOnly ? coasterCounts : rideCounts;
const visibleParks = PARKS.filter((park) =>
weekDates.some((date) => data[park.id]?.[date]?.isOpen)
);
const grouped = groupByRegion(visibleParks);
return (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
{/* ── Header ─────────────────────────────────────────────────────────── */}
<header style={{
position: "sticky",
top: 0,
zIndex: 20,
background: "var(--color-bg)",
borderBottom: "1px solid var(--color-border)",
}}>
{/* Row 1: Title + park count */}
<div style={{
padding: "12px 16px 10px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}>
<span style={{
fontSize: "1.1rem",
fontWeight: 700,
color: "var(--color-text)",
letterSpacing: "-0.02em",
}}>
Thoosie Calendar
</span>
<span style={{
background: visibleParks.length > 0 ? "var(--color-open-bg)" : "var(--color-surface)",
border: `1px solid ${visibleParks.length > 0 ? "var(--color-open-border)" : "var(--color-border)"}`,
borderRadius: 20,
padding: "4px 14px",
fontSize: "0.78rem",
color: visibleParks.length > 0 ? "var(--color-open-hours)" : "var(--color-text-muted)",
fontWeight: 600,
}}>
{visibleParks.length} of {PARKS.length} parks open this week
</span>
</div>
{/* Row 2: Week nav + legend (legend hidden on mobile) */}
<div style={{
padding: "8px 16px 10px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 16,
borderTop: "1px solid var(--color-border-subtle)",
}}>
<WeekNav
<HomePageClient
weekStart={weekStart}
weekDates={weekDates}
isCurrentWeek={isCurrentWeek}
coastersOnly={coastersOnly}
hasCoasterData={hasCoasterData}
/>
<div className="hidden sm:flex">
<Legend />
</div>
</div>
</header>
{/* ── Main content ───────────────────────────────────────────────────── */}
<main className="px-4 sm:px-6 pb-12">
{scrapedCount === 0 ? (
<EmptyState />
) : (
<>
{/* Mobile: card list (hidden on lg+) */}
<div className="lg:hidden">
<MobileCardList
grouped={grouped}
weekDates={weekDates}
data={data}
today={today}
rideCounts={activeCounts}
coastersOnly={coastersOnly}
/>
</div>
{/* Desktop: week table (hidden below lg) */}
<div className="hidden lg:block">
<WeekCalendar
parks={visibleParks}
weekDates={weekDates}
isCurrentWeek={isCurrentWeek}
data={data}
grouped={grouped}
rideCounts={activeCounts}
coastersOnly={coastersOnly}
rideCounts={rideCounts}
coasterCounts={coasterCounts}
hasCoasterData={hasCoasterData}
scrapedCount={scrapedCount}
/>
</div>
</>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,182 @@
"use client";
import { useState, useEffect } from "react";
import { WeekCalendar } from "./WeekCalendar";
import { MobileCardList } from "./MobileCardList";
import { WeekNav } from "./WeekNav";
import { Legend } from "./Legend";
import { EmptyState } from "./EmptyState";
import { PARKS, groupByRegion } from "@/lib/parks";
import type { DayData } from "@/lib/db";
const COASTER_MODE_KEY = "coasterMode";
interface HomePageClientProps {
weekStart: string;
weekDates: string[];
today: string;
isCurrentWeek: boolean;
data: Record<string, Record<string, DayData>>;
rideCounts: Record<string, number>;
coasterCounts: Record<string, number>;
hasCoasterData: boolean;
scrapedCount: number;
}
export function HomePageClient({
weekStart,
weekDates,
today,
isCurrentWeek,
data,
rideCounts,
coasterCounts,
hasCoasterData,
scrapedCount,
}: HomePageClientProps) {
const [coastersOnly, setCoastersOnly] = useState(false);
// Hydrate from localStorage after mount to avoid SSR mismatch.
useEffect(() => {
setCoastersOnly(localStorage.getItem(COASTER_MODE_KEY) === "true");
}, []);
const toggle = () => {
const next = !coastersOnly;
setCoastersOnly(next);
localStorage.setItem(COASTER_MODE_KEY, String(next));
};
const activeCounts = coastersOnly ? coasterCounts : rideCounts;
const visibleParks = PARKS.filter((park) =>
weekDates.some((date) => data[park.id]?.[date]?.isOpen)
);
const grouped = groupByRegion(visibleParks);
return (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
{/* ── Header ───────────────────────────────────────────────────────────── */}
<header style={{
position: "sticky",
top: 0,
zIndex: 20,
background: "var(--color-bg)",
borderBottom: "1px solid var(--color-border)",
}}>
{/* Row 1: Title + controls */}
<div style={{
padding: "12px 16px 10px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}>
<span style={{
fontSize: "1.1rem",
fontWeight: 700,
color: "var(--color-text)",
letterSpacing: "-0.02em",
}}>
Thoosie Calendar
</span>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
{hasCoasterData && (
<button
onClick={toggle}
style={{
display: "flex",
alignItems: "center",
gap: 5,
padding: "4px 12px",
borderRadius: 20,
border: coastersOnly
? "1px solid var(--color-accent)"
: "1px solid var(--color-border)",
background: coastersOnly
? "var(--color-accent-muted)"
: "var(--color-surface)",
color: coastersOnly
? "var(--color-accent)"
: "var(--color-text-muted)",
fontSize: "0.72rem",
fontWeight: 600,
cursor: "pointer",
transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
whiteSpace: "nowrap",
}}
>
🎢 Coaster Mode
</button>
)}
<span style={{
background: visibleParks.length > 0 ? "var(--color-open-bg)" : "var(--color-surface)",
border: `1px solid ${visibleParks.length > 0 ? "var(--color-open-border)" : "var(--color-border)"}`,
borderRadius: 20,
padding: "4px 14px",
fontSize: "0.78rem",
color: visibleParks.length > 0 ? "var(--color-open-hours)" : "var(--color-text-muted)",
fontWeight: 600,
}}>
{visibleParks.length} of {PARKS.length} parks open this week
</span>
</div>
</div>
{/* Row 2: Week nav + legend */}
<div style={{
padding: "8px 16px 10px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 16,
borderTop: "1px solid var(--color-border-subtle)",
}}>
<WeekNav
weekStart={weekStart}
weekDates={weekDates}
isCurrentWeek={isCurrentWeek}
/>
<div className="hidden sm:flex">
<Legend />
</div>
</div>
</header>
{/* ── Main content ────────────────────────────────────────────────────── */}
<main className="px-4 sm:px-6 pb-12">
{scrapedCount === 0 ? (
<EmptyState />
) : (
<>
{/* Mobile: card list (hidden on lg+) */}
<div className="lg:hidden">
<MobileCardList
grouped={grouped}
weekDates={weekDates}
data={data}
today={today}
rideCounts={activeCounts}
coastersOnly={coastersOnly}
/>
</div>
{/* Desktop: week table (hidden below lg) */}
<div className="hidden lg:block">
<WeekCalendar
parks={visibleParks}
weekDates={weekDates}
data={data}
grouped={grouped}
rideCounts={activeCounts}
coastersOnly={coastersOnly}
/>
</div>
</>
)}
</main>
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import type { LiveRidesResult, LiveRide } from "@/lib/scrapers/queuetimes";
interface LiveRidePanelProps {
@@ -13,6 +13,13 @@ export function LiveRidePanel({ liveRides, parkOpenToday }: LiveRidePanelProps)
const hasCoasters = rides.some((r) => r.isCoaster);
const [coastersOnly, setCoastersOnly] = useState(false);
// Pre-select coaster filter if Coaster Mode is enabled on the homepage.
useEffect(() => {
if (hasCoasters && localStorage.getItem("coasterMode") === "true") {
setCoastersOnly(true);
}
}, [hasCoasters]);
const visible = coastersOnly ? rides.filter((r) => r.isCoaster) : rides;
const openRides = visible.filter((r) => r.isOpen);
const closedRides = visible.filter((r) => !r.isOpen);

View File

@@ -7,8 +7,6 @@ interface WeekNavProps {
weekStart: string; // YYYY-MM-DD (Sunday)
weekDates: string[]; // 7 dates YYYY-MM-DD
isCurrentWeek: boolean;
coastersOnly: boolean;
hasCoasterData: boolean;
}
const MONTHS = [
@@ -33,15 +31,10 @@ function shiftWeek(weekStart: string, delta: number): string {
return d.toISOString().slice(0, 10);
}
export function WeekNav({ weekStart, weekDates, isCurrentWeek, coastersOnly, hasCoasterData }: WeekNavProps) {
export function WeekNav({ weekStart, weekDates, isCurrentWeek }: WeekNavProps) {
const router = useRouter();
const weekParam = `week=${weekStart}`;
const nav = (delta: number) => {
const base = `/?week=${shiftWeek(weekStart, delta)}`;
router.push(coastersOnly ? `${base}&coasters=1` : base);
};
const toggleCoasters = () => {
router.push(coastersOnly ? `/?${weekParam}` : `/?${weekParam}&coasters=1`);
router.push(`/?week=${shiftWeek(weekStart, delta)}`);
};
useEffect(() => {
@@ -99,30 +92,6 @@ export function WeekNav({ weekStart, weekDates, isCurrentWeek, coastersOnly, has
>
</button>
{hasCoasterData && (
<button
onClick={toggleCoasters}
style={{
marginLeft: 8,
display: "flex",
alignItems: "center",
gap: 5,
padding: "4px 12px",
borderRadius: 20,
border: coastersOnly ? "1px solid var(--color-accent)" : "1px solid var(--color-border)",
background: coastersOnly ? "var(--color-accent-muted)" : "var(--color-surface)",
color: coastersOnly ? "var(--color-accent)" : "var(--color-text-muted)",
fontSize: "0.72rem",
fontWeight: 600,
cursor: "pointer",
transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
whiteSpace: "nowrap",
}}
>
🎢 Coasters
</button>
)}
</div>
);
}