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-text: #4ade80;
|
||||||
--color-open-hours: #bbf7d0;
|
--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-bg: #0c1a3d;
|
||||||
--color-today-border: #2563eb;
|
--color-today-border: #2563eb;
|
||||||
--color-today-text: #93c5fd;
|
--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
|
// Open with confirmed hours
|
||||||
return (
|
return (
|
||||||
<td key={date} style={{
|
<td key={date} style={{
|
||||||
|
|||||||
22
lib/db.ts
22
lib/db.ts
@@ -17,6 +17,7 @@ export function openDb(): Database.Database {
|
|||||||
date TEXT NOT NULL, -- YYYY-MM-DD
|
date TEXT NOT NULL, -- YYYY-MM-DD
|
||||||
is_open INTEGER NOT NULL DEFAULT 0,
|
is_open INTEGER NOT NULL DEFAULT 0,
|
||||||
hours_label TEXT,
|
hours_label TEXT,
|
||||||
|
special_type TEXT, -- 'passholder_preview' | null
|
||||||
scraped_at TEXT NOT NULL,
|
scraped_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (park_id, date)
|
PRIMARY KEY (park_id, date)
|
||||||
);
|
);
|
||||||
@@ -28,6 +29,12 @@ export function openDb(): Database.Database {
|
|||||||
discovered_at TEXT NOT NULL
|
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;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,21 +43,24 @@ export function upsertDay(
|
|||||||
parkId: string,
|
parkId: string,
|
||||||
date: string,
|
date: string,
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
hoursLabel?: string
|
hoursLabel?: string,
|
||||||
|
specialType?: string
|
||||||
) {
|
) {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO park_days (park_id, date, is_open, hours_label, scraped_at)
|
INSERT INTO park_days (park_id, date, is_open, hours_label, special_type, scraped_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (park_id, date) DO UPDATE SET
|
ON CONFLICT (park_id, date) DO UPDATE SET
|
||||||
is_open = excluded.is_open,
|
is_open = excluded.is_open,
|
||||||
hours_label = excluded.hours_label,
|
hours_label = excluded.hours_label,
|
||||||
|
special_type = excluded.special_type,
|
||||||
scraped_at = excluded.scraped_at
|
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 {
|
export interface DayData {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
hoursLabel: string | null;
|
hoursLabel: string | null;
|
||||||
|
specialType: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,7 +75,7 @@ export function getDateRange(
|
|||||||
): Record<string, Record<string, DayData>> {
|
): Record<string, Record<string, DayData>> {
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT park_id, date, is_open, hours_label
|
`SELECT park_id, date, is_open, hours_label, special_type
|
||||||
FROM park_days
|
FROM park_days
|
||||||
WHERE date >= ? AND date <= ?`
|
WHERE date >= ? AND date <= ?`
|
||||||
)
|
)
|
||||||
@@ -74,6 +84,7 @@ export function getDateRange(
|
|||||||
date: string;
|
date: string;
|
||||||
is_open: number;
|
is_open: number;
|
||||||
hours_label: string | null;
|
hours_label: string | null;
|
||||||
|
special_type: string | null;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
const result: Record<string, Record<string, DayData>> = {};
|
const result: Record<string, Record<string, DayData>> = {};
|
||||||
@@ -82,6 +93,7 @@ export function getDateRange(
|
|||||||
result[row.park_id][row.date] = {
|
result[row.park_id][row.date] = {
|
||||||
isOpen: row.is_open === 1,
|
isOpen: row.is_open === 1,
|
||||||
hoursLabel: row.hours_label,
|
hoursLabel: row.hours_label,
|
||||||
|
specialType: row.special_type,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface DayResult {
|
|||||||
date: string; // YYYY-MM-DD
|
date: string; // YYYY-MM-DD
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
hoursLabel?: string;
|
hoursLabel?: string;
|
||||||
|
specialType?: "passholder_preview";
|
||||||
}
|
}
|
||||||
|
|
||||||
function sleep(ms: number) {
|
function sleep(ms: number) {
|
||||||
@@ -49,6 +50,7 @@ function parseApiDate(d: string): string {
|
|||||||
interface ApiOperatingItem {
|
interface ApiOperatingItem {
|
||||||
timeFrom: string; // "10:30" 24h
|
timeFrom: string; // "10:30" 24h
|
||||||
timeTo: string; // "20:00" 24h
|
timeTo: string; // "20:00" 24h
|
||||||
|
isBuyout?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiOperating {
|
interface ApiOperating {
|
||||||
@@ -56,9 +58,14 @@ interface ApiOperating {
|
|||||||
items: ApiOperatingItem[];
|
items: ApiOperatingItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ApiEvent {
|
||||||
|
extEventName: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ApiDay {
|
interface ApiDay {
|
||||||
date: string;
|
date: string;
|
||||||
isParkClosed: boolean;
|
isParkClosed: boolean;
|
||||||
|
events?: ApiEvent[];
|
||||||
operatings?: ApiOperating[];
|
operatings?: ApiOperating[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +142,18 @@ export async function scrapeMonth(
|
|||||||
item?.timeFrom && item?.timeTo
|
item?.timeFrom && item?.timeTo
|
||||||
? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}`
|
? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}`
|
||||||
: undefined;
|
: undefined;
|
||||||
// If the API says open but no hours are available, treat as closed
|
|
||||||
const isOpen = !d.isParkClosed && hoursLabel !== undefined;
|
const isPassholderPreview = d.events?.some((e) =>
|
||||||
return { date, isOpen, hoursLabel };
|
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 ──────────────────────────────────────────────────────────
|
// ── Parsed result ──────────────────────────────────────────────────────────
|
||||||
const operating =
|
const operating =
|
||||||
dayData.operatings?.find((o) => o.operatingTypeName === "Park") ??
|
dayData.operatings?.find((o: { operatingTypeName: string }) => o.operatingTypeName === "Park") ??
|
||||||
dayData.operatings?.[0];
|
dayData.operatings?.[0];
|
||||||
const item = operating?.items?.[0];
|
const item = operating?.items?.[0];
|
||||||
const hoursLabel =
|
const hoursLabel =
|
||||||
item?.timeFrom && item?.timeTo
|
item?.timeFrom && item?.timeTo
|
||||||
? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}`
|
? `${fmt24(item.timeFrom)} – ${fmt24(item.timeTo)}`
|
||||||
: undefined;
|
: 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("");
|
||||||
out("── Parsed result ────────────────────────────────────────────");
|
out("── Parsed result ────────────────────────────────────────────");
|
||||||
out(` isParkClosed : ${dayData.isParkClosed}`);
|
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"}`);
|
out(` operatings : ${dayData.operatings?.length ?? 0} entr${dayData.operatings?.length === 1 ? "y" : "ies"}`);
|
||||||
if (operating) {
|
if (operating) {
|
||||||
out(` selected : "${operating.operatingTypeName}" (${operating.items?.length ?? 0} item(s))`);
|
out(` selected : "${operating.operatingTypeName}" (${operating.items?.length ?? 0} item(s))`);
|
||||||
if (item) {
|
if (item) {
|
||||||
out(` timeFrom : ${item.timeFrom} → ${fmt24(item.timeFrom)}`);
|
out(` timeFrom : ${item.timeFrom} → ${fmt24(item.timeFrom)}`);
|
||||||
out(` timeTo : ${item.timeTo} → ${fmt24(item.timeTo)}`);
|
out(` timeTo : ${item.timeTo} → ${fmt24(item.timeTo)}`);
|
||||||
|
out(` isBuyout : ${isBuyout}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out(` isPassholderPreview : ${isPassholderPreview}`);
|
||||||
out(` hoursLabel : ${hoursLabel ?? "(none)"}`);
|
out(` hoursLabel : ${hoursLabel ?? "(none)"}`);
|
||||||
out(` isOpen : ${isOpen}`);
|
out(` isOpen : ${isOpen}`);
|
||||||
|
out(` specialType : ${specialType ?? "(none)"}`)
|
||||||
|
|
||||||
// ── Write to file ──────────────────────────────────────────────────────────
|
// ── Write to file ──────────────────────────────────────────────────────────
|
||||||
const debugDir = path.join(process.cwd(), "debug");
|
const debugDir = path.join(process.cwd(), "debug");
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
const days = await scrapeMonth(apiId, YEAR, month);
|
const days = await scrapeMonth(apiId, YEAR, month);
|
||||||
db.transaction(() => {
|
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;
|
openDays += days.filter((d) => d.isOpen).length;
|
||||||
fetched++;
|
fetched++;
|
||||||
|
|||||||
Reference in New Issue
Block a user