From 45b030653576db98482b34301f5f3b6814212d88 Mon Sep 17 00:00:00 2001 From: Dusan Jovanovic Date: Tue, 18 Aug 2026 10:20:46 +0200 Subject: [PATCH] feat(meetup): support event hosts, online venues and UTC event dates Three gaps blocked an adopter whose sessions are online, hosted by a named organizer, and dated in true UTC (GlobalCpp). eventHosts. CreateEventInput.eventHosts is [Int!] -- Meetup member IDs -- and was never sent, so a draft always fell back to the creating organizer. Config gains `hosts` (name -> member ID) and `defaultHosts`, and both create commands take `--host "Name,Other"`. Unknown names fail at config load or resolve time rather than silently producing a hostless event. When nothing resolves, the key is omitted entirely and Meetup's own default applies, so existing adopters see no change. Online venues. Meetup has no venue record for an online event, so list-venues never returns one and there was no way to express it: the venue map was Record and resolveVenueId rejected non-numeric values. venueId is a String on the API, so the map now accepts the literal "online" alongside numeric IDs. Timezones. Meetup reads startDateTime as wall time in the group's own timezone and accepts no offset, but the builder passed date.toISOString().slice(0,19) -- the UTC clock reading. That is correct only if the source stores local time with a nominal Z, which is what cppserbia does. An adopter storing real instants got every event shifted by the zone's offset: 2026-08-22T16:00:00Z became a 16:00 Chicago event instead of 11:00. Rather than change the reading for everyone, the interpretation is now explicit. With `timezone` set, the date is a true instant converted to wall time in that zone (DST resolved per date, via Intl). Without it, the previous behaviour stands, so cppserbia is unaffected -- its tests still produce the same payload byte for byte. Co-Authored-By: Claude Opus 5 --- packages/meetup/README.md | 61 +++++++++++- packages/meetup/src/cli.ts | 27 +++++- packages/meetup/src/config.test.ts | 91 ++++++++++++++++++ packages/meetup/src/config.ts | 60 ++++++++++++ packages/meetup/src/create-event.ts | 18 +++- packages/meetup/src/index.ts | 7 +- packages/meetup/src/payload.test.ts | 142 +++++++++++++++++++++++++++- packages/meetup/src/payload.ts | 71 +++++++++++++- packages/meetup/src/venues.ts | 21 +++- 9 files changed, 478 insertions(+), 20 deletions(-) create mode 100644 packages/meetup/src/config.test.ts diff --git a/packages/meetup/README.md b/packages/meetup/README.md index 25547da..6ae3515 100644 --- a/packages/meetup/README.md +++ b/packages/meetup/README.md @@ -44,14 +44,68 @@ Drop a `coopkit.config.json` at the repo root: { "meetup": { "groupUrlname": "your-group-slug", + "timezone": "America/Chicago", "venues": { "Venue Name, City, cc": 12345678, - "online": 23456789 - } + "online": "online" + }, + "hosts": { + "Rob Douglas": 13296813 + }, + "defaultHosts": ["Rob Douglas"] } } ``` +| Key | Required | Purpose | +| --- | --- | --- | +| `groupUrlname` | yes | Meetup group slug the drafts are created in. | +| `venues` | yes | Maps frontmatter venue keys to Meetup venue IDs. | +| `timezone` | no | IANA zone of the group. **Set this if your event dates are true UTC** — see below. | +| `hosts` | no | Named Meetup member IDs, so you can refer to hosts by name. | +| `defaultHosts` | no | Names from `hosts` used when an event names none. | + +### Online events + +Meetup has no venue record for an online event, so `list-venues` will never +return one. Use the literal string `"online"` as the venue ID: + +```json +"venues": { "online": "online" } +``` + +### Timezones — read this if your dates are UTC + +Meetup interprets `startDateTime` as **wall time in the group's own timezone**; +the API accepts no offset. So `event.date` has two possible readings, and the +`timezone` key picks which one you mean: + +- **`timezone` set** — `event.date` is a true instant and gets converted to wall + time in that zone. `date: 2026-08-22T16:00:00Z` with + `"timezone": "America/Chicago"` creates an **11:00** event. DST is handled per + date. +- **`timezone` omitted** — `event.date`'s UTC clock reading is used verbatim, so + `2026-08-14T18:00:00Z` creates an **18:00** local event. This is the original + behaviour and stays the default, because some adopters store local wall time + with a nominal `Z` suffix. + +Omitting `timezone` when your dates really are UTC silently shifts every event +by the zone's offset, so set it whenever your source stores real instants. + +### Hosts + +`eventHosts` takes Meetup **member IDs**, and the account must be a member of +the group. Name them in `hosts` and select them per run with `--host`, or set +`defaultHosts` to apply the same host every time. When no host is resolved the +key is omitted from the payload entirely and Meetup falls back to the creating +organizer. + +To find a member ID, query the group's organizer: + +```graphql +query { groupByUrlname(urlname: "your-group-slug") { organizer { id name } } } +``` + Discover venue IDs: ```bash @@ -67,6 +121,9 @@ bunx coopkit-meetup list-venues --group your-group-slug ```bash bunx coopkit-meetup create --dry-run events/2026-04-29-My-Event.md bunx coopkit-meetup create events/2026-04-29-My-Event.md + +# override the configured defaultHosts for one run +bunx coopkit-meetup create --host "Rob Douglas" events/2026-04-29-My-Event.md ``` Idempotent. Writes `event_url` + `event_id` back into the file's frontmatter on success. diff --git a/packages/meetup/src/cli.ts b/packages/meetup/src/cli.ts index 8d408a4..3bc339c 100644 --- a/packages/meetup/src/cli.ts +++ b/packages/meetup/src/cli.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import type { NormalizedEvent } from "@coopkit/core"; import { defineCommand, runMain } from "citty"; -import { loadMeetupConfig } from "./config.js"; +import { loadMeetupConfig, resolveHostIds } from "./config.js"; import { createMeetupDraft, createMeetupDraftFromFile } from "./create-event.js"; import { formatVenueKey, listVenues } from "./list-venues.js"; import { loadEnvFile } from "./load-env.js"; @@ -29,6 +29,15 @@ function writeResultFile( fs.writeFileSync(outputPath, `${JSON.stringify(payload)}\n`); } +/** Split a `--host "A,B"` value into trimmed names. */ +function parseHostArg(value: unknown): string[] | undefined { + if (typeof value !== "string" || value.trim() === "") return undefined; + return value + .split(",") + .map((s) => s.trim()) + .filter((s) => s !== ""); +} + const createCmd = defineCommand({ meta: { name: "create", @@ -50,6 +59,11 @@ const createCmd = defineCommand({ type: "string", description: "Path to coopkit.config.json (default: ./coopkit.config.json).", }, + host: { + type: "string", + description: + "Host name(s) from meetup.hosts, comma-separated. Defaults to meetup.defaultHosts.", + }, output: { type: "string", description: @@ -59,10 +73,13 @@ const createCmd = defineCommand({ async run({ args }) { loadEnvFile(); const config = loadMeetupConfig(args.config); + const hosts = resolveHostIds(config, parseHostArg(args.host)); const result = await createMeetupDraftFromFile({ eventFile: args.eventFile, groupUrlname: config.groupUrlname, venues: config.venues, + ...(config.timezone !== undefined ? { timezone: config.timezone } : {}), + ...(hosts.length > 0 ? { hosts } : {}), dryRun: Boolean(args["dry-run"]), }); writeResultFile(args.output, result); @@ -115,6 +132,11 @@ const createFromJsonCmd = defineCommand({ type: "string", description: "Path to coopkit.config.json (default: ./coopkit.config.json).", }, + host: { + type: "string", + description: + "Host name(s) from meetup.hosts, comma-separated. Defaults to meetup.defaultHosts.", + }, output: { type: "string", description: @@ -126,10 +148,13 @@ const createFromJsonCmd = defineCommand({ const config = loadMeetupConfig(args.config); const raw = args.file ? fs.readFileSync(args.file, "utf8") : readStdin(); const event = parseNormalizedEvent(raw); + const hosts = resolveHostIds(config, parseHostArg(args.host)); const result = await createMeetupDraft({ event, groupUrlname: config.groupUrlname, venues: config.venues, + ...(config.timezone !== undefined ? { timezone: config.timezone } : {}), + ...(hosts.length > 0 ? { hosts } : {}), dryRun: Boolean(args["dry-run"]), }); writeResultFile(args.output, result); diff --git a/packages/meetup/src/config.test.ts b/packages/meetup/src/config.test.ts new file mode 100644 index 0000000..d08bb00 --- /dev/null +++ b/packages/meetup/src/config.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { type MeetupConfig, loadMeetupConfig, resolveHostIds } from "./config.js"; + +function writeConfig(contents: unknown): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coopkit-config-")); + const file = path.join(dir, "coopkit.config.json"); + fs.writeFileSync(file, typeof contents === "string" ? contents : JSON.stringify(contents)); + return file; +} + +const BASE = { + groupUrlname: "chicago-c-cpp-users-group", + venues: { online: "online" }, +}; + +describe("loadMeetupConfig hosts/timezone", () => { + it("accepts a config with neither hosts nor timezone", () => { + const cfg = loadMeetupConfig(writeConfig({ meetup: BASE })); + expect(cfg.timezone).toBeUndefined(); + expect(cfg.hosts).toBeUndefined(); + }); + + it("loads timezone and hosts", () => { + const cfg = loadMeetupConfig( + writeConfig({ + meetup: { ...BASE, timezone: "America/Chicago", hosts: { "Rob Douglas": 13296813 } }, + }) + ); + expect(cfg.timezone).toBe("America/Chicago"); + expect(cfg.hosts).toEqual({ "Rob Douglas": 13296813 }); + }); + + it("rejects a non-string timezone", () => { + expect(() => loadMeetupConfig(writeConfig({ meetup: { ...BASE, timezone: 5 } }))).toThrow( + /meetup.timezone must be an IANA timezone string/ + ); + }); + + it("rejects a non-integer member ID", () => { + expect(() => + loadMeetupConfig(writeConfig({ meetup: { ...BASE, hosts: { Rob: "13296813" } } })) + ).toThrow(/positive integer Meetup member ID/); + }); + + it("rejects a defaultHosts name that is not in hosts", () => { + expect(() => + loadMeetupConfig( + writeConfig({ meetup: { ...BASE, hosts: { Rob: 13296813 }, defaultHosts: ["Bob"] } }) + ) + ).toThrow(/defaultHosts entry "Bob" is not a key of meetup.hosts/); + }); + + it("rejects defaultHosts when hosts is absent entirely", () => { + expect(() => + loadMeetupConfig(writeConfig({ meetup: { ...BASE, defaultHosts: ["Rob"] } })) + ).toThrow(/is not a key of meetup.hosts/); + }); +}); + +describe("resolveHostIds", () => { + const cfg: MeetupConfig = { + ...BASE, + venues: { online: "online" }, + hosts: { "Rob Douglas": 13296813, "Alex Smith": 256192100 }, + defaultHosts: ["Rob Douglas"], + }; + + it("falls back to defaultHosts when no names are given", () => { + expect(resolveHostIds(cfg)).toEqual([13296813]); + }); + + it("falls back to defaultHosts for an empty name list", () => { + expect(resolveHostIds(cfg, [])).toEqual([13296813]); + }); + + it("resolves explicit names in order, overriding the default", () => { + expect(resolveHostIds(cfg, ["Alex Smith", "Rob Douglas"])).toEqual([256192100, 13296813]); + }); + + it("returns [] when neither names nor defaultHosts exist", () => { + expect(resolveHostIds({ ...BASE, venues: { online: "online" } })).toEqual([]); + }); + + it("throws on an unknown name and lists the known ones", () => { + expect(() => resolveHostIds(cfg, ["Nobody"])).toThrow(/Unknown host "Nobody"/); + expect(() => resolveHostIds(cfg, ["Nobody"])).toThrow(/Known hosts: "Rob Douglas"/); + }); +}); diff --git a/packages/meetup/src/config.ts b/packages/meetup/src/config.ts index fe98701..103656e 100644 --- a/packages/meetup/src/config.ts +++ b/packages/meetup/src/config.ts @@ -5,6 +5,16 @@ import type { VenueMap } from "./venues.js"; export interface MeetupConfig { groupUrlname: string; venues: VenueMap; + /** + * IANA timezone of the group (e.g. "America/Chicago"). Set this when your + * event dates are true UTC instants; see `BuildPayloadInput.timezone` for + * why omitting it means "the date is already local wall time". + */ + timezone?: string; + /** Named Meetup member IDs, so config and CLI can refer to people by name. */ + hosts?: Record; + /** Names from `hosts` to list as event hosts when an event names none itself. */ + defaultHosts?: string[]; } export interface CoopkitConfig { @@ -45,5 +55,55 @@ export function loadMeetupConfig(configPath?: string): MeetupConfig { if (!meetup.venues || typeof meetup.venues !== "object") { throw new Error(`${resolved}: meetup.venues must be an object mapping venue names to IDs.`); } + if (meetup.timezone !== undefined && typeof meetup.timezone !== "string") { + throw new Error(`${resolved}: meetup.timezone must be an IANA timezone string.`); + } + if (meetup.hosts !== undefined) { + if (typeof meetup.hosts !== "object" || meetup.hosts === null) { + throw new Error(`${resolved}: meetup.hosts must be an object mapping names to member IDs.`); + } + for (const [name, id] of Object.entries(meetup.hosts)) { + if (typeof id !== "number" || !Number.isInteger(id) || id <= 0) { + throw new Error( + `${resolved}: meetup.hosts[${JSON.stringify(name)}] must be a positive integer Meetup member ID (got ${JSON.stringify(id)}).` + ); + } + } + } + if (meetup.defaultHosts !== undefined) { + if (!Array.isArray(meetup.defaultHosts)) { + throw new Error( + `${resolved}: meetup.defaultHosts must be an array of names from meetup.hosts.` + ); + } + // Fail here rather than at call time: a typo'd name would otherwise + // silently create the event with no host at all. + for (const name of meetup.defaultHosts) { + if (typeof name !== "string" || !meetup.hosts || !(name in meetup.hosts)) { + throw new Error( + `${resolved}: meetup.defaultHosts entry ${JSON.stringify(name)} is not a key of meetup.hosts.` + ); + } + } + } return meetup; } + +/** + * Resolve host names to Meetup member IDs, falling back to `defaultHosts`. + * Returns [] when neither is configured, which leaves `eventHosts` off the + * payload entirely (Meetup then defaults to the creating organizer). + */ +export function resolveHostIds(config: MeetupConfig, names?: string[]): number[] { + const wanted = names && names.length > 0 ? names : (config.defaultHosts ?? []); + return wanted.map((name) => { + const id = config.hosts?.[name]; + if (id === undefined) { + const known = Object.keys(config.hosts ?? {}); + const suffix = + known.length > 0 ? ` Known hosts: ${known.map((k) => JSON.stringify(k)).join(", ")}.` : ""; + throw new Error(`Unknown host ${JSON.stringify(name)}. Add it to meetup.hosts.${suffix}`); + } + return id; + }); +} diff --git a/packages/meetup/src/create-event.ts b/packages/meetup/src/create-event.ts index 00252b7..e08da4b 100644 --- a/packages/meetup/src/create-event.ts +++ b/packages/meetup/src/create-event.ts @@ -4,19 +4,19 @@ import type { EventFrontmatter, NormalizedEvent } from "@coopkit/core"; import { frontmatterToNormalizedEvent } from "@coopkit/core"; import matter from "gray-matter"; import { - createMeetupClient, MeetupApiError, type MeetupClient, type MeetupCredentials, + createMeetupClient, } from "./client.js"; import { - buildCreateEventPayload, type CreateEventPayload, + buildCreateEventPayload, detectContentType, isEventAlreadyCreated, stripLeadingHeading, } from "./payload.js"; -import { resolveVenueId, type VenueMap } from "./venues.js"; +import { type VenueMap, resolveVenueId } from "./venues.js"; const CREATE_EVENT_MUTATION = ` mutation CreateDraftEvent($input: CreateEventInput!) { @@ -145,6 +145,10 @@ export interface CreateMeetupDraftOptions { event: NormalizedEvent; groupUrlname: string; venues: VenueMap; + /** IANA timezone of the group. See `BuildPayloadInput.timezone`. */ + timezone?: string; + /** Meetup member IDs to list as event hosts. */ + hosts?: number[]; dryRun?: boolean; credentials?: MeetupCredentials; log?: (message: string) => void; @@ -178,6 +182,8 @@ export async function createMeetupDraft( event: options.event, groupUrlname: options.groupUrlname, resolveVenue: (name) => resolveVenueId(name, options.venues), + ...(options.timezone !== undefined ? { timezone: options.timezone } : {}), + ...(options.hosts !== undefined ? { hosts: options.hosts } : {}), }); if (options.dryRun) { @@ -217,6 +223,10 @@ export interface CreateMeetupDraftFromFileOptions { eventFile: string; groupUrlname: string; venues: VenueMap; + /** IANA timezone of the group. See `BuildPayloadInput.timezone`. */ + timezone?: string; + /** Meetup member IDs to list as event hosts. */ + hosts?: number[]; dryRun?: boolean; credentials?: MeetupCredentials; log?: (message: string) => void; @@ -260,6 +270,8 @@ export async function createMeetupDraftFromFile( event, groupUrlname: options.groupUrlname, venues: options.venues, + ...(options.timezone !== undefined ? { timezone: options.timezone } : {}), + ...(options.hosts !== undefined ? { hosts: options.hosts } : {}), dryRun: options.dryRun, credentials: options.credentials, log, diff --git a/packages/meetup/src/index.ts b/packages/meetup/src/index.ts index b62d8bc..b98ac5c 100644 --- a/packages/meetup/src/index.ts +++ b/packages/meetup/src/index.ts @@ -5,8 +5,8 @@ export type { MeetupGraphQLError, } from "./client.js"; -export { resolveVenueId } from "./venues.js"; -export type { VenueMap } from "./venues.js"; +export { ONLINE_VENUE_ID, resolveVenueId } from "./venues.js"; +export type { VenueId, VenueMap } from "./venues.js"; export { buildCreateEventPayload, @@ -14,6 +14,7 @@ export { detectContentType, isEventAlreadyCreated, stripLeadingHeading, + wallTimeInZone, } from "./payload.js"; export type { BuildPayloadInput, CreateEventPayload } from "./payload.js"; @@ -28,5 +29,5 @@ export type { export { fetchAllVenues, formatVenueKey, listVenues } from "./list-venues.js"; export type { ListVenuesOptions, MeetupVenue } from "./list-venues.js"; -export { DEFAULT_CONFIG_FILENAME, loadMeetupConfig } from "./config.js"; +export { DEFAULT_CONFIG_FILENAME, loadMeetupConfig, resolveHostIds } from "./config.js"; export type { CoopkitConfig, MeetupConfig } from "./config.js"; diff --git a/packages/meetup/src/payload.test.ts b/packages/meetup/src/payload.test.ts index 3605b1f..42609e3 100644 --- a/packages/meetup/src/payload.test.ts +++ b/packages/meetup/src/payload.test.ts @@ -5,8 +5,9 @@ import { detectContentType, isEventAlreadyCreated, stripLeadingHeading, + wallTimeInZone, } from "./payload.js"; -import { resolveVenueId } from "./venues.js"; +import { ONLINE_VENUE_ID, resolveVenueId } from "./venues.js"; describe("stripLeadingHeading", () => { it("removes a leading '# Title' line", () => { @@ -212,3 +213,142 @@ describe("detectContentType", () => { expect(detectContentType("application/octet-stream")).toBe("JPEG"); }); }); + +// A true-UTC instant: 16:00Z is 11:00 in Chicago (CDT, UTC-5) on this date. +const UTC_EVENT: NormalizedEvent = { + id: "2026-08-22-andy-soffer", + title: "Refactoring C++ Today", + date: new Date("2026-08-22T16:00:00Z"), + duration: "PT1H", + venueKey: "online", + description: "A pragmatic survey.", +}; + +describe("wallTimeInZone", () => { + it("converts a UTC instant to wall time in the target zone", () => { + expect(wallTimeInZone(new Date("2026-08-22T16:00:00Z"), "America/Chicago")).toBe( + "2026-08-22T11:00:00" + ); + }); + + it("handles a zone ahead of UTC", () => { + expect(wallTimeInZone(new Date("2026-08-14T16:00:00Z"), "Europe/Belgrade")).toBe( + "2026-08-14T18:00:00" + ); + }); + + it("rolls the date back when the local day differs from the UTC day", () => { + // 02:00Z on the 23rd is still 21:00 on the 22nd in Chicago. + expect(wallTimeInZone(new Date("2026-08-23T02:00:00Z"), "America/Chicago")).toBe( + "2026-08-22T21:00:00" + ); + }); + + it("renders midnight as 00, not 24", () => { + expect(wallTimeInZone(new Date("2026-08-22T05:00:00Z"), "America/Chicago")).toBe( + "2026-08-22T00:00:00" + ); + }); + + it("respects the zone's DST offset for the given date", () => { + // Chicago is UTC-6 (CST) in January, UTC-5 (CDT) in August. + expect(wallTimeInZone(new Date("2026-01-17T17:00:00Z"), "America/Chicago")).toBe( + "2026-01-17T11:00:00" + ); + }); + + it("throws a helpful error on an invalid timezone", () => { + expect(() => wallTimeInZone(new Date(), "Not/AZone")).toThrow(/Invalid timezone/); + }); +}); + +describe("buildCreateEventPayload timezone handling", () => { + const resolveVenue = () => ONLINE_VENUE_ID; + + it("treats the date as group-local wall time when no timezone is set (legacy)", () => { + const payload = buildCreateEventPayload({ + event: UTC_EVENT, + groupUrlname: "chicago-c-cpp-users-group", + resolveVenue, + }); + expect(payload.startDateTime).toBe("2026-08-22T16:00:00"); + }); + + it("converts a true-UTC date into group-local wall time when a timezone is set", () => { + const payload = buildCreateEventPayload({ + event: UTC_EVENT, + groupUrlname: "chicago-c-cpp-users-group", + resolveVenue, + timezone: "America/Chicago", + }); + expect(payload.startDateTime).toBe("2026-08-22T11:00:00"); + }); +}); + +describe("buildCreateEventPayload hosts", () => { + const resolveVenue = () => ONLINE_VENUE_ID; + + it("omits eventHosts entirely when no hosts are given", () => { + const payload = buildCreateEventPayload({ + event: UTC_EVENT, + groupUrlname: "g", + resolveVenue, + }); + expect("eventHosts" in payload).toBe(false); + }); + + it("omits eventHosts when the list is empty", () => { + const payload = buildCreateEventPayload({ + event: UTC_EVENT, + groupUrlname: "g", + resolveVenue, + hosts: [], + }); + expect("eventHosts" in payload).toBe(false); + }); + + it("passes member IDs through as eventHosts", () => { + const payload = buildCreateEventPayload({ + event: UTC_EVENT, + groupUrlname: "g", + resolveVenue, + hosts: [13296813, 256192100], + }); + expect(payload.eventHosts).toEqual([13296813, 256192100]); + }); + + it("does not alias the caller's array", () => { + const hosts = [13296813]; + const payload = buildCreateEventPayload({ + event: UTC_EVENT, + groupUrlname: "g", + resolveVenue, + hosts, + }); + hosts.push(999); + expect(payload.eventHosts).toEqual([13296813]); + }); +}); + +describe("resolveVenueId online support", () => { + it("returns the online sentinel unchanged", () => { + expect(resolveVenueId("online", { online: ONLINE_VENUE_ID })).toBe("online"); + }); + + it("serializes the sentinel into venueId", () => { + const payload = buildCreateEventPayload({ + event: UTC_EVENT, + groupUrlname: "g", + resolveVenue: (name) => resolveVenueId(name, { online: ONLINE_VENUE_ID }), + }); + expect(payload.venueId).toBe("online"); + }); + + it("still rejects a placeholder numeric ID", () => { + expect(() => resolveVenueId("bad", { bad: 0 })).toThrow(/placeholder ID/); + }); + + it("still resolves a real numeric ID", () => { + expect(resolveVenueId("Franklin Tap", { "Franklin Tap": 6500002 })).toBe(6500002); + }); +}); diff --git a/packages/meetup/src/payload.ts b/packages/meetup/src/payload.ts index b48e529..c87c631 100644 --- a/packages/meetup/src/payload.ts +++ b/packages/meetup/src/payload.ts @@ -1,5 +1,5 @@ import type { NormalizedEvent } from "@coopkit/core"; -import { resolveVenueId, type VenueMap } from "./venues.js"; +import { type VenueId, type VenueMap, resolveVenueId } from "./venues.js"; export interface CreateEventPayload { groupUrlname: string; @@ -9,12 +9,35 @@ export interface CreateEventPayload { duration: string; venueId: string; publishStatus: "DRAFT"; + /** Meetup member IDs to list as event hosts. Omitted when none are configured. */ + eventHosts?: number[]; } export interface BuildPayloadInput { event: NormalizedEvent; groupUrlname: string; - resolveVenue: (name: string) => number; + resolveVenue: (name: string) => VenueId; + /** + * IANA timezone of the Meetup group (e.g. "America/Chicago"). + * + * Meetup interprets `startDateTime` as *wall time in the group's own + * timezone* — it accepts no offset. That leaves two possible readings of + * `event.date`, and the two adopter conventions in the wild disagree: + * + * - **timezone set** — `event.date` is a true instant, and it is converted + * to wall time in this zone. Use this when your source stores real UTC + * (e.g. `date: 2026-08-22T16:00:00Z` meaning 11:00 in Chicago). + * - **timezone omitted** — `event.date`'s UTC clock reading is used + * verbatim, so `2026-08-14T18:00:00Z` creates an 18:00 local event. This + * is the original behaviour, kept as the default for adopters whose + * frontmatter already stores local wall time with a nominal `Z`. + * + * Getting this wrong shifts the event by the zone's offset, so set it + * whenever your dates are genuinely UTC. + */ + timezone?: string; + /** Meetup member IDs to list as event hosts. */ + hosts?: number[]; } const PLACEHOLDER_RE = /^<.*>$/; @@ -39,6 +62,36 @@ function naiveIsoString(date: Date): string { return date.toISOString().slice(0, 19); } +/** + * Render an instant as `YYYY-MM-DDTHH:mm:ss` wall time in `timeZone`, the + * format Meetup's `startDateTime` expects. `hourCycle: "h23"` keeps midnight + * as `00` rather than `24`. + */ +export function wallTimeInZone(date: Date, timeZone: string): string { + let parts: Intl.DateTimeFormatPart[]; + try { + parts = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).formatToParts(date); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid timezone ${JSON.stringify(timeZone)}: ${msg}`); + } + + const at: Partial> = {}; + for (const part of parts) { + if (part.type !== "literal") at[part.type] = part.value; + } + return `${at.year}-${at.month}-${at.day}T${at.hour}:${at.minute}:${at.second}`; +} + export function buildCreateEventPayload(input: BuildPayloadInput): CreateEventPayload { const { event, groupUrlname, resolveVenue } = input; @@ -55,15 +108,21 @@ export function buildCreateEventPayload(input: BuildPayloadInput): CreateEventPa throw new Error("Event is missing `duration`."); } - return { + const payload: CreateEventPayload = { groupUrlname, title: event.title, description: event.description ?? "", - startDateTime: naiveIsoString(event.date), + startDateTime: input.timezone + ? wallTimeInZone(event.date, input.timezone) + : naiveIsoString(event.date), duration: event.duration, venueId: String(resolveVenue(event.venueKey)), publishStatus: "DRAFT", }; + if (input.hosts && input.hosts.length > 0) { + payload.eventHosts = [...input.hosts]; + } + return payload; } /** @@ -73,12 +132,14 @@ export function buildCreateEventPayload(input: BuildPayloadInput): CreateEventPa export function buildCreateEventPayloadWithMap( event: NormalizedEvent, groupUrlname: string, - venues: VenueMap + venues: VenueMap, + options: { timezone?: string; hosts?: number[] } = {} ): CreateEventPayload { return buildCreateEventPayload({ event, groupUrlname, resolveVenue: (name) => resolveVenueId(name, venues), + ...options, }); } diff --git a/packages/meetup/src/venues.ts b/packages/meetup/src/venues.ts index d6f8e4a..a907c90 100644 --- a/packages/meetup/src/venues.ts +++ b/packages/meetup/src/venues.ts @@ -1,13 +1,22 @@ /** - * Map from event frontmatter `venues:` strings to numeric Meetup venue IDs. + * Map from event frontmatter `venues:` strings to Meetup venue IDs. * * Keys MUST be the exact string in event frontmatter — including quotes, * diacritics, and any ", City, cc" suffix. Use `coopkit-meetup list-venues` * to discover the right keys for your group. + * + * Values are numeric Meetup venue IDs, or the literal `"online"` for online + * events. Meetup has no venue record for an online event: `list-venues` will + * never return one, and `CreateEventInput.venueId` is a `String` that takes + * the sentinel `"online"` instead of an ID. */ -export type VenueMap = Record; +export type VenueId = number | "online"; + +export const ONLINE_VENUE_ID = "online" as const; + +export type VenueMap = Record; -export function resolveVenueId(venueName: string, map: VenueMap): number { +export function resolveVenueId(venueName: string, map: VenueMap): VenueId { if (!(venueName in map)) { const known = Object.keys(map); const keys = @@ -20,10 +29,12 @@ export function resolveVenueId(venueName: string, map: VenueMap): number { ); } const id = map[venueName]; - if (id === undefined || !Number.isFinite(id) || id <= 0) { + if (id === ONLINE_VENUE_ID) return id; + if (typeof id !== "number" || !Number.isFinite(id) || id <= 0) { throw new Error( `Venue ${JSON.stringify(venueName)} is registered in the venue map but has a ` + - `placeholder ID (${id}). Replace it with the real Meetup venue ID.` + `placeholder ID (${JSON.stringify(id)}). Replace it with the real Meetup venue ID, ` + + `or ${JSON.stringify(ONLINE_VENUE_ID)} for an online event.` ); } return id;