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:
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 };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user