Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 0 additions & 35 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -660,40 +660,6 @@ function App() {
? `View the full schedule and set times for ${eventData.name}. Browse all artists and plan your evening.`
: null

const eventJsonLd = eventData
? JSON.stringify({
'@context': 'https://schema.org',
'@type': 'MusicEvent',
name: eventData.name,
startDate: eventData.date,
endDate: eventData.end_date || eventData.date,
url: `https://settimes.ca/event/${slug}`,
description: `Full set times and schedule for ${eventData.name}.`,
location: {
'@type': 'Place',
...(eventData.city
? { address: { '@type': 'PostalAddress', addressLocality: eventData.city, addressCountry: 'CA' } }
: { name: 'Canada', address: { '@type': 'PostalAddress', addressCountry: 'CA' } }),
},
organizer: {
'@type': 'Organization',
name: 'SetTimes',
url: 'https://settimes.ca',
sameAs: ['https://www.instagram.com/settimes.ca'],
},
...(eventData.ticket_url && {
offers: {
'@type': 'Offer',
url: eventData.ticket_url,
availability: 'https://schema.org/InStock',
},
}),
...(bands?.length && {
performer: bands.filter(b => b.name).map(b => ({ '@type': 'MusicGroup', name: b.name })),
}),
})
: null

return (
<div className="min-h-screen pb-20">
<Helmet>
Expand All @@ -708,7 +674,6 @@ function App() {
<meta name="twitter:card" content="summary" />
{eventData && <meta name="twitter:title" content={`${eventData.name} | SetTimes`} />}
{eventDescription && <meta name="twitter:description" content={eventDescription} />}
{eventJsonLd && <script type="application/ld+json">{eventJsonLd}</script>}
</Helmet>
<OfflineIndicator />
{isArchived ? (
Expand Down
22 changes: 18 additions & 4 deletions functions/event/[slug].js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,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
`SELECT id, name, date, end_date, slug, description, city, ticket_url, created_at
FROM events
WHERE slug = ? AND (is_published = 1 OR status = 'archived')`,
)
Expand Down Expand Up @@ -112,26 +112,40 @@ export async function onRequest(context) {
// isn't a real http(s) URL, which drops the offers block entirely below.
const safeTicketUrl = normalizeHttpUrl(event.ticket_url);

// created_at is stored as SQLite `YYYY-MM-DD HH:MM:SS` (see CLAUDE.md); take
// just the date part for the Offer.validFrom date literal. Guard against a
// null/malformed value so the field is omitted rather than emitting garbage.
const validFromDate =
typeof event.created_at === "string" && /^\d{4}-\d{2}-\d{2}/.test(event.created_at)
? event.created_at.slice(0, 10)
: null;

const musicEvent = {
"@context": "https://schema.org",
"@type": "MusicEvent",
name: event.name,
url,
eventStatus: "EventScheduled",
eventAttendanceMode: "OfflineEventAttendanceMode",
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` } : {}),
...(event.date ? { endDate: event.end_date || event.date } : {}),
location,
...(plainDesc ? { description: plainDesc } : {}),
organizer: {
"@type": "Organization",
name: "SetTimes",
url: CANONICAL_HOST,
sameAs: ["https://www.instagram.com/settimes.ca"],
},
...(safeTicketUrl
? {
offers: {
"@type": "Offer",
url: safeTicketUrl,
price: "0",
priceCurrency: "CAD",
availability: "https://schema.org/InStock",
...(validFromDate ? { validFrom: validFromDate } : {}),
},
}
: {}),
Expand Down
70 changes: 70 additions & 0 deletions functions/event/__tests__/slug.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,73 @@ describe("SSR /event/[slug] — MusicEvent JSON-LD ticket_url sanitization (#504
expect(musicEvent.offers.url).toBe("https://tickets.example.com/crawl");
});
});

describe("SSR /event/[slug] — MusicEvent JSON-LD enrichment (#615)", () => {
test("emits full-URL eventStatus/eventAttendanceMode and an organizer block", async () => {
const { env, rawDb } = createTestEnv();
env.PUBLIC_DATA_PUBLISH_ENABLED = "true";
const event = insertEvent(rawDb, {
name: "Enriched Event",
slug: "slug-615-enriched",
date: "2026-08-02",
});
rawDb.prepare("UPDATE events SET is_published=1 WHERE id=?").run(event.id);

const response = await onRequest(makeContext({ env, slug: "slug-615-enriched" }));
expect(response.status).toBe(200);
const html = await response.text();

const [musicEvent] = extractJsonLd(html);
expect(musicEvent.eventStatus).toBe("https://schema.org/EventScheduled");
expect(musicEvent.eventAttendanceMode).toBe("https://schema.org/OfflineEventAttendanceMode");
expect(musicEvent.organizer).toEqual({
"@type": "Organization",
name: "SetTimes",
url: "https://settimes.ca",
sameAs: ["https://www.instagram.com/settimes.ca"],
});
});

test("offers includes validFrom (from created_at) and priceCurrency, but never price", async () => {
const { env, rawDb } = createTestEnv();
env.PUBLIC_DATA_PUBLISH_ENABLED = "true";
const event = insertEvent(rawDb, {
name: "Ticketed Event",
slug: "slug-615-ticketed",
date: "2026-08-02",
});
rawDb.prepare("UPDATE events SET is_published=1 WHERE id=?").run(event.id);
rawDb.prepare("UPDATE events SET ticket_url = ? WHERE id = ?").run("https://tickets.example.com/crawl", event.id);

const seededEvent = rawDb.prepare("SELECT created_at FROM events WHERE id = ?").get(event.id);
const expectedValidFrom = seededEvent.created_at.slice(0, 10);

const response = await onRequest(makeContext({ env, slug: "slug-615-ticketed" }));
expect(response.status).toBe(200);
const html = await response.text();

const [musicEvent] = extractJsonLd(html);
expect(musicEvent.offers).toBeDefined();
expect(musicEvent.offers.priceCurrency).toBe("CAD");
expect(musicEvent.offers.validFrom).toBe(expectedValidFrom);
expect(musicEvent.offers).not.toHaveProperty("price");
});

test("omits offers (and therefore validFrom) entirely when ticket_url is absent", async () => {
const { env, rawDb } = createTestEnv();
env.PUBLIC_DATA_PUBLISH_ENABLED = "true";
const event = insertEvent(rawDb, {
name: "No Ticket Event",
slug: "slug-615-no-ticket",
date: "2026-08-02",
});
rawDb.prepare("UPDATE events SET is_published=1 WHERE id=?").run(event.id);

const response = await onRequest(makeContext({ env, slug: "slug-615-no-ticket" }));
expect(response.status).toBe(200);
const html = await response.text();

const [musicEvent] = extractJsonLd(html);
expect(musicEvent.offers).toBeUndefined();
});
});
Loading