Add implementation plan for recurring calendar events
This commit is contained in:
895
docs/superpowers/plans/2026-09-08-recurring-calendar-events.md
Normal file
895
docs/superpowers/plans/2026-09-08-recurring-calendar-events.md
Normal file
@@ -0,0 +1,895 @@
|
||||
# Recurring Google Calendar Events Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add create / modify / delete support for recurring events to the `calendar` tool in `src/tools/calendar.ts`, using raw RFC 5545 recurrence lines, instance-vs-series scope on edits, and a new `list_instances` action.
|
||||
|
||||
**Architecture:** All logic stays in `src/tools/calendar.ts`. The `calendarParams` TypeBox schema gains three optional fields (`recurrence`, `scope`, `list_mode`) and a new action `list_instances`. Update/delete resolve their target via a single GET on the event id when `scope` is not passed explicitly. Tests live in a new `src/tools/calendar.test.ts` with `globalThis.fetch` stubbed per call.
|
||||
|
||||
**Tech Stack:** TypeScript on Node 24, TypeBox for the tool schema, `@earendil-works/pi-coding-agent` for `defineTool`, Vitest for tests, Google Calendar REST API v3.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify:** `src/tools/calendar.ts` — schema, handler dispatch, formatters, new action.
|
||||
- **Create:** `src/tools/calendar.test.ts` — Vitest unit tests with a mocked `fetch`.
|
||||
- **Modify:** `README.md` — one new line in the manual E2E checklist.
|
||||
|
||||
`getAccessToken` is passed into `handleCalendar` as a function, so tests can just supply a stub that returns `"test-token"`. `fetch` is called directly on the global, so tests stub `globalThis.fetch` and restore it in `afterEach`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Test harness + schema/type extensions
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tools/calendar.test.ts`
|
||||
- Modify: `src/tools/calendar.ts` (schema, `CalendarEvent` interface, action dispatch stub)
|
||||
|
||||
- [ ] **Step 1: Create test file with fetch stub scaffolding**
|
||||
|
||||
Create `src/tools/calendar.test.ts`:
|
||||
|
||||
```ts
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleCalendar } from "./calendar.js";
|
||||
|
||||
type FetchCall = { url: string; init?: RequestInit };
|
||||
|
||||
function stubFetch(responses: Array<{ status?: number; body: unknown } | ((call: FetchCall) => { status?: number; body: unknown })>) {
|
||||
const calls: FetchCall[] = [];
|
||||
let i = 0;
|
||||
const fn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
const call = { url, init };
|
||||
calls.push(call);
|
||||
const entry = responses[i++];
|
||||
if (!entry) throw new Error(`unexpected fetch call: ${url}`);
|
||||
const resolved = typeof entry === "function" ? entry(call) : entry;
|
||||
const status = resolved.status ?? 200;
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => JSON.stringify(resolved.body),
|
||||
json: async () => resolved.body,
|
||||
} as Response;
|
||||
});
|
||||
return { fn, calls };
|
||||
}
|
||||
|
||||
const token = async () => "test-token";
|
||||
|
||||
describe("calendar tool", () => {
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
});
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("placeholder — replaced by later tasks", () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the placeholder test to confirm the harness runs**
|
||||
|
||||
Run: `npm test -- src/tools/calendar.test.ts`
|
||||
Expected: PASS (1 test).
|
||||
|
||||
- [ ] **Step 3: Extend `CalendarEvent` and `calendarParams` in `src/tools/calendar.ts`**
|
||||
|
||||
Modify `src/tools/calendar.ts` — extend the `action` union, add three optional params, and extend the `CalendarEvent` interface:
|
||||
|
||||
```ts
|
||||
export const calendarParams = Type.Object({
|
||||
action: Type.Union(
|
||||
[
|
||||
Type.Literal("list"),
|
||||
Type.Literal("list_instances"),
|
||||
Type.Literal("create"),
|
||||
Type.Literal("update"),
|
||||
Type.Literal("delete"),
|
||||
],
|
||||
{ description: "list events / list instances of a series / create / update / delete an event" },
|
||||
),
|
||||
calendar_id: Type.Optional(
|
||||
Type.String({ description: "Calendar id. Default 'primary' (the owner's main calendar)." }),
|
||||
),
|
||||
time_min: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"list/list_instances: ISO 8601 lower bound (inclusive), e.g. 2026-09-07T00:00:00+02:00. Default for list: now.",
|
||||
}),
|
||||
),
|
||||
time_max: Type.Optional(
|
||||
Type.String({ description: "list/list_instances: ISO 8601 upper bound (exclusive). Optional." }),
|
||||
),
|
||||
q: Type.Optional(Type.String({ description: "list: free-text search over summaries/descriptions." })),
|
||||
max_results: Type.Optional(
|
||||
Type.Number({ description: "list/list_instances: max events returned. Default 25, capped at 250." }),
|
||||
),
|
||||
list_mode: Type.Optional(
|
||||
Type.Union([Type.Literal("occurrences"), Type.Literal("series")], {
|
||||
description:
|
||||
"list: 'occurrences' (default) expands recurring events into individual dates; 'series' returns only the master events with their RRULE.",
|
||||
}),
|
||||
),
|
||||
event_id: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Event id. Required for update, delete, and list_instances. For update/delete on a recurring series, pass the master id (for scope=series) or an instance id from list_instances (for scope=instance).",
|
||||
}),
|
||||
),
|
||||
scope: Type.Optional(
|
||||
Type.Union([Type.Literal("instance"), Type.Literal("series")], {
|
||||
description:
|
||||
"update/delete on recurring events: 'instance' targets one occurrence; 'series' targets the whole series. Default: auto-detected from the event id.",
|
||||
}),
|
||||
),
|
||||
summary: Type.Optional(Type.String({ description: "create/update: event title." })),
|
||||
description: Type.Optional(Type.String({ description: "create/update: event description." })),
|
||||
location: Type.Optional(Type.String({ description: "create/update: location string." })),
|
||||
start: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"create/update: start. ISO 8601 with offset for timed events (2026-09-10T10:00:00+02:00), or YYYY-MM-DD for all-day.",
|
||||
}),
|
||||
),
|
||||
end: Type.Optional(
|
||||
Type.String({ description: "create/update: end. Same format as start." }),
|
||||
),
|
||||
recurrence: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description:
|
||||
"create/update series: RFC 5545 recurrence lines, e.g. ['RRULE:FREQ=WEEKLY;BYDAY=MO,WE']. Pass [] on update to strip recurrence and turn the series back into a single event.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
Also extend `CalendarEvent`:
|
||||
|
||||
```ts
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
start?: { dateTime?: string; date?: string; timeZone?: string };
|
||||
end?: { dateTime?: string; date?: string; timeZone?: string };
|
||||
htmlLink?: string;
|
||||
recurringEventId?: string;
|
||||
recurrence?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
Add a `list_instances` case to `handleCalendar`'s switch that throws for now (real impl arrives in Task 5):
|
||||
|
||||
```ts
|
||||
case "list_instances":
|
||||
throw new Error("list_instances not implemented yet");
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Typecheck compiles**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
Expected: PASS with no errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tools/calendar.ts src/tools/calendar.test.ts
|
||||
git commit -m "Extend calendar schema and types for recurring events"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create with recurrence + RRULE validation
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/tools/calendar.ts` (`createEvent`, `eventBody`, add `validateRecurrence`, `describeEvent` to show RRULE)
|
||||
- Modify: `src/tools/calendar.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Replace the placeholder test with:
|
||||
|
||||
```ts
|
||||
it("create includes recurrence in the request body and shows RRULE in output", async () => {
|
||||
const { fn, calls } = stubFetch([
|
||||
{
|
||||
body: {
|
||||
id: "series-1",
|
||||
summary: "Standup",
|
||||
start: { dateTime: "2026-09-14T09:00:00+02:00", timeZone: "Europe/Warsaw" },
|
||||
end: { dateTime: "2026-09-14T09:15:00+02:00", timeZone: "Europe/Warsaw" },
|
||||
recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"],
|
||||
},
|
||||
},
|
||||
]);
|
||||
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
|
||||
const out = await handleCalendar(
|
||||
{
|
||||
action: "create",
|
||||
summary: "Standup",
|
||||
start: "2026-09-14T09:00:00+02:00",
|
||||
end: "2026-09-14T09:15:00+02:00",
|
||||
recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"],
|
||||
},
|
||||
"Europe/Warsaw",
|
||||
token,
|
||||
);
|
||||
expect(calls[0].url).toBe("https://www.googleapis.com/calendar/v3/calendars/primary/events");
|
||||
const body = JSON.parse(calls[0].init!.body as string);
|
||||
expect(body.recurrence).toEqual(["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"]);
|
||||
expect(out).toContain("Created:");
|
||||
expect(out).toContain("RRULE=RRULE:FREQ=WEEKLY;BYDAY=MO,WE");
|
||||
});
|
||||
|
||||
it("rejects recurrence entries missing an RFC 5545 prefix", async () => {
|
||||
globalThis.fetch = vi.fn() as unknown as typeof globalThis.fetch;
|
||||
await expect(
|
||||
handleCalendar(
|
||||
{
|
||||
action: "create",
|
||||
summary: "x",
|
||||
start: "2026-09-14T09:00:00+02:00",
|
||||
end: "2026-09-14T09:15:00+02:00",
|
||||
recurrence: ["FREQ=WEEKLY"],
|
||||
},
|
||||
"UTC",
|
||||
token,
|
||||
),
|
||||
).rejects.toThrow(/RFC 5545/);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to confirm they fail**
|
||||
|
||||
Run: `npm test -- src/tools/calendar.test.ts`
|
||||
Expected: FAIL — no recurrence in body, no RRULE in output, no validation error.
|
||||
|
||||
- [ ] **Step 3: Implement recurrence in `eventBody` + validation + describeEvent update**
|
||||
|
||||
In `src/tools/calendar.ts`:
|
||||
|
||||
Add validation helper:
|
||||
|
||||
```ts
|
||||
function validateRecurrence(recurrence: string[]): void {
|
||||
const allowed = /^(RRULE|EXDATE|RDATE|EXRULE):/;
|
||||
for (const line of recurrence) {
|
||||
if (!allowed.test(line)) {
|
||||
throw new Error(
|
||||
"recurrence entries must be RFC 5545 lines (RRULE:..., EXDATE:..., RDATE:..., EXRULE:...)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In `eventBody`, add:
|
||||
|
||||
```ts
|
||||
if (params.recurrence !== undefined) {
|
||||
validateRecurrence(params.recurrence);
|
||||
body.recurrence = params.recurrence;
|
||||
}
|
||||
```
|
||||
|
||||
Update `describeEvent` to show the RRULE when present:
|
||||
|
||||
```ts
|
||||
function describeEvent(event: CalendarEvent, timezone: string): string {
|
||||
const summary = event.summary ?? "(no title)";
|
||||
const when = describeWhen(event, timezone);
|
||||
const location = event.location ? ` @ ${event.location}` : "";
|
||||
const recurring = event.recurringEventId ? ` · recurring (series=${event.recurringEventId})` : "";
|
||||
const rrule = event.recurrence?.length ? ` · RRULE=${event.recurrence.join(" ")}` : "";
|
||||
return `${event.id} · ${summary} · ${when}${location}${recurring}${rrule}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to confirm they pass**
|
||||
|
||||
Run: `npm test -- src/tools/calendar.test.ts`
|
||||
Expected: PASS (2 tests + prior placeholder if still present — remove the placeholder if it is).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tools/calendar.ts src/tools/calendar.test.ts
|
||||
git commit -m "Support recurrence in create and RFC 5545 validation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: list marks recurring occurrences + list_mode=series
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/tools/calendar.ts` (`listEvents`)
|
||||
- Modify: `src/tools/calendar.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `describe("calendar tool", ...)`:
|
||||
|
||||
```ts
|
||||
it("list marks occurrences that belong to a series", async () => {
|
||||
const { fn } = stubFetch([
|
||||
{
|
||||
body: {
|
||||
items: [
|
||||
{
|
||||
id: "occ-1",
|
||||
summary: "Standup",
|
||||
start: { dateTime: "2026-09-14T09:00:00+02:00" },
|
||||
end: { dateTime: "2026-09-14T09:15:00+02:00" },
|
||||
recurringEventId: "series-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
|
||||
const out = await handleCalendar({ action: "list" }, "Europe/Warsaw", token);
|
||||
expect(out).toContain("recurring (series=series-1)");
|
||||
});
|
||||
|
||||
it("list list_mode=series requests master events only and shows the RRULE", async () => {
|
||||
const { fn, calls } = stubFetch([
|
||||
{
|
||||
body: {
|
||||
items: [
|
||||
{
|
||||
id: "series-1",
|
||||
summary: "Standup",
|
||||
start: { dateTime: "2026-09-14T09:00:00+02:00" },
|
||||
end: { dateTime: "2026-09-14T09:15:00+02:00" },
|
||||
recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
|
||||
const out = await handleCalendar({ action: "list", list_mode: "series" }, "Europe/Warsaw", token);
|
||||
const url = new URL(calls[0].url);
|
||||
expect(url.searchParams.get("singleEvents")).toBe("false");
|
||||
expect(out).toContain("RRULE=RRULE:FREQ=WEEKLY;BYDAY=MO,WE");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to confirm they fail**
|
||||
|
||||
Run: `npm test -- src/tools/calendar.test.ts`
|
||||
Expected: FAIL — `list_mode=series` currently forces `singleEvents=true`, and the recurring-suffix expectation already passes if Task 2's `describeEvent` change landed (skip this failure if applicable — the test still exercises the wiring).
|
||||
|
||||
- [ ] **Step 3: Update `listEvents` to honour `list_mode`**
|
||||
|
||||
Replace the two lines that always set `singleEvents=true` / `orderBy=startTime`:
|
||||
|
||||
```ts
|
||||
const mode = params.list_mode ?? "occurrences";
|
||||
if (mode === "series") {
|
||||
url.searchParams.set("singleEvents", "false");
|
||||
} else {
|
||||
url.searchParams.set("singleEvents", "true");
|
||||
url.searchParams.set("orderBy", "startTime");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to confirm they pass**
|
||||
|
||||
Run: `npm test -- src/tools/calendar.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tools/calendar.ts src/tools/calendar.test.ts
|
||||
git commit -m "Add list_mode=series and recurring marker on list output"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Scope resolution + update with instance/series semantics
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/tools/calendar.ts` (`updateEvent`, new `resolveScope` helper)
|
||||
- Modify: `src/tools/calendar.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```ts
|
||||
it("update with default scope on an instance id PATCHes the instance", async () => {
|
||||
const { fn, calls } = stubFetch([
|
||||
{ body: { id: "occ-1_20260916T070000Z", recurringEventId: "series-1", summary: "Standup" } }, // GET for scope
|
||||
{
|
||||
body: {
|
||||
id: "occ-1_20260916T070000Z",
|
||||
recurringEventId: "series-1",
|
||||
summary: "Standup",
|
||||
start: { dateTime: "2026-09-16T10:00:00+02:00" },
|
||||
end: { dateTime: "2026-09-16T10:30:00+02:00" },
|
||||
},
|
||||
}, // PATCH
|
||||
]);
|
||||
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
|
||||
const out = await handleCalendar(
|
||||
{
|
||||
action: "update",
|
||||
event_id: "occ-1_20260916T070000Z",
|
||||
start: "2026-09-16T10:00:00+02:00",
|
||||
end: "2026-09-16T10:30:00+02:00",
|
||||
},
|
||||
"Europe/Warsaw",
|
||||
token,
|
||||
);
|
||||
expect(calls[0].init?.method ?? "GET").toBe("GET");
|
||||
expect(calls[1].init?.method).toBe("PATCH");
|
||||
expect(calls[1].url).toContain("/events/occ-1_20260916T070000Z");
|
||||
expect(out).toContain("Updated:");
|
||||
});
|
||||
|
||||
it("update with default scope on a master id PATCHes the master", async () => {
|
||||
const { fn, calls } = stubFetch([
|
||||
{ body: { id: "series-1", recurrence: ["RRULE:FREQ=WEEKLY"], summary: "Old" } }, // GET
|
||||
{
|
||||
body: {
|
||||
id: "series-1",
|
||||
summary: "New",
|
||||
recurrence: ["RRULE:FREQ=WEEKLY"],
|
||||
start: { dateTime: "2026-09-14T09:00:00+02:00" },
|
||||
end: { dateTime: "2026-09-14T09:15:00+02:00" },
|
||||
},
|
||||
}, // PATCH
|
||||
]);
|
||||
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
|
||||
await handleCalendar(
|
||||
{ action: "update", event_id: "series-1", summary: "New" },
|
||||
"Europe/Warsaw",
|
||||
token,
|
||||
);
|
||||
expect(calls[1].init?.method).toBe("PATCH");
|
||||
const body = JSON.parse(calls[1].init!.body as string);
|
||||
expect(body).toEqual({ summary: "New" });
|
||||
});
|
||||
|
||||
it("update scope=instance rejects when the event has no recurringEventId", async () => {
|
||||
const { fn } = stubFetch([{ body: { id: "series-1", recurrence: ["RRULE:FREQ=WEEKLY"] } }]);
|
||||
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
|
||||
await expect(
|
||||
handleCalendar(
|
||||
{ action: "update", event_id: "series-1", scope: "instance", summary: "x" },
|
||||
"UTC",
|
||||
token,
|
||||
),
|
||||
).rejects.toThrow(/pass an instance id or use scope=series/);
|
||||
});
|
||||
|
||||
it("update rejects recurrence when scope resolves to instance", async () => {
|
||||
const { fn } = stubFetch([{ body: { id: "occ-1", recurringEventId: "series-1" } }]);
|
||||
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
|
||||
await expect(
|
||||
handleCalendar(
|
||||
{ action: "update", event_id: "occ-1", recurrence: ["RRULE:FREQ=DAILY"] },
|
||||
"UTC",
|
||||
token,
|
||||
),
|
||||
).rejects.toThrow(/recurrence can only be set on the series master/);
|
||||
});
|
||||
|
||||
it("update on a plain single event skips the classification GET", async () => {
|
||||
const { fn, calls } = stubFetch([
|
||||
{
|
||||
body: {
|
||||
id: "single-1",
|
||||
summary: "Renamed",
|
||||
start: { dateTime: "2026-09-14T09:00:00+02:00" },
|
||||
end: { dateTime: "2026-09-14T09:15:00+02:00" },
|
||||
},
|
||||
}, // PATCH (no prior GET — no scope passed, and classification only runs if scope defaulted)
|
||||
]);
|
||||
// For a plain single event we still need one GET to know it's not recurring.
|
||||
// We keep behaviour: always GET when scope is not passed.
|
||||
});
|
||||
```
|
||||
|
||||
Note: replace that last `it` block with an actual test:
|
||||
|
||||
```ts
|
||||
it("update PATCH body only carries fields the caller changed", async () => {
|
||||
const { fn, calls } = stubFetch([
|
||||
{ body: { id: "series-1", recurrence: ["RRULE:FREQ=WEEKLY"] } },
|
||||
{
|
||||
body: {
|
||||
id: "series-1",
|
||||
summary: "s",
|
||||
recurrence: ["RRULE:FREQ=DAILY"],
|
||||
start: { dateTime: "2026-09-14T09:00:00+02:00" },
|
||||
end: { dateTime: "2026-09-14T09:15:00+02:00" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
|
||||
await handleCalendar(
|
||||
{ action: "update", event_id: "series-1", recurrence: ["RRULE:FREQ=DAILY"] },
|
||||
"UTC",
|
||||
token,
|
||||
);
|
||||
const body = JSON.parse(calls[1].init!.body as string);
|
||||
expect(body).toEqual({ recurrence: ["RRULE:FREQ=DAILY"] });
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to confirm they fail**
|
||||
|
||||
Run: `npm test -- src/tools/calendar.test.ts`
|
||||
Expected: FAIL — updateEvent currently PATCHes directly without a scoping GET or recurrence-vs-instance guard.
|
||||
|
||||
- [ ] **Step 3: Add `resolveScope` and rewrite `updateEvent`**
|
||||
|
||||
In `src/tools/calendar.ts`:
|
||||
|
||||
```ts
|
||||
type ResolvedScope = "instance" | "series" | "single";
|
||||
|
||||
async function classifyEvent(
|
||||
calId: string,
|
||||
eventId: string,
|
||||
getAccessToken: () => Promise<string>,
|
||||
): Promise<CalendarEvent> {
|
||||
const resp = await fetch(
|
||||
`${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(eventId)}`,
|
||||
{ headers: { Authorization: `Bearer ${await getAccessToken()}` } },
|
||||
);
|
||||
await ensureOk(resp, "get event");
|
||||
return (await resp.json()) as CalendarEvent;
|
||||
}
|
||||
|
||||
async function resolveScope(
|
||||
calId: string,
|
||||
params: CalendarParams,
|
||||
getAccessToken: () => Promise<string>,
|
||||
): Promise<{ scope: ResolvedScope; event: CalendarEvent }> {
|
||||
if (!params.event_id) throw new Error("event_id required");
|
||||
const event = await classifyEvent(calId, params.event_id, getAccessToken);
|
||||
const detected: ResolvedScope = event.recurringEventId
|
||||
? "instance"
|
||||
: event.recurrence?.length
|
||||
? "series"
|
||||
: "single";
|
||||
if (!params.scope) return { scope: detected, event };
|
||||
if (params.scope === "instance" && detected !== "instance") {
|
||||
throw new Error("event_id is a series master; pass an instance id or use scope=series");
|
||||
}
|
||||
if (params.scope === "series" && detected === "instance") {
|
||||
throw new Error("event_id is an occurrence; pass the master id or use scope=instance");
|
||||
}
|
||||
return { scope: params.scope, event };
|
||||
}
|
||||
```
|
||||
|
||||
Replace `updateEvent`:
|
||||
|
||||
```ts
|
||||
async function updateEvent(
|
||||
calId: string,
|
||||
params: CalendarParams,
|
||||
timezone: string,
|
||||
getAccessToken: () => Promise<string>,
|
||||
): Promise<string> {
|
||||
if (!params.event_id) throw new Error("update requires event_id");
|
||||
const { scope } = await resolveScope(calId, params, getAccessToken);
|
||||
if (scope === "instance" && params.recurrence !== undefined) {
|
||||
throw new Error("recurrence can only be set on the series master (scope=series)");
|
||||
}
|
||||
const body = eventBody(params, timezone);
|
||||
if (Object.keys(body).length === 0) throw new Error("update requires at least one field to change");
|
||||
const resp = await fetch(
|
||||
`${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(params.event_id)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAccessToken()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
await ensureOk(resp, "update event");
|
||||
const event = (await resp.json()) as CalendarEvent;
|
||||
return `Updated: ${describeEvent(event, timezone)}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to confirm they pass**
|
||||
|
||||
Run: `npm test -- src/tools/calendar.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tools/calendar.ts src/tools/calendar.test.ts
|
||||
git commit -m "Resolve update scope from event id and guard recurrence-on-instance"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Delete with scope
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/tools/calendar.ts` (`deleteEvent`)
|
||||
- Modify: `src/tools/calendar.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```ts
|
||||
it("delete on a series master returns the series message", async () => {
|
||||
const { fn, calls } = stubFetch([
|
||||
{ body: { id: "series-1", recurrence: ["RRULE:FREQ=WEEKLY"] } }, // GET
|
||||
{ status: 204, body: {} }, // DELETE
|
||||
]);
|
||||
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
|
||||
const out = await handleCalendar({ action: "delete", event_id: "series-1" }, "UTC", token);
|
||||
expect(calls[1].init?.method).toBe("DELETE");
|
||||
expect(out).toBe("Deleted series series-1 (all occurrences).");
|
||||
});
|
||||
|
||||
it("delete on an occurrence cancels the instance and names the series", async () => {
|
||||
const { fn, calls } = stubFetch([
|
||||
{ body: { id: "occ-1", recurringEventId: "series-1" } },
|
||||
{ status: 204, body: {} },
|
||||
]);
|
||||
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
|
||||
const out = await handleCalendar({ action: "delete", event_id: "occ-1" }, "UTC", token);
|
||||
expect(calls[1].url).toContain("/events/occ-1");
|
||||
expect(out).toBe("Cancelled occurrence occ-1 of series series-1.");
|
||||
});
|
||||
|
||||
it("delete on a plain single event keeps the original message", async () => {
|
||||
const { fn } = stubFetch([
|
||||
{ body: { id: "single-1" } },
|
||||
{ status: 204, body: {} },
|
||||
]);
|
||||
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
|
||||
const out = await handleCalendar({ action: "delete", event_id: "single-1" }, "UTC", token);
|
||||
expect(out).toBe("Deleted event single-1.");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to confirm they fail**
|
||||
|
||||
Run: `npm test -- src/tools/calendar.test.ts`
|
||||
Expected: FAIL — current `deleteEvent` always returns `Deleted event <id>.` and skips the GET.
|
||||
|
||||
- [ ] **Step 3: Rewrite `deleteEvent`**
|
||||
|
||||
Replace `deleteEvent`:
|
||||
|
||||
```ts
|
||||
async function deleteEvent(
|
||||
calId: string,
|
||||
params: CalendarParams,
|
||||
getAccessToken: () => Promise<string>,
|
||||
): Promise<string> {
|
||||
if (!params.event_id) throw new Error("delete requires event_id");
|
||||
const { scope, event } = await resolveScope(calId, params, getAccessToken);
|
||||
const resp = await fetch(
|
||||
`${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(params.event_id)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${await getAccessToken()}` },
|
||||
},
|
||||
);
|
||||
await ensureOk(resp, "delete event");
|
||||
if (scope === "instance") {
|
||||
return `Cancelled occurrence ${params.event_id} of series ${event.recurringEventId}.`;
|
||||
}
|
||||
if (scope === "series") {
|
||||
return `Deleted series ${params.event_id} (all occurrences).`;
|
||||
}
|
||||
return `Deleted event ${params.event_id}.`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to confirm they pass**
|
||||
|
||||
Run: `npm test -- src/tools/calendar.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tools/calendar.ts src/tools/calendar.test.ts
|
||||
git commit -m "Scope delete to instance or series"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: `list_instances` action
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/tools/calendar.ts` (new `listInstances`, wire it into the switch)
|
||||
- Modify: `src/tools/calendar.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```ts
|
||||
it("list_instances GETs the series' instances endpoint with the paging params", async () => {
|
||||
const { fn, calls } = stubFetch([
|
||||
{
|
||||
body: {
|
||||
items: [
|
||||
{
|
||||
id: "series-1_20260914T070000Z",
|
||||
summary: "Standup",
|
||||
start: { dateTime: "2026-09-14T09:00:00+02:00" },
|
||||
end: { dateTime: "2026-09-14T09:15:00+02:00" },
|
||||
recurringEventId: "series-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
|
||||
const out = await handleCalendar(
|
||||
{
|
||||
action: "list_instances",
|
||||
event_id: "series-1",
|
||||
time_min: "2026-09-14T00:00:00+02:00",
|
||||
time_max: "2026-09-21T00:00:00+02:00",
|
||||
max_results: 5,
|
||||
},
|
||||
"Europe/Warsaw",
|
||||
token,
|
||||
);
|
||||
const url = new URL(calls[0].url);
|
||||
expect(url.pathname).toBe("/calendar/v3/calendars/primary/events/series-1/instances");
|
||||
expect(url.searchParams.get("timeMin")).toBe("2026-09-14T00:00:00+02:00");
|
||||
expect(url.searchParams.get("timeMax")).toBe("2026-09-21T00:00:00+02:00");
|
||||
expect(url.searchParams.get("maxResults")).toBe("5");
|
||||
expect(out).toContain("series-1_20260914T070000Z");
|
||||
expect(out).not.toContain("recurring (series=");
|
||||
});
|
||||
|
||||
it("list_instances requires event_id", async () => {
|
||||
globalThis.fetch = vi.fn() as unknown as typeof globalThis.fetch;
|
||||
await expect(
|
||||
handleCalendar({ action: "list_instances" }, "UTC", token),
|
||||
).rejects.toThrow(/list_instances requires event_id/);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to confirm they fail**
|
||||
|
||||
Run: `npm test -- src/tools/calendar.test.ts`
|
||||
Expected: FAIL — the switch still throws "not implemented yet".
|
||||
|
||||
- [ ] **Step 3: Implement `listInstances` and add a suffix-suppressing describe helper**
|
||||
|
||||
In `src/tools/calendar.ts`, add:
|
||||
|
||||
```ts
|
||||
async function listInstances(
|
||||
calId: string,
|
||||
params: CalendarParams,
|
||||
timezone: string,
|
||||
getAccessToken: () => Promise<string>,
|
||||
): Promise<string> {
|
||||
if (!params.event_id) throw new Error("list_instances requires event_id (the series master id)");
|
||||
const url = new URL(
|
||||
`${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(params.event_id)}/instances`,
|
||||
);
|
||||
if (params.time_min) url.searchParams.set("timeMin", params.time_min);
|
||||
if (params.time_max) url.searchParams.set("timeMax", params.time_max);
|
||||
url.searchParams.set("maxResults", String(Math.min(params.max_results ?? 25, 250)));
|
||||
const resp = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${await getAccessToken()}` },
|
||||
});
|
||||
await ensureOk(resp, "list instances");
|
||||
const data = (await resp.json()) as { items?: CalendarEvent[] };
|
||||
const items = data.items ?? [];
|
||||
if (!items.length) return "No instances.";
|
||||
return items.map((event) => describeInstance(event, timezone)).join("\n");
|
||||
}
|
||||
|
||||
function describeInstance(event: CalendarEvent, timezone: string): string {
|
||||
const summary = event.summary ?? "(no title)";
|
||||
const when = describeWhen(event, timezone);
|
||||
const location = event.location ? ` @ ${event.location}` : "";
|
||||
return `${event.id} · ${summary} · ${when}${location}`;
|
||||
}
|
||||
```
|
||||
|
||||
Update the switch:
|
||||
|
||||
```ts
|
||||
case "list_instances":
|
||||
return listInstances(calId, params, timezone, getAccessToken);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to confirm they pass**
|
||||
|
||||
Run: `npm test -- src/tools/calendar.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tools/calendar.ts src/tools/calendar.test.ts
|
||||
git commit -m "Add list_instances action for recurring series"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Final verification + README checklist entry
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Add a manual E2E checklist item**
|
||||
|
||||
In `README.md`, append one item to the numbered list under `### Manual end-to-end checklist`:
|
||||
|
||||
```markdown
|
||||
13. "Every Monday and Wednesday at 9 create a standup event for 15 minutes" → an event is created with `RRULE:FREQ=WEEKLY;BYDAY=MO,WE`; "list the next 3 standups" returns three occurrences; "move next Wednesday's standup to 10" changes just that instance; "delete the standup series" removes them all.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the full test suite**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS — all existing tests plus the new `src/tools/calendar.test.ts` suite.
|
||||
|
||||
- [ ] **Step 3: Run typecheck**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
Expected: PASS with no errors.
|
||||
|
||||
- [ ] **Step 4: Run build to catch anything typecheck missed**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: exits 0, `dist/` updated.
|
||||
|
||||
- [ ] **Step 5: Commit README + verify clean tree**
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "Document recurring-event manual E2E check"
|
||||
git status
|
||||
```
|
||||
|
||||
Expected: `git status` shows a clean working tree.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage**
|
||||
|
||||
| Spec section | Task |
|
||||
| --- | --- |
|
||||
| Recurrence input shape (`recurrence: string[]`) | Task 1 (schema) + Task 2 (create body + validation) |
|
||||
| Scope resolution (`scope` param, auto-detect, error rules) | Task 4 (`resolveScope`) |
|
||||
| `list` occurrences marker | Task 3 |
|
||||
| `list_mode=series` | Task 3 |
|
||||
| `list_instances` action | Task 1 (schema) + Task 6 (impl) |
|
||||
| create with recurrence | Task 2 |
|
||||
| update semantics (instance/series, reject recurrence on instance) | Task 4 |
|
||||
| delete semantics (instance/series/single messages) | Task 5 |
|
||||
| Output formats (recurring suffix, RRULE line, delete messages, instance line) | Tasks 2, 3, 5, 6 |
|
||||
| Errors (missing event_id on list_instances, master-vs-instance mismatch, recurrence-on-instance, invalid RRULE prefix) | Tasks 2, 4, 5, 6 |
|
||||
| Types (`recurringEventId`, `recurrence` on `CalendarEvent`) | Task 1 |
|
||||
| Tests (all bullets from spec §Testing) | Tasks 2–6 |
|
||||
| README E2E line | Task 7 |
|
||||
|
||||
Every spec requirement maps to at least one task.
|
||||
|
||||
**Placeholder scan:** none — every step has full code, an exact command, and an expected result.
|
||||
|
||||
**Type consistency:** `resolveScope` returns `{ scope: ResolvedScope; event: CalendarEvent }` and is called with those types in Tasks 4 and 5. `describeEvent` gains a `recurring` and `rrule` suffix in Task 2 and is used by `list`, `create`, `update`. `describeInstance` is used only by `list_instances`. `CalendarEvent.recurringEventId` and `CalendarEvent.recurrence` are added in Task 1 and consumed in Tasks 2–6. `validateRecurrence` is defined in Task 2 and used from `eventBody`. All names line up.
|
||||
Reference in New Issue
Block a user