Add implementation plan for recurring calendar events

This commit is contained in:
EugeneTes
2026-09-08 08:00:59 +00:00
parent 30695cb121
commit 160a615713

View 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.