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; +}