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({ /> )} - {event.title} + + {getWorkingLocationChipLabel(event)} + + {isWorkingLocationEvent(event) && isStart && ( +
+ {t("eventForm.workingLocation")} +
+ )} {isStart && (
)} - {event.title} + + {getWorkingLocationChipLabel(event)} + + {isWorkingLocationEvent(event) && ( + + {t("eventForm.workingLocation")} + + )} {event.ownerColor && (
+ {isWorkingLocation && ( + + {t("eventForm.workingLocation")} + + )} {!event.allDay && ( {new Date(event.start).toLocaleTimeString([], { diff --git a/templates/calendar/app/components/calendar/EventDetailPopover.tsx b/templates/calendar/app/components/calendar/EventDetailPopover.tsx index 8023214738..bf6207d2c8 100644 --- a/templates/calendar/app/components/calendar/EventDetailPopover.tsx +++ b/templates/calendar/app/components/calendar/EventDetailPopover.tsx @@ -97,6 +97,13 @@ import { setEventDetailPopoverOpen, } from "@/lib/popover-click-guard"; import { shortcutModifierLabel } from "@/lib/utils"; +import { + getWorkingLocationDetail, + getWorkingLocationLabel, + getWorkingLocationTitle, + getWorkingLocationType, + isWorkingLocationEvent, +} from "@/lib/working-location"; const ZOOM_AFTER_CONNECT_EVENT_ID_KEY = "calendar.zoomAfterConnectEventId"; const ZOOM_AFTER_CONNECT_MAX_AGE_MS = 10 * 60 * 1000; @@ -293,6 +300,8 @@ type EventUpdatePatch = Partial & { addZoom?: boolean; addAttendees?: CalendarEvent["attendees"]; scope?: UpdateEventScope; + workingLocationType?: "homeOffice" | "officeLocation" | "customLocation"; + workingLocationLabel?: string; }; function mergeAttendeesForPrompt( @@ -445,6 +454,15 @@ export function EventDetailPopover({ setSidebarEvent, setFocusedEvent, } = useCalendarContext(); + const isWorkingLocation = isWorkingLocationEvent(event); + const workingLocationType = getWorkingLocationType(event); + const workingLocationLabel = getWorkingLocationLabel(event); + const workingLocationDetail = getWorkingLocationDetail(event); + const editableLocationValue = isWorkingLocation + ? workingLocationLabel + : event.location || ""; + const canEditWorkingLocationLabel = + isWorkingLocation && workingLocationType !== "homeOffice"; // Inline editing state const [editingField, setEditingField] = useState(null); @@ -452,7 +470,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 +538,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 +568,7 @@ export function EventDetailPopover({ event.id, event.description, event.location, + event.workingLocationProperties, event.start, event.end, event.allDay, @@ -557,6 +576,7 @@ export function EventDetailPopover({ event.reminders, event.remindersUseDefault, event.attachments, + editableLocationValue, ]); useEffect(() => { @@ -605,7 +625,6 @@ export function EventDetailPopover({ ? event.visibility : "default"; const reminderValue = getReminderValue(event); - // Save a field update const saveField = useCallback( (updates: EventUpdatePatch) => { @@ -890,13 +909,23 @@ 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 = isWorkingLocation + ? workingLocationLabel + : (event.location || "").trim(); + if (trimmed !== currentValue.trim()) { + const updates: EventUpdatePatch = isWorkingLocation + ? { + workingLocationType, + workingLocationLabel: trimmed, + location: + workingLocationType === "homeOffice" ? event.location : trimmed, + } + : { location: trimmed }; if ( locationContainsMeetingLink && meetingLink && @@ -914,7 +943,18 @@ export function EventDetailPopover({ } setEditingField(null); return saved; - }, [editLocation, event.description, event.location, meetingLink, saveField]); + }, [ + editLocation, + editableLocationValue, + event.description, + event.location, + isWorkingLocation, + meetingLink, + saveField, + t, + workingLocationLabel, + workingLocationType, + ]); const handleSaveTime = useCallback(() => { const allDayEnd = new Date(`${editEndDate}T00:00:00`); @@ -1370,7 +1410,7 @@ export function EventDetailPopover({ setIsEditingTitle(true); }} > - {event.title} + {getWorkingLocationTitle(event)} )} @@ -1989,7 +2029,7 @@ export function EventDetailPopover({ } if (e.key === "Escape") { e.preventDefault(); - setEditLocation(event.location || ""); + setEditLocation(editableLocationValue); setEditingField(null); } e.stopPropagation(); @@ -1999,6 +2039,25 @@ export function EventDetailPopover({ className="flex-1 bg-transparent border-none outline-none text-sm text-foreground placeholder:text-muted-foreground/40 focus:ring-0" /> + ) : isWorkingLocation ? ( +
{ + if (isOverlay || !canEditWorkingLocationLabel) return; + setEditLocation(editableLocationValue); + setEditingField("location"); + }} + > + +
+
+ {workingLocationLabel} +
+
+ {workingLocationDetail || t("eventForm.workingLocation")} +
+
+
) : event.location && !locationIsMeetingLink ? (
{ - setEditLocation(""); + setEditLocation(editableLocationValue); setEditingField("location"); }} > @@ -2067,7 +2126,7 @@ export function EventDetailPopover({ className="flex items-center gap-3 px-4 py-1.5 cursor-pointer hover:bg-muted/50 rounded-md" onClick={() => { setEditLocation( - locationIsMeetingLink ? "" : event.location || "", + locationIsMeetingLink ? "" : editableLocationValue, ); setEditingField("location"); }} diff --git a/templates/calendar/app/components/calendar/WeekView.tsx b/templates/calendar/app/components/calendar/WeekView.tsx index 35c03b8fc9..d1dd8173db 100644 --- a/templates/calendar/app/components/calendar/WeekView.tsx +++ b/templates/calendar/app/components/calendar/WeekView.tsx @@ -1,3 +1,4 @@ +import { useT } from "@agent-native/core/client"; import type { CalendarEvent } from "@shared/api"; import { IconAlertTriangleFilled } from "@tabler/icons-react"; import { @@ -38,6 +39,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"; import { shouldRenderWeekDragSegment } from "./week-drag-segment"; @@ -322,6 +328,7 @@ const WeekEventCard = memo(function WeekEventCard({ onDraftCreate, onDraftDiscard, }: WeekEventCardProps) { + const t = useT(); const li = layout.get(event.id) ?? { left: 0, width: 0, @@ -608,6 +615,7 @@ export const WeekView = memo(function WeekView({ onDraftDiscard, isLoading = false, }: WeekViewProps) { + const t = useT(); const { setFocusedEvent } = useCalendarSetters(); const isMobile = useIsMobile(); const GUTTER_WIDTH = isMobile ? MOBILE_GUTTER_WIDTH : DESKTOP_GUTTER_WIDTH; @@ -1028,6 +1036,9 @@ export const WeekView = memo(function WeekView({ const colCount = days.length; const leftPct = (startCol / colCount) * 100; const widthPct = ((endCol - startCol + 1) / colCount) * 100; + const title = getWorkingLocationChipLabel(event); + const ariaTitle = getWorkingLocationTitle(event); + const isWorkingLocation = isWorkingLocationEvent(event); return ( )} - {event.title} + {title} + {isWorkingLocation && ( + + {t("eventForm.workingLocation")} + + )} {event.ownerColor && ( {isWorkingLocationEvent(event) && ( @@ -407,7 +410,7 @@ const DayEventCard = memo(function DayEventCard({ )} - {getWorkingLocationChipLabel(event)} + {getWorkingLocationChipLabel(event, workingLocationLabels)}
{isWorkingLocationEvent(event) && isStart && ( @@ -527,6 +530,7 @@ export const DayView = memo(function DayView({ isLoading = false, }: DayViewProps) { const t = useT(); + const workingLocationLabels = createWorkingLocationDisplayLabels(t); const { setFocusedEvent } = useCalendarSetters(); const { prefs } = useViewPreferences(); const [now, setNow] = useState(new Date()); @@ -578,6 +582,10 @@ export const DayView = memo(function DayView({ ); const allDayEvents = useMemo(() => 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]); @@ -736,79 +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 1d32a48e77..26a641afb3 100644 --- a/templates/calendar/app/components/calendar/EventCard.tsx +++ b/templates/calendar/app/components/calendar/EventCard.tsx @@ -10,6 +10,7 @@ import { import { EventStatusIcon } from "@/lib/rsvp-status"; import { cn } from "@/lib/utils"; import { + createWorkingLocationDisplayLabels, getWorkingLocationChipLabel, getWorkingLocationTitle, isWorkingLocationEvent, @@ -37,10 +38,11 @@ export function EventCard({ colorPreferences, }: EventCardProps) { const t = useT(); + const workingLocationLabels = createWorkingLocationDisplayLabels(t); const accentColor = getEventDisplayColor(event, colorPreferences); const ownerLabel = event.ownerName || event.overlayEmail; - const title = getWorkingLocationChipLabel(event); - const ariaTitle = getWorkingLocationTitle(event); + const title = getWorkingLocationChipLabel(event, workingLocationLabels); + const ariaTitle = getWorkingLocationTitle(event, workingLocationLabels); const isWorkingLocation = isWorkingLocationEvent(event); const handleDragStart = (e: React.DragEvent) => { 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 && ( (null); @@ -914,18 +909,9 @@ export function EventDetailPopover({ return false; } let saved = false; - const currentValue = isWorkingLocation - ? workingLocationLabel - : (event.location || "").trim(); + const currentValue = (event.location || "").trim(); if (trimmed !== currentValue.trim()) { - const updates: EventUpdatePatch = isWorkingLocation - ? { - workingLocationType, - workingLocationLabel: trimmed, - location: - workingLocationType === "homeOffice" ? event.location : trimmed, - } - : { location: trimmed }; + const updates: EventUpdatePatch = { location: trimmed }; if ( locationContainsMeetingLink && meetingLink && @@ -948,14 +934,26 @@ export function EventDetailPopover({ editableLocationValue, event.description, event.location, - isWorkingLocation, meetingLink, saveField, t, - workingLocationLabel, - workingLocationType, ]); + 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`); allDayEnd.setDate(allDayEnd.getDate() + 1); @@ -1317,7 +1315,11 @@ export function EventDetailPopover({
- {isDraft ? t("eventForm.draftEvent") : t("eventForm.event")} + {isWorkingLocation + ? t("eventForm.workingLocation") + : isDraft + ? t("eventForm.draftEvent") + : t("eventForm.event")}
@@ -1353,7 +1355,7 @@ export function EventDetailPopover({
{/* Title — always editable */} - {isEditingTitle ? ( + {isEditingTitle && !isWorkingLocation ? ( ) : (

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

)}
@@ -1486,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")} @@ -1576,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" ? ( @@ -2013,104 +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(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" - /> -
- ) : isWorkingLocation ? ( -
{ - if (isOverlay || !canEditWorkingLocationLabel) return; - setEditLocation(editableLocationValue); - setEditingField("location"); - }} - > - -
-
- {workingLocationLabel} -
-
- {workingLocationDetail || t("eventForm.workingLocation")} + {!isWorkingLocation && ( + <> + {/* 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" + />
-
-
- ) : event.location && !locationIsMeetingLink ? ( -
{ - if (isOverlay) return; - setEditingField("location"); - }} - > - - {locationIsUrl ? ( - - - e.stopPropagation()} - > + ) : event.location && !locationIsMeetingLink ? ( +
{ + if (isOverlay) return; + setEditingField("location"); + }} + > + + {locationIsUrl ? ( + + + e.stopPropagation()} + > + {event.location} + + + {event.location} + + ) : ( + {event.location} - - - {event.location} - - ) : ( - - {event.location} - - )} -
- ) : locationIsMeetingLink && meetingLink ? ( - <> -
- -
- - {getMeetingLabel(meetingLink.type, t)} - -
- {t("eventForm.savedAsVideoLink")} -
+ + )}
-
- {!isOverlay && ( + ) : locationIsMeetingLink && meetingLink ? ( + <> +
+ +
+ + {getMeetingLabel(meetingLink.type, t)} + +
+ {t("eventForm.savedAsVideoLink")} +
+
+
+ {!isOverlay && ( +
{ + setEditLocation(editableLocationValue); + setEditingField("location"); + }} + > + + + {t("eventForm.addLocation")} + +
+ )} + + ) : !isOverlay ? (
{ - setEditLocation(editableLocationValue); + setEditLocation( + locationIsMeetingLink ? "" : editableLocationValue, + ); setEditingField("location"); }} > @@ -2119,27 +2140,12 @@ export function EventDetailPopover({ {t("eventForm.addLocation")}
- )} + ) : null} - ) : !isOverlay ? ( -
{ - setEditLocation( - locationIsMeetingLink ? "" : editableLocationValue, - ); - 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) && ( <>
@@ -2191,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 d1dd8173db..29112e6c71 100644 --- a/templates/calendar/app/components/calendar/WeekView.tsx +++ b/templates/calendar/app/components/calendar/WeekView.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 { startOfWeek, endOfWeek, @@ -32,6 +32,10 @@ import { useViewPreferences, type ViewPreferences, } from "@/hooks/use-view-preferences"; +import { + layoutAllDayEvents, + partitionAllDayEvents, +} from "@/lib/all-day-layout"; import { getEventDisplayColor, allOtherDeclined } from "@/lib/event-colors"; import { shouldSuppressAfterPopoverClose, @@ -40,9 +44,9 @@ import { import { EventStatusIcon } from "@/lib/rsvp-status"; import { cn } from "@/lib/utils"; import { + createWorkingLocationDisplayLabels, getWorkingLocationChipLabel, getWorkingLocationTitle, - isWorkingLocationEvent, } from "@/lib/working-location"; import { EventDetailPopover } from "./EventDetailPopover"; @@ -211,31 +215,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); @@ -616,6 +595,7 @@ export const WeekView = memo(function WeekView({ isLoading = false, }: WeekViewProps) { const t = useT(); + const workingLocationLabels = createWorkingLocationDisplayLabels(t); const { setFocusedEvent } = useCalendarSetters(); const isMobile = useIsMobile(); const GUTTER_WIDTH = isMobile ? MOBILE_GUTTER_WIDTH : DESKTOP_GUTTER_WIDTH; @@ -680,18 +660,17 @@ 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 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(() => { @@ -714,68 +693,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 = 18; const allDayRowHeight = 20; - const allDaySectionHeight = hasAnyAllDay - ? allDayRows * allDayRowHeight + 6 + const workingLocationLaneHeight = hasWorkingLocations + ? workingLocationLayout.rowCount * workingLocationRowHeight + 4 : 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, @@ -1010,10 +944,28 @@ export const WeekView = memo(function WeekView({ > {/* Gutter label */}
- all day + {hasWorkingLocations && ( +
+
+ )} + {hasRegularAllDayEvents && ( + + {t("eventForm.allDay")} + + )}
{/* All-day columns container (relative, for absolute-positioned spans) */} @@ -1029,78 +981,166 @@ 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; - const title = getWorkingLocationChipLabel(event); - const ariaTitle = getWorkingLocationTitle(event); - const isWorkingLocation = isWorkingLocationEvent(event); - - return ( - - - - ); - })} + {laneSeparatorHeight > 0 && ( + {allDayHeaderSpacerWidth > 0 && (
void; +} + +type LabeledWorkingLocation = Exclude; + +function initialLabels(event: CalendarEvent) { + const type = getWorkingLocationType(event); + const editableLabel = getWorkingLocationEditableLabel(event); + return { + officeLocation: type === "officeLocation" ? editableLabel : "", + customLocation: type === "customLocation" ? editableLabel : "", + } satisfies Record; +} + +export function WorkingLocationEditor({ + event, + isRecurring, + readOnly = false, + disabled = false, + onSave, +}: WorkingLocationEditorProps) { + const t = useT(); + const workingLocationLabels = createWorkingLocationDisplayLabels(t); + const currentType = getWorkingLocationType(event); + const currentLabel = getWorkingLocationEditableLabel(event); + const [type, setType] = useState(currentType); + const [labels, setLabels] = useState(() => initialLabels(event)); + const [scope, setScope] = useState<"single" | "all">("single"); + const detail = getWorkingLocationDetail(event, workingLocationLabels); + + useEffect(() => { + setType(getWorkingLocationType(event)); + setLabels(initialLabels(event)); + setScope("single"); + }, [event.id, event.location, event.workingLocationProperties]); + + const label = type === "homeOffice" ? "" : labels[type]; + const isValid = type === "homeOffice" || label.trim().length > 0; + const isDirty = useMemo( + () => type !== currentType || label.trim() !== currentLabel.trim(), + [currentLabel, currentType, label, type], + ); + + if (readOnly) { + return ( +
+ +
+
+ {getWorkingLocationLabel(event, workingLocationLabels)} +
+
+ {detail || t("eventForm.workingLocation")} +
+
+
+ ); + } + + const options: Array<{ + value: WorkingLocationKind; + label: string; + icon: typeof IconHome; + }> = [ + { value: "homeOffice", label: t("eventForm.home"), icon: IconHome }, + { + value: "officeLocation", + label: t("eventForm.office"), + icon: IconBuilding, + }, + { + value: "customLocation", + label: t("eventForm.other"), + icon: IconMapPin, + }, + ]; + + return ( +
+
+ + {t("eventForm.workingLocation")} +
+ setType(value as WorkingLocationKind)} + className="grid grid-cols-3 gap-1.5" + aria-label={t("eventForm.workingLocation")} + disabled={disabled} + > + {options.map((option) => { + const Icon = option.icon; + const id = `working-location-${event.id}-${option.value}`; + return ( +
+ + +
+ ); + })} +
+ + {type !== "homeOffice" && ( +
+ + setLabels((current) => ({ + ...current, + [type]: event.target.value, + })) + } + aria-label={ + type === "officeLocation" + ? t("eventForm.office") + : t("eventForm.other") + } + placeholder={t("eventForm.addLocation")} + className="h-8 text-sm" + disabled={disabled} + /> + {type === "officeLocation" && detail && ( +

+ {detail} +

+ )} +
+ )} + + {isRecurring && ( +
+

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

+ setScope(value as "single" | "all")} + className="grid grid-cols-2 gap-1.5" + aria-label={t("eventForm.applyTo")} + disabled={disabled} + > + {( + [ + ["single", t("eventForm.thisDayOnly")], + ["all", t("eventForm.allDays")], + ] as const + ).map(([value, text]) => { + const id = `working-location-scope-${event.id}-${value}`; + return ( +
+ + +
+ ); + })} +
+
+ )} + +
+ +
+
+ ); +} diff --git a/templates/calendar/app/hooks/use-events.ts b/templates/calendar/app/hooks/use-events.ts index efac9eab73..a0b8f549bb 100644 --- a/templates/calendar/app/hooks/use-events.ts +++ b/templates/calendar/app/hooks/use-events.ts @@ -11,6 +11,12 @@ import { } from "@tanstack/react-query"; import { nanoid } from "nanoid"; +import { + buildWorkingLocationProperties, + getWorkingLocationEditableLabel, + getWorkingLocationType, +} from "@/lib/working-location"; + import { applyCalendarEventRsvp, calendarEventOverlapsListParams, @@ -316,27 +322,50 @@ export function useUpdateEvent() { sendUpdates, notificationMessage, scope, + workingLocationType, + workingLocationLabel, ...optimisticData } = newData; queryClient.setQueriesData( { queryKey: ["action", "list-events"] }, - (old) => - old?.map((e) => - e.id === optimisticData.id - ? { - ...e, - ...optimisticData, - ...(addAttendees - ? { - attendees: mergeAttendeeLists( - e.attendees, - addAttendees, - ), - } - : {}), - } - : e, - ), + (old) => { + const target = old?.find((event) => event.id === optimisticData.id); + return old?.map((e) => { + const matchesScope = + e.id === optimisticData.id || + (scope === "all" && + !!target?.recurringEventId && + e.recurringEventId === target.recurringEventId); + if (!matchesScope) return e; + const hasWorkingLocationUpdate = + workingLocationType !== undefined || + workingLocationLabel !== undefined; + const nextWorkingLocationType = + workingLocationType ?? getWorkingLocationType(e); + const nextWorkingLocationLabel = + workingLocationLabel ?? getWorkingLocationEditableLabel(e); + return { + ...e, + ...optimisticData, + ...(hasWorkingLocationUpdate + ? { + workingLocationProperties: buildWorkingLocationProperties( + e, + { + type: nextWorkingLocationType, + label: nextWorkingLocationLabel, + }, + ), + } + : {}), + ...(addAttendees + ? { + attendees: mergeAttendeeLists(e.attendees, addAttendees), + } + : {}), + }; + }); + }, ); return { previous }; }, diff --git a/templates/calendar/app/i18n-data.ts b/templates/calendar/app/i18n-data.ts index 158ed8a35b..62cbb7aa18 100644 --- a/templates/calendar/app/i18n-data.ts +++ b/templates/calendar/app/i18n-data.ts @@ -710,6 +710,7 @@ const enUS = { addingMeetingLink: "Adding {{provider}} link", alerts: "Alerts", allDay: "All day", + allDays: "All days", allEvents: "All events", applyChangesTo: "Apply changes to", applyTo: "Apply to", @@ -794,6 +795,7 @@ const enUS = { notifyGuestsQuestion: "Notify guests?", none: "None", office: "Office", + other: "Other", openInSidebar: "Open in sidebar", openInGoogleCalendar: "Open in Google Calendar", optionalDescription: "Optional description", @@ -847,6 +849,7 @@ const enUS = { start: "Start", startDate: "Start date", subscribedFeeds: "Subscribed feeds", + thisDayOnly: "This day only", thisEvent: "This event", timeSelected: "Time selected", timezone: "Timezone", @@ -870,6 +873,9 @@ const enUS = { weekly: "Weekly", workingFrom: "Working from", workingLocation: "Working location", + workingLocationDesk: "Desk {{desk}}", + workingLocationFloor: "Floor {{floor}}", + workingLocationTitle: "Working location: {{location}}", yearly: "Yearly", zoom: "Zoom", zoomAdded: "Zoom added", @@ -1140,6 +1146,7 @@ const calendarSurfaceTranslations = { addingMeetingLink: "添加 {{provider}} 链接", alerts: "警报", allDay: "全天", + allDays: "所有日期", allEvents: "所有活动", applyTo: "适用于", askAi: "询问人工智能", @@ -1202,6 +1209,7 @@ const calendarSurfaceTranslations = { noVideo: "没有视频", none: "无", office: "办公室", + other: "其他", openInSidebar: "在侧边栏中打开", optionalDescription: "可选描述", optionalLocation: "可选位置", @@ -1220,6 +1228,7 @@ const calendarSurfaceTranslations = { showAs: "显示为", start: "开始", startDate: "开始日期", + thisDayOnly: "仅当天", thisEvent: "本次活动", timeSelected: "选定时间", timezone: "时区", @@ -1233,6 +1242,9 @@ const calendarSurfaceTranslations = { weekly: "每周", workingFrom: "工作自", workingLocation: "工作地点", + workingLocationDesk: "工位 {{desk}}", + workingLocationFloor: "楼层 {{floor}}", + workingLocationTitle: "工作地点:{{location}}", yearly: "每年", zoom: "Zoom", zoomAdded: "添加Zoom", @@ -1433,6 +1445,7 @@ const calendarSurfaceTranslations = { addingMeetingLink: "Añadiendo enlace {{provider}}", alerts: "Alertas", allDay: "Todo el día", + allDays: "Todos los días", allEvents: "Todos los eventos", applyTo: "Referirse a", askAi: "Pregúntale a la IA", @@ -1498,6 +1511,7 @@ const calendarSurfaceTranslations = { noVideo: "Sin vídeo", none: "Ninguno", office: "Oficina", + other: "Otro", openInSidebar: "Abrir en la barra lateral", optionalDescription: "Descripción opcional", optionalLocation: "Ubicación opcional", @@ -1517,6 +1531,7 @@ const calendarSurfaceTranslations = { showAs: "Mostrar como", start: "Comenzar", startDate: "Fecha de inicio", + thisDayOnly: "Solo este día", thisEvent: "este evento", timeSelected: "Hora seleccionada", timezone: "Zona horaria", @@ -1530,6 +1545,9 @@ const calendarSurfaceTranslations = { weekly: "Semanalmente", workingFrom: "Trabajando desde", workingLocation: "Lugar de trabajo", + workingLocationDesk: "Escritorio {{desk}}", + workingLocationFloor: "Piso {{floor}}", + workingLocationTitle: "Lugar de trabajo: {{location}}", yearly: "Anual", zoom: "Zoom", zoomAdded: "Zoom añadido", @@ -1736,6 +1754,7 @@ const calendarSurfaceTranslations = { addingMeetingLink: "Ajout du lien {{provider}}", alerts: "Alertes", allDay: "Toute la journée", + allDays: "Tous les jours", allEvents: "Tous les événements", applyTo: "Postuler à", askAi: "Demandez à l'IA", @@ -1802,6 +1821,7 @@ const calendarSurfaceTranslations = { noVideo: "Pas de vidéo", none: "Aucun", office: "Bureau", + other: "Autre", openInSidebar: "Ouvrir dans la barre latérale", optionalDescription: "Description facultative", optionalLocation: "Emplacement facultatif", @@ -1821,6 +1841,7 @@ const calendarSurfaceTranslations = { showAs: "Afficher comme", start: "Commencer", startDate: "Date de début", + thisDayOnly: "Ce jour uniquement", thisEvent: "Cet événement", timeSelected: "Heure sélectionnée", timezone: "Fuseau horaire", @@ -1834,6 +1855,9 @@ const calendarSurfaceTranslations = { weekly: "Hebdomadaire", workingFrom: "Travailler à partir de", workingLocation: "Lieu de travail", + workingLocationDesk: "Bureau {{desk}}", + workingLocationFloor: "Étage {{floor}}", + workingLocationTitle: "Lieu de travail : {{location}}", yearly: "Annuel", zoom: "Zoom", zoomAdded: "Zoom ajouté", @@ -2039,6 +2063,7 @@ const calendarSurfaceTranslations = { addingMeetingLink: "{{provider}}-Link hinzufügen", alerts: "Warnungen", allDay: "Den ganzen Tag", + allDays: "Alle Tage", allEvents: "Alle Veranstaltungen", applyTo: "Bewerben Sie sich bei", askAi: "Fragen Sie die KI", @@ -2106,6 +2131,7 @@ const calendarSurfaceTranslations = { noVideo: "Kein Video", none: "Keine", office: "Büro", + other: "Andere", openInSidebar: "In der Seitenleiste öffnen", optionalDescription: "Optionale Beschreibung", optionalLocation: "Optionaler Standort", @@ -2125,6 +2151,7 @@ const calendarSurfaceTranslations = { showAs: "Anzeigen als", start: "Starten", startDate: "Startdatum", + thisDayOnly: "Nur dieser Tag", thisEvent: "Dieses Ereignis", timeSelected: "Zeit ausgewählt", timezone: "Zeitzone", @@ -2138,6 +2165,9 @@ const calendarSurfaceTranslations = { weekly: "Wöchentlich", workingFrom: "Arbeiten von", workingLocation: "Arbeitsort", + workingLocationDesk: "Schreibtisch {{desk}}", + workingLocationFloor: "Etage {{floor}}", + workingLocationTitle: "Arbeitsort: {{location}}", yearly: "Jährlich", zoom: "Zoom", zoomAdded: "Zoom hinzugefügt", @@ -2337,6 +2367,7 @@ const calendarSurfaceTranslations = { addingMeetingLink: "{{provider}}リンクの追加", alerts: "アラート", allDay: "一日中", + allDays: "すべての日", allEvents: "すべてのイベント", applyTo: "申請先", askAi: "AIに聞く", @@ -2403,6 +2434,7 @@ const calendarSurfaceTranslations = { noVideo: "ビデオなし", none: "なし", office: "オフィス", + other: "その他", openInSidebar: "サイドバーで開く", optionalDescription: "オプションの説明", optionalLocation: "オプションの場所", @@ -2422,6 +2454,7 @@ const calendarSurfaceTranslations = { showAs: "として表示", start: "スタート", startDate: "開始日", + thisDayOnly: "この日のみ", thisEvent: "このイベント", timeSelected: "選択された時間", timezone: "タイムゾーン", @@ -2435,6 +2468,9 @@ const calendarSurfaceTranslations = { weekly: "毎週", workingFrom: "から働く", workingLocation: "勤務地", + workingLocationDesk: "デスク {{desk}}", + workingLocationFloor: "{{floor}}階", + workingLocationTitle: "勤務地: {{location}}", yearly: "毎年", zoom: "Zoom", zoomAdded: "Zoom追加", @@ -2634,6 +2670,7 @@ const calendarSurfaceTranslations = { addingMeetingLink: "{{provider}} 링크 추가", alerts: "경고", allDay: "하루 종일", + allDays: "모든 날", allEvents: "모든 이벤트", applyTo: "신청 대상", askAi: "AI에게 물어보세요", @@ -2697,6 +2734,7 @@ const calendarSurfaceTranslations = { noVideo: "영상 없음", none: "없음", office: "사무실", + other: "기타", openInSidebar: "사이드바에서 열기", optionalDescription: "선택적 설명", optionalLocation: "선택적 위치", @@ -2716,6 +2754,7 @@ const calendarSurfaceTranslations = { showAs: "다음으로 표시", start: "시작", startDate: "시작일", + thisDayOnly: "이 날만", thisEvent: "이번 이벤트", timeSelected: "선택한 시간", timezone: "시간대", @@ -2729,6 +2768,9 @@ const calendarSurfaceTranslations = { weekly: "주간", workingFrom: "근무처:", workingLocation: "근무 위치", + workingLocationDesk: "좌석 {{desk}}", + workingLocationFloor: "{{floor}}층", + workingLocationTitle: "근무 위치: {{location}}", yearly: "매년", zoom: "Zoom", zoomAdded: "Zoom 추가됨", @@ -2931,6 +2973,7 @@ const calendarSurfaceTranslations = { addingMeetingLink: "Adicionando link {{provider}}", alerts: "Alertas", allDay: "O dia todo", + allDays: "Todos os dias", allEvents: "Todos os eventos", applyTo: "Candidatar-se a", askAi: "Pergunte à IA", @@ -2996,6 +3039,7 @@ const calendarSurfaceTranslations = { noVideo: "Nenhum vídeo", none: "Nenhum", office: "Escritório", + other: "Outro", openInSidebar: "Abrir na barra lateral", optionalDescription: "Descrição opcional", optionalLocation: "Localização opcional", @@ -3015,6 +3059,7 @@ const calendarSurfaceTranslations = { showAs: "Mostrar como", start: "Começar", startDate: "Data de início", + thisDayOnly: "Somente este dia", thisEvent: "Este evento", timeSelected: "Hora selecionada", timezone: "Fuso horário", @@ -3028,6 +3073,9 @@ const calendarSurfaceTranslations = { weekly: "Semanalmente", workingFrom: "Trabalhando de", workingLocation: "Local de trabalho", + workingLocationDesk: "Mesa {{desk}}", + workingLocationFloor: "Andar {{floor}}", + workingLocationTitle: "Local de trabalho: {{location}}", yearly: "Anualmente", zoom: "Zoom", zoomAdded: "Zoom adicionado", @@ -3225,6 +3273,7 @@ const calendarSurfaceTranslations = { addingMeetingLink: "{{provider}} लिंक जोड़ा जा रहा है", alerts: "अलर्ट", allDay: "सारा दिन", + allDays: "सभी दिन", allEvents: "सभी घटनाएँ", applyTo: "के लिए आवेदन करें", askAi: "एआई से पूछें", @@ -3288,6 +3337,7 @@ const calendarSurfaceTranslations = { noVideo: "कोई वीडियो नहीं", none: "कोई नहीं", office: "कार्यालय", + other: "अन्य", openInSidebar: "साइडबार में खोलें", optionalDescription: "वैकल्पिक विवरण", optionalLocation: "वैकल्पिक स्थान", @@ -3307,6 +3357,7 @@ const calendarSurfaceTranslations = { showAs: "के रूप में दिखाएँ", start: "प्रारंभ करें", startDate: "आरंभ तिथि", + thisDayOnly: "केवल यह दिन", thisEvent: "यह घटना", timeSelected: "समय चयनित", timezone: "समयक्षेत्र", @@ -3320,6 +3371,9 @@ const calendarSurfaceTranslations = { weekly: "साप्ताहिक", workingFrom: "से काम कर रहे हैं", workingLocation: "कार्य स्थान", + workingLocationDesk: "डेस्क {{desk}}", + workingLocationFloor: "मंज़िल {{floor}}", + workingLocationTitle: "कार्य स्थान: {{location}}", yearly: "वार्षिक", zoom: "Zoom", zoomAdded: "Zoom जोड़ा गया", @@ -3517,6 +3571,7 @@ const calendarSurfaceTranslations = { addingMeetingLink: "إضافة رابط {{provider}}", alerts: "التنبيهات", allDay: "طوال اليوم", + allDays: "كل الأيام", allEvents: "جميع الأحداث", applyTo: "تنطبق على", askAi: "اسأل الذكاء الاصطناعي", @@ -3581,6 +3636,7 @@ const calendarSurfaceTranslations = { noVideo: "لا يوجد فيديو", none: "لا شيء", office: "مكتب", + other: "أخرى", openInSidebar: "فتح في الشريط الجانبي", optionalDescription: "وصف اختياري", optionalLocation: "الموقع الاختياري", @@ -3600,6 +3656,7 @@ const calendarSurfaceTranslations = { showAs: "إظهار كـ", start: "ابدأ", startDate: "تاريخ البدء", + thisDayOnly: "هذا اليوم فقط", thisEvent: "هذا الحدث", timeSelected: "الوقت المحدد", timezone: "المنطقة الزمنية", @@ -3613,6 +3670,9 @@ const calendarSurfaceTranslations = { weekly: "أسبوعيا", workingFrom: "العمل من", workingLocation: "موقع العمل", + workingLocationDesk: "المكتب {{desk}}", + workingLocationFloor: "الطابق {{floor}}", + workingLocationTitle: "موقع العمل: {{location}}", yearly: "سنويا", zoom: "Zoom", zoomAdded: "تمت إضافة Zoom", diff --git a/templates/calendar/app/i18n/zh-TW.ts b/templates/calendar/app/i18n/zh-TW.ts index dfa4e47fc0..6b15813b55 100644 --- a/templates/calendar/app/i18n/zh-TW.ts +++ b/templates/calendar/app/i18n/zh-TW.ts @@ -670,6 +670,7 @@ const messages = { addingMeetingLink: "新增 {{provider}} 連結", alerts: "警報", allDay: "全天", + allDays: "所有日期", allEvents: "所有活動", applyChangesTo: "將更改應用到", applyTo: "適用於", @@ -750,6 +751,7 @@ const messages = { notifyGuestsQuestion: "通知來賓?", none: "無", office: "辦公室", + other: "其他", openInSidebar: "在側邊欄中開啟", openInGoogleCalendar: "在 Google Calendar 中開啟", optionalDescription: "可選描述", @@ -801,6 +803,7 @@ const messages = { start: "開始", startDate: "開始日期", subscribedFeeds: "已訂閱的來源", + thisDayOnly: "僅限當天", thisEvent: "本次活動", timeSelected: "選定時間", timezone: "時區", @@ -824,6 +827,9 @@ const messages = { weekly: "每週", workingFrom: "工作自", workingLocation: "工作地點", + workingLocationDesk: "座位 {{desk}}", + workingLocationFloor: "樓層 {{floor}}", + workingLocationTitle: "工作地點:{{location}}", yearly: "每年", zoom: "Zoom", zoomAdded: "新增Zoom", diff --git a/templates/calendar/app/lib/all-day-layout.test.ts b/templates/calendar/app/lib/all-day-layout.test.ts new file mode 100644 index 0000000000..44eed5c5a0 --- /dev/null +++ b/templates/calendar/app/lib/all-day-layout.test.ts @@ -0,0 +1,84 @@ +import type { CalendarEvent } from "@shared/api"; +import { describe, expect, it } from "vitest"; + +import { + getAllDaySpan, + layoutAllDayEvents, + partitionAllDayEvents, +} from "./all-day-layout"; + +const days = Array.from( + { length: 7 }, + (_, index) => new Date(2026, 6, 5 + index), +); + +function event( + id: string, + start: string, + end: string, + eventType: CalendarEvent["eventType"] = "default", +): CalendarEvent { + return { + id, + title: id, + description: "", + start, + end, + location: "", + allDay: true, + source: "google", + eventType, + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", + }; +} + +describe("all-day layout", () => { + it("renders a Tuesday-through-Thursday event as one span", () => { + expect( + getAllDaySpan(event("conference", "2026-07-07", "2026-07-10"), days), + ).toEqual({ startCol: 2, endCol: 4 }); + }); + + it("clips spans at both visible week boundaries", () => { + expect( + getAllDaySpan(event("roadshow", "2026-07-03", "2026-07-14"), days), + ).toEqual({ startCol: 0, endCol: 6 }); + }); + + it("assigns overlapping spans to deterministic non-overlapping rows", () => { + const layout = layoutAllDayEvents( + [ + event("beta", "2026-07-08", "2026-07-11"), + event("alpha", "2026-07-07", "2026-07-10"), + event("gamma", "2026-07-10", "2026-07-11"), + ], + days, + ); + + expect(layout.rowCount).toBe(2); + expect( + Object.fromEntries( + layout.placements.map((placement) => [ + placement.event.id, + placement.row, + ]), + ), + ).toEqual({ alpha: 0, beta: 1, gamma: 0 }); + }); + + it("partitions working locations away from ordinary all-day events", () => { + const workingLocation = event( + "home", + "2026-07-07", + "2026-07-08", + "workingLocation", + ); + const ordinaryEvent = event("holiday", "2026-07-07", "2026-07-08"); + + expect(partitionAllDayEvents([ordinaryEvent, workingLocation])).toEqual({ + workingLocations: [workingLocation], + regularEvents: [ordinaryEvent], + }); + }); +}); diff --git a/templates/calendar/app/lib/all-day-layout.ts b/templates/calendar/app/lib/all-day-layout.ts new file mode 100644 index 0000000000..fca3cbf67b --- /dev/null +++ b/templates/calendar/app/lib/all-day-layout.ts @@ -0,0 +1,94 @@ +import type { CalendarEvent } from "@shared/api"; +import { addDays, parseISO, startOfDay } from "date-fns"; + +import { isWorkingLocationEvent } from "@/lib/working-location"; + +export interface AllDaySpan { + event: CalendarEvent; + startCol: number; + endCol: number; +} + +export interface AllDayPlacement extends AllDaySpan { + row: number; +} + +export interface AllDayLayout { + placements: AllDayPlacement[]; + rowCount: number; +} + +export function partitionAllDayEvents(events: CalendarEvent[]) { + const workingLocations: CalendarEvent[] = []; + const regularEvents: CalendarEvent[] = []; + + for (const event of events) { + if (isWorkingLocationEvent(event)) workingLocations.push(event); + else regularEvents.push(event); + } + + return { workingLocations, regularEvents }; +} + +/** Determine which visible day columns an all-day event overlaps. */ +export function getAllDaySpan( + event: CalendarEvent, + days: Date[], +): Omit | null { + const eventStart = parseISO(event.start); + const eventEnd = event.end ? parseISO(event.end) : addDays(eventStart, 1); + + let startCol = -1; + let endCol = -1; + + for (let index = 0; index < days.length; index++) { + const dayStart = startOfDay(days[index]); + const dayEnd = addDays(dayStart, 1); + if (eventStart < dayEnd && eventEnd > dayStart) { + if (startCol === -1) startCol = index; + endCol = index; + } + } + + return startCol === -1 ? null : { startCol, endCol }; +} + +function compareSpans(a: AllDaySpan, b: AllDaySpan): number { + if (a.startCol !== b.startCol) return a.startCol - b.startCol; + if (a.endCol !== b.endCol) return b.endCol - a.endCol; + return a.event.id.localeCompare(b.event.id); +} + +export function layoutAllDayEvents( + events: CalendarEvent[], + days: Date[], +): AllDayLayout { + const spans = events + .map((event) => { + const span = getAllDaySpan(event, days); + return span ? { event, ...span } : null; + }) + .filter((span): span is AllDaySpan => span !== null) + .sort(compareSpans); + const rows: AllDaySpan[][] = []; + const placements: AllDayPlacement[] = []; + + for (const span of spans) { + let row = rows.findIndex((candidate) => + candidate.every( + (existing) => + span.endCol < existing.startCol || span.startCol > existing.endCol, + ), + ); + + if (row === -1) { + row = rows.length; + rows.push([]); + } + + rows[row].push(span); + placements.push({ ...span, row }); + } + + return { placements, rowCount: rows.length }; +} diff --git a/templates/calendar/app/lib/working-location.test.ts b/templates/calendar/app/lib/working-location.test.ts index 37cee111e6..2e1b23af6f 100644 --- a/templates/calendar/app/lib/working-location.test.ts +++ b/templates/calendar/app/lib/working-location.test.ts @@ -2,11 +2,30 @@ import type { CalendarEvent } from "@shared/api"; import { describe, expect, it } from "vitest"; import { + buildWorkingLocationProperties, + buildWorkingLocationUpdate, + createWorkingLocationDisplayLabels, getWorkingLocationChipLabel, getWorkingLocationDetail, + getWorkingLocationEditableLabel, getWorkingLocationTitle, } from "./working-location"; +const translatedLabels = createWorkingLocationDisplayLabels((key, values) => { + const translations: Record = { + "eventForm.home": "Casa", + "eventForm.office": "Oficina", + "eventForm.workingLocation": "Lugar de trabajo", + "eventForm.workingLocationTitle": "Lugar de trabajo: {{location}}", + "eventForm.workingLocationFloor": "Piso {{floor}}", + "eventForm.workingLocationDesk": "Escritorio {{desk}}", + }; + return Object.entries(values ?? {}).reduce( + (value, [name, replacement]) => value.replace(`{{${name}}}`, replacement), + translations[key] ?? key, + ); +}); + function event(overrides: Partial): CalendarEvent { return { id: "event-1", @@ -30,9 +49,11 @@ describe("working location display helpers", () => { workingLocationProperties: { type: "homeOffice", homeOffice: {} }, }); - expect(getWorkingLocationChipLabel(workingLocation)).toBe("Home"); - expect(getWorkingLocationTitle(workingLocation)).toBe( - "Working location: Home", + expect(getWorkingLocationChipLabel(workingLocation, translatedLabels)).toBe( + "Casa", + ); + expect(getWorkingLocationTitle(workingLocation, translatedLabels)).toBe( + "Lugar de trabajo: Casa", ); }); @@ -51,9 +72,11 @@ describe("working location display helpers", () => { }, }); - expect(getWorkingLocationChipLabel(workingLocation)).toBe("Pier 57"); - expect(getWorkingLocationDetail(workingLocation)).toBe( - "nyc / Floor 6 / Desk D14", + expect(getWorkingLocationChipLabel(workingLocation, translatedLabels)).toBe( + "Pier 57", + ); + expect(getWorkingLocationDetail(workingLocation, translatedLabels)).toBe( + "nyc / Piso 6 / Escritorio D14", ); }); @@ -69,6 +92,126 @@ describe("working location display helpers", () => { }, }); - expect(getWorkingLocationChipLabel(workingLocation)).toBe("nyc"); + expect(getWorkingLocationChipLabel(workingLocation, translatedLabels)).toBe( + "nyc", + ); + }); + + it("uses translated fallbacks instead of hardcoded generic labels", () => { + const office = event({ + title: "", + eventType: "workingLocation", + workingLocationProperties: { + type: "officeLocation", + officeLocation: {}, + }, + }); + const custom = event({ + title: "", + eventType: "workingLocation", + workingLocationProperties: { + type: "customLocation", + customLocation: {}, + }, + }); + + expect(getWorkingLocationChipLabel(office, translatedLabels)).toBe( + "Oficina", + ); + expect(getWorkingLocationChipLabel(custom, translatedLabels)).toBe( + "Lugar de trabajo", + ); + }); + + it("uses native labels as editable values without summary fallbacks", () => { + expect( + getWorkingLocationEditableLabel({ + location: "", + workingLocationProperties: { type: "homeOffice", homeOffice: {} }, + }), + ).toBe(""); + expect( + getWorkingLocationEditableLabel({ + location: "Legacy label", + workingLocationProperties: { + type: "officeLocation", + officeLocation: { label: "Pier 57" }, + }, + }), + ).toBe("Pier 57"); + expect( + getWorkingLocationEditableLabel({ + location: "", + workingLocationProperties: { + type: "officeLocation", + officeLocation: { buildingId: "nyc" }, + }, + }), + ).toBe(""); + }); + + it("does not synthesize a generic office placeholder for updates", () => { + expect( + buildWorkingLocationUpdate( + { + id: "google-instance-20260707", + accountEmail: "owner@example.com", + }, + { type: "officeLocation", label: "", scope: "single" }, + ), + ).toMatchObject({ + workingLocationType: "officeLocation", + workingLocationLabel: "", + }); + }); + + it("targets the instance id and omits Google's forbidden generic location", () => { + expect( + buildWorkingLocationUpdate( + { + id: "google-instance-20260707", + accountEmail: "owner@example.com", + }, + { type: "homeOffice", label: "Old custom label", scope: "single" }, + ), + ).toEqual({ + id: "google-instance-20260707", + accountEmail: "owner@example.com", + workingLocationType: "homeOffice", + workingLocationLabel: "", + scope: "single", + }); + }); + + it("preserves office metadata only while the location remains an office", () => { + const office = { + workingLocationProperties: { + type: "officeLocation" as const, + officeLocation: { buildingId: "nyc", deskId: "D14" }, + }, + }; + + expect( + buildWorkingLocationProperties(office, { + type: "officeLocation", + label: "Pier 57", + }), + ).toEqual({ + type: "officeLocation", + officeLocation: { + buildingId: "nyc", + deskId: "D14", + label: "Pier 57", + }, + }); + expect( + buildWorkingLocationProperties(office, { + type: "customLocation", + label: "Neighborhood cafe", + }), + ).toEqual({ + type: "customLocation", + customLocation: { label: "Neighborhood cafe" }, + }); }); }); diff --git a/templates/calendar/app/lib/working-location.ts b/templates/calendar/app/lib/working-location.ts index 71bf9a8f07..8068657fa2 100644 --- a/templates/calendar/app/lib/working-location.ts +++ b/templates/calendar/app/lib/working-location.ts @@ -1,10 +1,43 @@ -import type { CalendarEvent } from "@shared/api"; +import type { CalendarEvent, UpdateEventScope } from "@shared/api"; export type WorkingLocationKind = | "homeOffice" | "officeLocation" | "customLocation"; +export interface WorkingLocationSelection { + type: WorkingLocationKind; + label: string; + scope?: UpdateEventScope; +} + +export interface WorkingLocationDisplayLabels { + home: string; + office: string; + workingLocation: string; + title: (location: string) => string; + floor: (floor: string) => string; + desk: (desk: string) => string; +} + +type WorkingLocationTranslate = ( + key: string, + values?: Record, +) => string; + +export function createWorkingLocationDisplayLabels( + t: WorkingLocationTranslate, +): WorkingLocationDisplayLabels { + return { + home: t("eventForm.home"), + office: t("eventForm.office"), + workingLocation: t("eventForm.workingLocation"), + title: (location) => t("eventForm.workingLocationTitle", { location }), + floor: (floor) => t("eventForm.workingLocationFloor", { floor }), + desk: (desk) => t("eventForm.workingLocationDesk", { desk }), + }; +} + export function isWorkingLocationEvent( event: Pick, ) { @@ -22,48 +55,109 @@ export function getWorkingLocationLabel( CalendarEvent, "location" | "title" | "workingLocationProperties" >, + labels: WorkingLocationDisplayLabels, ): string { const properties = event.workingLocationProperties; - if (properties?.type === "homeOffice") return "Home"; + if (properties?.type === "homeOffice") return labels.home; if (properties?.type === "officeLocation") { return ( properties.officeLocation?.label || properties.officeLocation?.buildingId || event.location || event.title || - "Office" + labels.office ); } return ( properties?.customLocation?.label || event.location || event.title || - "Working location" + labels.workingLocation ); } -export function getWorkingLocationChipLabel(event: CalendarEvent): string { +export function getWorkingLocationEditableLabel( + event: Pick, +): string { + const properties = event.workingLocationProperties; + if (properties?.type === "officeLocation") { + return properties.officeLocation?.label || event.location || ""; + } + if (properties?.type === "customLocation") { + return properties.customLocation?.label || event.location || ""; + } + return ""; +} + +export function buildWorkingLocationUpdate( + event: Pick, + selection: WorkingLocationSelection, +) { + const label = selection.type === "homeOffice" ? "" : selection.label.trim(); + + return { + id: event.id, + accountEmail: event.accountEmail, + workingLocationType: selection.type, + workingLocationLabel: label, + scope: selection.scope, + }; +} + +export function buildWorkingLocationProperties( + event: Pick, + selection: Pick, +): NonNullable { + const label = selection.label.trim(); + if (selection.type === "homeOffice") { + return { type: "homeOffice", homeOffice: {} }; + } + if (selection.type === "officeLocation") { + return { + type: "officeLocation", + officeLocation: { + ...(event.workingLocationProperties?.type === "officeLocation" + ? event.workingLocationProperties.officeLocation + : {}), + label, + }, + }; + } + return { + type: "customLocation", + customLocation: { label }, + }; +} + +export function getWorkingLocationChipLabel( + event: CalendarEvent, + labels: WorkingLocationDisplayLabels, +): string { return isWorkingLocationEvent(event) - ? getWorkingLocationLabel(event) + ? getWorkingLocationLabel(event, labels) : event.title; } -export function getWorkingLocationTitle(event: CalendarEvent): string { +export function getWorkingLocationTitle( + event: CalendarEvent, + labels: WorkingLocationDisplayLabels, +): string { return isWorkingLocationEvent(event) - ? `Working location: ${getWorkingLocationLabel(event)}` + ? labels.title(getWorkingLocationLabel(event, labels)) : event.title; } export function getWorkingLocationDetail( event: Pick, + labels: WorkingLocationDisplayLabels, ): string | undefined { const office = event.workingLocationProperties?.officeLocation; if (!office) return undefined; const parts = [ office.buildingId, - office.floorId ? `Floor ${office.floorId}` : undefined, + office.floorId ? labels.floor(office.floorId) : undefined, office.floorSectionId, - office.deskId ? `Desk ${office.deskId}` : undefined, + office.deskId ? labels.desk(office.deskId) : undefined, ].filter(Boolean); return parts.length > 0 ? parts.join(" / ") : undefined; } diff --git a/templates/calendar/app/pages/CalendarView.tsx b/templates/calendar/app/pages/CalendarView.tsx index b4a4c7fd28..1bfd7108a3 100644 --- a/templates/calendar/app/pages/CalendarView.tsx +++ b/templates/calendar/app/pages/CalendarView.tsx @@ -730,6 +730,11 @@ export default function CalendarView() { return events.find((event) => event.id === candidate.id) ?? candidate; }, [events, sidebarEvent, focusedEvent]); + const refreshedSidebarEvent = useMemo(() => { + if (!sidebarEvent) return null; + return events.find((event) => event.id === sidebarEvent.id) ?? sidebarEvent; + }, [events, sidebarEvent]); + function handleNavigate(direction: "prev" | "next") { const fns = direction === "next" @@ -1670,7 +1675,7 @@ export default function CalendarView() { {/* Event detail sidebar — full height, outside the calendar column */} {eventDetailSidebar && ( setSidebarEvent(null)} onDelete={handleDeleteEvent} onTitleSave={handleTitleSave} diff --git a/templates/calendar/changelog/2026-07-07-working-locations-from-google-calendar-now-appear-as-native-.md b/templates/calendar/changelog/2026-07-07-working-locations-from-google-calendar-now-appear-as-native-.md index 64d2735d4c..018ef70b00 100644 --- a/templates/calendar/changelog/2026-07-07-working-locations-from-google-calendar-now-appear-as-native-.md +++ b/templates/calendar/changelog/2026-07-07-working-locations-from-google-calendar-now-appear-as-native-.md @@ -3,4 +3,4 @@ type: fixed date: 2026-07-07 --- -Working locations from Google Calendar now appear as native, non-blocking working-location events instead of generic all-day events. +Working locations now appear in a dedicated non-blocking lane and can be changed between Home, Office, and Other for one day or an entire recurring series. diff --git a/templates/calendar/server/lib/google-calendar.spec.ts b/templates/calendar/server/lib/google-calendar.spec.ts index 4046c669cd..af69f77ccf 100644 --- a/templates/calendar/server/lib/google-calendar.spec.ts +++ b/templates/calendar/server/lib/google-calendar.spec.ts @@ -627,6 +627,49 @@ describe("calendar recurring event updates", () => { }); }); +describe("calendar working-location updates", () => { + beforeEach(() => { + vi.clearAllMocks(); + listOAuthAccountsByOwnerMock.mockResolvedValue([ + { + accountId: "steve@example.com", + tokens: { + access_token: "access-token", + expiry_date: Date.now() + 10 * 60_000, + }, + }, + ]); + calendarPatchEventMock.mockResolvedValue({ + id: "working-location-1", + htmlLink: "https://calendar.google.com/event", + }); + }); + + it("omits generic location when patching native working-location properties", async () => { + await updateEvent("working-location-1", { + accountEmail: "steve@example.com", + location: "Pier 57", + workingLocationProperties: { + type: "officeLocation", + officeLocation: { label: "Pier 57" }, + }, + }); + + expect(calendarPatchEventMock).toHaveBeenCalledWith( + "access-token", + "primary", + "working-location-1", + { + workingLocationProperties: { + type: "officeLocation", + officeLocation: { label: "Pier 57" }, + }, + }, + expect.any(Object), + ); + }); +}); + describe("calendar RSVP updates", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/templates/calendar/server/lib/google-calendar.ts b/templates/calendar/server/lib/google-calendar.ts index e6e448be7f..23d9eb32fd 100644 --- a/templates/calendar/server/lib/google-calendar.ts +++ b/templates/calendar/server/lib/google-calendar.ts @@ -1179,7 +1179,10 @@ export async function updateEvent( if (eventPatch.title !== undefined) requestBody.summary = eventPatch.title; if (eventPatch.description !== undefined) requestBody.description = eventPatch.description; - if (eventPatch.location !== undefined) + if ( + eventPatch.location !== undefined && + eventPatch.workingLocationProperties === undefined + ) requestBody.location = eventPatch.location; if (eventPatch.start !== undefined) { requestBody.start = eventPatch.allDay From 14454dff258189541b940be368055ad338aeb483 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:44:26 -0400 Subject: [PATCH 3/8] Refine working location week display --- .../app/components/calendar/WeekView.tsx | 225 +++++++++++------- .../calendar/app/lib/all-day-layout.test.ts | 40 ++++ templates/calendar/app/lib/all-day-layout.ts | 32 +++ 3 files changed, 212 insertions(+), 85 deletions(-) diff --git a/templates/calendar/app/components/calendar/WeekView.tsx b/templates/calendar/app/components/calendar/WeekView.tsx index 29112e6c71..9064663d30 100644 --- a/templates/calendar/app/components/calendar/WeekView.tsx +++ b/templates/calendar/app/components/calendar/WeekView.tsx @@ -1,6 +1,11 @@ import { useT } from "@agent-native/core/client"; import type { CalendarEvent } from "@shared/api"; -import { IconAlertTriangleFilled, IconMapPin } from "@tabler/icons-react"; +import { + IconAlertTriangleFilled, + IconBuilding, + IconHome, + IconMapPin, +} from "@tabler/icons-react"; import { startOfWeek, endOfWeek, @@ -33,6 +38,7 @@ import { type ViewPreferences, } from "@/hooks/use-view-preferences"; import { + groupAdjacentAllDayPlacements, layoutAllDayEvents, partitionAllDayEvents, } from "@/lib/all-day-layout"; @@ -595,7 +601,10 @@ export const WeekView = memo(function WeekView({ isLoading = false, }: WeekViewProps) { const t = useT(); - const workingLocationLabels = createWorkingLocationDisplayLabels(t); + const workingLocationLabels = useMemo( + () => createWorkingLocationDisplayLabels(t), + [t], + ); const { setFocusedEvent } = useCalendarSetters(); const isMobile = useIsMobile(); const GUTTER_WIDTH = isMobile ? MOBILE_GUTTER_WIDTH : DESKTOP_GUTTER_WIDTH; @@ -668,6 +677,22 @@ export const WeekView = memo(function WeekView({ () => 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]); @@ -696,10 +721,10 @@ export const WeekView = memo(function WeekView({ const hasWorkingLocations = workingLocationLayout.rowCount > 0; const hasRegularAllDayEvents = regularAllDayLayout.rowCount > 0; const hasAnyAllDay = hasWorkingLocations || hasRegularAllDayEvents; - const workingLocationRowHeight = 18; + const workingLocationRowHeight = 16; const allDayRowHeight = 20; const workingLocationLaneHeight = hasWorkingLocations - ? workingLocationLayout.rowCount * workingLocationRowHeight + 4 + ? workingLocationLayout.rowCount * workingLocationRowHeight + 2 : 0; const laneSeparatorHeight = hasWorkingLocations && hasRegularAllDayEvents ? 1 : 0; @@ -947,17 +972,6 @@ export const WeekView = memo(function WeekView({ className="relative shrink-0 border-r border-border" style={{ width: `${GUTTER_WIDTH}px` }} > - {hasWorkingLocations && ( -
-
- )} {hasRegularAllDayEvents && ( - {workingLocationLayout.placements.map( - ({ event, startCol, endCol, row }) => { - const color = getEventDisplayColor(event, prefs); - const colCount = days.length; - const leftPct = (startCol / colCount) * 100; - const widthPct = ((endCol - startCol + 1) / colCount) * 100; - const title = getWorkingLocationChipLabel( - event, - workingLocationLabels, - ); - const ariaTitle = getWorkingLocationTitle( - event, - workingLocationLabels, - ); + {workingLocationGroups.map((group) => { + const firstPlacement = group[0]; + const lastPlacement = group[group.length - 1]; + const groupKey = group.map(({ event }) => event.id).join(":"); + const colCount = days.length; + const groupLeftPct = + (firstPlacement.startCol / colCount) * 100; + const groupWidthPct = + ((lastPlacement.endCol - firstPlacement.startCol + 1) / + colCount) * + 100; + const groupColor = getEventDisplayColor( + firstPlacement.event, + prefs, + ); - return ( - - - - ); - }, - )} + return ( +
+ + ); + })}
diff --git a/templates/calendar/app/lib/all-day-layout.test.ts b/templates/calendar/app/lib/all-day-layout.test.ts index 44eed5c5a0..0381a8c565 100644 --- a/templates/calendar/app/lib/all-day-layout.test.ts +++ b/templates/calendar/app/lib/all-day-layout.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { getAllDaySpan, + groupAdjacentAllDayPlacements, layoutAllDayEvents, partitionAllDayEvents, } from "./all-day-layout"; @@ -81,4 +82,43 @@ describe("all-day layout", () => { regularEvents: [ordinaryEvent], }); }); + + it("groups adjacent placements with the same visual identity", () => { + const layout = layoutAllDayEvents( + [ + event("home-mon", "2026-07-06", "2026-07-07", "workingLocation"), + event("home-tue", "2026-07-07", "2026-07-08", "workingLocation"), + event("office-wed", "2026-07-08", "2026-07-09", "workingLocation"), + ], + days, + ); + + const groups = groupAdjacentAllDayPlacements( + layout.placements, + ({ event: placementEvent }) => + placementEvent.id.startsWith("home") ? "home" : "office", + ); + + expect(groups.map((group) => group.map(({ event }) => event.id))).toEqual([ + ["home-mon", "home-tue"], + ["office-wed"], + ]); + }); + + it("does not join matching placements separated by a visible day", () => { + const layout = layoutAllDayEvents( + [ + event("home-mon", "2026-07-06", "2026-07-07", "workingLocation"), + event("home-wed", "2026-07-08", "2026-07-09", "workingLocation"), + ], + days, + ); + + const groups = groupAdjacentAllDayPlacements( + layout.placements, + () => "home", + ); + + expect(groups).toHaveLength(2); + }); }); diff --git a/templates/calendar/app/lib/all-day-layout.ts b/templates/calendar/app/lib/all-day-layout.ts index fca3cbf67b..04e1555460 100644 --- a/templates/calendar/app/lib/all-day-layout.ts +++ b/templates/calendar/app/lib/all-day-layout.ts @@ -18,6 +18,8 @@ export interface AllDayLayout { rowCount: number; } +export type AllDayPlacementGroup = AllDayPlacement[]; + export function partitionAllDayEvents(events: CalendarEvent[]) { const workingLocations: CalendarEvent[] = []; const regularEvents: CalendarEvent[] = []; @@ -92,3 +94,33 @@ export function layoutAllDayEvents( return { placements, rowCount: rows.length }; } + +/** Group visually adjacent placements while preserving each event's click target. */ +export function groupAdjacentAllDayPlacements( + placements: AllDayPlacement[], + getGroupKey: (placement: AllDayPlacement) => string, +): AllDayPlacementGroup[] { + const sorted = [...placements].sort( + (a, b) => a.row - b.row || a.startCol - b.startCol, + ); + const groups: AllDayPlacementGroup[] = []; + + for (const placement of sorted) { + const previousGroup = groups[groups.length - 1]; + const previousPlacement = previousGroup?.[previousGroup.length - 1]; + const isAdjacent = + previousPlacement?.row === placement.row && + previousPlacement.endCol + 1 === placement.startCol; + const isSameGroup = + previousPlacement !== undefined && + getGroupKey(previousPlacement) === getGroupKey(placement); + + if (previousGroup && isAdjacent && isSameGroup) { + previousGroup.push(placement); + } else { + groups.push([placement]); + } + } + + return groups; +} From c046b27169ce23fcef83107f9d7e19c672f70761 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:20:18 -0400 Subject: [PATCH 4/8] Fix working location updates --- .../calendar/WorkingLocationEditor.tsx | 2 +- templates/calendar/app/hooks/use-events.ts | 9 +++-- .../server/lib/google-calendar.spec.ts | 28 +++++++++++--- .../calendar/server/lib/google-calendar.ts | 37 +++++++++++++------ 4 files changed, 54 insertions(+), 22 deletions(-) diff --git a/templates/calendar/app/components/calendar/WorkingLocationEditor.tsx b/templates/calendar/app/components/calendar/WorkingLocationEditor.tsx index c646834cad..53f4ce352c 100644 --- a/templates/calendar/app/components/calendar/WorkingLocationEditor.tsx +++ b/templates/calendar/app/components/calendar/WorkingLocationEditor.tsx @@ -57,7 +57,7 @@ export function WorkingLocationEditor({ setType(getWorkingLocationType(event)); setLabels(initialLabels(event)); setScope("single"); - }, [event.id, event.location, event.workingLocationProperties]); + }, [currentLabel, currentType, event.id]); const label = type === "homeOffice" ? "" : labels[type]; const isValid = type === "homeOffice" || label.trim().length > 0; diff --git a/templates/calendar/app/hooks/use-events.ts b/templates/calendar/app/hooks/use-events.ts index a0b8f549bb..8f7b85e5e0 100644 --- a/templates/calendar/app/hooks/use-events.ts +++ b/templates/calendar/app/hooks/use-events.ts @@ -326,9 +326,15 @@ export function useUpdateEvent() { workingLocationLabel, ...optimisticData } = newData; + const hasWorkingLocationUpdate = + workingLocationType !== undefined || workingLocationLabel !== undefined; queryClient.setQueriesData( { queryKey: ["action", "list-events"] }, (old) => { + // A working-location change can alter the visual grouping of several + // days. Keep the editor mounted until Google confirms the write so a + // rejected request does not appear to save and then snap back. + if (hasWorkingLocationUpdate) return old; const target = old?.find((event) => event.id === optimisticData.id); return old?.map((e) => { const matchesScope = @@ -337,9 +343,6 @@ export function useUpdateEvent() { !!target?.recurringEventId && e.recurringEventId === target.recurringEventId); if (!matchesScope) return e; - const hasWorkingLocationUpdate = - workingLocationType !== undefined || - workingLocationLabel !== undefined; const nextWorkingLocationType = workingLocationType ?? getWorkingLocationType(e); const nextWorkingLocationLabel = diff --git a/templates/calendar/server/lib/google-calendar.spec.ts b/templates/calendar/server/lib/google-calendar.spec.ts index af69f77ccf..2e60d62290 100644 --- a/templates/calendar/server/lib/google-calendar.spec.ts +++ b/templates/calendar/server/lib/google-calendar.spec.ts @@ -12,6 +12,7 @@ const calendarListEventsMock = vi.hoisted(() => vi.fn()); const calendarFreeBusyMock = vi.hoisted(() => vi.fn()); const calendarInsertEventMock = vi.hoisted(() => vi.fn()); const calendarPatchEventMock = vi.hoisted(() => vi.fn()); +const calendarUpdateEventMock = vi.hoisted(() => vi.fn()); const dbExecuteMock = vi.hoisted(() => vi.fn()); const resolveSecretMock = vi.hoisted(() => vi.fn()); const runWithRequestContextMock = vi.hoisted(() => vi.fn()); @@ -51,6 +52,7 @@ vi.mock("./google-api.js", () => ({ calendarInsertEvent: calendarInsertEventMock, calendarDeleteEvent: vi.fn(), calendarPatchEvent: calendarPatchEventMock, + calendarUpdateEvent: calendarUpdateEventMock, calendarFreeBusy: calendarFreeBusyMock, })); @@ -639,13 +641,24 @@ describe("calendar working-location updates", () => { }, }, ]); - calendarPatchEventMock.mockResolvedValue({ + calendarUpdateEventMock.mockResolvedValue({ id: "working-location-1", htmlLink: "https://calendar.google.com/event", }); }); - it("omits generic location when patching native working-location properties", async () => { + it("updates working locations as complete status-event resources", async () => { + calendarGetEventMock.mockResolvedValue({ + id: "working-location-1", + summary: "Home", + eventType: "workingLocation", + start: { date: "2026-07-08" }, + end: { date: "2026-07-09" }, + visibility: "public", + transparency: "transparent", + workingLocationProperties: { type: "homeOffice", homeOffice: {} }, + }); + await updateEvent("working-location-1", { accountEmail: "steve@example.com", location: "Pier 57", @@ -655,18 +668,21 @@ describe("calendar working-location updates", () => { }, }); - expect(calendarPatchEventMock).toHaveBeenCalledWith( + expect(calendarUpdateEventMock).toHaveBeenCalledWith( "access-token", "primary", "working-location-1", - { + expect.objectContaining({ + eventType: "workingLocation", + start: { date: "2026-07-08" }, + end: { date: "2026-07-09" }, workingLocationProperties: { type: "officeLocation", officeLocation: { label: "Pier 57" }, }, - }, - expect.any(Object), + }), ); + expect(calendarPatchEventMock).not.toHaveBeenCalled(); }); }); diff --git a/templates/calendar/server/lib/google-calendar.ts b/templates/calendar/server/lib/google-calendar.ts index 23d9eb32fd..a187e82c3b 100644 --- a/templates/calendar/server/lib/google-calendar.ts +++ b/templates/calendar/server/lib/google-calendar.ts @@ -29,6 +29,7 @@ import { calendarInsertEvent, calendarDeleteEvent, calendarPatchEvent, + calendarUpdateEvent, peopleGetProfile, } from "./google-api.js"; import { @@ -1224,18 +1225,30 @@ export async function updateEvent( requestBody.conferenceData = createGoogleMeetRequest(); } - const response = await calendarPatchEvent( - client.accessToken, - "primary", - targetEventId, - requestBody, - { - sendUpdates: options?.sendUpdates, - conferenceDataVersion: options?.addGoogleMeet ? 1 : undefined, - supportsAttachments: - eventPatch.attachments !== undefined ? true : undefined, - }, - ); + // Google validates status events as complete resources during updates. A + // partial PATCH can reject otherwise valid working-location changes because + // required eventType/start/end fields are absent from the request body. + const response = eventPatch.workingLocationProperties + ? await calendarUpdateEvent(client.accessToken, "primary", targetEventId, { + ...(await calendarGetEvent( + client.accessToken, + "primary", + targetEventId, + )), + ...requestBody, + }) + : await calendarPatchEvent( + client.accessToken, + "primary", + targetEventId, + requestBody, + { + sendUpdates: options?.sendUpdates, + conferenceDataVersion: options?.addGoogleMeet ? 1 : undefined, + supportsAttachments: + eventPatch.attachments !== undefined ? true : undefined, + }, + ); return { htmlLink: response?.htmlLink || undefined, From 31984a64e15db7a2744c5e82c51a5a760cbd1ac8 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:40:24 -0400 Subject: [PATCH 5/8] Handle recurring working location overrides --- .../calendar/actions/update-event.test.ts | 133 ++++++++++++++---- templates/calendar/actions/update-event.ts | 116 +++++++++++++-- .../server/lib/google-calendar.spec.ts | 41 ++++++ .../calendar/server/lib/google-calendar.ts | 15 +- 4 files changed, 265 insertions(+), 40 deletions(-) diff --git a/templates/calendar/actions/update-event.test.ts b/templates/calendar/actions/update-event.test.ts index c6401f560e..a24ecbe04c 100644 --- a/templates/calendar/actions/update-event.test.ts +++ b/templates/calendar/actions/update-event.test.ts @@ -5,12 +5,16 @@ 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", () => ({ @@ -24,6 +28,28 @@ vi.mock("../server/lib/event-video-conferencing.js", () => ({ 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(); @@ -32,6 +58,11 @@ describe("update-event working locations", () => { 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 () => { @@ -91,26 +122,8 @@ describe("update-event working locations", () => { expect(updateEventMock.mock.calls[0]?.[1]).not.toHaveProperty("location"); }); - it("updates one recurring instance by its instance id and clears office labels for Home", async () => { - getEventMock.mockResolvedValue({ - 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", - }); + it("replaces one recurring working-location instance with a single-day override", async () => { + getEventMock.mockResolvedValue(recurringWorkingLocationEvent()); await runWithRequestContext({ userEmail: "owner@example.com" }, () => action.run({ @@ -122,17 +135,89 @@ describe("update-event working locations", () => { }), ); - expect(updateEventMock).toHaveBeenCalledWith( - "instance-20260707", + expect(createEventMock).toHaveBeenCalledWith( expect.objectContaining({ + start: "2026-07-07", + end: "2026-07-08", + allDay: true, + eventType: "workingLocation", workingLocationProperties: { type: "homeOffice", homeOffice: {}, }, }), - expect.objectContaining({ scope: "single" }), ); - expect(updateEventMock.mock.calls[0]?.[1]).not.toHaveProperty("location"); + 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 () => { diff --git a/templates/calendar/actions/update-event.ts b/templates/calendar/actions/update-event.ts index d352a58aae..dded1cf87f 100644 --- a/templates/calendar/actions/update-event.ts +++ b/templates/calendar/actions/update-event.ts @@ -67,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.", @@ -390,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; @@ -433,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/server/lib/google-calendar.spec.ts b/templates/calendar/server/lib/google-calendar.spec.ts index 2e60d62290..d5b24c091a 100644 --- a/templates/calendar/server/lib/google-calendar.spec.ts +++ b/templates/calendar/server/lib/google-calendar.spec.ts @@ -572,6 +572,47 @@ describe("calendar event creation", () => { undefined, ); }); + + it("lets Google derive the summary for working-location events", async () => { + await createEvent({ + id: "", + title: "Neighborhood cafe", + description: "", + location: "", + start: "2026-07-08", + end: "2026-07-09", + allDay: true, + source: "google", + accountEmail: "steve@example.com", + transparency: "transparent", + visibility: "public", + eventType: "workingLocation", + workingLocationProperties: { + type: "customLocation", + customLocation: { label: "Neighborhood cafe" }, + }, + createdAt: "2026-07-08T00:00:00.000Z", + updatedAt: "2026-07-08T00:00:00.000Z", + }); + + expect(calendarInsertEventMock).toHaveBeenCalledWith( + "access-token", + "primary", + expect.objectContaining({ + start: { date: "2026-07-08" }, + end: { date: "2026-07-09" }, + workingLocationProperties: { + type: "customLocation", + customLocation: { label: "Neighborhood cafe" }, + }, + }), + undefined, + ); + const body = calendarInsertEventMock.mock.calls[0]?.[2]; + expect(body).not.toHaveProperty("summary"); + expect(body).not.toHaveProperty("description"); + expect(body).not.toHaveProperty("location"); + }); }); describe("calendar recurring event updates", () => { diff --git a/templates/calendar/server/lib/google-calendar.ts b/templates/calendar/server/lib/google-calendar.ts index a187e82c3b..a759be61f2 100644 --- a/templates/calendar/server/lib/google-calendar.ts +++ b/templates/calendar/server/lib/google-calendar.ts @@ -1080,12 +1080,15 @@ export async function createEvent( throw new Error("Out of office and focus time events must be timed."); } - const body: any = { - summary: event.title, - description: event.description, - location: event.location, - ...buildDateRange(event), - }; + const body: any = + event.eventType === "workingLocation" + ? buildDateRange(event) + : { + summary: event.title, + description: event.description, + location: event.location, + ...buildDateRange(event), + }; applyEventOptions(body, event); if (event.attachments !== undefined) { body.attachments = event.attachments; From 66e8894c8556ba16a148ac072ba79cabc61764fe Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:28:21 -0400 Subject: [PATCH 6/8] Fix working location day edits --- .../calendar/EventDetailPopover.tsx | 149 +++++++++++------- .../calendar/app/hooks/use-events.test.ts | 33 +++- templates/calendar/app/hooks/use-events.ts | 17 +- .../calendar/app/lib/event-form-utils.test.ts | 30 ++++ .../calendar/app/lib/event-form-utils.ts | 19 ++- 5 files changed, 184 insertions(+), 64 deletions(-) diff --git a/templates/calendar/app/components/calendar/EventDetailPopover.tsx b/templates/calendar/app/components/calendar/EventDetailPopover.tsx index ae99daf78a..22a9f0af8c 100644 --- a/templates/calendar/app/components/calendar/EventDetailPopover.tsx +++ b/templates/calendar/app/components/calendar/EventDetailPopover.tsx @@ -85,7 +85,9 @@ import { getEventEndValidationMessage, getLocalTimezone, getRecurrencePreset, + normalizeAllDayEditEndDate, remindersToDraftState, + resolveTimeEditScope, type AttachmentDraft, type RecurrencePreset, type ReminderDraft, @@ -457,6 +459,7 @@ export function EventDetailPopover({ setFocusedEvent, } = useCalendarContext(); const isWorkingLocation = isWorkingLocationEvent(event); + const isSingleDayWorkingLocation = isWorkingLocation && event.allDay; const editableLocationValue = event.location || ""; // Inline editing state @@ -955,7 +958,12 @@ export function EventDetailPopover({ ); const handleSaveTime = useCallback(() => { - const allDayEnd = new Date(`${editEndDate}T00:00:00`); + const normalizedEndDate = normalizeAllDayEditEndDate( + isSingleDayWorkingLocation, + editDate, + editEndDate, + ); + const allDayEnd = new Date(`${normalizedEndDate}T00:00:00`); allDayEnd.setDate(allDayEnd.getDate() + 1); const newStart = event.allDay ? new Date(`${editDate}T00:00:00`).toISOString() @@ -983,7 +991,11 @@ export function EventDetailPopover({ allDay: event.allDay, startTimeZone: event.allDay ? undefined : editTimezone, endTimeZone: event.allDay ? undefined : editTimezone, - scope: isRecurringEvent ? editTimeScope : "single", + scope: resolveTimeEditScope( + isRecurringEvent, + isSingleDayWorkingLocation, + editTimeScope, + ), }); } setEditTimeScope("single"); @@ -998,6 +1010,7 @@ export function EventDetailPopover({ event.start, event.end, event.allDay, + isSingleDayWorkingLocation, isRecurringEvent, editTimeScope, saveField, @@ -1423,7 +1436,13 @@ export function EventDetailPopover({
-
+
- current < next ? next : current, + isSingleDayWorkingLocation + ? next + : current < next + ? next + : current, ); }} className="w-full rounded-md border border-border bg-transparent px-2 py-1 text-sm text-foreground" aria-label={t("eventForm.startDate")} /> - - setEditEndDate(e.target.value || editDate) - } - className="w-full rounded-md border border-border bg-transparent px-2 py-1 text-sm text-foreground" - aria-label={t("eventForm.endDate")} - /> -
-
- setEditStartTime(e.target.value)} - className="flex-1 rounded-md border border-border bg-transparent px-2 py-1 text-sm text-foreground" - /> - - → - - setEditEndTime(e.target.value)} - className="flex-1 rounded-md border border-border bg-transparent px-2 py-1 text-sm text-foreground" - /> + {!isSingleDayWorkingLocation && ( + + setEditEndDate(e.target.value || editDate) + } + className="w-full rounded-md border border-border bg-transparent px-2 py-1 text-sm text-foreground" + aria-label={t("eventForm.endDate")} + /> + )}
+ {!event.allDay && ( +
+ setEditStartTime(e.target.value)} + className="flex-1 rounded-md border border-border bg-transparent px-2 py-1 text-sm text-foreground" + /> + + → + + setEditEndTime(e.target.value)} + className="flex-1 rounded-md border border-border bg-transparent px-2 py-1 text-sm text-foreground" + /> +
+ )} {!event.allDay && ( )} - {isRecurringEvent && !isDraft && ( -
- - {t("eventForm.applyTo")} - - -
- )} + {isRecurringEvent && + !isDraft && + !isSingleDayWorkingLocation && ( +
+ + {t("eventForm.applyTo")} + + +
+ )}