feat: detect passholder preview days and filter plain buyouts
All checks were successful
Build and Deploy / Build & Push (push) Successful in 3m9s
All checks were successful
Build and Deploy / Build & Push (push) Successful in 3m9s
- Buyout days are now treated as closed unless they carry a Passholder Preview event, in which case they surface as a distinct purple cell in the UI showing "Passholder" + hours - DB gains a special_type column (auto-migrated on next startup) - scrape.ts threads specialType through to upsertDay - debug.ts now shows events, isBuyout, isPassholderPreview, and specialType in the parsed result section Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,11 @@
|
||||
--color-open-text: #4ade80;
|
||||
--color-open-hours: #bbf7d0;
|
||||
|
||||
--color-ph-bg: #1e0f2e;
|
||||
--color-ph-border: #7e22ce;
|
||||
--color-ph-hours: #e9d5ff;
|
||||
--color-ph-label: #c084fc;
|
||||
|
||||
--color-today-bg: #0c1a3d;
|
||||
--color-today-border: #2563eb;
|
||||
--color-today-text: #93c5fd;
|
||||
|
||||
@@ -177,6 +177,52 @@ export function WeekCalendar({ parks, weekDates, data }: WeekCalendarProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Passholder preview day
|
||||
if (dayData.specialType === "passholder_preview") {
|
||||
return (
|
||||
<td key={date} style={{
|
||||
...tdBase,
|
||||
padding: 4,
|
||||
borderLeft: isToday ? "1px solid var(--color-today-border)" : undefined,
|
||||
borderRight: isToday ? "1px solid var(--color-today-border)" : undefined,
|
||||
}}>
|
||||
<div style={{
|
||||
background: "var(--color-ph-bg)",
|
||||
border: "1px solid var(--color-ph-border)",
|
||||
borderRadius: 6,
|
||||
padding: "4px 4px",
|
||||
textAlign: "center",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
}}>
|
||||
<span style={{
|
||||
color: "var(--color-ph-label)",
|
||||
fontSize: "0.6rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
Passholder
|
||||
</span>
|
||||
<span style={{
|
||||
color: "var(--color-ph-hours)",
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 500,
|
||||
letterSpacing: "-0.01em",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{dayData.hoursLabel}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// Open with confirmed hours
|
||||
return (
|
||||
<td key={date} style={{
|
||||
|
||||
38
lib/db.ts
38
lib/db.ts
@@ -13,11 +13,12 @@ export function openDb(): Database.Database {
|
||||
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,
|
||||
scraped_at TEXT NOT NULL,
|
||||
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 (
|
||||
@@ -28,6 +29,12 @@ export function openDb(): Database.Database {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -36,21 +43,24 @@ export function upsertDay(
|
||||
parkId: string,
|
||||
date: string,
|
||||
isOpen: boolean,
|
||||
hoursLabel?: string
|
||||
hoursLabel?: string,
|
||||
specialType?: string
|
||||
) {
|
||||
db.prepare(`
|
||||
INSERT INTO park_days (park_id, date, is_open, hours_label, scraped_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
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,
|
||||
scraped_at = excluded.scraped_at
|
||||
`).run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, new Date().toISOString());
|
||||
is_open = excluded.is_open,
|
||||
hours_label = excluded.hours_label,
|
||||
special_type = excluded.special_type,
|
||||
scraped_at = excluded.scraped_at
|
||||
`).run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, specialType ?? null, new Date().toISOString());
|
||||
}
|
||||
|
||||
export interface DayData {
|
||||
isOpen: boolean;
|
||||
hoursLabel: string | null;
|
||||
specialType: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,7 +75,7 @@ export function getDateRange(
|
||||
): Record<string, Record<string, DayData>> {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT park_id, date, is_open, hours_label
|
||||
`SELECT park_id, date, is_open, hours_label, special_type
|
||||
FROM park_days
|
||||
WHERE date >= ? AND date <= ?`
|
||||
)
|
||||
@@ -74,6 +84,7 @@ export function getDateRange(
|
||||
date: string;
|
||||
is_open: number;
|
||||
hours_label: string | null;
|
||||
special_type: string | null;
|
||||
}[];
|
||||
|
||||
const result: Record<string, Record<string, DayData>> = {};
|
||||
@@ -82,6 +93,7 @@ export function getDateRange(
|
||||
result[row.park_id][row.date] = {
|
||||
isOpen: row.is_open === 1,
|
||||
hoursLabel: row.hours_label,
|
||||
specialType: row.special_type,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface DayResult {
|
||||
date: string; // YYYY-MM-DD
|
||||
isOpen: boolean;
|
||||
hoursLabel?: string;
|
||||
specialType?: "passholder_preview";
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
@@ -49,6 +50,7 @@ function parseApiDate(d: string): string {
|
||||
interface ApiOperatingItem {
|
||||
timeFrom: string; // "10:30" 24h
|
||||
timeTo: string; // "20:00" 24h
|
||||
isBuyout?: boolean;
|
||||
}
|
||||
|
||||
interface ApiOperating {
|
||||
@@ -56,9 +58,14 @@ interface ApiOperating {
|
||||
items: ApiOperatingItem[];
|
||||
}
|
||||
|
||||
interface ApiEvent {
|
||||
extEventName: string;
|
||||
}
|
||||
|
||||
interface ApiDay {
|
||||
date: string;
|
||||
isParkClosed: boolean;
|
||||
events?: ApiEvent[];
|
||||
operatings?: ApiOperating[];
|
||||
}
|
||||
|
||||
@@ -135,9 +142,18 @@ export async function scrapeMonth(
|
||||
item?.timeFrom && item?.timeTo
|
||||
? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}`
|
||||
: undefined;
|
||||
// If the API says open but no hours are available, treat as closed
|
||||
const isOpen = !d.isParkClosed && hoursLabel !== undefined;
|
||||
return { date, isOpen, hoursLabel };
|
||||
|
||||
const isPassholderPreview = d.events?.some((e) =>
|
||||
e.extEventName.toLowerCase().includes("passholder preview")
|
||||
) ?? false;
|
||||
|
||||
const isBuyout = item?.isBuyout ?? false;
|
||||
|
||||
// Buyout days are private events — treat as closed unless it's a passholder preview
|
||||
const isOpen = !d.isParkClosed && hoursLabel !== undefined && (!isBuyout || isPassholderPreview);
|
||||
const specialType: DayResult["specialType"] = isPassholderPreview ? "passholder_preview" : undefined;
|
||||
|
||||
return { date, isOpen, hoursLabel: isOpen ? hoursLabel : undefined, specialType };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -94,28 +94,37 @@ async function main() {
|
||||
|
||||
// ── Parsed result ──────────────────────────────────────────────────────────
|
||||
const operating =
|
||||
dayData.operatings?.find((o) => o.operatingTypeName === "Park") ??
|
||||
dayData.operatings?.find((o: { operatingTypeName: string }) => o.operatingTypeName === "Park") ??
|
||||
dayData.operatings?.[0];
|
||||
const item = operating?.items?.[0];
|
||||
const hoursLabel =
|
||||
item?.timeFrom && item?.timeTo
|
||||
? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}`
|
||||
: undefined;
|
||||
const isOpen = !dayData.isParkClosed && hoursLabel !== undefined;
|
||||
const isBuyout = item?.isBuyout ?? false;
|
||||
const isPassholderPreview = dayData.events?.some((e: { extEventName: string }) =>
|
||||
e.extEventName.toLowerCase().includes("passholder preview")
|
||||
) ?? false;
|
||||
const isOpen = !dayData.isParkClosed && hoursLabel !== undefined && (!isBuyout || isPassholderPreview);
|
||||
const specialType = isPassholderPreview ? "passholder_preview" : null;
|
||||
|
||||
out("");
|
||||
out("── Parsed result ────────────────────────────────────────────");
|
||||
out(` isParkClosed : ${dayData.isParkClosed}`);
|
||||
out(` operatings : ${dayData.operatings?.length ?? 0} entr${dayData.operatings?.length === 1 ? "y" : "ies"}`);
|
||||
out(` isParkClosed : ${dayData.isParkClosed}`);
|
||||
out(` events : ${dayData.events?.length ?? 0} (${dayData.events?.map((e: { extEventName: string }) => e.extEventName).join(", ") || "none"})`);
|
||||
out(` operatings : ${dayData.operatings?.length ?? 0} entr${dayData.operatings?.length === 1 ? "y" : "ies"}`);
|
||||
if (operating) {
|
||||
out(` selected : "${operating.operatingTypeName}" (${operating.items?.length ?? 0} item(s))`);
|
||||
out(` selected : "${operating.operatingTypeName}" (${operating.items?.length ?? 0} item(s))`);
|
||||
if (item) {
|
||||
out(` timeFrom : ${item.timeFrom} → ${fmt24(item.timeFrom)}`);
|
||||
out(` timeTo : ${item.timeTo} → ${fmt24(item.timeTo)}`);
|
||||
out(` timeFrom : ${item.timeFrom} → ${fmt24(item.timeFrom)}`);
|
||||
out(` timeTo : ${item.timeTo} → ${fmt24(item.timeTo)}`);
|
||||
out(` isBuyout : ${isBuyout}`);
|
||||
}
|
||||
}
|
||||
out(` hoursLabel : ${hoursLabel ?? "(none)"}`);
|
||||
out(` isOpen : ${isOpen}`);
|
||||
out(` isPassholderPreview : ${isPassholderPreview}`);
|
||||
out(` hoursLabel : ${hoursLabel ?? "(none)"}`);
|
||||
out(` isOpen : ${isOpen}`);
|
||||
out(` specialType : ${specialType ?? "(none)"}`)
|
||||
|
||||
// ── Write to file ──────────────────────────────────────────────────────────
|
||||
const debugDir = path.join(process.cwd(), "debug");
|
||||
|
||||
@@ -65,7 +65,7 @@ async function main() {
|
||||
try {
|
||||
const days = await scrapeMonth(apiId, YEAR, month);
|
||||
db.transaction(() => {
|
||||
for (const d of days) upsertDay(db, park.id, d.date, d.isOpen, d.hoursLabel);
|
||||
for (const d of days) upsertDay(db, park.id, d.date, d.isOpen, d.hoursLabel, d.specialType);
|
||||
})();
|
||||
openDays += days.filter((d) => d.isOpen).length;
|
||||
fetched++;
|
||||
|
||||
Reference in New Issue
Block a user