Compare commits
4 Commits
fdea8443fb
...
fbf4337a83
| Author | SHA1 | Date | |
|---|---|---|---|
| fbf4337a83 | |||
| 8e969165b4 | |||
| 43feb4cef0 | |||
| a87f97ef53 |
@@ -80,20 +80,22 @@
|
||||
clip-path: inset(0 -16px 0 0);
|
||||
}
|
||||
|
||||
/* ── Park row hover (group/group-hover via Tailwind not enough across sticky cols) */
|
||||
.park-row:hover td,
|
||||
.park-row:hover th {
|
||||
background-color: var(--color-surface-hover) !important;
|
||||
}
|
||||
|
||||
/* ── Park name link hover ────────────────────────────────────────────────── */
|
||||
.park-name-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: color 120ms ease;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
.park-name-link:hover {
|
||||
color: var(--color-accent);
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
/* ── Mobile park card hover ─────────────────────────────────────────────── */
|
||||
.park-card {
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
.park-card:hover {
|
||||
background: var(--color-surface-hover) !important;
|
||||
}
|
||||
|
||||
/* ── Pulse animation for skeleton ───────────────────────────────────────── */
|
||||
|
||||
40
app/page.tsx
40
app/page.tsx
@@ -5,6 +5,9 @@ import { Legend } from "@/components/Legend";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { PARKS, groupByRegion } from "@/lib/parks";
|
||||
import { openDb, getDateRange } from "@/lib/db";
|
||||
import { getTodayLocal, isWithinOperatingWindow } from "@/lib/env";
|
||||
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
||||
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ week?: string }>;
|
||||
@@ -18,9 +21,10 @@ function getWeekStart(param: string | undefined): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
const today = new Date();
|
||||
today.setDate(today.getDate() - today.getDay());
|
||||
return today.toISOString().slice(0, 10);
|
||||
const todayIso = getTodayLocal();
|
||||
const d = new Date(todayIso + "T00:00:00");
|
||||
d.setDate(d.getDate() - d.getDay());
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getWeekDates(sundayIso: string): string[] {
|
||||
@@ -32,9 +36,10 @@ function getWeekDates(sundayIso: string): string[] {
|
||||
}
|
||||
|
||||
function getCurrentWeekStart(): string {
|
||||
const today = new Date();
|
||||
today.setDate(today.getDate() - today.getDay());
|
||||
return today.toISOString().slice(0, 10);
|
||||
const todayIso = getTodayLocal();
|
||||
const d = new Date(todayIso + "T00:00:00");
|
||||
d.setDate(d.getDate() - d.getDay());
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default async function HomePage({ searchParams }: PageProps) {
|
||||
@@ -42,7 +47,7 @@ export default async function HomePage({ searchParams }: PageProps) {
|
||||
const weekStart = getWeekStart(params.week);
|
||||
const weekDates = getWeekDates(weekStart);
|
||||
const endDate = weekDates[6];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = getTodayLocal();
|
||||
const isCurrentWeek = weekStart === getCurrentWeekStart();
|
||||
|
||||
const db = openDb();
|
||||
@@ -54,6 +59,25 @@ 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.
|
||||
let rideCounts: Record<string, number> = {};
|
||||
if (weekDates.includes(today)) {
|
||||
const openTodayParks = PARKS.filter((p) => {
|
||||
const dayData = data[p.id]?.[today];
|
||||
if (!dayData?.isOpen || !QUEUE_TIMES_IDS[p.id] || !dayData.hoursLabel) return false;
|
||||
return isWithinOperatingWindow(dayData.hoursLabel);
|
||||
});
|
||||
const results = await Promise.all(
|
||||
openTodayParks.map(async (p) => {
|
||||
const result = await fetchLiveRides(QUEUE_TIMES_IDS[p.id], null, 300);
|
||||
const count = result ? result.rides.filter((r) => r.isOpen).length : 0;
|
||||
return [p.id, count] as [string, number];
|
||||
})
|
||||
);
|
||||
rideCounts = Object.fromEntries(results.filter(([, count]) => count > 0));
|
||||
}
|
||||
|
||||
const visibleParks = PARKS.filter((park) =>
|
||||
weekDates.some((date) => data[park.id]?.[date]?.isOpen)
|
||||
);
|
||||
@@ -133,6 +157,7 @@ export default async function HomePage({ searchParams }: PageProps) {
|
||||
weekDates={weekDates}
|
||||
data={data}
|
||||
today={today}
|
||||
rideCounts={rideCounts}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -143,6 +168,7 @@ export default async function HomePage({ searchParams }: PageProps) {
|
||||
weekDates={weekDates}
|
||||
data={data}
|
||||
grouped={grouped}
|
||||
rideCounts={rideCounts}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ParkMonthCalendar } from "@/components/ParkMonthCalendar";
|
||||
import { LiveRidePanel } from "@/components/LiveRidePanel";
|
||||
import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags";
|
||||
import type { LiveRidesResult } from "@/lib/scrapers/queuetimes"; // used as prop type below
|
||||
import { getTodayLocal, isWithinOperatingWindow } from "@/lib/env";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -23,8 +24,8 @@ function parseMonthParam(param: string | undefined): { year: number; month: numb
|
||||
return { year: y, month: m };
|
||||
}
|
||||
}
|
||||
const now = new Date();
|
||||
return { year: now.getFullYear(), month: now.getMonth() + 1 };
|
||||
const [y, m] = getTodayLocal().split("-").map(Number);
|
||||
return { year: y, month: m };
|
||||
}
|
||||
|
||||
export default async function ParkPage({ params, searchParams }: PageProps) {
|
||||
@@ -34,7 +35,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
||||
const park = PARK_MAP.get(id);
|
||||
if (!park) notFound();
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = getTodayLocal();
|
||||
const { year, month } = parseMonthParam(monthParam);
|
||||
|
||||
const db = openDb();
|
||||
@@ -53,8 +54,22 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
||||
let liveRides: LiveRidesResult | null = null;
|
||||
let ridesResult: RidesFetchResult | null = null;
|
||||
|
||||
// Determine if we're within the 1h-before-open to 1h-after-close window.
|
||||
const withinWindow = todayData?.hoursLabel
|
||||
? isWithinOperatingWindow(todayData.hoursLabel)
|
||||
: false;
|
||||
|
||||
if (queueTimesId) {
|
||||
liveRides = await fetchLiveRides(queueTimesId, coasterSet);
|
||||
const raw = await fetchLiveRides(queueTimesId, coasterSet);
|
||||
if (raw) {
|
||||
// Outside the window: show the ride list but force all rides closed
|
||||
liveRides = withinWindow
|
||||
? raw
|
||||
: {
|
||||
...raw,
|
||||
rides: raw.rides.map((r) => ({ ...r, isOpen: false, waitMinutes: 0 })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Only hit the schedule API as a fallback when live data is unavailable
|
||||
|
||||
@@ -57,8 +57,8 @@ export function LiveRidePanel({ liveRides, parkOpenToday }: LiveRidePanelProps)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Closed count badge */}
|
||||
{anyOpen && closedRides.length > 0 && (
|
||||
{/* Closed count badge — always shown when there are closed rides */}
|
||||
{closedRides.length > 0 && (
|
||||
<div style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
@@ -69,7 +69,7 @@ export function LiveRidePanel({ liveRides, parkOpenToday }: LiveRidePanelProps)
|
||||
color: "var(--color-text-muted)",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{closedRides.length} closed / down
|
||||
{closedRides.length} {anyOpen ? "closed / down" : "rides total"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,9 +8,10 @@ interface MobileCardListProps {
|
||||
weekDates: string[];
|
||||
data: Record<string, Record<string, DayData>>;
|
||||
today: string;
|
||||
rideCounts?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function MobileCardList({ grouped, weekDates, data, today }: MobileCardListProps) {
|
||||
export function MobileCardList({ grouped, weekDates, data, today, rideCounts }: MobileCardListProps) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20, paddingTop: 14 }}>
|
||||
{Array.from(grouped.entries()).map(([region, parks]) => (
|
||||
@@ -50,6 +51,7 @@ export function MobileCardList({ grouped, weekDates, data, today }: MobileCardLi
|
||||
weekDates={weekDates}
|
||||
parkData={data[park.id] ?? {}}
|
||||
today={today}
|
||||
openRideCount={rideCounts?.[park.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,12 @@ interface ParkCardProps {
|
||||
weekDates: string[]; // 7 dates YYYY-MM-DD, Sun–Sat
|
||||
parkData: Record<string, DayData>;
|
||||
today: string;
|
||||
openRideCount?: number;
|
||||
}
|
||||
|
||||
const DOW = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
|
||||
export function ParkCard({ park, weekDates, parkData, today, openRideCount }: ParkCardProps) {
|
||||
const openDays = weekDates.filter((d) => parkData[d]?.isOpen && parkData[d]?.hoursLabel);
|
||||
const isOpenToday = openDays.includes(today);
|
||||
|
||||
@@ -21,12 +22,11 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
|
||||
data-park={park.name.toLowerCase()}
|
||||
style={{ textDecoration: "none", display: "block" }}
|
||||
>
|
||||
<div style={{
|
||||
<div className="park-card" style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
transition: "border-color 120ms ease",
|
||||
}}>
|
||||
{/* ── Card header ───────────────────────────────────────────────────── */}
|
||||
<div style={{
|
||||
@@ -54,6 +54,7 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 5, flexShrink: 0 }}>
|
||||
{isOpenToday ? (
|
||||
<div style={{
|
||||
background: "var(--color-open-bg)",
|
||||
@@ -64,7 +65,6 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
|
||||
fontWeight: 700,
|
||||
color: "var(--color-open-text)",
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
letterSpacing: "0.03em",
|
||||
}}>
|
||||
Open today
|
||||
@@ -79,11 +79,21 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
|
||||
fontWeight: 500,
|
||||
color: "var(--color-text-muted)",
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
Closed today
|
||||
</div>
|
||||
)}
|
||||
{isOpenToday && openRideCount !== undefined && (
|
||||
<div style={{
|
||||
fontSize: "0.65rem",
|
||||
color: "var(--color-open-hours)",
|
||||
fontWeight: 500,
|
||||
textAlign: "right",
|
||||
}}>
|
||||
{openRideCount} open
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Open days list ────────────────────────────────────────────────── */}
|
||||
|
||||
@@ -3,12 +3,14 @@ import Link from "next/link";
|
||||
import type { Park } from "@/lib/scrapers/types";
|
||||
import type { DayData } from "@/lib/db";
|
||||
import type { Region } from "@/lib/parks";
|
||||
import { getTodayLocal } from "@/lib/env";
|
||||
|
||||
interface WeekCalendarProps {
|
||||
parks: Park[];
|
||||
weekDates: string[]; // 7 dates, YYYY-MM-DD, Sun–Sat
|
||||
data: Record<string, Record<string, DayData>>; // parkId → date → DayData
|
||||
grouped?: Map<Region, Park[]>; // pre-grouped parks (if provided, renders region headers)
|
||||
rideCounts?: Record<string, number>; // parkId → open ride count for today
|
||||
}
|
||||
|
||||
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
@@ -29,11 +31,9 @@ function parseDate(iso: string) {
|
||||
|
||||
function DayCell({
|
||||
dayData,
|
||||
isToday,
|
||||
isWeekend,
|
||||
}: {
|
||||
dayData: DayData | undefined;
|
||||
isToday: boolean;
|
||||
isWeekend: boolean;
|
||||
}) {
|
||||
const base: React.CSSProperties = {
|
||||
@@ -43,14 +43,7 @@ function DayCell({
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
borderLeft: "1px solid var(--color-border)",
|
||||
height: 56,
|
||||
background: isToday
|
||||
? "var(--color-today-bg)"
|
||||
: isWeekend
|
||||
? "var(--color-weekend-header)"
|
||||
: "transparent",
|
||||
borderLeftColor: isToday ? "var(--color-today-border)" : undefined,
|
||||
borderRightColor: isToday ? "var(--color-today-border)" : undefined,
|
||||
borderRight: isToday ? "1px solid var(--color-today-border)" : undefined,
|
||||
background: isWeekend ? "var(--color-weekend-header)" : "transparent",
|
||||
transition: "background 120ms ease",
|
||||
};
|
||||
|
||||
@@ -171,14 +164,14 @@ function ParkRow({
|
||||
weekDates,
|
||||
parsedDates,
|
||||
parkData,
|
||||
today,
|
||||
rideCounts,
|
||||
}: {
|
||||
park: Park;
|
||||
parkIdx: number;
|
||||
weekDates: string[];
|
||||
parsedDates: ReturnType<typeof parseDate>[];
|
||||
parkData: Record<string, DayData>;
|
||||
today: string;
|
||||
rideCounts?: Record<string, number>;
|
||||
}) {
|
||||
const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)";
|
||||
return (
|
||||
@@ -191,7 +184,7 @@ function ParkRow({
|
||||
position: "sticky",
|
||||
left: 0,
|
||||
zIndex: 5,
|
||||
padding: "10px 14px",
|
||||
padding: 0,
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
whiteSpace: "nowrap",
|
||||
@@ -199,21 +192,33 @@ function ParkRow({
|
||||
background: rowBg,
|
||||
transition: "background 120ms ease",
|
||||
}}>
|
||||
<Link href={`/park/${park.id}`} className="park-name-link">
|
||||
<span style={{ fontWeight: 500, fontSize: "0.85rem", lineHeight: 1.2 }}>
|
||||
{park.name}
|
||||
</span>
|
||||
<Link href={`/park/${park.id}`} className="park-name-link" style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 14px",
|
||||
gap: 10,
|
||||
}}>
|
||||
<div>
|
||||
<span style={{ fontWeight: 500, fontSize: "0.85rem", lineHeight: 1.2, color: "var(--color-text)" }}>
|
||||
{park.name}
|
||||
</span>
|
||||
<div style={{ fontSize: "0.7rem", color: "var(--color-text-muted)", marginTop: 2 }}>
|
||||
{park.location.city}, {park.location.state}
|
||||
</div>
|
||||
</div>
|
||||
{rideCounts?.[park.id] !== undefined && (
|
||||
<div style={{ fontSize: "0.65rem", color: "var(--color-open-hours)", fontWeight: 500, whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{rideCounts[park.id]} open
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<div style={{ fontSize: "0.7rem", color: "var(--color-text-muted)", marginTop: 2 }}>
|
||||
{park.location.city}, {park.location.state}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{weekDates.map((date, i) => (
|
||||
<DayCell
|
||||
key={date}
|
||||
dayData={parkData[date]}
|
||||
isToday={date === today}
|
||||
isWeekend={parsedDates[i].isWeekend}
|
||||
/>
|
||||
))}
|
||||
@@ -221,8 +226,8 @@ function ParkRow({
|
||||
);
|
||||
}
|
||||
|
||||
export function WeekCalendar({ parks, weekDates, data, grouped }: WeekCalendarProps) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
export function WeekCalendar({ parks, weekDates, data, grouped, rideCounts }: WeekCalendarProps) {
|
||||
const today = getTodayLocal();
|
||||
const parsedDates = weekDates.map(parseDate);
|
||||
|
||||
const firstMonth = parsedDates[0].month;
|
||||
@@ -335,7 +340,7 @@ export function WeekCalendar({ parks, weekDates, data, grouped }: WeekCalendarPr
|
||||
weekDates={weekDates}
|
||||
parsedDates={parsedDates}
|
||||
parkData={data[park.id] ?? {}}
|
||||
today={today}
|
||||
rideCounts={rideCounts}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
|
||||
46
lib/env.ts
46
lib/env.ts
@@ -11,3 +11,49 @@ export function parseStalenessHours(envVar: string | undefined, defaultHours: nu
|
||||
const parsed = parseInt(envVar ?? "", 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultHours;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns today's date as YYYY-MM-DD using local wall-clock time with a 3 AM
|
||||
* switchover. Before 3 AM local time we still consider it "yesterday", so the
|
||||
* calendar doesn't flip to the next day at midnight while people are still out
|
||||
* at the park.
|
||||
*
|
||||
* Important: `new Date().toISOString()` returns UTC, which causes the date to
|
||||
* advance at 8 PM EDT (UTC-4) or 7 PM EST (UTC-5) — too early. This helper
|
||||
* corrects that by using local year/month/day components and rolling back one
|
||||
* day when the local hour is before 3.
|
||||
*/
|
||||
export function getTodayLocal(): string {
|
||||
const now = new Date();
|
||||
if (now.getHours() < 3) {
|
||||
now.setDate(now.getDate() - 1);
|
||||
}
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the current local time is within 1 hour before open
|
||||
* or 1 hour after close, based on a hoursLabel like "10am – 6pm".
|
||||
* Falls back to true when the label can't be parsed.
|
||||
*/
|
||||
export function isWithinOperatingWindow(hoursLabel: string): boolean {
|
||||
const m = hoursLabel.match(
|
||||
/^(\d+)(?::(\d+))?(am|pm)\s*[–-]\s*(\d+)(?::(\d+))?(am|pm)$/i
|
||||
);
|
||||
if (!m) return true;
|
||||
const toMinutes = (h: string, min: string | undefined, period: string) => {
|
||||
let hours = parseInt(h, 10);
|
||||
const minutes = min ? parseInt(min, 10) : 0;
|
||||
if (period.toLowerCase() === "pm" && hours !== 12) hours += 12;
|
||||
if (period.toLowerCase() === "am" && hours === 12) hours = 0;
|
||||
return hours * 60 + minutes;
|
||||
};
|
||||
const openMin = toMinutes(m[1], m[2], m[3]);
|
||||
const closeMin = toMinutes(m[4], m[5], m[6]);
|
||||
const now = new Date();
|
||||
const nowMin = now.getHours() * 60 + now.getMinutes();
|
||||
return nowMin >= openMin - 60 && nowMin <= closeMin + 60;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user