feat: detect passholder preview days and filter plain buyouts
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:
2026-04-04 10:53:05 -04:00
parent 7c28d8f89f
commit 91e09b0548
6 changed files with 114 additions and 26 deletions

View File

@@ -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;

View File

@@ -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={{

View File

@@ -17,6 +17,7 @@ export function openDb(): Database.Database {
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)
);
@@ -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,
special_type = excluded.special_type,
scraped_at = excluded.scraped_at
`).run(parkId, date, isOpen ? 1 : 0, hoursLabel ?? null, new Date().toISOString());
`).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;

View File

@@ -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 };
});
}

View File

@@ -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(` 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))`);
if (item) {
out(` timeFrom : ${item.timeFrom}${fmt24(item.timeFrom)}`);
out(` timeTo : ${item.timeTo}${fmt24(item.timeTo)}`);
out(` isBuyout : ${isBuyout}`);
}
}
out(` isPassholderPreview : ${isPassholderPreview}`);
out(` hoursLabel : ${hoursLabel ?? "(none)"}`);
out(` isOpen : ${isOpen}`);
out(` specialType : ${specialType ?? "(none)"}`)
// ── Write to file ──────────────────────────────────────────────────────────
const debugDir = path.join(process.cwd(), "debug");

View File

@@ -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++;