feat: show live open ride count in park name cell
- Fetch Queue-Times ride counts for parks open today (5min cache) - Only shown within 1h before open to 1h after scheduled close - Count displayed on the right of the park name/location cell (desktop) and below the open badge (mobile) - Whole park cell is now a clickable link - Hover warms the park cell background; no row-wide highlight Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -80,20 +80,22 @@
|
|||||||
clip-path: inset(0 -16px 0 0);
|
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 hover ────────────────────────────────────────────────── */
|
||||||
.park-name-link {
|
.park-name-link {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
transition: color 120ms ease;
|
transition: background 150ms ease;
|
||||||
}
|
}
|
||||||
.park-name-link:hover {
|
.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 ───────────────────────────────────────── */
|
/* ── Pulse animation for skeleton ───────────────────────────────────────── */
|
||||||
|
|||||||
46
app/page.tsx
46
app/page.tsx
@@ -6,11 +6,36 @@ import { EmptyState } from "@/components/EmptyState";
|
|||||||
import { PARKS, groupByRegion } from "@/lib/parks";
|
import { PARKS, groupByRegion } from "@/lib/parks";
|
||||||
import { openDb, getDateRange } from "@/lib/db";
|
import { openDb, getDateRange } from "@/lib/db";
|
||||||
import { getTodayLocal } from "@/lib/env";
|
import { getTodayLocal } from "@/lib/env";
|
||||||
|
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
||||||
|
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
searchParams: Promise<{ week?: string }>;
|
searchParams: Promise<{ week?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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".
|
||||||
|
*/
|
||||||
|
function isWithinOperatingWindow(hoursLabel: string): boolean {
|
||||||
|
const m = hoursLabel.match(
|
||||||
|
/^(\d+)(?::(\d+))?(am|pm)\s*[–-]\s*(\d+)(?::(\d+))?(am|pm)$/i
|
||||||
|
);
|
||||||
|
if (!m) return true; // unparseable — show anyway
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
function getWeekStart(param: string | undefined): string {
|
function getWeekStart(param: string | undefined): string {
|
||||||
if (param && /^\d{4}-\d{2}-\d{2}$/.test(param)) {
|
if (param && /^\d{4}-\d{2}-\d{2}$/.test(param)) {
|
||||||
const d = new Date(param + "T00:00:00");
|
const d = new Date(param + "T00:00:00");
|
||||||
@@ -57,6 +82,25 @@ export default async function HomePage({ searchParams }: PageProps) {
|
|||||||
0
|
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) =>
|
const visibleParks = PARKS.filter((park) =>
|
||||||
weekDates.some((date) => data[park.id]?.[date]?.isOpen)
|
weekDates.some((date) => data[park.id]?.[date]?.isOpen)
|
||||||
);
|
);
|
||||||
@@ -136,6 +180,7 @@ export default async function HomePage({ searchParams }: PageProps) {
|
|||||||
weekDates={weekDates}
|
weekDates={weekDates}
|
||||||
data={data}
|
data={data}
|
||||||
today={today}
|
today={today}
|
||||||
|
rideCounts={rideCounts}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -146,6 +191,7 @@ export default async function HomePage({ searchParams }: PageProps) {
|
|||||||
weekDates={weekDates}
|
weekDates={weekDates}
|
||||||
data={data}
|
data={data}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
|
rideCounts={rideCounts}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ interface MobileCardListProps {
|
|||||||
weekDates: string[];
|
weekDates: string[];
|
||||||
data: Record<string, Record<string, DayData>>;
|
data: Record<string, Record<string, DayData>>;
|
||||||
today: string;
|
today: string;
|
||||||
|
rideCounts?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MobileCardList({ grouped, weekDates, data, today }: MobileCardListProps) {
|
export function MobileCardList({ grouped, weekDates, data, today, rideCounts }: 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]) => (
|
||||||
@@ -50,6 +51,7 @@ export function MobileCardList({ grouped, weekDates, data, today }: MobileCardLi
|
|||||||
weekDates={weekDates}
|
weekDates={weekDates}
|
||||||
parkData={data[park.id] ?? {}}
|
parkData={data[park.id] ?? {}}
|
||||||
today={today}
|
today={today}
|
||||||
|
openRideCount={rideCounts?.[park.id]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ interface ParkCardProps {
|
|||||||
weekDates: string[]; // 7 dates YYYY-MM-DD, Sun–Sat
|
weekDates: string[]; // 7 dates YYYY-MM-DD, Sun–Sat
|
||||||
parkData: Record<string, DayData>;
|
parkData: Record<string, DayData>;
|
||||||
today: string;
|
today: string;
|
||||||
|
openRideCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }: ParkCardProps) {
|
export function ParkCard({ park, weekDates, parkData, today, openRideCount }: 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);
|
||||||
|
|
||||||
@@ -21,12 +22,11 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
|
|||||||
data-park={park.name.toLowerCase()}
|
data-park={park.name.toLowerCase()}
|
||||||
style={{ textDecoration: "none", display: "block" }}
|
style={{ textDecoration: "none", display: "block" }}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div className="park-card" style={{
|
||||||
background: "var(--color-surface)",
|
background: "var(--color-surface)",
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
transition: "border-color 120ms ease",
|
|
||||||
}}>
|
}}>
|
||||||
{/* ── Card header ───────────────────────────────────────────────────── */}
|
{/* ── Card header ───────────────────────────────────────────────────── */}
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -54,6 +54,7 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 5, flexShrink: 0 }}>
|
||||||
{isOpenToday ? (
|
{isOpenToday ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: "var(--color-open-bg)",
|
background: "var(--color-open-bg)",
|
||||||
@@ -64,7 +65,6 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
|
|||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "var(--color-open-text)",
|
color: "var(--color-open-text)",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
flexShrink: 0,
|
|
||||||
letterSpacing: "0.03em",
|
letterSpacing: "0.03em",
|
||||||
}}>
|
}}>
|
||||||
Open today
|
Open today
|
||||||
@@ -79,11 +79,21 @@ export function ParkCard({ park, weekDates, parkData, today }: ParkCardProps) {
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: "var(--color-text-muted)",
|
color: "var(--color-text-muted)",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
}}>
|
||||||
Closed today
|
Closed today
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isOpenToday && openRideCount !== undefined && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
color: "var(--color-open-hours)",
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: "right",
|
||||||
|
}}>
|
||||||
|
{openRideCount} open
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Open days list ────────────────────────────────────────────────── */}
|
{/* ── Open days list ────────────────────────────────────────────────── */}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface WeekCalendarProps {
|
|||||||
weekDates: string[]; // 7 dates, YYYY-MM-DD, Sun–Sat
|
weekDates: string[]; // 7 dates, YYYY-MM-DD, Sun–Sat
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
@@ -163,12 +164,14 @@ function ParkRow({
|
|||||||
weekDates,
|
weekDates,
|
||||||
parsedDates,
|
parsedDates,
|
||||||
parkData,
|
parkData,
|
||||||
|
rideCounts,
|
||||||
}: {
|
}: {
|
||||||
park: Park;
|
park: Park;
|
||||||
parkIdx: number;
|
parkIdx: number;
|
||||||
weekDates: string[];
|
weekDates: string[];
|
||||||
parsedDates: ReturnType<typeof parseDate>[];
|
parsedDates: ReturnType<typeof parseDate>[];
|
||||||
parkData: Record<string, DayData>;
|
parkData: Record<string, DayData>;
|
||||||
|
rideCounts?: Record<string, number>;
|
||||||
}) {
|
}) {
|
||||||
const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)";
|
const rowBg = parkIdx % 2 === 0 ? "var(--color-bg)" : "var(--color-surface)";
|
||||||
return (
|
return (
|
||||||
@@ -181,7 +184,7 @@ function ParkRow({
|
|||||||
position: "sticky",
|
position: "sticky",
|
||||||
left: 0,
|
left: 0,
|
||||||
zIndex: 5,
|
zIndex: 5,
|
||||||
padding: "10px 14px",
|
padding: 0,
|
||||||
borderBottom: "1px solid var(--color-border)",
|
borderBottom: "1px solid var(--color-border)",
|
||||||
borderRight: "1px solid var(--color-border)",
|
borderRight: "1px solid var(--color-border)",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
@@ -189,14 +192,27 @@ function ParkRow({
|
|||||||
background: rowBg,
|
background: rowBg,
|
||||||
transition: "background 120ms ease",
|
transition: "background 120ms ease",
|
||||||
}}>
|
}}>
|
||||||
<Link href={`/park/${park.id}`} className="park-name-link">
|
<Link href={`/park/${park.id}`} className="park-name-link" style={{
|
||||||
<span style={{ fontWeight: 500, fontSize: "0.85rem", lineHeight: 1.2 }}>
|
display: "flex",
|
||||||
{park.name}
|
alignItems: "center",
|
||||||
</span>
|
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>
|
</Link>
|
||||||
<div style={{ fontSize: "0.7rem", color: "var(--color-text-muted)", marginTop: 2 }}>
|
|
||||||
{park.location.city}, {park.location.state}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{weekDates.map((date, i) => (
|
{weekDates.map((date, i) => (
|
||||||
@@ -210,7 +226,7 @@ function ParkRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WeekCalendar({ parks, weekDates, data, grouped }: WeekCalendarProps) {
|
export function WeekCalendar({ parks, weekDates, data, grouped, rideCounts }: WeekCalendarProps) {
|
||||||
const today = getTodayLocal();
|
const today = getTodayLocal();
|
||||||
const parsedDates = weekDates.map(parseDate);
|
const parsedDates = weekDates.map(parseDate);
|
||||||
|
|
||||||
@@ -324,6 +340,7 @@ export function WeekCalendar({ parks, weekDates, data, grouped }: WeekCalendarPr
|
|||||||
weekDates={weekDates}
|
weekDates={weekDates}
|
||||||
parsedDates={parsedDates}
|
parsedDates={parsedDates}
|
||||||
parkData={data[park.id] ?? {}}
|
parkData={data[park.id] ?? {}}
|
||||||
|
rideCounts={rideCounts}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|||||||
Reference in New Issue
Block a user