diff --git a/functions/event/[slug].js b/functions/event/[slug].js index 9aba59d2..0fd374f7 100644 --- a/functions/event/[slug].js +++ b/functions/event/[slug].js @@ -5,6 +5,96 @@ import { isPublicDataEnabled } from "../utils/publicGate.js"; import { escapeAttr, toPlainText, serveWithInjectedMeta, WATERLOO_ADDRESS, CANONICAL_HOST } from "../utils/ssrMeta.js"; import { normalizeHttpUrl } from "../utils/validation.js"; import { sortableName } from "../utils/sortableName.js"; +import { torontoUtcOffset } from "../utils/eventDay.js"; + +// Sets starting before 06:00 are after-midnight sets that belong to the +// PREVIOUS festival day (AFTER_MIDNIGHT_THRESHOLD_HOUR = 6 convention; +// canonical definition frontend/src/utils/festivalDays.js, see CLAUDE.md). +// Kept local rather than imported (functions/ doesn't import frontend/src) — +// mirrors the same re-encoding in functions/api/events/timeline.js. +const AFTER_MIDNIGHT_THRESHOLD_HOUR = 6; + +/** + * Extracts the integer hour from a performances.start_time value ("HH:MM", + * "HH:MM:SS", or legacy "YYYY-MM-DD HH:MM"); null when absent/unparseable. + * Mirrors normalizeStartTime in functions/api/events/timeline.js. + */ +function startHour(startTime) { + if (typeof startTime !== "string") return null; + const timePart = startTime.includes(" ") ? startTime.split(" ")[1] : startTime; + const hour = Number.parseInt((timePart ?? "").slice(0, 2), 10); + return Number.isFinite(hour) ? hour : null; +} + +/** + * YYYY-MM-DD -> the following calendar day, DST-proof via UTC math (the + * string is a date literal, not a moment in time). Mirrors nextCalendarDay in + * functions/api/feeds/ical.js. + */ +function nextCalendarDay(dateStr) { + const next = new Date(`${dateStr}T00:00:00Z`); + next.setUTCDate(next.getUTCDate() + 1); + return next.toISOString().slice(0, 10); +} + +/** YYYY-MM-DD -> the preceding calendar day. See nextCalendarDay. */ +function previousCalendarDay(dateStr) { + const prev = new Date(`${dateStr}T00:00:00Z`); + prev.setUTCDate(prev.getUTCDate() - 1); + return prev.toISOString().slice(0, 10); +} + +/** + * Every calendar day from startDate to endDate inclusive, ascending. + * Lexicographic YYYY-MM-DD comparison is safe (repo convention, see + * CLAUDE.md). Guarded at 366 iterations so a malformed end_date before date + * can't spin the loop. + */ +function eachCalendarDay(startDate, endDate) { + const days = []; + let cursor = startDate; + let guard = 0; + while (cursor <= endDate && guard < 366) { + days.push(cursor); + cursor = nextCalendarDay(cursor); + guard++; + } + return days; +} + +// "Weekday, Month D" label for a subEvent's name, in the event's own +// timezone. Same noon-probe trick as torontoUtcOffset — never ambiguous +// around a DST transition, which always lands in the early morning. +const DAY_LABEL_FORMAT = new Intl.DateTimeFormat("en-US", { + timeZone: "America/Toronto", + weekday: "long", + month: "long", + day: "numeric", +}); +function festivalDayLabel(dateStr) { + return DAY_LABEL_FORMAT.format(new Date(`${dateStr}T12:00:00Z`)); +} + +/** + * Buckets a performance row into one of `festivalDays` (a MULTI-DAY event's + * full [date, end_date] span), applying the after-midnight convention: a set + * starting before 06:00 belongs to the PREVIOUS festival day, not the + * calendar day its performance_date literally names (#542 PR-4; see + * functions/api/events/timeline.js's isGatedBeforeStart for the same + * exclusion applied to the day-1 start edge). NULL performance_date inherits + * the event's own start date (#543 convention, ical.js/schedule.js). If the + * shift lands outside the event's own day span (e.g. a stray early set on + * literal day 1), it's clamped back to day 1 rather than silently dropped + * from every subEvent. + */ +function festivalDayForPerformance(row, event, festivalDays) { + let day = row.performance_date || event.date; + const hour = startHour(row.start_time); + if (hour !== null && hour < AFTER_MIDNIGHT_THRESHOLD_HOUR) { + day = previousCalendarDay(day); + } + return festivalDays.includes(day) ? day : event.date; +} export async function onRequest(context) { const { params, env, request } = context; @@ -17,7 +107,7 @@ export async function onRequest(context) { let event; try { event = await env.DB.prepare( - `SELECT id, name, date, end_date, slug, description, city, ticket_url, poster_url, created_at + `SELECT id, name, date, end_date, slug, description, city, ticket_url, poster_url, created_at, reveal_mode FROM events WHERE slug = ? AND (is_published = 1 OR status = 'archived')`, ) @@ -29,19 +119,31 @@ export async function onRequest(context) { } if (!event) return env.ASSETS.fetch(request); - // Fetch the event's bands and distinct venues in parallel. - let bands = []; + // Fetch the event's performances (band + per-set festival day) and + // distinct venues in parallel. Row-level (not DISTINCT bp.id) because + // subEvent grouping below needs each performance's own performance_date / + // start_time; the flat `bands` performer list is deduped from these rows + // in JS instead. + // + // Reveal-mode gate (#542 PR-4, folded pre-existing bug): mirrors the + // `(e.reveal_mode = 0 OR p.is_announced = 1)` filter already applied in + // schedule.js/ical.js/timeline.js. Without it, an unannounced band on a + // reveal-mode event would leak into crawler-facing JSON-LD before the + // admin ever announces it. No reveal-mode event exists in prod today, so + // this was latent, not active. + let performanceRows = []; let venues = []; try { - const [bandsResult, venuesResult] = await Promise.all([ + const [performancesResult, venuesResult] = await Promise.all([ env.DB.prepare( - `SELECT DISTINCT bp.id, bp.name + `SELECT bp.id, bp.name, p.performance_date, p.start_time FROM performances p JOIN band_profiles bp ON p.band_profile_id = bp.id WHERE p.event_id = ? + AND (? = 0 OR p.is_announced = 1) ORDER BY bp.name`, ) - .bind(event.id) + .bind(event.id, event.reveal_mode ?? 0) .all(), env.DB.prepare( `SELECT DISTINCT v.id, v.name, v.address_line1, v.address, v.city, v.region, v.postal_code @@ -53,18 +155,98 @@ export async function onRequest(context) { .bind(event.id) .all(), ]); - bands = bandsResult.results ?? []; + performanceRows = performancesResult.results ?? []; venues = venuesResult.results ?? []; } catch (err) { // Non-fatal: fall through with empty arrays; MusicEvent still renders. console.error("SSR event bands/venues lookup failed:", slug, err); } + // Flat performer list: one entry per band, first-seen from the + // (already reveal-mode-gated) performance rows above — a band playing two + // sets must not appear twice. + const bandById = new Map(); + for (const row of performanceRows) { + if (!bandById.has(row.id)) bandById.set(row.id, { id: row.id, name: row.name }); + } + const bands = [...bandById.values()]; + // SQLite ORDER BY can't strip a leading article inline (#587); the query // above is a coarse pre-sort and the JSON-LD performer list is re-sorted // here by the article-stripped key so "The Anti-Queens" lists under A. bands.sort((a, b) => sortableName(a.name).localeCompare(sortableName(b.name))); + // Build MusicEvent location: use per-venue MusicVenue entries when available, + // otherwise fall back to a generic Place for the Waterloo Region. Computed + // here (before subEvent) because each subEvent carries the same location — + // `location` is a Google-required Event property, and the Rich Results Test + // validates each nested subEvent node too, so omitting it there is a + // "Missing field 'location'" failure (Vera, #542 PR-4). + const location = + venues.length > 0 + ? venues.map((v) => ({ + "@type": "MusicVenue", + name: v.name, + address: { + "@type": "PostalAddress", + ...(v.address_line1 || v.address ? { streetAddress: v.address_line1 || v.address } : {}), + addressLocality: v.city || "Waterloo", + addressRegion: v.region || "ON", + ...(v.postal_code ? { postalCode: v.postal_code } : {}), + addressCountry: "CA", + }, + })) + : { + "@type": "Place", + name: event.city || "Waterloo Region, ON", + address: WATERLOO_ADDRESS, + }; + + // Per-day subEvent (#542 PR-4): MULTI-DAY events only (end_date > date). + // One MusicEvent per festival day in the event's own span, each carrying + // that day's performers (bucketed via festivalDayForPerformance, which + // applies the after-midnight convention). Single-day events never build + // this — `subEvent` stays an empty array and the conditional spread below + // omits the key entirely, keeping their JSON-LD byte-identical to before. + const isMultiDay = Boolean(event.end_date && event.end_date > event.date); + let subEvent = []; + if (isMultiDay) { + const festivalDays = eachCalendarDay(event.date, event.end_date); + const performersByDay = new Map(festivalDays.map((d) => [d, new Map()])); + + for (const row of performanceRows) { + const day = festivalDayForPerformance(row, event, festivalDays); + const bucket = performersByDay.get(day); + if (!bucket.has(row.id)) bucket.set(row.id, { id: row.id, name: row.name }); + } + + subEvent = festivalDays.map((day) => { + const performers = [...performersByDay.get(day).values()].sort((a, b) => + sortableName(a.name).localeCompare(sortableName(b.name)), + ); + return { + "@type": "MusicEvent", + name: `${event.name} — ${festivalDayLabel(day)}`, + // Same show-start convention as the top-level MusicEvent below. + startDate: `${day}T18:45:00${torontoUtcOffset(day)}`, + endDate: day, + eventStatus: "https://schema.org/EventScheduled", + eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode", + // Google requires `location` on every Event node, subEvents included. + location, + ...(performers.length > 0 + ? { + performer: performers.map((b) => ({ + "@type": "MusicGroup", + name: b.name, + url: `${CANONICAL_HOST}/band/${b.id}`, + })), + } + : {}), + }; + }); + } + // Pin to the production host — preview deploys must not self-canonicalise. const url = `${CANONICAL_HOST}/event/${event.slug}`; const where = event.city || "Waterloo Region"; @@ -96,28 +278,6 @@ export async function onRequest(context) { metaTags.push(``); } - // Build MusicEvent location: use per-venue MusicVenue entries when available, - // otherwise fall back to a generic Place for the Waterloo Region. - const location = - venues.length > 0 - ? venues.map((v) => ({ - "@type": "MusicVenue", - name: v.name, - address: { - "@type": "PostalAddress", - ...(v.address_line1 || v.address ? { streetAddress: v.address_line1 || v.address } : {}), - addressLocality: v.city || "Waterloo", - addressRegion: v.region || "ON", - ...(v.postal_code ? { postalCode: v.postal_code } : {}), - addressCountry: "CA", - }, - })) - : { - "@type": "Place", - name: event.city || "Waterloo Region, ON", - address: WATERLOO_ADDRESS, - }; - // Read-path sanitize (#504): a pre-validation legacy ticket_url (e.g. a // javascript: scheme) must never be reflected into the Offer.url of the // MusicEvent JSON-LD — normalizeHttpUrl returns null for anything that @@ -139,8 +299,11 @@ export async function onRequest(context) { url, eventStatus: "https://schema.org/EventScheduled", eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode", - // Show doors at 6:30PM; set times start 6:45PM — use the show-start per spec. - ...(event.date ? { startDate: `${event.date}T18:45:00-04:00` } : {}), + // Show doors at 6:30PM; set times start 6:45PM — use the show-start per + // spec. torontoUtcOffset is DST-aware (#542 PR-4, folded pre-existing + // bug): a hardcoded -04:00 (EDT) is wrong for a winter event (e.g. a + // February show is EST, -05:00). + ...(event.date ? { startDate: `${event.date}T18:45:00${torontoUtcOffset(event.date)}` } : {}), ...(event.date ? { endDate: event.end_date || event.date } : {}), location, ...(plainDesc ? { description: plainDesc } : {}), @@ -171,6 +334,7 @@ export async function onRequest(context) { })), } : {}), + ...(isMultiDay ? { subEvent } : {}), }; const breadcrumb = { diff --git a/functions/event/__tests__/slug.test.js b/functions/event/__tests__/slug.test.js index 8ede8ec7..28c0b2f7 100644 --- a/functions/event/__tests__/slug.test.js +++ b/functions/event/__tests__/slug.test.js @@ -9,7 +9,7 @@ */ import { describe, expect, test } from "vitest"; import { onRequest } from "../[slug].js"; -import { createTestEnv, insertEvent } from "../../api/test-utils.js"; +import { createTestEnv, insertEvent, insertVenue, insertBand } from "../../api/test-utils.js"; const STUB_HTML = ` @@ -228,3 +228,194 @@ describe("SSR /event/[slug] — poster_url image + og:image/twitter:image (#616) expect(musicEvent.image).toBeUndefined(); }); }); + +describe("SSR /event/[slug] — per-day subEvent JSON-LD (#542 PR-4)", () => { + test("multi-day event: subEvent array, one per festival day, each with that day's performers + dates", async () => { + const { env, rawDb } = createTestEnv(); + env.PUBLIC_DATA_PUBLISH_ENABLED = "true"; + const event = insertEvent(rawDb, { + name: "Multi-Day Fest", + slug: "slug-542-multiday", + date: "2026-08-01", + }); + rawDb.prepare("UPDATE events SET is_published=1, end_date=? WHERE id=?").run("2026-08-02", event.id); + const venue = insertVenue(rawDb, { name: "Main Stage" }); + + const day1Band = insertBand(rawDb, { + name: "Day One Band", + event_id: event.id, + venue_id: venue.id, + start_time: "20:00", + end_time: "21:00", + }); + rawDb.prepare("UPDATE performances SET performance_date=? WHERE id=?").run("2026-08-01", day1Band.id); + + const day2Band = insertBand(rawDb, { + name: "Day Two Band", + event_id: event.id, + venue_id: venue.id, + start_time: "20:00", + end_time: "21:00", + }); + rawDb.prepare("UPDATE performances SET performance_date=? WHERE id=?").run("2026-08-02", day2Band.id); + + const response = await onRequest(makeContext({ env, slug: "slug-542-multiday" })); + expect(response.status).toBe(200); + const html = await response.text(); + + const [musicEvent] = extractJsonLd(html); + expect(musicEvent.subEvent).toHaveLength(2); + + const [subDay1, subDay2] = musicEvent.subEvent; + expect(subDay1["@type"]).toBe("MusicEvent"); + expect(subDay1.name).toBe(musicEvent.name + " — Saturday, August 1"); + expect(subDay1.startDate).toBe("2026-08-01T18:45:00-04:00"); + expect(subDay1.endDate).toBe("2026-08-01"); + // location is a Google-required Event property on every node, subEvents + // included — it must match the top-level MusicEvent's location (#542 PR-4). + expect(subDay1.location).toEqual(musicEvent.location); + expect(subDay1.eventStatus).toBe("https://schema.org/EventScheduled"); + expect(subDay1.performer).toEqual([ + { "@type": "MusicGroup", name: "Day One Band", url: expect.stringContaining("/band/") }, + ]); + + expect(subDay2.startDate).toBe("2026-08-02T18:45:00-04:00"); + expect(subDay2.endDate).toBe("2026-08-02"); + expect(subDay2.performer).toEqual([ + { "@type": "MusicGroup", name: "Day Two Band", url: expect.stringContaining("/band/") }, + ]); + + // Existing top-level performer list is unchanged — still both bands, not + // just one day's worth (subEvent is additive, not a replacement). + expect(musicEvent.performer).toHaveLength(2); + }); + + test("single-day event: no subEvent key at all (regression guard — output unchanged from #616/#617)", async () => { + const { env, rawDb } = createTestEnv(); + env.PUBLIC_DATA_PUBLISH_ENABLED = "true"; + const event = insertEvent(rawDb, { + name: "Single Night Show", + slug: "slug-542-singleday", + date: "2026-08-01", + }); + rawDb.prepare("UPDATE events SET is_published=1 WHERE id=?").run(event.id); + const venue = insertVenue(rawDb, { name: "Solo Venue" }); + insertBand(rawDb, { name: "Only Band", event_id: event.id, venue_id: venue.id }); + + const response = await onRequest(makeContext({ env, slug: "slug-542-singleday" })); + expect(response.status).toBe(200); + const html = await response.text(); + + const [musicEvent] = extractJsonLd(html); + expect(musicEvent.subEvent).toBeUndefined(); + expect(musicEvent.performer).toHaveLength(1); + }); + + test("an end_date equal to date (not actually multi-day) also gets no subEvent", async () => { + const { env, rawDb } = createTestEnv(); + env.PUBLIC_DATA_PUBLISH_ENABLED = "true"; + const event = insertEvent(rawDb, { + name: "Same-Date Show", + slug: "slug-542-same-date", + date: "2026-08-01", + }); + rawDb.prepare("UPDATE events SET is_published=1, end_date=? WHERE id=?").run("2026-08-01", event.id); + + const response = await onRequest(makeContext({ env, slug: "slug-542-same-date" })); + const html = await response.text(); + const [musicEvent] = extractJsonLd(html); + expect(musicEvent.subEvent).toBeUndefined(); + }); + + test("reveal-mode multi-day event: unannounced bands absent from both top-level performer AND every subEvent", async () => { + const { env, rawDb } = createTestEnv(); + env.PUBLIC_DATA_PUBLISH_ENABLED = "true"; + const event = insertEvent(rawDb, { + name: "Reveal Mode Fest", + slug: "slug-542-reveal", + date: "2026-08-01", + }); + rawDb.prepare("UPDATE events SET is_published=1, end_date=?, reveal_mode=1 WHERE id=?").run("2026-08-02", event.id); + const venue = insertVenue(rawDb, { name: "Reveal Stage" }); + + const announced = insertBand(rawDb, { name: "Announced Band", event_id: event.id, venue_id: venue.id }); + rawDb + .prepare("UPDATE performances SET is_announced=1, performance_date=? WHERE id=?") + .run("2026-08-01", announced.id); + + const hidden = insertBand(rawDb, { name: "Hidden Band", event_id: event.id, venue_id: venue.id }); + rawDb.prepare("UPDATE performances SET is_announced=0, performance_date=? WHERE id=?").run("2026-08-01", hidden.id); + + const response = await onRequest(makeContext({ env, slug: "slug-542-reveal" })); + expect(response.status).toBe(200); + const html = await response.text(); + const [musicEvent] = extractJsonLd(html); + + expect(musicEvent.performer).toHaveLength(1); + expect(musicEvent.performer[0].name).toBe("Announced Band"); + expect(html).not.toContain("Hidden Band"); + + const day1 = musicEvent.subEvent.find((d) => d.endDate === "2026-08-01"); + expect(day1.performer).toHaveLength(1); + expect(day1.performer[0].name).toBe("Announced Band"); + + // Day 2 has no announced performances at all — performer key is omitted + // entirely (mirrors the top-level bands.length > 0 convention). + const day2 = musicEvent.subEvent.find((d) => d.endDate === "2026-08-02"); + expect(day2.performer).toBeUndefined(); + }); + + test("after-midnight set (start before 06:00) buckets into the PREVIOUS festival day's subEvent, not its own performance_date", async () => { + const { env, rawDb } = createTestEnv(); + env.PUBLIC_DATA_PUBLISH_ENABLED = "true"; + const event = insertEvent(rawDb, { + name: "Late Night Fest", + slug: "slug-542-after-midnight", + date: "2026-08-01", + }); + rawDb.prepare("UPDATE events SET is_published=1, end_date=? WHERE id=?").run("2026-08-02", event.id); + const venue = insertVenue(rawDb, { name: "Late Stage" }); + + // Stored with performance_date = the literal calendar date the 1 AM set + // falls on (Aug 2) — but per the after-midnight convention (CLAUDE.md, + // AFTER_MIDNIGHT_THRESHOLD_HOUR = 6) it's really part of Aug 1 evening's + // lineup and must bucket into Day 1's subEvent, not Day 2's. + const lateBand = insertBand(rawDb, { + name: "After Midnight Band", + event_id: event.id, + venue_id: venue.id, + start_time: "01:00", + end_time: "02:00", + }); + rawDb.prepare("UPDATE performances SET performance_date=? WHERE id=?").run("2026-08-02", lateBand.id); + + const response = await onRequest(makeContext({ env, slug: "slug-542-after-midnight" })); + const html = await response.text(); + const [musicEvent] = extractJsonLd(html); + + const day1 = musicEvent.subEvent.find((d) => d.endDate === "2026-08-01"); + const day2 = musicEvent.subEvent.find((d) => d.endDate === "2026-08-02"); + expect(day1.performer?.map((p) => p.name)).toEqual(["After Midnight Band"]); + expect(day2.performer).toBeUndefined(); + }); + + test("startDate uses the correct America/Toronto offset: -04:00 (EDT) in summer, -05:00 (EST) in winter (#542 PR-4, folded pre-existing bug)", async () => { + const { env, rawDb } = createTestEnv(); + env.PUBLIC_DATA_PUBLISH_ENABLED = "true"; + + const summerEvent = insertEvent(rawDb, { name: "Summer Show", slug: "slug-542-summer", date: "2026-08-02" }); + rawDb.prepare("UPDATE events SET is_published=1 WHERE id=?").run(summerEvent.id); + + // lwbc15 precedent (CLAUDE.md #542): a February event is EST, not EDT. + const winterEvent = insertEvent(rawDb, { name: "Winter Show", slug: "slug-542-winter", date: "2026-02-14" }); + rawDb.prepare("UPDATE events SET is_published=1 WHERE id=?").run(winterEvent.id); + + const summerRes = await onRequest(makeContext({ env, slug: "slug-542-summer" })); + const [summerMusicEvent] = extractJsonLd(await summerRes.text()); + expect(summerMusicEvent.startDate).toBe("2026-08-02T18:45:00-04:00"); + + const winterRes = await onRequest(makeContext({ env, slug: "slug-542-winter" })); + const [winterMusicEvent] = extractJsonLd(await winterRes.text()); + expect(winterMusicEvent.startDate).toBe("2026-02-14T18:45:00-05:00"); + }); +}); diff --git a/functions/utils/eventDay.js b/functions/utils/eventDay.js index ce54f2bf..33b1a06b 100644 --- a/functions/utils/eventDay.js +++ b/functions/utils/eventDay.js @@ -53,3 +53,37 @@ export function eventLocalClock(now = new Date()) { const time = TORONTO_TIME.format(now).replace(/^24:/, "00:"); return { date, time }; } + +// Formats the America/Toronto UTC offset ("GMT-04:00" / "GMT-05:00") for a +// given instant. `longOffset` (not `shortOffset`) is required for a +// consistently zero-padded "±HH:MM" — some ICU builds render shortOffset +// without the leading zero (e.g. "GMT-4"). +const TORONTO_OFFSET = new Intl.DateTimeFormat("en-US", { + timeZone: "America/Toronto", + timeZoneName: "longOffset", +}); + +/** + * Returns the America/Toronto UTC offset ("-04:00" EDT or "-05:00" EST) in + * effect on a given YYYY-MM-DD calendar date, for embedding in a schema.org + * date literal (e.g. MusicEvent.startDate). A hardcoded "-04:00" is wrong for + * roughly five months a year (EST, e.g. a February event) — this delegates + * to Intl's ICU timezone tables instead of hand-rolling DST cutover math, + * which drifts without the platform's tz database. + * + * Probes at noon UTC on the given date (07:00–08:00 Toronto): Toronto's DST + * transitions land at 2 AM local, so this instant is always past the cutover + * and on the same calendar date being asked about — never ambiguous. + * + * @param {string} dateStr - YYYY-MM-DD + * @returns {string} e.g. "-04:00"; falls back to "-05:00" (EST) if dateStr + * is missing/malformed rather than throwing. + */ +export function torontoUtcOffset(dateStr) { + if (typeof dateStr !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + return "-05:00"; + } + const probe = new Date(`${dateStr}T12:00:00Z`); + const gmt = TORONTO_OFFSET.formatToParts(probe).find((p) => p.type === "timeZoneName")?.value; + return gmt ? gmt.replace("GMT", "") : "-05:00"; +}