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;