Skip to content

feat: per-day subEvent JSON-LD for multi-day events (#542 PR-4)#634

Merged
BreakableHoodie merged 4 commits into
mainfrom
feat/542-subevent-jsonld
Jul 18, 2026
Merged

feat: per-day subEvent JSON-LD for multi-day events (#542 PR-4)#634
BreakableHoodie merged 4 commits into
mainfrom
feat/542-subevent-jsonld

Conversation

@BreakableHoodie

Copy link
Copy Markdown
Owner

⚠️ HOLD FOR SEO VALIDATION — do not merge until Dre runs Google's Rich Results Test

This rewrites crawler-facing structured data (design risk 7/10). Before merge, validate a real multi-day event URL (e.g. Buddies Fest 2 once deployed to preview) in Google's Rich Results Test and confirm the subEvent renders cleanly without regressing existing MusicEvent rich-result eligibility. The change is additive-only (single-day output is byte-identical, pinned by a regression test), so rollback is a clean single-file revert.

Summary

Multi-day events (end_date > date) gain a subEvent array in their MusicEvent JSON-LD — one MusicEvent per festival day, carrying that day's performers and dates. Performances are grouped by performance_date using the after-midnight convention (a set before 06:00 belongs to the previous evening's day — verified: a 1 AM set buckets into Day 1, not Day 2). Single-day events get no subEvent and render byte-identically. Builds additively on the #616 (poster image) and #617 (dedup + enrich) MusicEvent work — none of those fields change.

Closes #542 (final sub-PR; PR-1 staleness #604, PR-2 day-aware ICS, and PR-3 day tabs are the siblings)

What changed

File Change
functions/utils/eventDay.js torontoUtcOffset(dateStr) — ICU longOffset probed at local noon (DST-unambiguous), falls back to -05:00 on bad input
functions/event/[slug].js subEvent array for multi-day events; bands query selects performance_date/start_time + gains the reveal_mode gate; top-level + per-subEvent startDate use torontoUtcOffset()
functions/event/__tests__/slug.test.js +6 tests: subEvent shape, single-day regression guard, end_date===date edge, reveal-mode gating (top + sub), after-midnight bucketing, summer/winter offset

Two folded latent bugs (both in this file, which the PR rewrites anyway):

  1. reveal_mode gate — JSON-LD previously emitted all performers; now filters to announced sets on reveal-mode events (matches schedule.js/ical.js/timeline.js), so unannounced bands can't leak to crawlers. Latent today (no reveal-mode events in prod).
  2. hardcoded -04:00 — replaced with the DST-correct offset, so February events emit -05:00.

Security / correctness notes

The reveal_mode gate is a latent info-disclosure fix (unannounced lineup leaking to crawlers). Scope note / follow-up: the separate venues query still has no reveal_mode gate — a venue whose only performance is unannounced could appear in location on a reveal-mode event. Left out to keep this SEO PR a single clean revertable commit; filed as a follow-up.

Verification

  • Tests: backend 801 pass | 6 todo (76 files); slug.test.js 14/14 (8 + 6 new)
  • ESLint: 0 errors · Format check: clean · (backend-only, no build/openapi impact)
  • Google Rich Results Test on a live multi-day URL — DID NOT RUN (needs deploy + Dre); this is the merge gate

Built by Sonny · Reviewed by Vera (pending) & Theo · 🤖 Claude Code

Multi-day events (end_date > date) gain a subEvent array in their
MusicEvent JSON-LD — one MusicEvent per festival day with that day's
performers and dates, grouped by performance_date with the
after-midnight convention (a 1 AM set belongs to the previous
evening's day). Single-day events are byte-identical (no subEvent).

Folds in two latent bugs the design pass found in the same file:
- reveal_mode gate: JSON-LD now filters to announced sets on
  reveal-mode events (matching schedule.js/ical.js/timeline.js), so
  unannounced bands never leak into crawler-facing structured data.
- hardcoded -04:00: startDate now uses torontoUtcOffset() (ICU
  longOffset probe, DST-correct) so winter events emit -05:00, not
  a year-round EDT offset.

Additive-only and revertable as a single file; needs Google Rich
Results validation before merge (SEO-facing).

Closes #542

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@BreakableHoodie BreakableHoodie added enhancement New feature or request priority:p3 Lower priority labels Jul 18, 2026
Copilot AI review requested due to automatic review settings July 18, 2026 17:45
@BreakableHoodie BreakableHoodie added enhancement New feature or request priority:p3 Lower priority labels Jul 18, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Google requires `location` on every Event node, and the Rich Results
Test validates nested subEvents too — so a subEvent without it is a
"Missing field 'location'" failure, exactly what this PR is gated on.
Hoist the location computation above the subEvent builder and spread
it (plus the recommended eventStatus/eventAttendanceMode) onto each
day. Also correct the "local noon" comment (the probe is noon UTC =
07:00-08:00 Toronto) and pin subEvent name+location in the test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@BreakableHoodie

Copy link
Copy Markdown
Owner Author

Addressed Vera's review (commit pushed):

  • Important — subEvents now carry location (+ eventStatus/eventAttendanceMode). This was the single most likely Rich Results failure: Google requires location on every Event node including nested subEvents. Hoisted the location computation above the subEvent builder; each day now spreads the same venues array. This should make your Rich Results Test pass first try rather than surfacing a 'Missing field location' error.
  • Fixed the torontoUtcOffset comment ("local noon" → "noon UTC = 07:00–08:00 Toronto").
  • Pinned subEvent name + location in the multi-day test.
  • Venue reveal_mode gate → filed as follow-up bug(seo): event JSON-LD venues query lacks reveal_mode gate (latent info-disclosure) #635 (out of scope for this single-revert SEO PR).

Still DRAFT / held for your Google Rich Results Test on a live multi-day URL before merge. 801 backend tests green.

Copilot AI review requested due to automatic review settings July 18, 2026 17:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings July 18, 2026 20:34
@BreakableHoodie
BreakableHoodie marked this pull request as ready for review July 18, 2026 20:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

BreakableHoodie added a commit that referenced this pull request Jul 18, 2026
Turns three of the repo's prose-and-agent-guarded invariants into
deterministic regression tests (additive only, zero production code
touched):

- frontend after-midnight threshold: AFTER_MIDNIGHT_THRESHOLD_HOUR
  === 6, the derived MINUTES constant stays consistent, and
  bandUtils.js still imports the constant rather than re-encoding 6
  (#550 bug class).
- SQLite datetime format: toSqliteDateTime()'s output is space-
  separated with no T/Z/ms — the exact shape whose T-separator caused
  the SEC-F1 invite-expiry bypass. Tested through the real
  checkAuthRateLimit path since the helper isn't exported.
- JSON-LD golden snapshot of /event/[slug]: a pinned, deterministic
  event (fixed created_at so offers.validFrom is reproducible) locks
  the crawler-facing MusicEvent + BreadcrumbList shape. Any future
  edit to that block — which churned across #615/#616/#634 this week —
  now surfaces as a reviewable snapshot diff; the location field Vera
  hand-caught can no longer silently regress. Intentional changes land
  via `vitest -u` + snapshot review, never blind.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 18, 2026 20:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@BreakableHoodie
BreakableHoodie merged commit b3c0ab5 into main Jul 18, 2026
21 checks passed
@BreakableHoodie
BreakableHoodie deleted the feat/542-subevent-jsonld branch July 18, 2026 20:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request priority:p3 Lower priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(multiday): Phase 2 polish — day tabs, per-day SEO/ICS/hours [tracking]

2 participants