From 25d69622b9ce21f05e719013ae075ef06f74ee29 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Thu, 17 Sep 2026 15:37:03 +0000 Subject: [PATCH] Preserve Meetup event times in calendar feed --- .github/scripts/sync-meetup-events.mjs | 29 +++++++++------ .github/scripts/sync-meetup-events.test.mjs | 41 ++++++++++++++++++++- .github/workflows/build.yml | 3 ++ content/calendar/meetup-316490583.md | 3 +- layouts/_partials/event.ics | 9 ++++- 5 files changed, 69 insertions(+), 16 deletions(-) diff --git a/.github/scripts/sync-meetup-events.mjs b/.github/scripts/sync-meetup-events.mjs index 346bf3aab..404557e48 100644 --- a/.github/scripts/sync-meetup-events.mjs +++ b/.github/scripts/sync-meetup-events.mjs @@ -49,10 +49,13 @@ function eventsFromIcal(calendar) { return { events, groupName }; } -function date(value, field, url) { - const match = value?.match(/^(\d{4})(\d{2})(\d{2})/); - if (!match) throw new Error(`Meetup event ${url} has no valid ${field}.`); - return `${match[1]}-${match[2]}-${match[3]}`; +function eventTime(value, field, url) { + if (typeof value !== 'string') throw new Error(`Meetup event ${url} has no valid ${field}.`); + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value; + + const time = new Date(value); + if (Number.isNaN(time.valueOf())) throw new Error(`Meetup event ${url} has no valid ${field}.`); + return time.toISOString(); } function eventId(event, url) { @@ -104,12 +107,16 @@ function eventSchema(html, url) { throw new Error(`Meetup event page has no Event structured data: ${url}`); } -function eventMetadata(event) { +export function eventMetadata(event, url) { const virtual = /(?:Online|Mixed)EventAttendanceMode$/.test(event.eventAttendanceMode ?? ''); const locations = Array.isArray(event.location) ? event.location : [event.location].filter(Boolean); const place = locations.find(({ '@type': type }) => type === 'Place'); + const times = { + startDate: eventTime(event.startDate, 'start date', url), + endDate: event.endDate ? eventTime(event.endDate, 'end date', url) : undefined, + }; - if (!place) return { virtual, where: virtual ? 'Online' : '' }; + if (!place) return { ...times, virtual, where: virtual ? 'Online' : '' }; const address = place.address; const addressParts = typeof address === 'string' @@ -119,19 +126,17 @@ function eventMetadata(event) { (parts, part) => parts.some((existing) => existing.toLowerCase().includes(part.toLowerCase())) ? parts : [...parts, part], [], ).join(', '); - return { virtual, where }; + return { ...times, virtual, where }; } function eventFile(event, metadata, groupName) { const url = event.URL; if (!url) throw new Error('Meetup calendar event has no URL.'); - const startDate = date(event.DTSTART, 'start date', url); - const endDate = event.DTEND ? date(event.DTEND, 'end date', url) : undefined; - const endDateField = endDate && endDate !== startDate ? `endDate: ${yaml(endDate)}\n` : ''; + const endDateField = metadata.endDate ? `endDate: ${yaml(metadata.endDate)}\n` : ''; const description = unescapeIcal(event.DESCRIPTION).trim(); - return `---\nmeetupEventId: ${yaml(eventId(event, url))}\nmeetupSource: meetup\nstartDate: ${yaml(startDate)}\n${endDateField}title: ${yaml(unescapeIcal(event.SUMMARY))}\nexternalUrl: ${yaml(url)}\nvirtual: ${metadata.virtual}\nwhere: ${yaml(metadata.where || groupName)}\n---\n${description}\n`; + return `---\nmeetupEventId: ${yaml(eventId(event, url))}\nmeetupSource: meetup\nstartDate: ${yaml(metadata.startDate)}\n${endDateField}title: ${yaml(unescapeIcal(event.SUMMARY))}\nexternalUrl: ${yaml(url)}\nvirtual: ${metadata.virtual}\nwhere: ${yaml(metadata.where || groupName)}\n---\n${description}\n`; } async function groups() { @@ -167,7 +172,7 @@ async function fetchGroupEvents(urlname) { const page = await fetch(event.URL); if (!page.ok) throw new Error(`Meetup event page returned ${page.status}: ${event.URL}`); - return { event, metadata: eventMetadata(eventSchema(await page.text(), event.URL)) }; + return { event, metadata: eventMetadata(eventSchema(await page.text(), event.URL), event.URL) }; })); return { events, groupName: calendar.groupName || urlname }; diff --git a/.github/scripts/sync-meetup-events.test.mjs b/.github/scripts/sync-meetup-events.test.mjs index c6f392c98..f8dc63a07 100644 --- a/.github/scripts/sync-meetup-events.test.mjs +++ b/.github/scripts/sync-meetup-events.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { calendarSyncPlan } from './sync-meetup-events.mjs'; +import { calendarSyncPlan, eventMetadata } from './sync-meetup-events.mjs'; const managedEvent = { file: 'content/calendar/meetup-123.md', @@ -34,3 +34,42 @@ test('retains manual historical events when Meetup reports cancellation', () => assert.deepEqual([...plan.cancelled], []); }); + +test('preserves the start and end instants supplied by Meetup', () => { + const event = { + UID: 'event_456@meetup.com', + DTSTART: '20260916T000000Z', + DTEND: '20260916T010000Z', + SUMMARY: 'Intune - Not Just Another Graph Resource', + DESCRIPTION: 'Hailey presents Intune automation.', + URL: 'https://www.meetup.com/example/events/456/', + }; + const metadata = eventMetadata({ + eventAttendanceMode: 'https://schema.org/OnlineEventAttendanceMode', + location: { '@type': 'VirtualLocation' }, + startDate: '2026-09-16T19:00:00-04:00', + endDate: '2026-09-16T20:00:00-04:00', + }, event.URL); + + assert.deepEqual(metadata, { + virtual: true, + where: 'Online', + startDate: '2026-09-16T23:00:00.000Z', + endDate: '2026-09-17T00:00:00.000Z', + }); + + const plan = calendarSyncPlan([], [{ events: [{ event, metadata }], groupName: 'Example group' }]); + + assert.equal(plan.desired.get('content/calendar/meetup-456.md'), `--- +meetupEventId: "456" +meetupSource: meetup +startDate: "2026-09-16T23:00:00.000Z" +endDate: "2026-09-17T00:00:00.000Z" +title: "Intune - Not Just Another Graph Resource" +externalUrl: "https://www.meetup.com/example/events/456/" +virtual: true +where: "Online" +--- +Hailey presents Intune automation. +`); +}); diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a963dbf0d..3fb0c20e6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,6 +34,9 @@ jobs: npm install fi + - name: Test Meetup calendar synchronization + run: node --test .github/scripts/sync-meetup-events.test.mjs + - name: Create data directory run: mkdir -p data diff --git a/content/calendar/meetup-316490583.md b/content/calendar/meetup-316490583.md index e88a9e9df..3c53dd307 100644 --- a/content/calendar/meetup-316490583.md +++ b/content/calendar/meetup-316490583.md @@ -1,7 +1,8 @@ --- meetupEventId: "316490583" meetupSource: meetup -startDate: "2026-09-16" +startDate: "2026-09-16T18:00:00-04:00" +endDate: "2026-09-16T20:00:00-04:00" title: "Intune - Not Just Another Graph Resource" externalUrl: "https://www.meetup.com/research-triangle-powershell-users-group/events/316490583/" virtual: true diff --git a/layouts/_partials/event.ics b/layouts/_partials/event.ics index a99dad901..d0a1ca7bc 100644 --- a/layouts/_partials/event.ics +++ b/layouts/_partials/event.ics @@ -2,20 +2,25 @@ {{- if or (eq hugo.Environment "development") (eq hugo.Environment "debug") }}{{ warnf "Partial used: %s" templates.Current.Name }}{{ end -}} {{- $timezone := partial "ical/get_timezone.ics" . -}} {{- $lang := .Site.Language.Lang -}} +{{- $startDate := (time.AsTime .Params.startDate $timezone) | time.In $timezone -}} +{{- $allDay := not (strings.Contains .Params.startDate "T") -}} {{- $endDate := "" -}} {{- if .Params.endDate -}} {{- $endDate = (time.AsTime .Params.endDate $timezone) | time.In $timezone -}} {{- end -}} +{{- if $allDay -}} + {{- $endDate = (cond (ne $endDate "") $endDate $startDate).AddDate 0 0 1 -}} +{{- end -}} {{- with dict `` ` ` "description" (dict "text" .Plain "lang" $lang) `` ` ` "summary" (dict "text" .Title "lang" $lang) `` ` - ` "eventStart" (dict "dateTime" ((time.AsTime .Params.startDate $timezone) | time.In $timezone) "timeZoneID" $timezone) `` ` + ` "eventStart" (dict "dateTime" $startDate "timeZoneID" $timezone) `` ` ` "eventEnd" (cond (ne $endDate "") (dict "dateTime" $endDate "timeZoneID" $timezone) nil) `` ` ` "location" (dict "text" (or .Params.where .Params.location) "lang" $lang) `` ` ` "url" (or .Params.externalUrl ((.OutputFormats.Get "HTML").Permalink)) `` ` ` "contact" (cond (and .Params.orga .Params.orgaEmail) (dict "text" (printf "%s: %s" .Params.orga .Params.orgaEmail) "lang" $lang) nil) `` ` ` "color" "7C3AED" `` ` - ` "status" (cond (.Params.cancelled | default false) "CANCELLED" "CONFIRMED") `` ` + ` "allDay" $allDay `` ` ` "uid" .File.UniqueID `` ` ` "timestamp" .Date `` ` ` "created" .Date `` `