Skip to content

Commit 5709d7f

Browse files
committed
Preserve Meetup event times in calendar feed
1 parent ce6d396 commit 5709d7f

5 files changed

Lines changed: 69 additions & 16 deletions

File tree

.github/scripts/sync-meetup-events.mjs

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,13 @@ function eventsFromIcal(calendar) {
4949
return { events, groupName };
5050
}
5151

52-
function date(value, field, url) {
53-
const match = value?.match(/^(\d{4})(\d{2})(\d{2})/);
54-
if (!match) throw new Error(`Meetup event ${url} has no valid ${field}.`);
55-
return `${match[1]}-${match[2]}-${match[3]}`;
52+
function eventTime(value, field, url) {
53+
if (typeof value !== 'string') throw new Error(`Meetup event ${url} has no valid ${field}.`);
54+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
55+
56+
const time = new Date(value);
57+
if (Number.isNaN(time.valueOf())) throw new Error(`Meetup event ${url} has no valid ${field}.`);
58+
return time.toISOString();
5659
}
5760

5861
function eventId(event, url) {
@@ -104,12 +107,16 @@ function eventSchema(html, url) {
104107
throw new Error(`Meetup event page has no Event structured data: ${url}`);
105108
}
106109

107-
function eventMetadata(event) {
110+
export function eventMetadata(event, url) {
108111
const virtual = /(?:Online|Mixed)EventAttendanceMode$/.test(event.eventAttendanceMode ?? '');
109112
const locations = Array.isArray(event.location) ? event.location : [event.location].filter(Boolean);
110113
const place = locations.find(({ '@type': type }) => type === 'Place');
114+
const times = {
115+
startDate: eventTime(event.startDate, 'start date', url),
116+
endDate: event.endDate ? eventTime(event.endDate, 'end date', url) : undefined,
117+
};
111118

112-
if (!place) return { virtual, where: virtual ? 'Online' : '' };
119+
if (!place) return { ...times, virtual, where: virtual ? 'Online' : '' };
113120

114121
const address = place.address;
115122
const addressParts = typeof address === 'string'
@@ -119,19 +126,17 @@ function eventMetadata(event) {
119126
(parts, part) => parts.some((existing) => existing.toLowerCase().includes(part.toLowerCase())) ? parts : [...parts, part],
120127
[],
121128
).join(', ');
122-
return { virtual, where };
129+
return { ...times, virtual, where };
123130
}
124131

125132
function eventFile(event, metadata, groupName) {
126133
const url = event.URL;
127134
if (!url) throw new Error('Meetup calendar event has no URL.');
128135

129-
const startDate = date(event.DTSTART, 'start date', url);
130-
const endDate = event.DTEND ? date(event.DTEND, 'end date', url) : undefined;
131-
const endDateField = endDate && endDate !== startDate ? `endDate: ${yaml(endDate)}\n` : '';
136+
const endDateField = metadata.endDate ? `endDate: ${yaml(metadata.endDate)}\n` : '';
132137
const description = unescapeIcal(event.DESCRIPTION).trim();
133138

134-
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`;
139+
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`;
135140
}
136141

137142
async function groups() {
@@ -167,7 +172,7 @@ async function fetchGroupEvents(urlname) {
167172

168173
const page = await fetch(event.URL);
169174
if (!page.ok) throw new Error(`Meetup event page returned ${page.status}: ${event.URL}`);
170-
return { event, metadata: eventMetadata(eventSchema(await page.text(), event.URL)) };
175+
return { event, metadata: eventMetadata(eventSchema(await page.text(), event.URL), event.URL) };
171176
}));
172177

173178
return { events, groupName: calendar.groupName || urlname };

.github/scripts/sync-meetup-events.test.mjs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import assert from 'node:assert/strict';
22
import test from 'node:test';
33

4-
import { calendarSyncPlan } from './sync-meetup-events.mjs';
4+
import { calendarSyncPlan, eventMetadata } from './sync-meetup-events.mjs';
55

66
const managedEvent = {
77
file: 'content/calendar/meetup-123.md',
@@ -34,3 +34,42 @@ test('retains manual historical events when Meetup reports cancellation', () =>
3434

3535
assert.deepEqual([...plan.cancelled], []);
3636
});
37+
38+
test('preserves the start and end instants supplied by Meetup', () => {
39+
const event = {
40+
UID: 'event_456@meetup.com',
41+
DTSTART: '20260916T000000Z',
42+
DTEND: '20260916T010000Z',
43+
SUMMARY: 'Intune - Not Just Another Graph Resource',
44+
DESCRIPTION: 'Hailey presents Intune automation.',
45+
URL: 'https://www.meetup.com/example/events/456/',
46+
};
47+
const metadata = eventMetadata({
48+
eventAttendanceMode: 'https://schema.org/OnlineEventAttendanceMode',
49+
location: { '@type': 'VirtualLocation' },
50+
startDate: '2026-09-16T19:00:00-04:00',
51+
endDate: '2026-09-16T20:00:00-04:00',
52+
}, event.URL);
53+
54+
assert.deepEqual(metadata, {
55+
virtual: true,
56+
where: 'Online',
57+
startDate: '2026-09-16T23:00:00.000Z',
58+
endDate: '2026-09-17T00:00:00.000Z',
59+
});
60+
61+
const plan = calendarSyncPlan([], [{ events: [{ event, metadata }], groupName: 'Example group' }]);
62+
63+
assert.equal(plan.desired.get('content/calendar/meetup-456.md'), `---
64+
meetupEventId: "456"
65+
meetupSource: meetup
66+
startDate: "2026-09-16T23:00:00.000Z"
67+
endDate: "2026-09-17T00:00:00.000Z"
68+
title: "Intune - Not Just Another Graph Resource"
69+
externalUrl: "https://www.meetup.com/example/events/456/"
70+
virtual: true
71+
where: "Online"
72+
---
73+
Hailey presents Intune automation.
74+
`);
75+
});

.github/workflows/build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ jobs:
3434
npm install
3535
fi
3636
37+
- name: Test Meetup calendar synchronization
38+
run: node --test .github/scripts/sync-meetup-events.test.mjs
39+
3740
- name: Create data directory
3841
run: mkdir -p data
3942

content/calendar/meetup-316490583.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
---
22
meetupEventId: "316490583"
33
meetupSource: meetup
4-
startDate: "2026-09-16"
4+
startDate: "2026-09-16T18:00:00-04:00"
5+
endDate: "2026-09-16T20:00:00-04:00"
56
title: "Intune - Not Just Another Graph Resource"
67
externalUrl: "https://www.meetup.com/research-triangle-powershell-users-group/events/316490583/"
78
virtual: true

layouts/_partials/event.ics

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,25 @@
22
{{- if or (eq hugo.Environment "development") (eq hugo.Environment "debug") }}{{ warnf "Partial used: %s" templates.Current.Name }}{{ end -}}
33
{{- $timezone := partial "ical/get_timezone.ics" . -}}
44
{{- $lang := .Site.Language.Lang -}}
5+
{{- $startDate := (time.AsTime .Params.startDate $timezone) | time.In $timezone -}}
6+
{{- $allDay := not (strings.Contains .Params.startDate "T") -}}
57
{{- $endDate := "" -}}
68
{{- if .Params.endDate -}}
79
{{- $endDate = (time.AsTime .Params.endDate $timezone) | time.In $timezone -}}
810
{{- end -}}
11+
{{- if $allDay -}}
12+
{{- $endDate = (cond (ne $endDate "") $endDate $startDate).AddDate 0 0 1 -}}
13+
{{- end -}}
914
{{- with dict `` `
1015
` "description" (dict "text" .Plain "lang" $lang) `` `
1116
` "summary" (dict "text" .Title "lang" $lang) `` `
12-
` "eventStart" (dict "dateTime" ((time.AsTime .Params.startDate $timezone) | time.In $timezone) "timeZoneID" $timezone) `` `
17+
` "eventStart" (dict "dateTime" $startDate "timeZoneID" $timezone) `` `
1318
` "eventEnd" (cond (ne $endDate "") (dict "dateTime" $endDate "timeZoneID" $timezone) nil) `` `
1419
` "location" (dict "text" (or .Params.where .Params.location) "lang" $lang) `` `
1520
` "url" (or .Params.externalUrl ((.OutputFormats.Get "HTML").Permalink)) `` `
1621
` "contact" (cond (and .Params.orga .Params.orgaEmail) (dict "text" (printf "%s: %s" .Params.orga .Params.orgaEmail) "lang" $lang) nil) `` `
1722
` "color" "7C3AED" `` `
18-
` "status" (cond (.Params.cancelled | default false) "CANCELLED" "CONFIRMED") `` `
23+
` "allDay" $allDay `` `
1924
` "uid" .File.UniqueID `` `
2025
` "timestamp" .Date `` `
2126
` "created" .Date `` `

0 commit comments

Comments
 (0)