refactor: make frontend a pure presentation layer fetching from backend API
Server components now fetch composed data from the backend instead of directly querying SQLite and external APIs. Removes better-sqlite3 dependency from the frontend entirely. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,59 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { PARKS } from "@/lib/parks";
|
|
||||||
import { openDb, getMonthCalendar } from "@/lib/db";
|
|
||||||
import type { Park } from "@/lib/scrapers/types";
|
|
||||||
|
|
||||||
export interface ParksApiResponse {
|
|
||||||
parks: Park[];
|
|
||||||
calendar: Record<string, boolean[]>;
|
|
||||||
month: string;
|
|
||||||
daysInMonth: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDaysInMonth(year: number, month: number): number {
|
|
||||||
return new Date(year, month, 0).getDate();
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMonthParam(
|
|
||||||
monthParam: string | null
|
|
||||||
): { year: number; month: number } | null {
|
|
||||||
if (!monthParam) return null;
|
|
||||||
const match = monthParam.match(/^(\d{4})-(\d{2})$/);
|
|
||||||
if (!match) return null;
|
|
||||||
const year = parseInt(match[1], 10);
|
|
||||||
const month = parseInt(match[2], 10);
|
|
||||||
if (month < 1 || month > 12) return null;
|
|
||||||
return { year, month };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
||||||
const monthParam = request.nextUrl.searchParams.get("month");
|
|
||||||
const parsed = parseMonthParam(monthParam);
|
|
||||||
|
|
||||||
if (!parsed) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid or missing ?month=YYYY-MM parameter" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { year, month } = parsed;
|
|
||||||
const daysInMonth = getDaysInMonth(year, month);
|
|
||||||
|
|
||||||
const db = openDb();
|
|
||||||
const calendar = getMonthCalendar(db, year, month);
|
|
||||||
db.close();
|
|
||||||
|
|
||||||
const response: ParksApiResponse = {
|
|
||||||
parks: PARKS,
|
|
||||||
calendar,
|
|
||||||
month: `${year}-${String(month).padStart(2, "0")}`,
|
|
||||||
daysInMonth,
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(response, {
|
|
||||||
headers: {
|
|
||||||
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+8
-120
@@ -1,12 +1,7 @@
|
|||||||
import { HomePageClient } from "@/components/HomePageClient";
|
import { HomePageClient } from "@/components/HomePageClient";
|
||||||
import { PARKS } from "@/lib/parks";
|
import { getTodayLocal } from "@/lib/env";
|
||||||
import { openDb, getDateRange } from "@/lib/db";
|
|
||||||
import { getTodayLocal, isWithinOperatingWindow, getOperatingStatus } from "@/lib/env";
|
const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:3001";
|
||||||
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
|
||||||
import { fetchToday } from "@/lib/scrapers/sixflags";
|
|
||||||
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
|
||||||
import { getCoasterSet, hasCoasterData } from "@/lib/coaster-data";
|
|
||||||
import type { DayData } from "@/lib/db";
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
searchParams: Promise<{ week?: string }>;
|
searchParams: Promise<{ week?: string }>;
|
||||||
@@ -26,121 +21,14 @@ function getWeekStart(param: string | undefined): string {
|
|||||||
return d.toISOString().slice(0, 10);
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWeekDates(sundayIso: string): string[] {
|
|
||||||
return Array.from({ length: 7 }, (_, i) => {
|
|
||||||
const d = new Date(sundayIso + "T00:00:00");
|
|
||||||
d.setDate(d.getDate() + i);
|
|
||||||
return d.toISOString().slice(0, 10);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCurrentWeekStart(): string {
|
|
||||||
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) {
|
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 weekDates = getWeekDates(weekStart);
|
|
||||||
const endDate = weekDates[6];
|
|
||||||
const today = getTodayLocal();
|
|
||||||
const isCurrentWeek = weekStart === getCurrentWeekStart();
|
|
||||||
|
|
||||||
const db = openDb();
|
const data = await fetch(
|
||||||
const data = getDateRange(db, weekStart, endDate);
|
`${BACKEND_URL}/api/calendar/week?start=${weekStart}`,
|
||||||
|
{ next: { revalidate: 120 } },
|
||||||
|
).then((r) => r.json());
|
||||||
|
|
||||||
// Merge live today data from the Six Flags API (dateless endpoint, 5-min ISR cache).
|
return <HomePageClient {...data} />;
|
||||||
// This ensures weather delays, early closures, and hour changes surface within 5 minutes
|
|
||||||
// without waiting for the next scheduled scrape. Only fetched when viewing the current week.
|
|
||||||
if (weekDates.includes(today)) {
|
|
||||||
const todayResults = await Promise.all(
|
|
||||||
PARKS.map(async (p) => {
|
|
||||||
const live = await fetchToday(p.apiId, 300); // 5-min ISR cache
|
|
||||||
return live ? { parkId: p.id, live } : null;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
for (const result of todayResults) {
|
|
||||||
if (!result) continue;
|
|
||||||
const { parkId, live } = result;
|
|
||||||
if (!data[parkId]) data[parkId] = {};
|
|
||||||
data[parkId][today] = {
|
|
||||||
isOpen: live.isOpen,
|
|
||||||
hoursLabel: live.hoursLabel ?? null,
|
|
||||||
specialType: live.specialType ?? null,
|
|
||||||
} satisfies DayData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
db.close();
|
|
||||||
|
|
||||||
const scrapedCount = Object.values(data).reduce(
|
|
||||||
(sum, parkData) => sum + Object.keys(parkData).length,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
const coasterDataAvailable = hasCoasterData();
|
|
||||||
|
|
||||||
let rideCounts: Record<string, number> = {};
|
|
||||||
let coasterCounts: Record<string, number> = {};
|
|
||||||
let closingParkIds: string[] = [];
|
|
||||||
let openParkIds: string[] = [];
|
|
||||||
let weatherDelayParkIds: string[] = [];
|
|
||||||
if (weekDates.includes(today)) {
|
|
||||||
// Parks within operating hours right now (for open dot — independent of ride counts)
|
|
||||||
const openTodayParks = PARKS.filter((p) => {
|
|
||||||
const dayData = data[p.id]?.[today];
|
|
||||||
if (!dayData?.isOpen || !dayData.hoursLabel) return false;
|
|
||||||
return isWithinOperatingWindow(dayData.hoursLabel, p.timezone);
|
|
||||||
});
|
|
||||||
openParkIds = openTodayParks.map((p) => p.id);
|
|
||||||
closingParkIds = openTodayParks
|
|
||||||
.filter((p) => {
|
|
||||||
const dayData = data[p.id]?.[today];
|
|
||||||
return dayData?.hoursLabel
|
|
||||||
? getOperatingStatus(dayData.hoursLabel, p.timezone) === "closing"
|
|
||||||
: false;
|
|
||||||
})
|
|
||||||
.map((p) => p.id);
|
|
||||||
// Only fetch ride counts for parks that have queue-times coverage
|
|
||||||
const trackedParks = openTodayParks.filter((p) => QUEUE_TIMES_IDS[p.id]);
|
|
||||||
const results = await Promise.all(
|
|
||||||
trackedParks.map(async (p) => {
|
|
||||||
const coasterSet = getCoasterSet(p.id);
|
|
||||||
const result = await fetchLiveRides(QUEUE_TIMES_IDS[p.id], coasterSet, 300);
|
|
||||||
const rideCount = result ? result.rides.filter((r) => r.isOpen).length : null;
|
|
||||||
const coasterCount = result ? result.rides.filter((r) => r.isOpen && r.isCoaster).length : 0;
|
|
||||||
return { id: p.id, rideCount, coasterCount };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
// Parks with queue-times coverage but 0 open rides = likely weather delay
|
|
||||||
weatherDelayParkIds = results
|
|
||||||
.filter(({ rideCount }) => rideCount === 0)
|
|
||||||
.map(({ id }) => id);
|
|
||||||
rideCounts = Object.fromEntries(
|
|
||||||
results.filter(({ rideCount }) => rideCount != null && rideCount > 0).map(({ id, rideCount }) => [id, rideCount!])
|
|
||||||
);
|
|
||||||
coasterCounts = Object.fromEntries(
|
|
||||||
results.filter(({ coasterCount }) => coasterCount > 0).map(({ id, coasterCount }) => [id, coasterCount])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HomePageClient
|
|
||||||
weekStart={weekStart}
|
|
||||||
weekDates={weekDates}
|
|
||||||
today={today}
|
|
||||||
isCurrentWeek={isCurrentWeek}
|
|
||||||
data={data}
|
|
||||||
rideCounts={rideCounts}
|
|
||||||
coasterCounts={coasterCounts}
|
|
||||||
openParkIds={openParkIds}
|
|
||||||
closingParkIds={closingParkIds}
|
|
||||||
weatherDelayParkIds={weatherDelayParkIds}
|
|
||||||
hasCoasterData={coasterDataAvailable}
|
|
||||||
scrapedCount={scrapedCount}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-61
@@ -1,33 +1,27 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { BackToCalendarLink } from "@/components/BackToCalendarLink";
|
import { BackToCalendarLink } from "@/components/BackToCalendarLink";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { PARK_MAP } from "@/lib/parks";
|
import { PARK_MAP } from "@/lib/parks";
|
||||||
import { openDb, getParkMonthData } from "@/lib/db";
|
|
||||||
import { scrapeRidesForDay } from "@/lib/scrapers/sixflags";
|
|
||||||
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
|
||||||
import { fetchToday } from "@/lib/scrapers/sixflags";
|
|
||||||
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
|
||||||
import { getCoasterSet } from "@/lib/coaster-data";
|
|
||||||
import { ParkMonthCalendar } from "@/components/ParkMonthCalendar";
|
import { ParkMonthCalendar } from "@/components/ParkMonthCalendar";
|
||||||
import { LiveRidePanel } from "@/components/LiveRidePanel";
|
import { LiveRidePanel } from "@/components/LiveRidePanel";
|
||||||
import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags";
|
import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags";
|
||||||
import type { LiveRidesResult } from "@/lib/scrapers/queuetimes"; // used as prop type below
|
import type { LiveRidesResult } from "@/lib/scrapers/queuetimes";
|
||||||
import { getTodayLocal, isWithinOperatingWindow } from "@/lib/env";
|
import { getTodayLocal } from "@/lib/env";
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:3001";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
searchParams: Promise<{ month?: string }>;
|
searchParams: Promise<{ month?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMonthParam(param: string | undefined): { year: number; month: number } {
|
function parseMonthParam(param: string | undefined): string {
|
||||||
if (param && /^\d{4}-\d{2}$/.test(param)) {
|
if (param && /^\d{4}-\d{2}$/.test(param)) {
|
||||||
const [y, m] = param.split("-").map(Number);
|
const [y, m] = param.split("-").map(Number);
|
||||||
if (y >= 2020 && y <= 2030 && m >= 1 && m <= 12) {
|
if (y >= 2020 && y <= 2030 && m >= 1 && m <= 12) {
|
||||||
return { year: y, month: m };
|
return param;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const [y, m] = getTodayLocal().split("-").map(Number);
|
return getTodayLocal().slice(0, 7);
|
||||||
return { year: y, month: m };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ParkPage({ params, searchParams }: PageProps) {
|
export default async function ParkPage({ params, searchParams }: PageProps) {
|
||||||
@@ -37,54 +31,30 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
|||||||
const park = PARK_MAP.get(id);
|
const park = PARK_MAP.get(id);
|
||||||
if (!park) notFound();
|
if (!park) notFound();
|
||||||
|
|
||||||
const today = getTodayLocal();
|
const monthStr = parseMonthParam(monthParam);
|
||||||
const { year, month } = parseMonthParam(monthParam);
|
const [year, month] = monthStr.split("-").map(Number);
|
||||||
|
|
||||||
const db = openDb();
|
const [calendarData, ridesData] = await Promise.all([
|
||||||
const monthData = getParkMonthData(db, id, year, month);
|
fetch(`${BACKEND_URL}/api/calendar/${id}/month?month=${monthStr}`, {
|
||||||
db.close();
|
next: { revalidate: 300 },
|
||||||
|
}).then((r) => r.json()),
|
||||||
|
fetch(`${BACKEND_URL}/api/parks/${id}/rides`, {
|
||||||
|
next: { revalidate: 60 },
|
||||||
|
}).then((r) => r.json()),
|
||||||
|
]);
|
||||||
|
|
||||||
const liveToday = await fetchToday(park.apiId, 300).catch(() => null);
|
const { monthData, today } = calendarData;
|
||||||
const todayData = liveToday
|
const {
|
||||||
? { isOpen: liveToday.isOpen, hoursLabel: liveToday.hoursLabel ?? null, specialType: liveToday.specialType ?? null }
|
parkOpenToday,
|
||||||
: monthData[today];
|
isWeatherDelay,
|
||||||
const parkOpenToday = todayData?.isOpen && todayData?.hoursLabel;
|
liveRides,
|
||||||
|
scheduleFallback: ridesResult,
|
||||||
// ── Ride data: try live Queue-Times first, fall back to schedule ──────────
|
}: {
|
||||||
const queueTimesId = QUEUE_TIMES_IDS[id];
|
parkOpenToday: boolean;
|
||||||
const coasterSet = getCoasterSet(id);
|
isWeatherDelay: boolean;
|
||||||
|
liveRides: LiveRidesResult | null;
|
||||||
let liveRides: LiveRidesResult | null = null;
|
scheduleFallback: RidesFetchResult | null;
|
||||||
let ridesResult: RidesFetchResult | null = null;
|
} = ridesData;
|
||||||
|
|
||||||
// Determine if we're within the 1h-before-open to 1h-after-close window.
|
|
||||||
const withinWindow = todayData?.hoursLabel
|
|
||||||
? isWithinOperatingWindow(todayData.hoursLabel, park.timezone)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
if (queueTimesId) {
|
|
||||||
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 })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Weather delay: park is within operating hours but queue-times shows 0 open rides
|
|
||||||
const isWeatherDelay =
|
|
||||||
withinWindow &&
|
|
||||||
liveRides !== null &&
|
|
||||||
liveRides.rides.length > 0 &&
|
|
||||||
liveRides.rides.every((r) => !r.isOpen);
|
|
||||||
|
|
||||||
if (!liveRides) {
|
|
||||||
ridesResult = await scrapeRidesForDay(park.apiId, today);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||||
@@ -162,13 +132,13 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
|||||||
{liveRides ? (
|
{liveRides ? (
|
||||||
<LiveRidePanel
|
<LiveRidePanel
|
||||||
liveRides={liveRides}
|
liveRides={liveRides}
|
||||||
parkOpenToday={!!parkOpenToday}
|
parkOpenToday={parkOpenToday}
|
||||||
isWeatherDelay={isWeatherDelay}
|
isWeatherDelay={isWeatherDelay}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RideList
|
<RideList
|
||||||
ridesResult={ridesResult}
|
ridesResult={ridesResult}
|
||||||
parkOpenToday={!!parkOpenToday}
|
parkOpenToday={parkOpenToday}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import type Database from "better-sqlite3";
|
import type Database from "better-sqlite3";
|
||||||
import { getDb } from "./index";
|
import { getDb } from "./index";
|
||||||
|
|
||||||
export interface DayData {
|
import type { DayData } from "../../../lib/types";
|
||||||
isOpen: boolean;
|
export type { DayData };
|
||||||
hoursLabel: string | null;
|
|
||||||
specialType: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DayRow {
|
interface DayRow {
|
||||||
park_id: string;
|
park_id: string;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { WeekNav } from "./WeekNav";
|
|||||||
import { Legend } from "./Legend";
|
import { Legend } from "./Legend";
|
||||||
import { EmptyState } from "./EmptyState";
|
import { EmptyState } from "./EmptyState";
|
||||||
import { PARKS, groupByRegion } from "@/lib/parks";
|
import { PARKS, groupByRegion } from "@/lib/parks";
|
||||||
import type { DayData } from "@/lib/db";
|
import type { DayData } from "@/lib/types";
|
||||||
|
|
||||||
const REFRESH_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
|
const REFRESH_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
|
||||||
const OPEN_REFRESH_BUFFER_MS = 30_000; // 30s after opening time before hitting the API
|
const OPEN_REFRESH_BUFFER_MS = 30_000; // 30s after opening time before hitting the API
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Park } from "@/lib/scrapers/types";
|
import type { Park } from "@/lib/scrapers/types";
|
||||||
import type { DayData } from "@/lib/db";
|
import type { DayData } from "@/lib/types";
|
||||||
import type { Region } from "@/lib/parks";
|
import type { Region } from "@/lib/parks";
|
||||||
import { ParkCard } from "./ParkCard";
|
import { ParkCard } from "./ParkCard";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Park } from "@/lib/scrapers/types";
|
import type { Park } from "@/lib/scrapers/types";
|
||||||
import type { DayData } from "@/lib/db";
|
import type { DayData } from "@/lib/types";
|
||||||
import { getTimezoneAbbr } from "@/lib/env";
|
import { getTimezoneAbbr } from "@/lib/env";
|
||||||
|
|
||||||
interface ParkCardProps {
|
interface ParkCardProps {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { DayData } from "@/lib/db";
|
import type { DayData } from "@/lib/types";
|
||||||
import { getTimezoneAbbr } from "@/lib/env";
|
import { getTimezoneAbbr } from "@/lib/env";
|
||||||
|
|
||||||
interface ParkMonthCalendarProps {
|
interface ParkMonthCalendarProps {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Park } from "@/lib/scrapers/types";
|
import type { Park } from "@/lib/scrapers/types";
|
||||||
import type { DayData } from "@/lib/db";
|
import type { DayData } from "@/lib/types";
|
||||||
import type { Region } from "@/lib/parks";
|
import type { Region } from "@/lib/parks";
|
||||||
import { getTodayLocal, getTimezoneAbbr } from "@/lib/env";
|
import { getTodayLocal, getTimezoneAbbr } from "@/lib/env";
|
||||||
|
|
||||||
|
|||||||
@@ -1,288 +0,0 @@
|
|||||||
import Database from "better-sqlite3";
|
|
||||||
import path from "path";
|
|
||||||
import fs from "fs";
|
|
||||||
|
|
||||||
const DATA_DIR = path.join(process.cwd(), "data");
|
|
||||||
const DB_PATH = path.join(DATA_DIR, "parks.db");
|
|
||||||
|
|
||||||
export type DbInstance = Database.Database;
|
|
||||||
|
|
||||||
export function openDb(): Database.Database {
|
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
||||||
const db = new Database(DB_PATH);
|
|
||||||
db.pragma("journal_mode = WAL");
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS park_days (
|
|
||||||
park_id TEXT NOT NULL,
|
|
||||||
date TEXT NOT NULL, -- YYYY-MM-DD
|
|
||||||
is_open INTEGER NOT NULL DEFAULT 0,
|
|
||||||
hours_label TEXT,
|
|
||||||
special_type TEXT, -- 'passholder_preview' | null
|
|
||||||
scraped_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (park_id, date)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS park_api_ids (
|
|
||||||
park_id TEXT PRIMARY KEY,
|
|
||||||
api_id INTEGER NOT NULL,
|
|
||||||
api_abbreviation TEXT,
|
|
||||||
api_name TEXT,
|
|
||||||
discovered_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
// Migrate existing databases that predate the special_type column
|
|
||||||
try {
|
|
||||||
db.exec(`ALTER TABLE park_days ADD COLUMN special_type TEXT`);
|
|
||||||
} catch {
|
|
||||||
// Column already exists — safe to ignore
|
|
||||||
}
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function upsertDay(
|
|
||||||
db: Database.Database,
|
|
||||||
parkId: string,
|
|
||||||
date: string,
|
|
||||||
isOpen: boolean,
|
|
||||||
hoursLabel?: string,
|
|
||||||
specialType?: string
|
|
||||||
) {
|
|
||||||
// Today and future dates: full upsert — hours can change (e.g. weather delays,
|
|
||||||
// early closures) and the dateless API endpoint now returns today's live data.
|
|
||||||
//
|
|
||||||
// Past dates: INSERT-only — never overwrite once the day has passed.
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO park_days (park_id, date, is_open, hours_label, special_type, scraped_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT (park_id, date) DO UPDATE SET
|
|
||||||
is_open = excluded.is_open,
|
|
||||||
hours_label = excluded.hours_label,
|
|
||||||
special_type = excluded.special_type,
|
|
||||||
scraped_at = excluded.scraped_at
|
|
||||||
WHERE park_days.date >= date('now')
|
|
||||||
`).run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, specialType ?? null, new Date().toISOString());
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DayData {
|
|
||||||
isOpen: boolean;
|
|
||||||
hoursLabel: string | null;
|
|
||||||
specialType: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns scraped data for all parks across a date range.
|
|
||||||
* Shape: { parkId: { 'YYYY-MM-DD': DayData } }
|
|
||||||
* Missing dates mean that date hasn't been scraped yet (not necessarily closed).
|
|
||||||
*/
|
|
||||||
export function getDateRange(
|
|
||||||
db: Database.Database,
|
|
||||||
startDate: string,
|
|
||||||
endDate: string
|
|
||||||
): Record<string, Record<string, DayData>> {
|
|
||||||
const rows = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT park_id, date, is_open, hours_label, special_type
|
|
||||||
FROM park_days
|
|
||||||
WHERE date >= ? AND date <= ?`
|
|
||||||
)
|
|
||||||
.all(startDate, endDate) as {
|
|
||||||
park_id: string;
|
|
||||||
date: string;
|
|
||||||
is_open: number;
|
|
||||||
hours_label: string | null;
|
|
||||||
special_type: string | null;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
const result: Record<string, Record<string, DayData>> = {};
|
|
||||||
for (const row of rows) {
|
|
||||||
if (!result[row.park_id]) result[row.park_id] = {};
|
|
||||||
result[row.park_id][row.date] = {
|
|
||||||
isOpen: row.is_open === 1,
|
|
||||||
hoursLabel: row.hours_label,
|
|
||||||
specialType: row.special_type,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns scraped DayData for a single park for an entire month.
|
|
||||||
* Shape: { 'YYYY-MM-DD': DayData }
|
|
||||||
*/
|
|
||||||
export function getParkMonthData(
|
|
||||||
db: Database.Database,
|
|
||||||
parkId: string,
|
|
||||||
year: number,
|
|
||||||
month: number,
|
|
||||||
): Record<string, DayData> {
|
|
||||||
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
|
||||||
const rows = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT date, is_open, hours_label, special_type
|
|
||||||
FROM park_days
|
|
||||||
WHERE park_id = ? AND date LIKE ? || '-%'
|
|
||||||
ORDER BY date`
|
|
||||||
)
|
|
||||||
.all(parkId, prefix) as {
|
|
||||||
date: string;
|
|
||||||
is_open: number;
|
|
||||||
hours_label: string | null;
|
|
||||||
special_type: string | null;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
const result: Record<string, DayData> = {};
|
|
||||||
for (const row of rows) {
|
|
||||||
result[row.date] = {
|
|
||||||
isOpen: row.is_open === 1,
|
|
||||||
hoursLabel: row.hours_label,
|
|
||||||
specialType: row.special_type,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns a map of parkId → boolean[] (index 0 = day 1) for a given month. */
|
|
||||||
export function getMonthCalendar(
|
|
||||||
db: Database.Database,
|
|
||||||
year: number,
|
|
||||||
month: number
|
|
||||||
): Record<string, boolean[]> {
|
|
||||||
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
|
||||||
const rows = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT park_id, date, is_open
|
|
||||||
FROM park_days
|
|
||||||
WHERE date LIKE ? || '-%'
|
|
||||||
ORDER BY date`
|
|
||||||
)
|
|
||||||
.all(prefix) as { park_id: string; date: string; is_open: number }[];
|
|
||||||
|
|
||||||
const result: Record<string, boolean[]> = {};
|
|
||||||
for (const row of rows) {
|
|
||||||
if (!result[row.park_id]) result[row.park_id] = [];
|
|
||||||
const day = parseInt(row.date.slice(8), 10);
|
|
||||||
result[row.park_id][day - 1] = row.is_open === 1;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
import { parseStalenessHours } from "./env";
|
|
||||||
const STALE_AFTER_MS = parseStalenessHours(process.env.PARK_HOURS_STALENESS_HOURS, 72) * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true when the scraper should skip this park+month.
|
|
||||||
*
|
|
||||||
* Two reasons to skip:
|
|
||||||
* 1. The month is entirely in the past — the API will never return data for
|
|
||||||
* those dates again, so re-scraping wastes a call and risks nothing but
|
|
||||||
* wasted time. Historical records are preserved forever by upsertDay.
|
|
||||||
* 2. The month was scraped within the last 7 days — data is still fresh.
|
|
||||||
*/
|
|
||||||
export function isMonthScraped(
|
|
||||||
db: Database.Database,
|
|
||||||
parkId: string,
|
|
||||||
year: number,
|
|
||||||
month: number
|
|
||||||
): boolean {
|
|
||||||
// Compute the last calendar day of this month (avoids timezone issues).
|
|
||||||
const daysInMonth = new Date(year, month, 0).getDate();
|
|
||||||
const lastDay = `${year}-${String(month).padStart(2, "0")}-${String(daysInMonth).padStart(2, "0")}`;
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
// Past month — history is locked in, no API data available, always skip.
|
|
||||||
if (lastDay < today) return true;
|
|
||||||
|
|
||||||
// Current/future month — skip only if recently scraped.
|
|
||||||
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
|
||||||
const row = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT MAX(scraped_at) AS last_scraped
|
|
||||||
FROM park_days
|
|
||||||
WHERE park_id = ? AND date LIKE ? || '-%'`
|
|
||||||
)
|
|
||||||
.get(parkId, prefix) as { last_scraped: string | null };
|
|
||||||
|
|
||||||
if (!row.last_scraped) return false;
|
|
||||||
const ageMs = Date.now() - new Date(row.last_scraped).getTime();
|
|
||||||
return ageMs < STALE_AFTER_MS;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getApiId(db: Database.Database, parkId: string): number | null {
|
|
||||||
const row = db
|
|
||||||
.prepare("SELECT api_id FROM park_api_ids WHERE park_id = ?")
|
|
||||||
.get(parkId) as { api_id: number } | undefined;
|
|
||||||
return row?.api_id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setApiId(
|
|
||||||
db: Database.Database,
|
|
||||||
parkId: string,
|
|
||||||
apiId: number,
|
|
||||||
apiAbbreviation?: string,
|
|
||||||
apiName?: string
|
|
||||||
) {
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO park_api_ids (park_id, api_id, api_abbreviation, api_name, discovered_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT (park_id) DO UPDATE SET
|
|
||||||
api_id = excluded.api_id,
|
|
||||||
api_abbreviation = excluded.api_abbreviation,
|
|
||||||
api_name = excluded.api_name,
|
|
||||||
discovered_at = excluded.discovered_at
|
|
||||||
`).run(
|
|
||||||
parkId,
|
|
||||||
apiId,
|
|
||||||
apiAbbreviation ?? null,
|
|
||||||
apiName ?? null,
|
|
||||||
new Date().toISOString()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the next park+month to scrape.
|
|
||||||
* Priority: never-scraped first, then oldest scraped_at.
|
|
||||||
* Considers current month through monthsAhead months into the future.
|
|
||||||
*/
|
|
||||||
export function getNextScrapeTarget(
|
|
||||||
db: Database.Database,
|
|
||||||
parkIds: string[],
|
|
||||||
monthsAhead = 12
|
|
||||||
): { parkId: string; year: number; month: number } | null {
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const candidates: {
|
|
||||||
parkId: string;
|
|
||||||
year: number;
|
|
||||||
month: number;
|
|
||||||
lastScraped: string | null;
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
for (const parkId of parkIds) {
|
|
||||||
for (let i = 0; i < monthsAhead; i++) {
|
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
|
|
||||||
const year = d.getFullYear();
|
|
||||||
const month = d.getMonth() + 1;
|
|
||||||
const prefix = `${year}-${String(month).padStart(2, "0")}`;
|
|
||||||
|
|
||||||
const row = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT MAX(scraped_at) AS last_scraped
|
|
||||||
FROM park_days
|
|
||||||
WHERE park_id = ? AND date LIKE ? || '-%'`
|
|
||||||
)
|
|
||||||
.get(parkId, prefix) as { last_scraped: string | null };
|
|
||||||
|
|
||||||
candidates.push({ parkId, year, month, lastScraped: row.last_scraped });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Never-scraped (null) first, then oldest scraped_at
|
|
||||||
candidates.sort((a, b) => {
|
|
||||||
if (!a.lastScraped && !b.lastScraped) return 0;
|
|
||||||
if (!a.lastScraped) return -1;
|
|
||||||
if (!b.lastScraped) return 1;
|
|
||||||
return a.lastScraped.localeCompare(b.lastScraped);
|
|
||||||
});
|
|
||||||
|
|
||||||
const top = candidates[0];
|
|
||||||
return top ? { parkId: top.parkId, year: top.year, month: top.month } : null;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface DayData {
|
||||||
|
isOpen: boolean;
|
||||||
|
hoursLabel: string | null;
|
||||||
|
specialType: string | null;
|
||||||
|
}
|
||||||
@@ -11,8 +11,6 @@ const CSP = [
|
|||||||
].join("; ");
|
].join("; ");
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// better-sqlite3 is a native module — must not be bundled by webpack
|
|
||||||
serverExternalPackages: ["better-sqlite3"],
|
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
|
||||||
async headers() {
|
async headers() {
|
||||||
|
|||||||
Generated
+3
-777
File diff suppressed because it is too large
Load Diff
@@ -11,14 +11,12 @@
|
|||||||
"test": "tsx --test tests/*.test.ts"
|
"test": "tsx --test tests/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.8.0",
|
|
||||||
"next": "^15.3.0",
|
"next": "^15.3.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
+1
-1
@@ -23,5 +23,5 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "backend"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user