From fd0dfeeeea984699b32a6220b4b51aa27331ca03 Mon Sep 17 00:00:00 2001 From: Dusan Jovanovic Date: Tue, 18 Aug 2026 14:16:22 +0200 Subject: [PATCH] fix(meetup)!: give each group its own timezone in a multi-group run Cross-posting one session to a Pro network put it at a different instant in every group. Meetup reads startDateTime as wall time in the *receiving* group's zone and accepts no offset, but createMeetupDrafts applied a single `timezone` and sent the resulting wall time to all of them. Caught on real drafts: a session at 2026-08-22T16:00:00Z was created as 11:00-05:00 in Chicago (right), 11:00+02:00 in Belgrade (7h early) and 11:00-04:00 in Toronto (1h early). Three different instants for one talk. Config gains `groupTimezones`, keyed by group urlname like `groupHosts`, and GroupTarget carries the resolved zone so createMeetupDrafts converts per group rather than once. A run now renders 16:00Z as 11:00 Chicago / 18:00 Belgrade / 12:00 Toronto -- one instant, three wall clocks. BREAKING: a multi-group run with only the shared `timezone` now throws, naming the groups that need an entry. This is deliberate. The old behaviour had no correct interpretation once more than one zone is involved, and failing loudly beats scheduling a talk at the wrong hour in front of an audience. Single-group configs are unaffected, and a narrowed run (--groups cpp-serbia) still accepts the shared `timezone`. Also adds a `list-groups` command that prints each configured group's timezone as a paste-ready `groupTimezones` map, mirroring list-venues -- Meetup already knows every group's zone, so nobody should be hand-guessing it. Co-Authored-By: Claude Opus 5 --- packages/meetup/README.md | 34 +++++++++- packages/meetup/src/cli.test.ts | 77 +++++++++++++++++++++ packages/meetup/src/cli.ts | 57 ++++++++++++++-- packages/meetup/src/config.test.ts | 81 +++++++++++++++++++++++ packages/meetup/src/config.ts | 55 +++++++++++++++ packages/meetup/src/create-drafts.test.ts | 38 +++++++++++ packages/meetup/src/create-event.ts | 20 ++++-- packages/meetup/src/index.ts | 3 + packages/meetup/src/list-groups.ts | 55 +++++++++++++++ 9 files changed, 408 insertions(+), 12 deletions(-) create mode 100644 packages/meetup/src/list-groups.ts diff --git a/packages/meetup/README.md b/packages/meetup/README.md index 539ae4a..d88df3c 100644 --- a/packages/meetup/README.md +++ b/packages/meetup/README.md @@ -61,7 +61,8 @@ Drop a `coopkit.config.json` at the repo root: | --- | --- | --- | | `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. | +| `timezone` | no | IANA zone, for a **single**-group config. **Set this if your event dates are true UTC** — see below. | +| `groupTimezones` | for multi-group | Per-group IANA zone. Required once more than one group is targeted. | | `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. | @@ -102,9 +103,13 @@ in a network almost always have different organizers: { "meetup": { "groupUrlname": "chicago-c-cpp-users-group", - "timezone": "America/Chicago", "venues": { "online": "online" }, "groups": ["cpp-serbia", "CPPTORONTO"], + "groupTimezones": { + "chicago-c-cpp-users-group": "America/Chicago", + "cpp-serbia": "Europe/Belgrade", + "CPPTORONTO": "America/Toronto" + }, "hosts": { "Rob Douglas": 13296813, "Alex Smith": 256192100, "Jordan Lee": 274644230 }, "groupHosts": { "chicago-c-cpp-users-group": ["Rob Douglas"], @@ -119,6 +124,21 @@ in a network almost always have different organizers: case-insensitively (Meetup urlnames are case-insensitive, and a network may report `CPPTORONTO` where your config says `cpptoronto`). +> **`groupTimezones` is required for a multi-group run, and this is not a style +> preference.** Meetup has no single "event time": `startDateTime` is wall time in +> the *receiving group's* own zone, with no offset accepted. Send one group's wall +> time to every group and you create a **different instant in each** — a session at +> 16:00Z becomes 11:00 in Chicago and also 11:00 in Belgrade, seven hours apart. +> A multi-group run with only the shared `timezone` therefore **fails** rather than +> silently placing it at the wrong time. Run `coopkit-meetup list-groups` to print the map: +> +> ```bash +> bunx coopkit-meetup list-groups +> ``` +> +> `timezone` remains valid for a single-group config, and a narrowed run +> (`--groups cpp-serbia`) still accepts it. + ```bash # every group in the config bunx coopkit-meetup create-from-json --groups all --dry-run event.json @@ -251,6 +271,16 @@ No writeback — the JSON path is for one-shot creation. Adopters who need bookk bunx coopkit-meetup list-venues ``` +### List groups (timezones) + +```bash +bunx coopkit-meetup list-groups +``` + +Prints each configured group's name and the timezone Meetup holds for it, plus a +ready-to-paste `groupTimezones` map. A group that cannot be read is listed with +`(unknown)` and warned about rather than aborting the listing. + ## Library API ```ts diff --git a/packages/meetup/src/cli.test.ts b/packages/meetup/src/cli.test.ts index ce12626..7b079b0 100644 --- a/packages/meetup/src/cli.test.ts +++ b/packages/meetup/src/cli.test.ts @@ -78,3 +78,80 @@ describe("create-from-json --output (dry-run)", () => { } }); }); + +// A multi-group config. Each group needs its own timezone, or resolveGroupTargets +// refuses the run -- see "per-group timezones" in config.test.ts. +const MULTI_CONFIG = JSON.stringify({ + meetup: { + groupUrlname: "cpp-serbia", + groups: ["chicago-c-cpp-users-group"], + venues: { online: 42 }, + groupTimezones: { + "cpp-serbia": "Europe/Belgrade", + "chicago-c-cpp-users-group": "America/Chicago", + }, + }, +}); + +/** Same event, but pointing at a venue key the config does not define. */ +const EVENT_BAD_VENUE = JSON.stringify({ ...JSON.parse(EVENT), venueKey: "nope" }); + +describe("exit codes", () => { + it("exits 1 when a group in a multi-group run fails", async () => { + const dir = scratch(); + try { + const config = join(dir, "coopkit.config.json"); + const event = join(dir, "event.json"); + const out = join(dir, "result.json"); + writeFileSync(config, MULTI_CONFIG); + // An unknown venue throws inside the per-group try, so every group fails + // while the run itself completes -- no network, no credentials needed. + writeFileSync(event, EVENT_BAD_VENUE); + + const { code } = await run([ + "create-from-json", + "--groups", + "all", + "--dry-run", + "--config", + config, + "--output", + out, + event, + ]); + + expect(code).toBe(1); + expect(JSON.parse(readFileSync(out, "utf8")).status).toBe("partial"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("exits 0 when every group in a multi-group run succeeds", async () => { + const dir = scratch(); + try { + const config = join(dir, "coopkit.config.json"); + const event = join(dir, "event.json"); + const out = join(dir, "result.json"); + writeFileSync(config, MULTI_CONFIG); + writeFileSync(event, EVENT); + + const { code } = await run([ + "create-from-json", + "--groups", + "all", + "--dry-run", + "--config", + config, + "--output", + out, + event, + ]); + + expect(code).toBe(0); + expect(JSON.parse(readFileSync(out, "utf8")).status).toBe("dry-run"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/meetup/src/cli.ts b/packages/meetup/src/cli.ts index f212f72..54e1c90 100644 --- a/packages/meetup/src/cli.ts +++ b/packages/meetup/src/cli.ts @@ -9,6 +9,7 @@ import { createMeetupDraftFromFile, createMeetupDrafts, } from "./create-event.js"; +import { listGroups } from "./list-groups.js"; import { formatVenueKey, listVenues } from "./list-venues.js"; import { loadEnvFile } from "./load-env.js"; @@ -123,15 +124,14 @@ const createCmd = defineCommand({ eventFile: args.eventFile, groupUrlname: target.urlname, venues: config.venues, - ...(config.timezone !== undefined ? { timezone: config.timezone } : {}), + ...((target.timezone ?? config.timezone) !== undefined + ? { timezone: (target.timezone ?? config.timezone) as string } + : {}), ...(target.hosts.length > 0 ? { hosts: target.hosts } : {}), includeSpeaker: target.includeSpeaker, dryRun: Boolean(args["dry-run"]), }); writeResultFile(args.output, result); - if (result.status === "skipped") { - process.exit(0); - } }, }); @@ -220,7 +220,9 @@ const createFromJsonCmd = defineCommand({ event, groupUrlname: target.urlname, venues: config.venues, - ...(config.timezone !== undefined ? { timezone: config.timezone } : {}), + ...((target.timezone ?? config.timezone) !== undefined + ? { timezone: (target.timezone ?? config.timezone) as string } + : {}), ...(target.hosts.length > 0 ? { hosts: target.hosts } : {}), includeSpeaker: target.includeSpeaker, dryRun: Boolean(args["dry-run"]), @@ -229,6 +231,8 @@ const createFromJsonCmd = defineCommand({ return; } + // `targets` carry a per-group timezone; config.timezone stays only as the + // fallback for a single-group config that names no groupTimezones. const result = await createMeetupDrafts({ event, groups: targets, @@ -305,6 +309,42 @@ const listVenuesCmd = defineCommand({ }, }); +const listGroupsCmd = defineCommand({ + meta: { + name: "list-groups", + description: + "Show each configured group's timezone and print a ready-to-paste groupTimezones map.", + }, + args: { + config: { + type: "string", + description: "Path to coopkit.config.json (default: ./coopkit.config.json).", + }, + }, + async run({ args }) { + loadEnvFile(); + const config = loadMeetupConfig(args.config); + const urlnames = [config.groupUrlname, ...(config.groups ?? [])]; + const groups = await listGroups({ urlnames }); + + for (const g of groups) { + console.error(` ${g.urlname} ${g.name ?? "(unreadable)"} ${g.timezone ?? "(unknown)"}`); + } + + const unknown = groups.filter((g) => !g.timezone).map((g) => g.urlname); + if (unknown.length > 0) { + console.error( + `\n[warn] No timezone for: ${unknown.join(", ")}. Check the urlname and that the account can read the group.` + ); + } + + console.error("\n--- Suggested `meetup.groupTimezones` for coopkit.config.json ---\n"); + const map: Record = {}; + for (const g of groups) if (g.timezone) map[g.urlname] = g.timezone; + console.log(JSON.stringify({ groupTimezones: map }, null, 2)); + }, +}); + const main = defineCommand({ meta: { name: "coopkit-meetup", @@ -314,7 +354,12 @@ const main = defineCommand({ create: createCmd, "create-from-json": createFromJsonCmd, "list-venues": listVenuesCmd, + "list-groups": listGroupsCmd, }, }); -runMain(main).then(() => process.exit(0)); +// Exit explicitly: the HTTP client uses global fetch, whose keep-alive sockets +// keep the process alive for seconds after the last call, which reads as a hang +// on CI. No argument, so a process.exitCode set by a handler (a partial +// multi-group failure) survives instead of being overwritten with 0. +runMain(main).then(() => process.exit()); diff --git a/packages/meetup/src/config.test.ts b/packages/meetup/src/config.test.ts index 4126048..d14ad2c 100644 --- a/packages/meetup/src/config.test.ts +++ b/packages/meetup/src/config.test.ts @@ -242,3 +242,84 @@ describe("speakerDetails gating", () => { ).toThrow(/speakerDetails must be true\/false or an array of group urlnames/); }); }); + +describe("per-group timezones", () => { + const NETWORK: MeetupConfig = { + groupUrlname: "chicago-c-cpp-users-group", + venues: { online: "online" }, + groups: ["cpp-serbia", "CPPTORONTO"], + groupTimezones: { + "chicago-c-cpp-users-group": "America/Chicago", + "cpp-serbia": "Europe/Belgrade", + CPPTORONTO: "America/Toronto", + }, + }; + + it("gives every group its own timezone", () => { + expect(resolveGroupTargets(NETWORK).map((t) => [t.urlname, t.timezone])).toEqual([ + ["chicago-c-cpp-users-group", "America/Chicago"], + ["cpp-serbia", "Europe/Belgrade"], + ["CPPTORONTO", "America/Toronto"], + ]); + }); + + it("matches groupTimezones keys case-insensitively", () => { + const targets = resolveGroupTargets({ + ...NETWORK, + groups: ["cpptoronto"], + groupTimezones: { + "chicago-c-cpp-users-group": "America/Chicago", + CPPTORONTO: "America/Toronto", + }, + }); + expect(targets.find((t) => t.urlname === "cpptoronto")?.timezone).toBe("America/Toronto"); + }); + + it("refuses a multi-group run that has only one shared timezone", () => { + // The bug this guards: one wall time sent to groups in different zones is a + // different instant in each. + expect(() => + resolveGroupTargets({ + groupUrlname: "chicago-c-cpp-users-group", + venues: { online: "online" }, + groups: ["cpp-serbia"], + timezone: "America/Chicago", + }) + ).toThrow(/cannot describe a multi-group run/); + }); + + it("names the groups missing a timezone", () => { + expect(() => + resolveGroupTargets({ + ...NETWORK, + timezone: "America/Chicago", + groupTimezones: { "chicago-c-cpp-users-group": "America/Chicago" }, + }) + ).toThrow(/"cpp-serbia", "CPPTORONTO"/); + }); + + it("allows a single-group run with only the shared timezone", () => { + const targets = resolveGroupTargets({ + groupUrlname: "solo-group", + venues: { online: "online" }, + timezone: "America/Chicago", + }); + expect(targets[0]?.timezone).toBe("America/Chicago"); + }); + + it("allows a narrowed single-group run even when the config is multi-group", () => { + const targets = resolveGroupTargets( + { ...NETWORK, groupTimezones: undefined, timezone: "Europe/Belgrade" }, + { only: ["cpp-serbia"] } + ); + expect(targets.map((t) => [t.urlname, t.timezone])).toEqual([ + ["cpp-serbia", "Europe/Belgrade"], + ]); + }); + + it("rejects a malformed groupTimezones value", () => { + expect(() => + loadMeetupConfig(writeConfig({ meetup: { ...BASE, groupTimezones: { "a-group": 5 } } })) + ).toThrow(/must be a non-empty IANA timezone string/); + }); +}); diff --git a/packages/meetup/src/config.ts b/packages/meetup/src/config.ts index 65fac48..88530db 100644 --- a/packages/meetup/src/config.ts +++ b/packages/meetup/src/config.ts @@ -33,6 +33,17 @@ export interface MeetupConfig { * `true` for every group, or a list of the group urlnames that support it. */ speakerDetails?: boolean | string[]; + /** + * Per-group IANA timezone, keyed by group urlname. + * + * **Required when targeting more than one group**, because Meetup has no + * single "event time": `startDateTime` is wall time in the *receiving group's* + * own zone. Sending one group's wall time to all of them creates a different + * instant in each — a session at 16:00Z becomes 11:00 in Chicago but also + * 11:00 in Belgrade, seven hours apart. Run `coopkit-meetup list-groups` to + * print a ready-to-paste map of what Meetup reports for each of your groups. + */ + groupTimezones?: Record; } export interface CoopkitConfig { @@ -128,6 +139,21 @@ export function loadMeetupConfig(configPath?: string): MeetupConfig { ); } } + if (meetup.groupTimezones !== undefined) { + const tzs = meetup.groupTimezones; + if (typeof tzs !== "object" || tzs === null || Array.isArray(tzs)) { + throw new Error( + `${resolved}: meetup.groupTimezones must be an object mapping group urlnames to IANA timezones.` + ); + } + for (const [group, tz] of Object.entries(tzs)) { + if (typeof tz !== "string" || tz === "") { + throw new Error( + `${resolved}: meetup.groupTimezones[${JSON.stringify(group)}] must be a non-empty IANA timezone string.` + ); + } + } + } if (meetup.defaultHosts !== undefined) { if (!Array.isArray(meetup.defaultHosts)) { throw new Error( @@ -172,6 +198,11 @@ export interface GroupTarget { hosts: number[]; /** Whether this group accepts Meetup's Pro speaker profile. */ includeSpeaker: boolean; + /** + * IANA timezone this group's wall time is expressed in. Undefined means the + * event date is already group-local wall time (the legacy reading). + */ + timezone?: string; } /** Does this group opt into speakerDetails? See `MeetupConfig.speakerDetails`. */ @@ -225,13 +256,37 @@ export function resolveGroupTargets( Object.entries(config.groupHosts ?? {}).map(([g, names]) => [g.toLowerCase(), names]) ); + const tzByGroup = new Map( + Object.entries(config.groupTimezones ?? {}).map(([g, tz]) => [g.toLowerCase(), tz]) + ); + + // One `timezone` shared across several groups is never right: Meetup reads + // startDateTime in the *receiving* group's zone, so the same wall time is a + // different instant per group. Refuse rather than silently placing it at the wrong time. + if (selected.length > 1 && config.timezone !== undefined) { + const unmapped = selected.filter((u) => !tzByGroup.has(u.toLowerCase())); + if (unmapped.length > 0) { + const missing = unmapped.map((u) => JSON.stringify(u)).join(", "); + throw new Error( + [ + "meetup.timezone alone cannot describe a multi-group run: Meetup interprets", + "the event time in each group's own timezone, so one wall time would land at", + `a different instant in each. Add meetup.groupTimezones entries for ${missing}`, + "(run `coopkit-meetup list-groups` to print them).", + ].join(" ") + ); + } + } + return selected.map((urlname) => { const perGroup = hostsByGroup.get(urlname.toLowerCase()); const names = perGroup ?? options.hostNames; + const timezone = tzByGroup.get(urlname.toLowerCase()) ?? config.timezone; return { urlname, hosts: resolveHostIds(config, names), includeSpeaker: groupAcceptsSpeaker(config, urlname), + ...(timezone !== undefined ? { timezone } : {}), }; }); } diff --git a/packages/meetup/src/create-drafts.test.ts b/packages/meetup/src/create-drafts.test.ts index 7cc48aa..550177a 100644 --- a/packages/meetup/src/create-drafts.test.ts +++ b/packages/meetup/src/create-drafts.test.ts @@ -137,3 +137,41 @@ describe("multi-group --output status", () => { expect([...statuses]).toEqual(["dry-run"]); }); }); + +describe("per-group timezone produces one instant everywhere", () => { + it("renders 16:00Z as each group's own local wall time", async () => { + const { results } = await createMeetupDrafts({ + event: EVENT, // 2026-08-22T16:00:00Z + groups: [ + { urlname: "chicago-c-cpp-users-group", timezone: "America/Chicago" }, + { urlname: "cpp-serbia", timezone: "Europe/Belgrade" }, + { urlname: "CPPTORONTO", timezone: "America/Toronto" }, + ], + venues: { ...VENUES }, + dryRun: true, + log: () => {}, + }); + + const starts = results.map((r) => + r.ok && r.result.status === "dry-run" ? r.result.payload.startDateTime : undefined + ); + // Same instant, three wall clocks: CDT -5, CEST +2, EDT -4. + expect(starts).toEqual(["2026-08-22T11:00:00", "2026-08-22T18:00:00", "2026-08-22T12:00:00"]); + }); + + it("falls back to the shared timezone for a group that names none", async () => { + const { results } = await createMeetupDrafts({ + event: EVENT, + groups: [{ urlname: "solo-group" }], + venues: { ...VENUES }, + timezone: "America/Chicago", + dryRun: true, + log: () => {}, + }); + const payload = + results[0]?.ok && results[0].result.status === "dry-run" + ? results[0].result.payload + : undefined; + expect(payload?.startDateTime).toBe("2026-08-22T11:00:00"); + }); +}); diff --git a/packages/meetup/src/create-event.ts b/packages/meetup/src/create-event.ts index 5c3cd6c..c4a1cd5 100644 --- a/packages/meetup/src/create-event.ts +++ b/packages/meetup/src/create-event.ts @@ -299,10 +299,20 @@ export async function createMeetupDraftFromFile( export interface CreateMeetupDraftsOptions { event: NormalizedEvent; - /** Groups to create the event in, in order, each with its own hosts. */ - groups: Array<{ urlname: string; hosts?: number[]; includeSpeaker?: boolean }>; + /** + * Groups to create the event in, in order, each with its own hosts and its own + * timezone. The timezone must be per group: Meetup reads `startDateTime` as + * wall time in the *receiving* group's zone, so one shared wall time would put + * the event at a different instant in each group. + */ + groups: Array<{ + urlname: string; + hosts?: number[]; + includeSpeaker?: boolean; + timezone?: string; + }>; venues: VenueMap; - /** IANA timezone of the groups. See `BuildPayloadInput.timezone`. */ + /** Fallback IANA timezone for groups that name none. See `BuildPayloadInput.timezone`. */ timezone?: string; dryRun?: boolean; credentials?: MeetupCredentials; @@ -350,7 +360,9 @@ export async function createMeetupDrafts( event: options.event, groupUrlname: group.urlname, venues: options.venues, - ...(options.timezone !== undefined ? { timezone: options.timezone } : {}), + ...((group.timezone ?? options.timezone) !== undefined + ? { timezone: (group.timezone ?? options.timezone) as string } + : {}), ...(group.hosts !== undefined ? { hosts: group.hosts } : {}), ...(group.includeSpeaker !== undefined ? { includeSpeaker: group.includeSpeaker } : {}), ...(options.dryRun !== undefined ? { dryRun: options.dryRun } : {}), diff --git a/packages/meetup/src/index.ts b/packages/meetup/src/index.ts index 0ccd3cf..3a8b383 100644 --- a/packages/meetup/src/index.ts +++ b/packages/meetup/src/index.ts @@ -46,3 +46,6 @@ export type { CoopkitConfig, GroupTarget, MeetupConfig } from "./config.js"; export { classifySocialUrl, speakerDetailsFrom } from "./speaker.js"; export type { SocialNetworkService, SpeakerDetailsInput } from "./speaker.js"; + +export { listGroups } from "./list-groups.js"; +export type { ListGroupsOptions, MeetupGroupInfo } from "./list-groups.js"; diff --git a/packages/meetup/src/list-groups.ts b/packages/meetup/src/list-groups.ts new file mode 100644 index 0000000..c1ce8f4 --- /dev/null +++ b/packages/meetup/src/list-groups.ts @@ -0,0 +1,55 @@ +import { type MeetupClient, type MeetupCredentials, createMeetupClient } from "./client.js"; + +export interface MeetupGroupInfo { + urlname: string; + name: string | null; + /** IANA timezone Meetup holds for the group — what it reads event times in. */ + timezone: string | null; +} + +const GROUP_INFO_QUERY = ` + query GroupInfo($urlname: String!) { + groupByUrlname(urlname: $urlname) { + urlname + name + timezone + } + } +`; + +export interface ListGroupsOptions { + urlnames: string[]; + client?: MeetupClient; + credentials?: MeetupCredentials; +} + +/** + * Look up each group's canonical urlname, name and timezone. + * + * The timezone is the point: Meetup interprets an event's `startDateTime` as + * wall time in the group's own zone, so cross-posting one session to several + * groups needs each group's zone to land on the same instant. A group that + * cannot be read comes back with nulls rather than aborting the whole listing. + */ +export async function listGroups(options: ListGroupsOptions): Promise { + const client = options.client ?? createMeetupClient(options.credentials); + + const out: MeetupGroupInfo[] = []; + for (const urlname of options.urlnames) { + try { + const data = await client.graphql<{ groupByUrlname: MeetupGroupInfo | null }>( + GROUP_INFO_QUERY, + { urlname } + ); + const group = data.groupByUrlname; + out.push({ + urlname: group?.urlname ?? urlname, + name: group?.name ?? null, + timezone: group?.timezone ?? null, + }); + } catch { + out.push({ urlname, name: null, timezone: null }); + } + } + return out; +}