From dfbf2b1d450f5c85f25f9e14fb1dc1761c46b893 Mon Sep 17 00:00:00 2001
From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com>
Date: Tue, 7 Jul 2026 10:23:34 -0400
Subject: [PATCH 1/8] Handle Google working locations in calendar
---
.../.agents/skills/event-management/SKILL.md | 26 ++-
templates/calendar/AGENTS.md | 8 +
templates/calendar/README.md | 3 +-
templates/calendar/actions/create-event.ts | 13 +-
.../actions/event-action-helpers.test.ts | 69 +++++++-
.../calendar/actions/event-action-helpers.ts | 49 ++++++
.../calendar/actions/update-event.test.ts | 152 ++++++++++++++++++
templates/calendar/actions/update-event.ts | 85 +++++++++-
.../app/components/calendar/DayView.tsx | 39 ++++-
.../app/components/calendar/EventCard.tsx | 28 +++-
.../calendar/EventDetailPopover.tsx | 81 ++++++++--
.../app/components/calendar/WeekView.tsx | 22 ++-
templates/calendar/app/hooks/use-events.ts | 2 +
.../calendar/app/lib/working-location.test.ts | 74 +++++++++
.../calendar/app/lib/working-location.ts | 69 ++++++++
...m-google-calendar-now-appear-as-native-.md | 6 +
.../server/lib/calendar-availability.spec.ts | 8 +
.../server/lib/calendar-availability.ts | 2 +
.../server/lib/google-calendar.spec.ts | 40 +++++
19 files changed, 738 insertions(+), 38 deletions(-)
create mode 100644 templates/calendar/actions/update-event.test.ts
create mode 100644 templates/calendar/app/lib/working-location.test.ts
create mode 100644 templates/calendar/app/lib/working-location.ts
create mode 100644 templates/calendar/changelog/2026-07-07-working-locations-from-google-calendar-now-appear-as-native-.md
diff --git a/templates/calendar/.agents/skills/event-management/SKILL.md b/templates/calendar/.agents/skills/event-management/SKILL.md
index bf4e2beb26..5e7b2ac844 100644
--- a/templates/calendar/.agents/skills/event-management/SKILL.md
+++ b/templates/calendar/.agents/skills/event-management/SKILL.md
@@ -113,12 +113,19 @@ 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.
+
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.
@@ -208,6 +215,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 \
@@ -220,6 +233,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
diff --git a/templates/calendar/AGENTS.md b/templates/calendar/AGENTS.md
index 2759d43d47..9ea6e12634 100644
--- a/templates/calendar/AGENTS.md
+++ b/templates/calendar/AGENTS.md
@@ -37,6 +37,14 @@ 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.
+- 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
diff --git a/templates/calendar/README.md b/templates/calendar/README.md
index 059b63656a..c13c0dc0d1 100644
--- a/templates/calendar/README.md
+++ b/templates/calendar/README.md
@@ -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
diff --git a/templates/calendar/actions/create-event.ts b/templates/calendar/actions/create-event.ts
index 87240c3c51..e7c3c34b67 100644
--- a/templates/calendar/actions/create-event.ts
+++ b/templates/calendar/actions/create-event.ts
@@ -27,6 +27,7 @@ import {
reminderMethodInput,
reminderMinutesInput,
remindersInput,
+ validateStatusEventTiming,
visibilityInput,
workingLocationTypeInput,
} from "./event-action-helpers.js";
@@ -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(
diff --git a/templates/calendar/actions/event-action-helpers.test.ts b/templates/calendar/actions/event-action-helpers.test.ts
index 0bc5d28e8f..23449e5fb2 100644
--- a/templates/calendar/actions/event-action-helpers.test.ts
+++ b/templates/calendar/actions/event-action-helpers.test.ts
@@ -9,6 +9,7 @@ vi.mock("../server/lib/google-calendar.js", () => ({}));
import {
buildStatusEventFields,
ensureOrganizerInAttendees,
+ validateStatusEventTiming,
} from "./event-action-helpers";
describe("ensureOrganizerInAttendees", () => {
@@ -57,7 +58,6 @@ describe("ensureOrganizerInAttendees", () => {
]);
});
});
-
describe("buildStatusEventFields", () => {
it("creates native out-of-office fields", () => {
expect(buildStatusEventFields({ eventType: "outOfOffice" })).toEqual({
@@ -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.");
+ });
});
diff --git a/templates/calendar/actions/event-action-helpers.ts b/templates/calendar/actions/event-action-helpers.ts
index 63e5e66391..c23004a267 100644
--- a/templates/calendar/actions/event-action-helpers.ts
+++ b/templates/calendar/actions/event-action-helpers.ts
@@ -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.");
+ }
+ }
+}
diff --git a/templates/calendar/actions/update-event.test.ts b/templates/calendar/actions/update-event.test.ts
new file mode 100644
index 0000000000..49c350dd35
--- /dev/null
+++ b/templates/calendar/actions/update-event.test.ts
@@ -0,0 +1,152 @@
+import { runWithRequestContext } from "@agent-native/core/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const isConnectedMock = vi.hoisted(() => vi.fn());
+const getAuthStatusMock = vi.hoisted(() => vi.fn());
+const getEventMock = vi.hoisted(() => vi.fn());
+const updateEventMock = vi.hoisted(() => vi.fn());
+
+vi.mock("../server/lib/google-calendar.js", () => ({
+ isConnected: isConnectedMock,
+ getAuthStatus: getAuthStatusMock,
+ getEvent: getEventMock,
+ updateEvent: updateEventMock,
+}));
+
+vi.mock("../server/lib/event-guest-notifications.js", () => ({
+ normalizeGuestNotificationMessage: vi.fn((message) => message),
+ sendEventGuestNotificationNote: vi.fn(),
+}));
+
+vi.mock("../server/lib/event-video-conferencing.js", () => ({
+ prepareZoomMeetingPatch: vi.fn(),
+}));
+
+import action from "./update-event";
+
+describe("update-event working locations", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ isConnectedMock.mockResolvedValue(true);
+ getAuthStatusMock.mockResolvedValue({ accounts: [] });
+ updateEventMock.mockResolvedValue({
+ htmlLink: "https://calendar.google.com/event",
+ });
+ });
+
+ it("patches working-location metadata on existing Google working-location events", async () => {
+ getEventMock.mockResolvedValue({
+ id: "google-working-location-1",
+ title: "Working location",
+ description: "",
+ location: "",
+ start: "2026-07-06",
+ end: "2026-07-07",
+ allDay: true,
+ source: "google",
+ accountEmail: "owner@example.com",
+ eventType: "workingLocation",
+ workingLocationProperties: {
+ type: "officeLocation",
+ officeLocation: {
+ label: "Old office",
+ buildingId: "nyc",
+ floorId: "6",
+ floorSectionId: "east",
+ deskId: "D14",
+ },
+ },
+ createdAt: "2026-07-06T00:00:00.000Z",
+ updatedAt: "2026-07-06T00:00:00.000Z",
+ });
+
+ await runWithRequestContext({ userEmail: "owner@example.com" }, () =>
+ action.run({
+ id: "google-working-location-1",
+ workingLocationType: "officeLocation",
+ workingLocationLabel: "Pier 57",
+ }),
+ );
+
+ expect(updateEventMock).toHaveBeenCalledWith(
+ "working-location-1",
+ expect.objectContaining({
+ accountEmail: "owner@example.com",
+ transparency: "transparent",
+ visibility: "public",
+ workingLocationProperties: {
+ type: "officeLocation",
+ officeLocation: {
+ label: "Pier 57",
+ buildingId: "nyc",
+ floorId: "6",
+ floorSectionId: "east",
+ deskId: "D14",
+ },
+ },
+ }),
+ expect.any(Object),
+ );
+ });
+
+ it("does not try to convert a normal event into a working-location event", async () => {
+ getEventMock.mockResolvedValue({
+ id: "google-event-1",
+ title: "Normal meeting",
+ description: "",
+ location: "",
+ start: "2026-07-06T15:00:00.000Z",
+ end: "2026-07-06T15:30:00.000Z",
+ allDay: false,
+ source: "google",
+ accountEmail: "owner@example.com",
+ eventType: "default",
+ createdAt: "2026-07-06T00:00:00.000Z",
+ updatedAt: "2026-07-06T00:00:00.000Z",
+ });
+
+ await expect(
+ runWithRequestContext({ userEmail: "owner@example.com" }, () =>
+ action.run({
+ id: "google-event-1",
+ workingLocationType: "customLocation",
+ workingLocationLabel: "Home",
+ }),
+ ),
+ ).rejects.toThrow(
+ "Working location details can only be updated on existing working-location events.",
+ );
+ expect(updateEventMock).not.toHaveBeenCalled();
+ });
+
+ it("rejects multi-day all-day updates for working-location events before patching Google", async () => {
+ getEventMock.mockResolvedValue({
+ id: "google-working-location-1",
+ title: "Home",
+ description: "",
+ location: "",
+ start: "2026-07-06",
+ end: "2026-07-07",
+ allDay: true,
+ source: "google",
+ accountEmail: "owner@example.com",
+ eventType: "workingLocation",
+ workingLocationProperties: {
+ type: "homeOffice",
+ homeOffice: {},
+ },
+ createdAt: "2026-07-06T00:00:00.000Z",
+ updatedAt: "2026-07-06T00:00:00.000Z",
+ });
+
+ await expect(
+ runWithRequestContext({ userEmail: "owner@example.com" }, () =>
+ action.run({
+ id: "google-working-location-1",
+ end: "2026-07-11",
+ }),
+ ),
+ ).rejects.toThrow("All-day working location events must be a single day.");
+ expect(updateEventMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/templates/calendar/actions/update-event.ts b/templates/calendar/actions/update-event.ts
index 79f3062f7b..13aad1800f 100644
--- a/templates/calendar/actions/update-event.ts
+++ b/templates/calendar/actions/update-event.ts
@@ -13,6 +13,7 @@ import {
attachmentsInput,
attendeesInput,
buildReminderOverrides,
+ buildStatusEventFields,
cliBoolean,
googleColorIdInput,
normalizeAttendees,
@@ -23,7 +24,9 @@ import {
remindersInput,
requireActionUserEmail,
resolveOwnedAccountEmail,
+ validateStatusEventTiming,
visibilityInput,
+ workingLocationTypeInput,
} from "./event-action-helpers.js";
function mergeAttendees(
@@ -80,6 +83,15 @@ export default defineAction({
title: z.string().optional().describe("New event title"),
description: z.string().optional().describe("New event description"),
location: z.string().optional().describe("New event location"),
+ workingLocationType: workingLocationTypeInput.describe(
+ "For existing working-location events: homeOffice, officeLocation, or customLocation. Google Calendar event types cannot be changed after creation.",
+ ),
+ workingLocationLabel: z
+ .string()
+ .optional()
+ .describe(
+ "For existing working-location events: label shown in Google Calendar.",
+ ),
start: z.string().optional().describe("New start time/date as ISO string"),
end: z.string().optional().describe("New end time/date as ISO string"),
startTimeZone: z
@@ -92,10 +104,10 @@ export default defineAction({
.describe("IANA timezone for the event end, e.g. America/New_York"),
allDay: cliBoolean.optional().describe("Whether the event is all-day"),
transparency: availabilityInput.describe(
- "Google Calendar availability: opaque blocks time (Busy), transparent does not block time (Free).",
+ "Google Calendar availability: opaque blocks time (Busy), transparent does not block time (Free). Existing working-location events are always sent to Google as transparent.",
),
visibility: visibilityInput.describe(
- "Google Calendar visibility: default, public, private, or confidential.",
+ "Google Calendar visibility: default, public, private, or confidential. Existing working-location events are always sent to Google as public.",
),
status: z
.enum(["confirmed", "tentative", "cancelled"])
@@ -193,6 +205,13 @@ export default defineAction({
reminderMethod: args.reminderMethod,
useDefaultReminders: args.remindersUseDefault,
});
+ const hasWorkingLocationPatch =
+ args.workingLocationType !== undefined ||
+ args.workingLocationLabel !== undefined;
+ const hasTimePatch =
+ args.start !== undefined ||
+ args.end !== undefined ||
+ args.allDay !== undefined;
const attendeesToAdd = normalizeAttendees(args.addAttendees);
let attendees = normalizeAttendees(args.attendees);
@@ -216,7 +235,8 @@ export default defineAction({
attendeesToAdd !== undefined ||
Object.keys(reminderFields).length > 0 ||
args.addGoogleMeet === true ||
- args.addZoom === true;
+ args.addZoom === true ||
+ hasWorkingLocationPatch;
if (!hasPatch) {
throw new Error("No event updates provided.");
@@ -253,6 +273,65 @@ export default defineAction({
return existingEvent;
};
+ if (hasTimePatch) {
+ const existingEvent = await loadExistingEvent();
+ const existingStatusEventType =
+ existingEvent.eventType === "outOfOffice" ||
+ existingEvent.eventType === "focusTime" ||
+ existingEvent.eventType === "workingLocation"
+ ? existingEvent.eventType
+ : "default";
+ validateStatusEventTiming({
+ eventType: existingStatusEventType,
+ allDay: args.allDay ?? existingEvent.allDay,
+ start: args.start ?? existingEvent.start,
+ end: args.end ?? existingEvent.end,
+ });
+ }
+
+ if (hasWorkingLocationPatch) {
+ const existingEvent = await loadExistingEvent();
+ if (existingEvent.eventType !== "workingLocation") {
+ throw new Error(
+ "Working location details can only be updated on existing working-location events. Google Calendar event types cannot be changed after creation.",
+ );
+ }
+ const nextWorkingLocationType =
+ args.workingLocationType ??
+ existingEvent.workingLocationProperties?.type ??
+ "customLocation";
+ const nextWorkingLocationLabel =
+ args.workingLocationLabel ??
+ args.location ??
+ existingEvent.location ??
+ existingEvent.title;
+ const workingLocationFields = buildStatusEventFields({
+ eventType: "workingLocation",
+ title: args.title ?? existingEvent.title,
+ location: args.location ?? existingEvent.location,
+ workingLocationType: nextWorkingLocationType,
+ workingLocationLabel: nextWorkingLocationLabel,
+ });
+ const nextWorkingLocationProperties =
+ nextWorkingLocationType === "officeLocation"
+ ? {
+ type: "officeLocation" as const,
+ officeLocation: {
+ ...(existingEvent.workingLocationProperties?.type ===
+ "officeLocation"
+ ? existingEvent.workingLocationProperties.officeLocation
+ : {}),
+ label: nextWorkingLocationLabel,
+ },
+ }
+ : workingLocationFields.workingLocationProperties;
+ Object.assign(updates, {
+ transparency: "transparent",
+ visibility: "public",
+ workingLocationProperties: nextWorkingLocationProperties,
+ });
+ }
+
if (attendeesToAdd !== undefined) {
const existingEvent = await loadExistingEvent();
attendees = mergeAttendees(existingEvent.attendees, attendeesToAdd);
diff --git a/templates/calendar/app/components/calendar/DayView.tsx b/templates/calendar/app/components/calendar/DayView.tsx
index 0d794bf895..a29c49a034 100644
--- a/templates/calendar/app/components/calendar/DayView.tsx
+++ b/templates/calendar/app/components/calendar/DayView.tsx
@@ -35,6 +35,11 @@ import {
} from "@/lib/popover-click-guard";
import { EventStatusIcon } from "@/lib/rsvp-status";
import { cn } from "@/lib/utils";
+import {
+ getWorkingLocationChipLabel,
+ getWorkingLocationTitle,
+ isWorkingLocationEvent,
+} from "@/lib/working-location";
import { EventDetailPopover } from "./EventDetailPopover";
@@ -248,6 +253,7 @@ const DayEventCard = memo(function DayEventCard({
onDraftCreate,
onDraftDiscard,
}: DayEventCardProps) {
+ const t = useT();
const li = layout.get(event.id) ?? {
left: 0,
width: 100,
@@ -315,10 +321,10 @@ const DayEventCard = memo(function DayEventCard({
)}
aria-label={
event.ownerName || event.overlayEmail
- ? `${event.title}, ${
+ ? `${getWorkingLocationTitle(event)}, ${
event.ownerName || event.overlayEmail
}'s calendar`
- : event.title
+ : getWorkingLocationTitle(event)
}
style={{
...posStyle,
@@ -373,8 +379,13 @@ const DayEventCard = memo(function DayEventCard({
!isPast && !isDeclined && "font-semibold",
)}
>
- {event.title}
+ {getWorkingLocationChipLabel(event)}
+ {isWorkingLocationEvent(event) && (
+
+ {t("eventForm.workingLocation")}
+
+ )}
) : (
<>
@@ -395,8 +406,15 @@ const DayEventCard = memo(function DayEventCard({
/>
)}
- {t("eventForm.allDay")} -
-
+
+ {t("eventForm.allDay")} +
++ {t("eventForm.conferencingLinkOnSave")} +
+ {detail} +
+ )} ++ {t("eventForm.applyTo")} +
+