feat: coaster filter toggle on homepage
All checks were successful
Build and Deploy / Build & Push (push) Successful in 1m14s

- 🎢 Coasters button in nav bar (URL-driven: ?coasters=1)
- When active, swaps ride counts for coaster counts per park
- Label switches between "X rides operating" / "X coasters operating"
- Arrow key navigation preserves coaster filter state
- Only shown when coaster data exists in park-meta

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 21:03:00 -04:00
parent f1fec2355c
commit 7456ead430
5 changed files with 78 additions and 16 deletions

View File

@@ -8,9 +8,10 @@ import { openDb, getDateRange } from "@/lib/db";
import { getTodayLocal, isWithinOperatingWindow } from "@/lib/env"; import { getTodayLocal, isWithinOperatingWindow } from "@/lib/env";
import { fetchLiveRides } from "@/lib/scrapers/queuetimes"; import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map"; import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
import { readParkMeta, getCoasterSet } from "@/lib/park-meta";
interface PageProps { interface PageProps {
searchParams: Promise<{ week?: string }>; searchParams: Promise<{ week?: string; coasters?: string }>;
} }
function getWeekStart(param: string | undefined): string { function getWeekStart(param: string | undefined): string {
@@ -45,6 +46,7 @@ function getCurrentWeekStart(): string {
export default async function HomePage({ searchParams }: PageProps) { export default async function HomePage({ searchParams }: PageProps) {
const params = await searchParams; const params = await searchParams;
const weekStart = getWeekStart(params.week); const weekStart = getWeekStart(params.week);
const coastersOnly = params.coasters === "1";
const weekDates = getWeekDates(weekStart); const weekDates = getWeekDates(weekStart);
const endDate = weekDates[6]; const endDate = weekDates[6];
const today = getTodayLocal(); const today = getTodayLocal();
@@ -61,7 +63,11 @@ export default async function HomePage({ searchParams }: PageProps) {
// Fetch live ride counts for parks open today (cached 5 min via Queue-Times). // 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. // Only shown when the current time is within 1h before open to 1h after close.
const parkMeta = readParkMeta();
const hasCoasterData = PARKS.some((p) => (parkMeta[p.id]?.coasters.length ?? 0) > 0);
let rideCounts: Record<string, number> = {}; let rideCounts: Record<string, number> = {};
let coasterCounts: Record<string, number> = {};
if (weekDates.includes(today)) { if (weekDates.includes(today)) {
const openTodayParks = PARKS.filter((p) => { const openTodayParks = PARKS.filter((p) => {
const dayData = data[p.id]?.[today]; const dayData = data[p.id]?.[today];
@@ -70,14 +76,23 @@ export default async function HomePage({ searchParams }: PageProps) {
}); });
const results = await Promise.all( const results = await Promise.all(
openTodayParks.map(async (p) => { openTodayParks.map(async (p) => {
const result = await fetchLiveRides(QUEUE_TIMES_IDS[p.id], null, 300); const coasterSet = getCoasterSet(p.id, parkMeta);
const count = result ? result.rides.filter((r) => r.isOpen).length : 0; const result = await fetchLiveRides(QUEUE_TIMES_IDS[p.id], coasterSet, 300);
return [p.id, count] as [string, number]; const rideCount = result ? result.rides.filter((r) => r.isOpen).length : 0;
const coasterCount = result ? result.rides.filter((r) => r.isOpen && r.isCoaster).length : 0;
return { id: p.id, rideCount, coasterCount };
}) })
); );
rideCounts = Object.fromEntries(results.filter(([, count]) => count > 0)); rideCounts = Object.fromEntries(
results.filter(({ rideCount }) => rideCount > 0).map(({ id, rideCount }) => [id, rideCount])
);
coasterCounts = Object.fromEntries(
results.filter(({ coasterCount }) => coasterCount > 0).map(({ id, coasterCount }) => [id, coasterCount])
);
} }
const activeCounts = coastersOnly ? coasterCounts : rideCounts;
const visibleParks = PARKS.filter((park) => const visibleParks = PARKS.filter((park) =>
weekDates.some((date) => data[park.id]?.[date]?.isOpen) weekDates.some((date) => data[park.id]?.[date]?.isOpen)
); );
@@ -137,6 +152,8 @@ export default async function HomePage({ searchParams }: PageProps) {
weekStart={weekStart} weekStart={weekStart}
weekDates={weekDates} weekDates={weekDates}
isCurrentWeek={isCurrentWeek} isCurrentWeek={isCurrentWeek}
coastersOnly={coastersOnly}
hasCoasterData={hasCoasterData}
/> />
<div className="hidden sm:flex"> <div className="hidden sm:flex">
<Legend /> <Legend />
@@ -157,7 +174,8 @@ export default async function HomePage({ searchParams }: PageProps) {
weekDates={weekDates} weekDates={weekDates}
data={data} data={data}
today={today} today={today}
rideCounts={rideCounts} rideCounts={activeCounts}
coastersOnly={coastersOnly}
/> />
</div> </div>
@@ -168,7 +186,8 @@ export default async function HomePage({ searchParams }: PageProps) {
weekDates={weekDates} weekDates={weekDates}
data={data} data={data}
grouped={grouped} grouped={grouped}
rideCounts={rideCounts} rideCounts={activeCounts}
coastersOnly={coastersOnly}
/> />
</div> </div>
</> </>

View File

@@ -9,9 +9,10 @@ interface MobileCardListProps {
data: Record<string, Record<string, DayData>>; data: Record<string, Record<string, DayData>>;
today: string; today: string;
rideCounts?: Record<string, number>; rideCounts?: Record<string, number>;
coastersOnly?: boolean;
} }
export function MobileCardList({ grouped, weekDates, data, today, rideCounts }: MobileCardListProps) { export function MobileCardList({ grouped, weekDates, data, today, rideCounts, coastersOnly }: MobileCardListProps) {
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 20, paddingTop: 14 }}> <div style={{ display: "flex", flexDirection: "column", gap: 20, paddingTop: 14 }}>
{Array.from(grouped.entries()).map(([region, parks]) => ( {Array.from(grouped.entries()).map(([region, parks]) => (
@@ -52,6 +53,7 @@ export function MobileCardList({ grouped, weekDates, data, today, rideCounts }:
parkData={data[park.id] ?? {}} parkData={data[park.id] ?? {}}
today={today} today={today}
openRideCount={rideCounts?.[park.id]} openRideCount={rideCounts?.[park.id]}
coastersOnly={coastersOnly}
/> />
))} ))}
</div> </div>

View File

@@ -8,11 +8,12 @@ interface ParkCardProps {
parkData: Record<string, DayData>; parkData: Record<string, DayData>;
today: string; today: string;
openRideCount?: number; openRideCount?: number;
coastersOnly?: boolean;
} }
const DOW = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const DOW = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
export function ParkCard({ park, weekDates, parkData, today, openRideCount }: ParkCardProps) { export function ParkCard({ park, weekDates, parkData, today, openRideCount, coastersOnly }: ParkCardProps) {
const openDays = weekDates.filter((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel); const openDays = weekDates.filter((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel);
const isOpenToday = openDays.includes(today); const isOpenToday = openDays.includes(today);
@@ -90,7 +91,9 @@ export function ParkCard({ park, weekDates, parkData, today, openRideCount }: Pa
fontWeight: 500, fontWeight: 500,
textAlign: "right", textAlign: "right",
}}> }}>
{openRideCount} {openRideCount === 1 ? "ride" : "rides"} operating {openRideCount} {coastersOnly
? (openRideCount === 1 ? "coaster" : "coasters")
: (openRideCount === 1 ? "ride" : "rides")} operating
</div> </div>
)} )}
</div> </div>

View File

@@ -10,7 +10,8 @@ interface WeekCalendarProps {
weekDates: string[]; // 7 dates, YYYY-MM-DD, SunSat weekDates: string[]; // 7 dates, YYYY-MM-DD, SunSat
data: Record<string, Record<string, DayData>>; // parkId → date → DayData data: Record<string, Record<string, DayData>>; // parkId → date → DayData
grouped?: Map<Region, Park[]>; // pre-grouped parks (if provided, renders region headers) grouped?: Map<Region, Park[]>; // pre-grouped parks (if provided, renders region headers)
rideCounts?: Record<string, number>; // parkId → open ride count for today rideCounts?: Record<string, number>; // parkId → open ride/coaster count for today
coastersOnly?: boolean;
} }
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -165,6 +166,7 @@ function ParkRow({
parsedDates, parsedDates,
parkData, parkData,
rideCounts, rideCounts,
coastersOnly,
}: { }: {
park: Park; park: Park;
parkIdx: number; parkIdx: number;
@@ -172,6 +174,7 @@ function ParkRow({
parsedDates: ReturnType<typeof parseDate>[]; parsedDates: ReturnType<typeof parseDate>[];
parkData: Record<string, DayData>; parkData: Record<string, DayData>;
rideCounts?: Record<string, number>; rideCounts?: Record<string, number>;
coastersOnly?: boolean;
}) { }) {
const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)"; const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)";
return ( return (
@@ -221,7 +224,9 @@ function ParkRow({
</div> </div>
{rideCounts?.[park.id] !== undefined && ( {rideCounts?.[park.id] !== undefined && (
<div style={{ fontSize: "0.72rem", color: "var(--color-open-hours)", fontWeight: 600, whiteSpace: "nowrap", flexShrink: 0 }}> <div style={{ fontSize: "0.72rem", color: "var(--color-open-hours)", fontWeight: 600, whiteSpace: "nowrap", flexShrink: 0 }}>
{rideCounts[park.id]} {rideCounts[park.id] === 1 ? "ride" : "rides"} operating {rideCounts[park.id]} {coastersOnly
? (rideCounts[park.id] === 1 ? "coaster" : "coasters")
: (rideCounts[park.id] === 1 ? "ride" : "rides")} operating
</div> </div>
)} )}
</Link> </Link>
@@ -238,7 +243,7 @@ function ParkRow({
); );
} }
export function WeekCalendar({ parks, weekDates, data, grouped, rideCounts }: WeekCalendarProps) { export function WeekCalendar({ parks, weekDates, data, grouped, rideCounts, coastersOnly }: WeekCalendarProps) {
const today = getTodayLocal(); const today = getTodayLocal();
const parsedDates = weekDates.map(parseDate); const parsedDates = weekDates.map(parseDate);
@@ -353,6 +358,7 @@ export function WeekCalendar({ parks, weekDates, data, grouped, rideCounts }: We
parsedDates={parsedDates} parsedDates={parsedDates}
parkData={data[park.id] ?? {}} parkData={data[park.id] ?? {}}
rideCounts={rideCounts} rideCounts={rideCounts}
coastersOnly={coastersOnly}
/> />
))} ))}
</Fragment> </Fragment>

View File

@@ -7,6 +7,8 @@ interface WeekNavProps {
weekStart: string; // YYYY-MM-DD (Sunday) weekStart: string; // YYYY-MM-DD (Sunday)
weekDates: string[]; // 7 dates YYYY-MM-DD weekDates: string[]; // 7 dates YYYY-MM-DD
isCurrentWeek: boolean; isCurrentWeek: boolean;
coastersOnly: boolean;
hasCoasterData: boolean;
} }
const MONTHS = [ const MONTHS = [
@@ -31,10 +33,16 @@ function shiftWeek(weekStart: string, delta: number): string {
return d.toISOString().slice(0, 10); return d.toISOString().slice(0, 10);
} }
export function WeekNav({ weekStart, weekDates, isCurrentWeek }: WeekNavProps) { export function WeekNav({ weekStart, weekDates, isCurrentWeek, coastersOnly, hasCoasterData }: WeekNavProps) {
const router = useRouter(); const router = useRouter();
const nav = (delta: number) => const weekParam = `week=${weekStart}`;
router.push(`/?week=${shiftWeek(weekStart, delta)}`); 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`);
};
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -91,6 +99,30 @@ export function WeekNav({ weekStart, weekDates, isCurrentWeek }: WeekNavProps) {
> >
</button> </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> </div>
); );
} }