Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions templates/calendar/.agents/skills/event-management/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,26 @@ pnpm action create-event \
# Working location
pnpm action create-event \
--title "Working from home" \
--start 2026-04-03T09:00:00 \
--end 2026-04-03T17:00:00 \
--start 2026-04-03 \
--end 2026-04-04 \
--allDay true \
--eventType workingLocation \
--workingLocationType homeOffice
```

Working-location events sync from Google with `workingLocationProperties` and
render as native working locations in the UI instead of generic all-day events.
They are transparent/non-blocking for availability. Google allows timed working
locations or single-day all-day working locations; multi-day all-day ranges must
be represented as separate daily working-location events.

For a visible occurrence in a recurring working-location series, default to
`scope: "single"` and pass the occurrence's event `id`, not its
`recurringEventId`. Use `scope: "all"` only when the user explicitly asks to
change every day in the series. Keep office building/floor/desk metadata when
editing an office label, and clear incompatible location labels when changing
between Home, Office, and Other.

Do not use `eventType` for Tasks or appointment schedules. Google Calendar
Tasks are a separate product/API surface, and appointment schedules should use
booking links or availability workflows instead.
Expand Down Expand Up @@ -208,6 +222,12 @@ pnpm action update-event --id google-event-id --attendees "alice@example.com" --
pnpm action update-event --id google-event-id --addGoogleMeet=true
pnpm action update-event --id google-event-id --addZoom=true

# Update an existing working-location event's native metadata
pnpm action update-event \
--id google-working-location-id \
--workingLocationType officeLocation \
--workingLocationLabel "Pier 57"

# Add multiple alerts, a Google event color, and an attachment
pnpm action update-event \
--id google-event-id \
Expand All @@ -220,6 +240,15 @@ pnpm action update-event \

For "add Zoom to this meeting", fetch or use the visible event id and call `update-event --addZoom=true`. Do not create an extension for Zoom; Zoom is a first-party calendar integration handled by the event actions and the Settings page.

Google Calendar does not allow changing an existing event's `eventType`; use
`workingLocationType` and `workingLocationLabel` only on events that already
have `eventType: "workingLocation"`.

Google Calendar API v3 currently documents working locations on Events, but the
Settings API/discovery document does not expose working-hours settings. Treat
working-hours overlays or Find a Time constraints as a follow-up only after a
real provider data path exists.

For recurring events, pass a Google Calendar RRULE in `--recurrence`. Example: to make a daily event weekdays only, use:

```bash
Expand Down
11 changes: 11 additions & 0 deletions templates/calendar/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ Detailed event, availability, booking, storage, and UI rules live in
with `stageAs` and analyze them with `query-staged-dataset`.
- For Google Calendar, distinguish an empty calendar from missing auth,
reauth-needed, or fetch failures.
- Google Calendar working locations are status events (`eventType:
"workingLocation"`). Sync and display them as working locations, keep them
transparent/non-blocking, and preserve `workingLocationProperties` instead of
treating the summary as a generic all-day event title.
- When updating one visible occurrence in a recurring working-location series,
pass that occurrence's event `id` with `scope: "single"` by default. Use the
series scope only when the user explicitly chooses all days.
- Google Calendar API v3 exposes working locations through Events. The current
Settings API and Calendar v3 discovery document do not expose working-hours
settings, so do not promise working-hours UI or overlays unless a real
provider data path has been verified first.
- Use framework sharing actions for calendars/events/booking resources when
applicable.
- Booking-link sharing controls who can manage the link. Public booking access
Expand Down
3 changes: 2 additions & 1 deletion templates/calendar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ the agent can do through the same actions.
## Features

