diff --git a/templates/calendar/.agents/skills/event-management/SKILL.md b/templates/calendar/.agents/skills/event-management/SKILL.md index bf4e2beb26..14a32e63bc 100644 --- a/templates/calendar/.agents/skills/event-management/SKILL.md +++ b/templates/calendar/.agents/skills/event-management/SKILL.md @@ -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. @@ -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 \ @@ -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 diff --git a/templates/calendar/AGENTS.md b/templates/calendar/AGENTS.md index 2759d43d47..f71d683dfd 100644 --- a/templates/calendar/AGENTS.md +++ b/templates/calendar/AGENTS.md @@ -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 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..a24ecbe04c --- /dev/null +++ b/templates/calendar/actions/update-event.test.ts @@ -0,0 +1,392 @@ +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()); +const createEventMock = vi.hoisted(() => vi.fn()); +const deleteEventMock = vi.hoisted(() => vi.fn()); + +vi.mock("../server/lib/google-calendar.js", () => ({ + isConnected: isConnectedMock, + getAuthStatus: getAuthStatusMock, + getEvent: getEventMock, + updateEvent: updateEventMock, + createEvent: createEventMock, + deleteEvent: deleteEventMock, +})); + +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"; + +function recurringWorkingLocationEvent() { + return { + id: "google-instance-20260707", + recurringEventId: "working-location-series", + title: "Office", + description: "", + location: "Pier 57", + start: "2026-07-07", + end: "2026-07-08", + allDay: true, + source: "google", + accountEmail: "owner@example.com", + eventType: "workingLocation", + workingLocationProperties: { + type: "officeLocation", + officeLocation: { label: "Pier 57", buildingId: "nyc" }, + }, + createdAt: "2026-07-06T00:00:00.000Z", + updatedAt: "2026-07-06T00:00:00.000Z", + }; +} + +describe("update-event working locations", () => { + beforeEach(() => { + vi.clearAllMocks(); + isConnectedMock.mockResolvedValue(true); + getAuthStatusMock.mockResolvedValue({ accounts: [] }); + updateEventMock.mockResolvedValue({ + htmlLink: "https://calendar.google.com/event", + }); + createEventMock.mockResolvedValue({ + id: "working-location-override", + htmlLink: "https://calendar.google.com/override", + }); + deleteEventMock.mockResolvedValue(undefined); + }); + + 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", + location: "Forbidden generic location", + }), + ); + + 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), + ); + expect(updateEventMock.mock.calls[0]?.[1]).not.toHaveProperty("location"); + }); + + it("replaces one recurring working-location instance with a single-day override", async () => { + getEventMock.mockResolvedValue(recurringWorkingLocationEvent()); + + await runWithRequestContext({ userEmail: "owner@example.com" }, () => + action.run({ + id: "google-instance-20260707", + workingLocationType: "homeOffice", + workingLocationLabel: "", + location: "", + scope: "single", + }), + ); + + expect(createEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + start: "2026-07-07", + end: "2026-07-08", + allDay: true, + eventType: "workingLocation", + workingLocationProperties: { + type: "homeOffice", + homeOffice: {}, + }, + }), + ); + expect(createEventMock.mock.calls[0]?.[0]).not.toHaveProperty("recurrence"); + expect(createEventMock.mock.calls[0]?.[0]).not.toHaveProperty( + "recurringEventId", + ); + expect(deleteEventMock).toHaveBeenCalledWith( + "instance-20260707", + "owner@example.com", + { scope: "single" }, + ); + expect(updateEventMock).not.toHaveBeenCalled(); + }); + + it("removes the replacement if cancelling the recurring instance fails", async () => { + getEventMock.mockResolvedValue(recurringWorkingLocationEvent()); + const cancellationError = new Error("Google rejected cancellation"); + deleteEventMock + .mockRejectedValueOnce(cancellationError) + .mockResolvedValueOnce(undefined); + + await expect( + runWithRequestContext({ userEmail: "owner@example.com" }, () => + action.run({ + id: "google-instance-20260707", + workingLocationType: "homeOffice", + scope: "single", + }), + ), + ).rejects.toThrow("Google rejected cancellation"); + + expect(deleteEventMock).toHaveBeenNthCalledWith( + 2, + "working-location-override", + "owner@example.com", + { scope: "single" }, + ); + }); + + it("does not cancel the original instance when Google omits the replacement id", async () => { + getEventMock.mockResolvedValue(recurringWorkingLocationEvent()); + createEventMock.mockResolvedValue({}); + + await expect( + runWithRequestContext({ userEmail: "owner@example.com" }, () => + action.run({ + id: "google-instance-20260707", + workingLocationType: "homeOffice", + scope: "single", + }), + ), + ).rejects.toThrow("Google did not return an id"); + + expect(deleteEventMock).not.toHaveBeenCalled(); + }); + + it("rejects mixed edits for a single recurring working-location occurrence", async () => { + getEventMock.mockResolvedValue(recurringWorkingLocationEvent()); + + await expect( + runWithRequestContext({ userEmail: "owner@example.com" }, () => + action.run({ + id: "google-instance-20260707", + workingLocationType: "homeOffice", + colorId: "2", + scope: "single", + }), + ), + ).rejects.toThrow("Change the working location separately"); + + expect(createEventMock).not.toHaveBeenCalled(); + expect(deleteEventMock).not.toHaveBeenCalled(); + expect(updateEventMock).not.toHaveBeenCalled(); + }); + + it("drops incompatible office metadata when switching to a custom location", async () => { + getEventMock.mockResolvedValue({ + id: "google-working-location-1", + title: "Office", + description: "", + location: "Pier 57", + start: "2026-07-07", + end: "2026-07-08", + allDay: true, + source: "google", + accountEmail: "owner@example.com", + eventType: "workingLocation", + workingLocationProperties: { + type: "officeLocation", + officeLocation: { + label: "Pier 57", + buildingId: "nyc", + 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: "customLocation", + workingLocationLabel: "Neighborhood cafe", + location: "Neighborhood cafe", + }), + ); + + expect(updateEventMock).toHaveBeenCalledWith( + "working-location-1", + expect.objectContaining({ + workingLocationProperties: { + type: "customLocation", + customLocation: { label: "Neighborhood cafe" }, + }, + }), + expect.any(Object), + ); + expect(updateEventMock.mock.calls[0]?.[1]).not.toHaveProperty("location"); + }); + + it("rejects a generic location-only edit on an existing working-location event", async () => { + getEventMock.mockResolvedValue({ + id: "google-working-location-1", + title: "Home", + description: "", + location: "", + start: "2026-07-07", + end: "2026-07-08", + 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", + location: "Pier 57", + }), + ), + ).rejects.toThrow( + "Working-location events do not support a generic location. Use workingLocationType and workingLocationLabel instead.", + ); + expect(updateEventMock).not.toHaveBeenCalled(); + }); + + it("keeps generic location edits working for ordinary events", async () => { + getEventMock.mockResolvedValue({ + id: "google-event-1", + title: "Team meeting", + description: "", + location: "Old room", + start: "2026-07-07T15:00:00.000Z", + end: "2026-07-07T15: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 runWithRequestContext({ userEmail: "owner@example.com" }, () => + action.run({ + id: "google-event-1", + location: "Conference room B", + }), + ); + + expect(updateEventMock).toHaveBeenCalledWith( + "event-1", + expect.objectContaining({ location: "Conference room B" }), + 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..dded1cf87f 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( @@ -64,6 +67,16 @@ function mergeAttendees( return Array.from(merged.values()); } +function workingLocationTitle( + properties: NonNullable, +): string { + if (properties.type === "homeOffice") return "Home"; + if (properties.type === "officeLocation") { + return properties.officeLocation?.label || "Office"; + } + return properties.customLocation?.label || "Working location"; +} + export default defineAction({ description: "Update a Google Calendar event. Supports title, description, location, time, event color, attachments, reminders, and recurrence rules such as RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR.", @@ -80,6 +93,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 +114,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 +215,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 +245,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 +283,80 @@ export default defineAction({ return existingEvent; }; + if (args.location !== undefined && !hasWorkingLocationPatch) { + const existingEvent = await loadExistingEvent(); + if (existingEvent.eventType === "workingLocation") { + throw new Error( + "Working-location events do not support a generic location. Use workingLocationType and workingLocationLabel instead.", + ); + } + } + + 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 existingWorkingLocationLabel = + existingEvent.workingLocationProperties?.type === "officeLocation" + ? existingEvent.workingLocationProperties.officeLocation?.label + : existingEvent.workingLocationProperties?.type === "customLocation" + ? existingEvent.workingLocationProperties.customLocation?.label + : undefined; + const nextWorkingLocationLabel = + args.workingLocationLabel ?? + args.location ?? + existingWorkingLocationLabel ?? + existingEvent.title; + const workingLocationFields = buildStatusEventFields({ + eventType: "workingLocation", + title: args.title ?? existingEvent.title, + 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, + }); + delete updates.location; + } + if (attendeesToAdd !== undefined) { const existingEvent = await loadExistingEvent(); attendees = mergeAttendees(existingEvent.attendees, attendeesToAdd); @@ -296,15 +400,101 @@ export default defineAction({ }; } - const result = await googleCalendar.updateEvent(googleEventId, updates, { - sendUpdates: - args.sendUpdates ?? - (guestNotificationMessage || (attendeesToAdd?.length ?? 0) > 0 - ? "all" - : undefined), - addGoogleMeet: args.addGoogleMeet, - scope: args.scope, - }); + let result: Awaited>; + let returnedGoogleEventId = googleEventId; + const replaceRecurringWorkingLocation = + hasWorkingLocationPatch && + (args.scope ?? "single") === "single" && + existingEvent?.recurringEventId && + updates.workingLocationProperties; + + if (replaceRecurringWorkingLocation) { + const hasUnsupportedReplacementPatch = + args.title !== undefined || + args.description !== undefined || + args.start !== undefined || + args.end !== undefined || + args.startTimeZone !== undefined || + args.endTimeZone !== undefined || + args.allDay !== undefined || + args.status !== undefined || + args.colorId !== undefined || + args.attachments !== undefined || + args.reminders !== undefined || + args.reminderMinutes !== undefined || + args.remindersUseDefault !== undefined || + args.addGoogleMeet === true || + args.addZoom === true || + args.recurrence !== undefined || + args.attendees !== undefined || + args.addAttendees !== undefined || + args.notificationMessage !== undefined; + if (hasUnsupportedReplacementPatch) { + throw new Error( + "Change the working location separately from other event fields for a single recurring occurrence.", + ); + } + const now = new Date().toISOString(); + const replacement = await googleCalendar.createEvent({ + id: "", + title: workingLocationTitle(updates.workingLocationProperties!), + description: "", + start: existingEvent!.start, + end: existingEvent!.end, + startTimeZone: existingEvent!.startTimeZone, + endTimeZone: existingEvent!.endTimeZone, + location: "", + allDay: existingEvent!.allDay, + source: "google", + accountEmail, + colorId: existingEvent!.colorId, + transparency: "transparent", + visibility: "public", + status: "confirmed", + eventType: "workingLocation", + workingLocationProperties: updates.workingLocationProperties, + createdAt: now, + updatedAt: now, + }); + if (!replacement.id) { + throw new Error( + "Google did not return an id for the working location.", + ); + } + try { + await googleCalendar.deleteEvent(googleEventId, accountEmail, { + scope: "single", + }); + } catch (error) { + try { + await googleCalendar.deleteEvent(replacement.id, accountEmail, { + scope: "single", + }); + } catch (cleanupError) { + console.error( + "Failed to clean up a working-location replacement after the original occurrence could not be cancelled.", + cleanupError, + ); + } + throw error; + } + returnedGoogleEventId = replacement.id; + result = { + htmlLink: replacement.htmlLink, + meetLink: replacement.meetLink, + conferenceData: replacement.conferenceData, + }; + } else { + result = await googleCalendar.updateEvent(googleEventId, updates, { + sendUpdates: + args.sendUpdates ?? + (guestNotificationMessage || (attendeesToAdd?.length ?? 0) > 0 + ? "all" + : undefined), + addGoogleMeet: args.addGoogleMeet, + scope: args.scope, + }); + } const returnedPatch: Partial = {}; if (result.htmlLink) returnedPatch.htmlLink = result.htmlLink; @@ -339,7 +529,7 @@ export default defineAction({ return { success: true, - id: `google-${googleEventId}`, + id: `google-${returnedGoogleEventId}`, accountEmail, updated: updatedKeys, htmlLink: result.htmlLink, diff --git a/templates/calendar/app/components/calendar/DayView.tsx b/templates/calendar/app/components/calendar/DayView.tsx index 0d794bf895..08f0f20e0d 100644 --- a/templates/calendar/app/components/calendar/DayView.tsx +++ b/templates/calendar/app/components/calendar/DayView.tsx @@ -1,6 +1,6 @@ import { useT } from "@agent-native/core/client"; import type { CalendarEvent } from "@shared/api"; -import { IconAlertTriangleFilled } from "@tabler/icons-react"; +import { IconAlertTriangleFilled, IconMapPin } from "@tabler/icons-react"; import { eachHourOfInterval, format, @@ -28,6 +28,7 @@ import { useViewPreferences, type ViewPreferences, } from "@/hooks/use-view-preferences"; +import { partitionAllDayEvents } from "@/lib/all-day-layout"; import { getEventDisplayColor, allOtherDeclined } from "@/lib/event-colors"; import { shouldSuppressAfterPopoverClose, @@ -35,6 +36,12 @@ import { } from "@/lib/popover-click-guard"; import { EventStatusIcon } from "@/lib/rsvp-status"; import { cn } from "@/lib/utils"; +import { + createWorkingLocationDisplayLabels, + getWorkingLocationChipLabel, + getWorkingLocationTitle, + isWorkingLocationEvent, +} from "@/lib/working-location"; import { EventDetailPopover } from "./EventDetailPopover"; @@ -248,6 +255,8 @@ const DayEventCard = memo(function DayEventCard({ onDraftCreate, onDraftDiscard, }: DayEventCardProps) { + const t = useT(); + const workingLocationLabels = createWorkingLocationDisplayLabels(t); const li = layout.get(event.id) ?? { left: 0, width: 100, @@ -315,10 +324,10 @@ const DayEventCard = memo(function DayEventCard({ )} aria-label={ event.ownerName || event.overlayEmail - ? `${event.title}, ${ + ? `${getWorkingLocationTitle(event, workingLocationLabels)}, ${ event.ownerName || event.overlayEmail }'s calendar` - : event.title + : getWorkingLocationTitle(event, workingLocationLabels) } style={{ ...posStyle, @@ -373,8 +382,13 @@ const DayEventCard = memo(function DayEventCard({ !isPast && !isDeclined && "font-semibold", )} > - {event.title} + {getWorkingLocationChipLabel(event, workingLocationLabels)} + {isWorkingLocationEvent(event) && ( + + {t("eventForm.workingLocation")} + + )} ) : ( <> @@ -395,8 +409,15 @@ const DayEventCard = memo(function DayEventCard({ /> )} - {event.title} + + {getWorkingLocationChipLabel(event, workingLocationLabels)} + + {isWorkingLocationEvent(event) && isStart && ( +
+ {t("eventForm.workingLocation")} +
+ )} {isStart && (
events.filter((e) => e.allDay), [events]); + const { workingLocations, regularEvents: regularAllDayEvents } = useMemo( + () => partitionAllDayEvents(allDayEvents), + [allDayEvents], + ); const timedEvents = useMemo(() => events.filter((e) => !e.allDay), [events]); const layout = useMemo(() => computeLayout(timedEvents), [timedEvents]); @@ -718,72 +744,161 @@ export const DayView = memo(function DayView({
- {/* All-day events */} + {/* Working locations and ordinary all-day events */} {allDayEvents.length > 0 && ( -
-

- {t("eventForm.allDay")} -

-
- {allDayEvents.map((event) => { - const color = getEventDisplayColor(event, prefs); - return ( - - - - ); - })} -
+
+ {workingLocations.length > 0 && ( +
+

+

+
+ {workingLocations.map((event) => { + const color = getEventDisplayColor(event, prefs); + return ( + + + + ); + })} +
+
+ )} + + {regularAllDayEvents.length > 0 && ( +
0 && "border-t border-border/60", + )} + > +

+ {t("eventForm.allDay")} +

+
+ {regularAllDayEvents.map((event) => { + const color = getEventDisplayColor(event, prefs); + return ( + + + + ); + })} +
+
+ )}
)} diff --git a/templates/calendar/app/components/calendar/EventCard.tsx b/templates/calendar/app/components/calendar/EventCard.tsx index 3a5c6632c0..26a641afb3 100644 --- a/templates/calendar/app/components/calendar/EventCard.tsx +++ b/templates/calendar/app/components/calendar/EventCard.tsx @@ -1,3 +1,4 @@ +import { useT } from "@agent-native/core/client"; import type { CalendarEvent } from "@shared/api"; import { IconAlertTriangleFilled } from "@tabler/icons-react"; @@ -8,6 +9,12 @@ import { } from "@/lib/event-colors"; import { EventStatusIcon } from "@/lib/rsvp-status"; import { cn } from "@/lib/utils"; +import { + createWorkingLocationDisplayLabels, + getWorkingLocationChipLabel, + getWorkingLocationTitle, + isWorkingLocationEvent, +} from "@/lib/working-location"; interface EventCardProps { event: CalendarEvent; @@ -30,8 +37,13 @@ export function EventCard({ dimmed = false, colorPreferences, }: EventCardProps) { + const t = useT(); + const workingLocationLabels = createWorkingLocationDisplayLabels(t); const accentColor = getEventDisplayColor(event, colorPreferences); const ownerLabel = event.ownerName || event.overlayEmail; + const title = getWorkingLocationChipLabel(event, workingLocationLabels); + const ariaTitle = getWorkingLocationTitle(event, workingLocationLabels); + const isWorkingLocation = isWorkingLocationEvent(event); const handleDragStart = (e: React.DragEvent) => { e.dataTransfer.setData("text/plain", event.id); @@ -55,7 +67,7 @@ export function EventCard({ event.ownerColor && "pr-3.5", )} aria-label={ - ownerLabel ? `${event.title}, ${ownerLabel}'s calendar` : event.title + ownerLabel ? `${ariaTitle}, ${ownerLabel}'s calendar` : ariaTitle } style={{ backgroundColor: `${accentColor}25`, @@ -73,7 +85,12 @@ export function EventCard({ /> )} - {event.title} + {title} + {isWorkingLocation && ( + + {t("eventForm.workingLocation")} + + )} {event.ownerColor && (
+ {isWorkingLocation && ( + + {t("eventForm.workingLocation")} + + )} {!event.allDay && ( {new Date(event.start).toLocaleTimeString([], { diff --git a/templates/calendar/app/components/calendar/EventDetailPanel.tsx b/templates/calendar/app/components/calendar/EventDetailPanel.tsx index 8d952fdb3a..ba80cee357 100644 --- a/templates/calendar/app/components/calendar/EventDetailPanel.tsx +++ b/templates/calendar/app/components/calendar/EventDetailPanel.tsx @@ -23,6 +23,7 @@ import { AutoGrowTextarea, } from "@/components/calendar/EventDescription"; import { useGuestNotificationPrompt } from "@/components/calendar/GuestNotificationDialog"; +import { WorkingLocationEditor } from "@/components/calendar/WorkingLocationEditor"; import { useCalendarContext } from "@/components/layout/AppLayout"; import { Button } from "@/components/ui/button"; import { @@ -34,6 +35,13 @@ import { import { useUpdateEvent } from "@/hooks/use-events"; import { useViewPreferences } from "@/hooks/use-view-preferences"; import { cn } from "@/lib/utils"; +import { + buildWorkingLocationUpdate, + createWorkingLocationDisplayLabels, + getWorkingLocationTitle, + isWorkingLocationEvent, + type WorkingLocationSelection, +} from "@/lib/working-location"; function buildEventDetailSlotContext(event: CalendarEvent) { return { @@ -111,6 +119,7 @@ export function EventDetailPanel({ onTitleSave, }: EventDetailPanelProps) { const t = useT(); + const workingLocationLabels = createWorkingLocationDisplayLabels(t); const { setEventDetailSidebar } = useCalendarContext(); useViewPreferences(); const isOpen = event !== null; @@ -125,6 +134,10 @@ export function EventDetailPanel({ const { promptGuestNotification, guestNotificationDialog } = useGuestNotificationPrompt(); const isOverlay = !!event?.overlayEmail; + const isWorkingLocation = event ? isWorkingLocationEvent(event) : false; + const isRecurringEvent = !!( + event?.recurringEventId || event?.recurrence?.length + ); const lastSavedDescriptionRef = useRef(event?.description || ""); const meetingLink = event ? extractMeetingLink(event) : null; const ownerLabel = event?.ownerName || event?.overlayEmail; @@ -247,6 +260,16 @@ export function EventDetailPanel({ [event, promptGuestNotification, updateEvent], ); + const handleSaveWorkingLocation = useCallback( + (selection: WorkingLocationSelection) => { + if (!event) return; + updateEvent.mutate(buildWorkingLocationUpdate(event, selection), { + onError: () => toast.error(t("calendarView.failedUpdateEvent")), + }); + }, + [event, t, updateEvent], + ); + return ( {isOpen && ( @@ -268,7 +291,9 @@ export function EventDetailPanel({ {/* Header */}
- {t("eventForm.event")} + {isWorkingLocation + ? t("eventForm.workingLocation") + : t("eventForm.event")}
@@ -300,7 +325,7 @@ export function EventDetailPanel({ {/* Content */}
{/* Title — click to edit */} - {isEditingTitle ? ( + {isEditingTitle && !isWorkingLocation ? ( ) : (

{ + if (isWorkingLocation) return; setEditingTitle(event.title); setIsEditingTitle(true); }} > - {event.title} + {getWorkingLocationTitle(event, workingLocationLabels)}

)} @@ -368,13 +397,20 @@ export function EventDetailPanel({
- {/* Location */} - {event.location && ( + {isWorkingLocation ? ( + + ) : event.location ? (
{event.location}
- )} + ) : null} {event.overlayEmail && ownerLabel && (
@@ -391,32 +427,33 @@ export function EventDetailPanel({
)} - {meetingLink ? ( - - - {t("eventForm.joinMeeting")} - - ) : !isOverlay ? ( - - ) : null} + {!isWorkingLocation && + (meetingLink ? ( + + + {t("eventForm.joinMeeting")} + + ) : !isOverlay ? ( + + ) : null)} {/* Description — always shown, editable; hidden for overlay events with no description */} - {(!isOverlay || event.description) && ( + {!isWorkingLocation && (!isOverlay || event.description) && (
{isOverlay ? ( @@ -446,49 +483,55 @@ export function EventDetailPanel({ )} {/* Attachments */} - {event.attachments && event.attachments.length > 0 && ( -
- {event.attachments.map((att, i) => ( - - {att.iconLink ? ( - - ) : ( - - )} - - {att.title} - - - - ))} -
- )} + {!isWorkingLocation && + event.attachments && + event.attachments.length > 0 && ( +
+ {event.attachments.map((att, i) => ( + + {att.iconLink ? ( + + ) : ( + + )} + + {att.title} + + + + ))} +
+ )} {/* Attendees */} - {event.attendees && event.attendees.length > 0 && ( - - )} + {!isWorkingLocation && + event.attendees && + event.attendees.length > 0 && ( + + )} {/* Research Meeting */} - {event.attendees && event.attendees.length > 0 && ( - - )} + {!isWorkingLocation && + event.attendees && + event.attendees.length > 0 && ( + + )} - {eventDetailSlotContext && ( + {!isWorkingLocation && eventDetailSlotContext && ( & { addZoom?: boolean; addAttendees?: CalendarEvent["attendees"]; scope?: UpdateEventScope; + workingLocationType?: "homeOffice" | "officeLocation" | "customLocation"; + workingLocationLabel?: string; }; function mergeAttendeesForPrompt( @@ -427,6 +437,7 @@ export function EventDetailPopover({ onDraftDiscard, }: EventDetailPopoverProps) { const t = useT(); + const workingLocationLabels = createWorkingLocationDisplayLabels(t); const isMobile = useIsMobile(); const [open, setOpen] = useState(defaultOpen); const [editingTitle, setEditingTitle] = useState( @@ -445,6 +456,8 @@ export function EventDetailPopover({ setSidebarEvent, setFocusedEvent, } = useCalendarContext(); + const isWorkingLocation = isWorkingLocationEvent(event); + const editableLocationValue = event.location || ""; // Inline editing state const [editingField, setEditingField] = useState(null); @@ -452,7 +465,7 @@ export function EventDetailPopover({ const [editDescription, setEditDescription] = useState( event.description || "", ); - const [editLocation, setEditLocation] = useState(event.location || ""); + const [editLocation, setEditLocation] = useState(editableLocationValue); const [editDate, setEditDate] = useState(() => toDateInputValue(event.start)); const [editEndDate, setEditEndDate] = useState(() => event.allDay @@ -520,7 +533,7 @@ export function EventDetailPopover({ useEffect(() => { if (editingField !== "description") setEditDescription(event.description || ""); - if (editingField !== "location") setEditLocation(event.location || ""); + if (editingField !== "location") setEditLocation(editableLocationValue); if (editingField !== "time") { setEditDate(toDateInputValue(event.start)); setEditEndDate( @@ -550,6 +563,7 @@ export function EventDetailPopover({ event.id, event.description, event.location, + event.workingLocationProperties, event.start, event.end, event.allDay, @@ -557,6 +571,7 @@ export function EventDetailPopover({ event.reminders, event.remindersUseDefault, event.attachments, + editableLocationValue, ]); useEffect(() => { @@ -605,7 +620,6 @@ export function EventDetailPopover({ ? event.visibility : "default"; const reminderValue = getReminderValue(event); - // Save a field update const saveField = useCallback( (updates: EventUpdatePatch) => { @@ -890,13 +904,14 @@ export function EventDetailPopover({ const locationContainsMeetingLink = !!meetingLink && event.location?.includes(meetingLink.url); if (locationContainsMeetingLink && !trimmed) { - setEditLocation(event.location || ""); + setEditLocation(editableLocationValue); setEditingField(null); return false; } let saved = false; - if (trimmed !== (event.location || "").trim()) { - const updates: Partial = { location: trimmed }; + const currentValue = (event.location || "").trim(); + if (trimmed !== currentValue.trim()) { + const updates: EventUpdatePatch = { location: trimmed }; if ( locationContainsMeetingLink && meetingLink && @@ -914,7 +929,30 @@ export function EventDetailPopover({ } setEditingField(null); return saved; - }, [editLocation, event.description, event.location, meetingLink, saveField]); + }, [ + editLocation, + editableLocationValue, + event.description, + event.location, + meetingLink, + saveField, + t, + ]); + + const handleSaveWorkingLocation = useCallback( + (selection: WorkingLocationSelection) => { + const update = buildWorkingLocationUpdate(event, selection); + if (isDraft) { + const { id: _id, scope: _scope, ...draftUpdate } = update; + onDraftUpdate?.(event.id, draftUpdate); + return; + } + updateEvent.mutate(update, { + onError: () => toast.error(t("calendarView.failedUpdateEvent")), + }); + }, + [event, isDraft, onDraftUpdate, t, updateEvent], + ); const handleSaveTime = useCallback(() => { const allDayEnd = new Date(`${editEndDate}T00:00:00`); @@ -1277,7 +1315,11 @@ export function EventDetailPopover({
- {isDraft ? t("eventForm.draftEvent") : t("eventForm.event")} + {isWorkingLocation + ? t("eventForm.workingLocation") + : isDraft + ? t("eventForm.draftEvent") + : t("eventForm.event")}
@@ -1313,7 +1355,7 @@ export function EventDetailPopover({
{/* Title — always editable */} - {isEditingTitle ? ( + {isEditingTitle && !isWorkingLocation ? ( ) : (

{ - if (isOverlay) return; + if (isOverlay || isWorkingLocation) return; setEditingTitle(event.title); setIsEditingTitle(true); }} > - {event.title} + {getWorkingLocationTitle(event, workingLocationLabels)}

)}
@@ -1446,10 +1488,14 @@ export function EventDetailPopover({ - {t("eventForm.thisEvent")} + {isWorkingLocation + ? t("eventForm.thisDayOnly") + : t("eventForm.thisEvent")} - {t("eventForm.allEvents")} + {isWorkingLocation + ? t("eventForm.allDays") + : t("eventForm.allEvents")} @@ -1536,7 +1582,7 @@ export function EventDetailPopover({
)} - {!event.allDay && !isOverlay && ( + {!event.allDay && !isOverlay && !isWorkingLocation && (
- {/* Separator */} -
- - {/* Attendees — always shown */} - {event.attendees && event.attendees.length > 0 ? ( - - ) : !isOverlay ? ( -
-
- - - {t("eventForm.noGuests")} - -
-
- ) : null} - - {/* Add guest input */} - {!isOverlay && ( -
-
- - attendee.email, - )} - onAdd={handleAddAttendee} - placeholder={t("eventForm.addGuests")} - variant="inline" - showChips={false} - showAddButton - inputClassName="text-foreground placeholder:text-muted-foreground/40" + {!isWorkingLocation && ( + <> + {/* Separator */} +
+ + {/* Attendees — always shown */} + {event.attendees && event.attendees.length > 0 ? ( + -
-
- )} + ) : !isOverlay ? ( +
+
+ + + {t("eventForm.noGuests")} + +
+
+ ) : null} + + {/* Add guest input */} + {!isOverlay && ( +
+
+ + attendee.email, + )} + onAdd={handleAddAttendee} + placeholder={t("eventForm.addGuests")} + variant="inline" + showChips={false} + showAddButton + inputClassName="text-foreground placeholder:text-muted-foreground/40" + /> +
+
+ )} + + {/* Research Meeting button */} + {event.attendees && event.attendees.length > 0 && ( + <> +
+
+ +
+ + )} - {/* Research Meeting button */} - {event.attendees && event.attendees.length > 0 && ( - <>
- +
)} -
-
- -
- {/* Meeting link */} - {meetingLink ? ( - <> -
-
- - - {getMeetingLabel(meetingLink.type, t)} - - - {shortcutModifierLabel()} - - - J - - - - {(meetingLink.pin || meetingLink.passcode) && ( -
- {meetingLink.pin && ( - - {t("eventForm.pin", { pin: meetingLink.pin })} - - )} - {meetingLink.pin && meetingLink.passcode && ( - · - )} - {meetingLink.passcode && ( - - {t("eventForm.passcode", { - passcode: meetingLink.passcode, - })} - - )} -
- )} -
- - ) : pendingConferenceProvider ? ( - <> -
-
-
- {pendingConferenceProvider === "zoom" ? ( - - ) : ( - - )} - - {pendingConferenceProvider === "zoom" - ? t("eventForm.zoom") - : t("eventForm.googleMeet")} - - + + {getMeetingLabel(meetingLink.type, t)} + + + {shortcutModifierLabel()} + + + J + + + + {(meetingLink.pin || meetingLink.passcode) && ( +
+ {meetingLink.pin && ( + + {t("eventForm.pin", { pin: meetingLink.pin })} + + )} + {meetingLink.pin && meetingLink.passcode && ( + · + )} + {meetingLink.passcode && ( + + {t("eventForm.passcode", { + passcode: meetingLink.passcode, + })} + + )} +
+ )}
-

- {t("eventForm.conferencingLinkOnSave")} -

-
- - ) : !isOverlay ? ( - <> -
- {editingField === "meetingLink" ? ( + + ) : pendingConferenceProvider ? ( + <> +
-
- - setEditMeetingLink(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleSaveMeetingLink(); - } - if (e.key === "Escape") { - e.preventDefault(); - setEditMeetingLink(""); - setEditingField(null); - } - e.stopPropagation(); - }} - onBlur={handleSaveMeetingLink} - placeholder={t("eventForm.pasteMeetingLink")} - className="flex-1 bg-transparent border-none outline-none text-sm text-foreground placeholder:text-muted-foreground/40 focus:ring-0" - /> +
+ {pendingConferenceProvider === "zoom" ? ( + + ) : ( + + )} + + {pendingConferenceProvider === "zoom" + ? t("eventForm.zoom") + : t("eventForm.googleMeet")} + +
+

+ {t("eventForm.conferencingLinkOnSave")} +

- ) : ( -
- {pendingVideoProvider ? ( - - ) : ( + + ) : !isOverlay ? ( + <> +
+ {editingField === "meetingLink" ? ( +
- - - + + setEditMeetingLink(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSaveMeetingLink(); + } + if (e.key === "Escape") { + e.preventDefault(); + setEditMeetingLink(""); + setEditingField(null); + } + e.stopPropagation(); + }} + onBlur={handleSaveMeetingLink} + placeholder={t("eventForm.pasteMeetingLink")} + className="flex-1 bg-transparent border-none outline-none text-sm text-foreground placeholder:text-muted-foreground/40 focus:ring-0" + />
- )} -
- )} - - ) : null} +
+ ) : ( +
+ {pendingVideoProvider ? ( + + ) : ( +
+ + + +
+ )} +
+ )} + + ) : null)} {/* Attachments */} - {!isOverlay && ( + {!isOverlay && !isWorkingLocation && ( <>
{editingField === "attachments" ? ( @@ -1973,85 +2034,104 @@ export function EventDetailPopover({ )} - {/* Location — always shown, editable */} -
- {editingField === "location" ? ( -
- - setEditLocation(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleSaveLocation(); - } - if (e.key === "Escape") { - e.preventDefault(); - setEditLocation(event.location || ""); - setEditingField(null); - } - e.stopPropagation(); - }} - onBlur={handleSaveLocation} - placeholder={t("eventForm.addLocation")} - className="flex-1 bg-transparent border-none outline-none text-sm text-foreground placeholder:text-muted-foreground/40 focus:ring-0" - /> -
- ) : event.location && !locationIsMeetingLink ? ( -
{ - if (isOverlay) return; - setEditingField("location"); - }} - > - - {locationIsUrl ? ( - - - e.stopPropagation()} - > - {event.location} - - - {event.location} - - ) : ( - - {event.location} - - )} -
- ) : locationIsMeetingLink && meetingLink ? ( + {!isWorkingLocation && ( <> -
- -
- - {getMeetingLabel(meetingLink.type, t)} - -
- {t("eventForm.savedAsVideoLink")} -
+ {/* Location — always shown, editable */} +
+ {editingField === "location" ? ( +
+ + setEditLocation(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSaveLocation(); + } + if (e.key === "Escape") { + e.preventDefault(); + setEditLocation(editableLocationValue); + setEditingField(null); + } + e.stopPropagation(); + }} + onBlur={handleSaveLocation} + placeholder={t("eventForm.addLocation")} + className="flex-1 bg-transparent border-none outline-none text-sm text-foreground placeholder:text-muted-foreground/40 focus:ring-0" + />
-
- {!isOverlay && ( + ) : event.location && !locationIsMeetingLink ? ( +
{ + if (isOverlay) return; + setEditingField("location"); + }} + > + + {locationIsUrl ? ( + + + e.stopPropagation()} + > + {event.location} + + + {event.location} + + ) : ( + + {event.location} + + )} +
+ ) : locationIsMeetingLink && meetingLink ? ( + <> +
+ +
+ + {getMeetingLabel(meetingLink.type, t)} + +
+ {t("eventForm.savedAsVideoLink")} +
+
+
+ {!isOverlay && ( +
{ + setEditLocation(editableLocationValue); + setEditingField("location"); + }} + > + + + {t("eventForm.addLocation")} + +
+ )} + + ) : !isOverlay ? (
{ - setEditLocation(""); + setEditLocation( + locationIsMeetingLink ? "" : editableLocationValue, + ); setEditingField("location"); }} > @@ -2060,27 +2140,12 @@ export function EventDetailPopover({ {t("eventForm.addLocation")}
- )} + ) : null} - ) : !isOverlay ? ( -
{ - setEditLocation( - locationIsMeetingLink ? "" : event.location || "", - ); - setEditingField("location"); - }} - > - - - {t("eventForm.addLocation")} - -
- ) : null} + )} {/* Description — always shown for editable events; hidden for overlay events with no description */} - {(!isOverlay || event.description) && ( + {!isWorkingLocation && (!isOverlay || event.description) && ( <>
@@ -2132,180 +2197,182 @@ export function EventDetailPopover({ )} {/* Reminders */} - {!isOverlay && editingField === "reminders" ? ( - <> -
-
-
- - - {t("eventForm.eventAlerts")} - -
- -
- - + {!isWorkingLocation && + (!isOverlay && editingField === "reminders" ? ( + <> +
+
+
+ + + {t("eventForm.eventAlerts")} + +
+ +
+ + +
-
- - ) : event.reminders && event.reminders.length > 0 ? ( - <> -
-
- -
- {event.reminders.map((r, i) => ( -
- {formatReminderText(r.minutes)} -
- ))} + + ) : event.reminders && event.reminders.length > 0 ? ( + <> +
+
+ +
+ {event.reminders.map((r, i) => ( +
+ {formatReminderText(r.minutes)} +
+ ))} +
-
- - ) : null} + + ) : null)} {/* Availability, visibility, and alerts */} - {!isOverlay ? ( - <> -
-
-
-
- - {t("eventForm.showAs")} - - -
-
- - {t("eventForm.visibility")} - - + {!isWorkingLocation && + (!isOverlay ? ( + <> +
+
+
+
+ + {t("eventForm.showAs")} + + +
+
+ + {t("eventForm.visibility")} + + +
+
+ + {t("eventForm.alerts")} + + +
-
- - {t("eventForm.alerts")} - - +
+ +
-
- - + + ) : event.status || event.visibility ? ( + <> +
+
+
+ + {event.transparency === "transparent" + ? t("eventForm.free") + : t("eventForm.busy")} + {event.visibility && event.visibility !== "default" + ? ` · ${event.visibility} visibility` + : ""} +
-
- - ) : event.status || event.visibility ? ( - <> -
-
-
- - {event.transparency === "transparent" - ? t("eventForm.free") - : t("eventForm.busy")} - {event.visibility && event.visibility !== "default" - ? ` · ${event.visibility} visibility` - : ""} - -
- - ) : null} + + ) : null)} {/* Overlay person badge */} {event.overlayEmail && ( diff --git a/templates/calendar/app/components/calendar/WeekView.tsx b/templates/calendar/app/components/calendar/WeekView.tsx index 35c03b8fc9..9064663d30 100644 --- a/templates/calendar/app/components/calendar/WeekView.tsx +++ b/templates/calendar/app/components/calendar/WeekView.tsx @@ -1,5 +1,11 @@ +import { useT } from "@agent-native/core/client"; import type { CalendarEvent } from "@shared/api"; -import { IconAlertTriangleFilled } from "@tabler/icons-react"; +import { + IconAlertTriangleFilled, + IconBuilding, + IconHome, + IconMapPin, +} from "@tabler/icons-react"; import { startOfWeek, endOfWeek, @@ -31,6 +37,11 @@ import { useViewPreferences, type ViewPreferences, } from "@/hooks/use-view-preferences"; +import { + groupAdjacentAllDayPlacements, + layoutAllDayEvents, + partitionAllDayEvents, +} from "@/lib/all-day-layout"; import { getEventDisplayColor, allOtherDeclined } from "@/lib/event-colors"; import { shouldSuppressAfterPopoverClose, @@ -38,6 +49,11 @@ import { } from "@/lib/popover-click-guard"; import { EventStatusIcon } from "@/lib/rsvp-status"; import { cn } from "@/lib/utils"; +import { + createWorkingLocationDisplayLabels, + getWorkingLocationChipLabel, + getWorkingLocationTitle, +} from "@/lib/working-location"; import { EventDetailPopover } from "./EventDetailPopover"; import { shouldRenderWeekDragSegment } from "./week-drag-segment"; @@ -205,31 +221,6 @@ function computeLayout( return result; } -/** Determine which day columns an all-day event spans within a given week */ -function getAllDaySpan( - event: CalendarEvent, - days: Date[], -): { startCol: number; endCol: number } | null { - const evStart = parseISO(event.start); - const evEnd = event.end ? parseISO(event.end) : addDays(evStart, 1); - - let startCol = -1; - let endCol = -1; - - for (let i = 0; i < days.length; i++) { - const dayStart = startOfDay(days[i]); - const dayEnd = addDays(dayStart, 1); - // Event overlaps this day if it starts before day ends and ends after day starts - if (evStart < dayEnd && evEnd > dayStart) { - if (startCol === -1) startCol = i; - endCol = i; - } - } - - if (startCol === -1) return null; - return { startCol, endCol }; -} - function getSegmentStyle(event: CalendarEvent, day: Date) { const evStart = parseISO(event.start); const evEnd = parseISO(event.end); @@ -322,6 +313,7 @@ const WeekEventCard = memo(function WeekEventCard({ onDraftCreate, onDraftDiscard, }: WeekEventCardProps) { + const t = useT(); const li = layout.get(event.id) ?? { left: 0, width: 0, @@ -608,6 +600,11 @@ export const WeekView = memo(function WeekView({ onDraftDiscard, isLoading = false, }: WeekViewProps) { + const t = useT(); + const workingLocationLabels = useMemo( + () => createWorkingLocationDisplayLabels(t), + [t], + ); const { setFocusedEvent } = useCalendarSetters(); const isMobile = useIsMobile(); const GUTTER_WIDTH = isMobile ? MOBILE_GUTTER_WIDTH : DESKTOP_GUTTER_WIDTH; @@ -672,18 +669,33 @@ export const WeekView = memo(function WeekView({ const timedEvents = useMemo(() => events.filter((e) => !e.allDay), [events]); - // Pre-compute all-day event spans - const allDaySpans = useMemo(() => { - const spans: { event: CalendarEvent; startCol: number; endCol: number }[] = - []; - for (const ev of allDayEvents) { - const span = getAllDaySpan(ev, days); - if (span) { - spans.push({ event: ev, ...span }); - } - } - return spans; - }, [allDayEvents, days]); + const { workingLocations, regularEvents } = useMemo( + () => partitionAllDayEvents(allDayEvents), + [allDayEvents], + ); + const workingLocationLayout = useMemo( + () => layoutAllDayEvents(workingLocations, days), + [days, workingLocations], + ); + const workingLocationGroups = useMemo( + () => + groupAdjacentAllDayPlacements( + workingLocationLayout.placements, + ({ event }) => + [ + event.accountEmail, + event.overlayEmail, + event.ownerColor, + getEventDisplayColor(event, prefs), + getWorkingLocationChipLabel(event, workingLocationLabels), + JSON.stringify(event.workingLocationProperties ?? {}), + ].join(":"), + ), + [prefs, workingLocationLabels, workingLocationLayout.placements], + ); + const regularAllDayLayout = useMemo(() => { + return layoutAllDayEvents(regularEvents, days); + }, [days, regularEvents]); // Pre-compute timed events per day with layout — include events spanning into this day const dayData = useMemo(() => { @@ -706,68 +718,23 @@ export const WeekView = memo(function WeekView({ const showNowIndicator = nowMinutes >= 0 && nowMinutes <= (END_HOUR - START_HOUR) * 60; - const hasAnyAllDay = allDaySpans.length > 0; - - // Compute the number of "rows" needed for all-day events (to handle stacking) - const allDayRows = useMemo(() => { - if (allDaySpans.length === 0) return 0; - // Simple row-packing algorithm - const rows: { startCol: number; endCol: number }[][] = []; - for (const span of allDaySpans) { - let placed = false; - for (const row of rows) { - // i18n-ignore scanner false positive for layout property access - const hasConflict = row.some( - (existing) => - /* i18n-ignore scanner false positive */ span.startCol <= - existing.endCol && span.endCol >= existing.startCol, - ); - if (!hasConflict) { - row.push(span); - placed = true; - break; - } - } - if (!placed) { - rows.push([span]); - } - } - return rows.length; - }, [allDaySpans]); - - // Assign row index to each all-day span - const allDayRowAssignments = useMemo(() => { - const assignments = new Map(); - if (allDaySpans.length === 0) return assignments; - const rows: { startCol: number; endCol: number; id: string }[][] = []; - for (const span of allDaySpans) { - let placed = false; - for (let r = 0; r < rows.length; r++) { - // i18n-ignore scanner false positive for layout property access - const hasConflict = rows[r].some( - (existing) => - /* i18n-ignore scanner false positive */ span.startCol <= - existing.endCol && span.endCol >= existing.startCol, - ); - if (!hasConflict) { - rows[r].push({ ...span, id: span.event.id }); - assignments.set(span.event.id, r); - placed = true; - break; - } - } - if (!placed) { - rows.push([{ ...span, id: span.event.id }]); - assignments.set(span.event.id, rows.length - 1); - } - } - return assignments; - }, [allDaySpans]); - + const hasWorkingLocations = workingLocationLayout.rowCount > 0; + const hasRegularAllDayEvents = regularAllDayLayout.rowCount > 0; + const hasAnyAllDay = hasWorkingLocations || hasRegularAllDayEvents; + const workingLocationRowHeight = 16; const allDayRowHeight = 20; - const allDaySectionHeight = hasAnyAllDay - ? allDayRows * allDayRowHeight + 6 + const workingLocationLaneHeight = hasWorkingLocations + ? workingLocationLayout.rowCount * workingLocationRowHeight + 2 + : 0; + const laneSeparatorHeight = + hasWorkingLocations && hasRegularAllDayEvents ? 1 : 0; + const regularAllDayLaneOffset = + workingLocationLaneHeight + laneSeparatorHeight; + const regularAllDayLaneHeight = hasRegularAllDayEvents + ? regularAllDayLayout.rowCount * allDayRowHeight + 6 : 0; + const allDaySectionHeight = + workingLocationLaneHeight + laneSeparatorHeight + regularAllDayLaneHeight; const allDayHeaderSpacerWidth = Math.max( 0, timeGridScrollbarWidth - allDayScrollbarWidth, @@ -1002,10 +969,17 @@ export const WeekView = memo(function WeekView({ > {/* Gutter label */}
- all day + {hasRegularAllDayEvents && ( + + {t("eventForm.allDay")} + + )}
{/* All-day columns container (relative, for absolute-positioned spans) */} @@ -1021,70 +995,207 @@ export const WeekView = memo(function WeekView({ /> ))} - {/* Spanning all-day event bars */} - {allDaySpans.map(({ event, startCol, endCol }) => { - const color = getEventDisplayColor(event, prefs); - const rowIdx = allDayRowAssignments.get(event.id) ?? 0; - const colCount = days.length; - const leftPct = (startCol / colCount) * 100; - const widthPct = ((endCol - startCol + 1) / colCount) * 100; - - return ( - - - - ); - })} + {laneSeparatorHeight > 0 && ( +