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 { PARKS } from "@/lib/parks";
|
||||
import { openDb, getDateRange } from "@/lib/db";
|
||||
import { getTodayLocal, isWithinOperatingWindow, getOperatingStatus } from "@/lib/env";
|
||||
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";
|
||||
import { getTodayLocal } from "@/lib/env";
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:3001";
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ week?: string }>;
|
||||
@@ -26,121 +21,14 @@ function getWeekStart(param: string | undefined): string {
|
||||
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) {
|
||||
const params = await searchParams;
|
||||
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 = getDateRange(db, weekStart, endDate);
|
||||
const data = await fetch(
|
||||
`${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).
|
||||
// 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}
|
||||
/>
|
||||
);
|
||||
return <HomePageClient {...data} />;
|
||||
}
|
||||
|
||||
+31
-61
@@ -1,33 +1,27 @@
|
||||
import Link from "next/link";
|
||||
import { BackToCalendarLink } from "@/components/BackToCalendarLink";
|
||||
import { notFound } from "next/navigation";
|
||||
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 { 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";
|
||||
import type { LiveRidesResult } from "@/lib/scrapers/queuetimes";
|
||||
import { getTodayLocal } from "@/lib/env";
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:3001";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: 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)) {
|
||||
const [y, m] = param.split("-").map(Number);
|
||||
if (y >= 2020 && y <= 2030 && m >= 1 && m <= 12) {
|
||||
return { year: y, month: m };
|
||||
return param;
|
||||
}
|
||||
}
|
||||
const [y, m] = getTodayLocal().split("-").map(Number);
|
||||
return { year: y, month: m };
|
||||
return getTodayLocal().slice(0, 7);
|
||||
}
|
||||
|
||||
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);
|
||||
if (!park) notFound();
|
||||
|
||||
const today = getTodayLocal();
|
||||
const { year, month } = parseMonthParam(monthParam);
|
||||
const monthStr = parseMonthParam(monthParam);
|
||||
const [year, month] = monthStr.split("-").map(Number);
|
||||
|
||||
const db = openDb();
|
||||
const monthData = getParkMonthData(db, id, year, month);
|
||||
db.close();
|
||||
const [calendarData, ridesData] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}/api/calendar/${id}/month?month=${monthStr}`, {
|
||||
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 todayData = liveToday
|
||||
? { isOpen: liveToday.isOpen, hoursLabel: liveToday.hoursLabel ?? null, specialType: liveToday.specialType ?? null }
|
||||
: monthData[today];
|
||||
const parkOpenToday = todayData?.isOpen && todayData?.hoursLabel;
|
||||
|
||||
// ── Ride data: try live Queue-Times first, fall back to schedule ──────────
|
||||
const queueTimesId = QUEUE_TIMES_IDS[id];
|
||||
const coasterSet = getCoasterSet(id);
|
||||
|
||||
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, 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);
|
||||
}
|
||||
const { monthData, today } = calendarData;
|
||||
const {
|
||||
parkOpenToday,
|
||||
isWeatherDelay,
|
||||
liveRides,
|
||||
scheduleFallback: ridesResult,
|
||||
}: {
|
||||
parkOpenToday: boolean;
|
||||
isWeatherDelay: boolean;
|
||||
liveRides: LiveRidesResult | null;
|
||||
scheduleFallback: RidesFetchResult | null;
|
||||
} = ridesData;
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||
@@ -162,13 +132,13 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
||||
{liveRides ? (
|
||||
<LiveRidePanel
|
||||
liveRides={liveRides}
|
||||
parkOpenToday={!!parkOpenToday}
|
||||
parkOpenToday={parkOpenToday}
|
||||
isWeatherDelay={isWeatherDelay}
|
||||
/>
|
||||
) : (
|
||||
<RideList
|
||||
ridesResult={ridesResult}
|
||||
parkOpenToday={!!parkOpenToday}
|
||||
parkOpenToday={parkOpenToday}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user