- Day, week, and month views with multiple Google accounts overlayed.
- Google Calendar sync and read-only ICS feed subscriptions.
- Google Calendar sync, native working locations, and read-only ICS feed
subscriptions.
- Weekly availability with timezone support for slot-finding.
- Calendly-style public booking links at `/book/{slug}` with custom fields.
- Ask the agent anything schedule-related, from "am I free Thursday?" to
Expand Down
13 changes: 7 additions & 6 deletions templates/calendar/actions/create-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
reminderMethodInput,
reminderMinutesInput,
remindersInput,
validateStatusEventTiming,
visibilityInput,
workingLocationTypeInput,
} from "./event-action-helpers.js";
Expand Down Expand Up @@ -117,12 +118,12 @@ export default defineAction({
if (args.addGoogleMeet && args.addZoom) {
throw new Error("Choose either Google Meet or Zoom, not both.");
}
if (
(args.eventType === "outOfOffice" || args.eventType === "focusTime") &&
args.allDay === true
) {
throw new Error("Out of office and focus time events must be timed.");
}
validateStatusEventTiming({
eventType: args.eventType,
allDay: args.allDay,
start: args.start,
end: args.end,
});

if (!(await googleCalendar.isConnected(email))) {
throw new Error(
Expand Down
69 changes: 68 additions & 1 deletion templates/calendar/actions/event-action-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ vi.mock("../server/lib/google-calendar.js", () => ({}));
import {
buildStatusEventFields,
ensureOrganizerInAttendees,
validateStatusEventTiming,
} from "./event-action-helpers";

describe("ensureOrganizerInAttendees", () => {
Expand Down Expand Up @@ -57,7 +58,6 @@ describe("ensureOrganizerInAttendees", () => {
]);
});
});

describe("buildStatusEventFields", () => {
it("creates native out-of-office fields", () => {
expect(buildStatusEventFields({ eventType: "outOfOffice" })).toEqual({
Expand Down Expand Up @@ -97,4 +97,71 @@ describe("buildStatusEventFields", () => {
},
});
});

it("creates labeled office working-location fields", () => {
expect(
buildStatusEventFields({
eventType: "workingLocation",
workingLocationType: "officeLocation",
workingLocationLabel: "Pier 57",
}),
).toMatchObject({
transparency: "transparent",
visibility: "public",
workingLocationProperties: {
type: "officeLocation",
officeLocation: { label: "Pier 57" },
},
});
});
});

describe("validateStatusEventTiming", () => {
it("rejects all-day out-of-office and focus-time events", () => {
const args = {
allDay: true,
start: "2026-07-06",
end: "2026-07-07",
};

expect(() =>
validateStatusEventTiming({ ...args, eventType: "outOfOffice" }),
).toThrow("Out of office and focus time events must be timed.");
expect(() =>
validateStatusEventTiming({ ...args, eventType: "focusTime" }),
).toThrow("Out of office and focus time events must be timed.");
});

it("allows single-day all-day working locations", () => {
expect(() =>
validateStatusEventTiming({
eventType: "workingLocation",
allDay: true,
start: "2026-07-06",
end: "2026-07-07",
}),
).not.toThrow();
});

it("allows single-day all-day working locations from ISO datetimes", () => {
expect(() =>
validateStatusEventTiming({
eventType: "workingLocation",
allDay: true,
start: "2026-07-06T04:00:00.000Z",
end: "2026-07-07T04:00:00.000Z",
}),
).not.toThrow();
});

it("rejects multi-day all-day working locations", () => {
expect(() =>
validateStatusEventTiming({
eventType: "workingLocation",
allDay: true,
start: "2026-07-06",
end: "2026-07-11",
}),
).toThrow("All-day working location events must be a single day.");
});
});
49 changes: 49 additions & 0 deletions templates/calendar/actions/event-action-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,52 @@ export function buildStatusEventFields(args: {
: { type, customLocation: { label } },
};
}

function allDayDatePart(value: string): string {
const dateOnlyPattern = /^\d{4}-\d{2}-\d{2}$/;
if (dateOnlyPattern.test(value)) return value;
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new Error(
"All-day status events must use valid date or datetime start and end values.",
);
}
return date.toISOString().slice(0, 10);
}

function allDaySpanDays(start: string, end: string): number {
const startDate = allDayDatePart(start);
const endDate = allDayDatePart(end);
const startMs = Date.UTC(
Number(startDate.slice(0, 4)),
Number(startDate.slice(5, 7)) - 1,
Number(startDate.slice(8, 10)),
);
const endMs = Date.UTC(
Number(endDate.slice(0, 4)),
Number(endDate.slice(5, 7)) - 1,
Number(endDate.slice(8, 10)),
);
return Math.round((endMs - startMs) / 86_400_000);
}

export function validateStatusEventTiming(args: {
eventType?: "default" | "outOfOffice" | "focusTime" | "workingLocation";
allDay?: boolean;
start: string;
end: string;
}) {
if (
(args.eventType === "outOfOffice" || args.eventType === "focusTime") &&
args.allDay === true
) {
throw new Error("Out of office and focus time events must be timed.");
}

if (args.eventType === "workingLocation" && args.allDay === true) {
const days = allDaySpanDays(args.start, args.end);
if (days !== 1) {
throw new Error("All-day working location events must be a single day.");
}
}
}
Loading
Loading