@agent-native/core 0.159.5 → 0.160.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/assets/server/lib/generation.ts +10 -6
  3. package/corpus/templates/calendar/.agents/skills/event-management/SKILL.md +18 -4
  4. package/corpus/templates/calendar/actions/create-event.ts +4 -2
  5. package/corpus/templates/calendar/actions/event-action-helpers.ts +56 -9
  6. package/corpus/templates/calendar/actions/update-event.ts +1 -0
  7. package/corpus/templates/calendar/agent-native.config.ts +1 -0
  8. package/corpus/templates/calendar/app/components/calendar/CreateEventDialog.tsx +8 -2
  9. package/corpus/templates/calendar/app/components/calendar/DayView.tsx +102 -68
  10. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +150 -49
  11. package/corpus/templates/calendar/app/components/calendar/MonthView.tsx +57 -23
  12. package/corpus/templates/calendar/app/components/calendar/OutOfOfficeEvent.tsx +4 -1
  13. package/corpus/templates/calendar/app/components/calendar/TimezoneSwitchDialog.tsx +70 -0
  14. package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +127 -88
  15. package/corpus/templates/calendar/app/components/calendar/WorkingLocationEditor.tsx +59 -25
  16. package/corpus/templates/calendar/app/hooks/use-event-drag.ts +55 -18
  17. package/corpus/templates/calendar/app/i18n-data.ts +60 -0
  18. package/corpus/templates/calendar/app/lib/all-day-layout.ts +16 -8
  19. package/corpus/templates/calendar/app/lib/calendar-drafts.ts +56 -0
  20. package/corpus/templates/calendar/app/lib/calendar-timezone.ts +351 -0
  21. package/corpus/templates/calendar/app/lib/event-layout.ts +14 -8
  22. package/corpus/templates/calendar/app/lib/out-of-office.ts +29 -61
  23. package/corpus/templates/calendar/app/lib/working-location.ts +34 -2
  24. package/corpus/templates/calendar/app/pages/CalendarView.tsx +317 -139
  25. package/corpus/templates/calendar/changelog/2026-08-13-calendar-stays-pinned-to-the-saved-timezone-and-asks-before-.md +6 -0
  26. package/corpus/templates/calendar/changelog/2026-08-13-working-locations-can-be-added-from-calendar-days-with-a-ful.md +6 -0
  27. package/corpus/templates/calendar/changelog/2026-08-14-choosing-office-or-other-when-adding-a-working-location-creates.md +6 -0
  28. package/corpus/templates/calendar/changelog/2026-08-14-creating-an-other-working-location-now-keeps-the-custom-name.md +6 -0
  29. package/corpus/templates/calendar/changelog/2026-08-14-timed-working-locations-keep-home-office-or-custom-titles.md +6 -0
  30. package/corpus/templates/calendar/changelog/2026-08-14-timed-working-locations-now-keep-a-home-office-or-custom-title.md +6 -0
  31. package/corpus/templates/calendar/changelog/2026-08-14-turning-a-midnight-ending-working-location-back-to-all-day.md +6 -0
  32. package/corpus/templates/calendar/server/lib/google-calendar.ts +22 -1
  33. package/dist/agent/engine/builder-engine.d.ts +16 -2
  34. package/dist/agent/engine/builder-engine.js +178 -115
  35. package/dist/agent/engine/builtin.js +13 -4
  36. package/dist/agent/engine/credential-errors.d.ts +23 -0
  37. package/dist/agent/engine/credential-errors.js +19 -0
  38. package/dist/agent/engine/error-detail.d.ts +15 -0
  39. package/dist/agent/engine/error-detail.js +29 -0
  40. package/dist/agent/engine/registry.d.ts +14 -3
  41. package/dist/agent/engine/registry.js +106 -61
  42. package/dist/agent/engine/types.d.ts +16 -0
  43. package/dist/agent/engine/types.js +9 -0
  44. package/dist/agent/production-agent.d.ts +7 -0
  45. package/dist/agent/production-agent.js +68 -31
  46. package/dist/agent/run-manager.js +30 -0
  47. package/dist/agent/thread-data-builder.js +4 -0
  48. package/dist/agent/tool-approval-migrations.d.ts +1 -0
  49. package/dist/agent/tool-approval-migrations.js +10 -0
  50. package/dist/agent/tool-approval-store.d.ts +5 -3
  51. package/dist/agent/tool-approval-store.js +11 -6
  52. package/dist/agent/types.d.ts +9 -0
  53. package/dist/cli/code-agent-connector.js +79 -7
  54. package/dist/cli/portal-transfer.d.ts +43 -0
  55. package/dist/cli/portal-transfer.js +201 -0
  56. package/dist/client/agent-chat-adapter.js +46 -15
  57. package/dist/client/error-format.js +25 -2
  58. package/dist/client/sse-event-processor.d.ts +2 -0
  59. package/dist/client/sse-event-processor.js +12 -1
  60. package/dist/collab/awareness.d.ts +2 -2
  61. package/dist/collab/struct-routes.d.ts +1 -1
  62. package/dist/jobs/background-automation-runner.js +9 -15
  63. package/dist/notifications/routes.d.ts +6 -6
  64. package/dist/observability/routes.d.ts +5 -5
  65. package/dist/resources/handlers.d.ts +1 -1
  66. package/dist/secrets/register-framework-secrets.js +25 -0
  67. package/dist/secrets/routes.d.ts +9 -9
  68. package/dist/server/agent-chat-plugin.js +9 -2
  69. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  70. package/dist/server/credential-provider.d.ts +80 -0
  71. package/dist/server/credential-provider.js +168 -1
  72. package/dist/server/google-realtime-session.d.ts +1 -1
  73. package/dist/server/google-realtime-session.js +6 -6
  74. package/dist/server/index.d.ts +1 -1
  75. package/dist/server/index.js +6 -1
  76. package/dist/server/realtime-token.d.ts +1 -1
  77. package/dist/server/realtime-voice.js +12 -6
  78. package/dist/server/transcribe-voice.d.ts +1 -1
  79. package/dist/server/transcribe-voice.js +18 -16
  80. package/dist/transcription/builder-transcription.js +8 -4
  81. package/package.json +3 -3
@@ -0,0 +1,351 @@
1
+ import type { CalendarEvent } from "@shared/api";
2
+ import {
3
+ addDays,
4
+ endOfMonth,
5
+ endOfWeek,
6
+ format,
7
+ startOfMonth,
8
+ startOfWeek,
9
+ } from "date-fns";
10
+
11
+ import { dateTimeInTimezoneToIso } from "./event-form-utils";
12
+
13
+ export const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
14
+
15
+ export interface CalendarDateTimeParts {
16
+ date: string;
17
+ hour: number;
18
+ minute: number;
19
+ second: number;
20
+ }
21
+
22
+ export interface CalendarDayBounds {
23
+ date: string;
24
+ start: Date;
25
+ end: Date;
26
+ }
27
+
28
+ export interface CalendarEventSegment {
29
+ topMinutes: number;
30
+ durationMinutes: number;
31
+ startsOnDay: boolean;
32
+ endsOnDay: boolean;
33
+ startMinutes: number;
34
+ endMinutes: number;
35
+ }
36
+
37
+ export function getBrowserTimezone(): string {
38
+ try {
39
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
40
+ } catch {
41
+ return "UTC";
42
+ }
43
+ }
44
+
45
+ export function isValidTimezone(timezone: string): boolean {
46
+ try {
47
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format();
48
+ return true;
49
+ } catch (error) {
50
+ if (error instanceof RangeError) return false;
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ export function normalizeTimezone(timezone?: string): string {
56
+ return timezone && isValidTimezone(timezone)
57
+ ? timezone
58
+ : getBrowserTimezone();
59
+ }
60
+
61
+ /** Date carriers are kept at local noon so browser DST never changes their date. */
62
+ export function dateKeyToDate(date: string): Date {
63
+ const [year, month, day] = date.split("-").map(Number);
64
+ return new Date(year, month - 1, day, 12, 0, 0, 0);
65
+ }
66
+
67
+ export function dateToCalendarDateKey(date: Date): string {
68
+ return format(date, "yyyy-MM-dd");
69
+ }
70
+
71
+ export function addCalendarDays(date: string, amount: number): string {
72
+ const [year, month, day] = date.split("-").map(Number);
73
+ const next = new Date(Date.UTC(year, month - 1, day + amount));
74
+ return next.toISOString().slice(0, 10);
75
+ }
76
+
77
+ function dateTimeParts(value: Date | string, timezone: string) {
78
+ const parsed = value instanceof Date ? value : new Date(value);
79
+ if (Number.isNaN(parsed.getTime())) return null;
80
+
81
+ try {
82
+ const parts = new Intl.DateTimeFormat("en-US", {
83
+ timeZone: timezone,
84
+ hourCycle: "h23",
85
+ year: "numeric",
86
+ month: "2-digit",
87
+ day: "2-digit",
88
+ hour: "2-digit",
89
+ minute: "2-digit",
90
+ second: "2-digit",
91
+ }).formatToParts(parsed);
92
+ const valueFor = (type: Intl.DateTimeFormatPartTypes) =>
93
+ parts.find((part) => part.type === type)?.value;
94
+ const year = valueFor("year");
95
+ const month = valueFor("month");
96
+ const day = valueFor("day");
97
+ const hour = valueFor("hour");
98
+ const minute = valueFor("minute");
99
+ const second = valueFor("second");
100
+ if (!year || !month || !day || !hour || !minute || !second) return null;
101
+
102
+ return {
103
+ date: `${year}-${month}-${day}`,
104
+ hour: Number(hour),
105
+ minute: Number(minute),
106
+ second: Number(second),
107
+ } satisfies CalendarDateTimeParts;
108
+ } catch (error) {
109
+ if (error instanceof RangeError) return null;
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ export function getDateTimePartsInTimezone(
115
+ value: Date | string,
116
+ timezone: string,
117
+ ): CalendarDateTimeParts | null {
118
+ return dateTimeParts(value, normalizeTimezone(timezone));
119
+ }
120
+
121
+ export function getDateKeyInTimezone(
122
+ value: Date | string,
123
+ timezone: string,
124
+ ): string | null {
125
+ return dateTimeParts(value, normalizeTimezone(timezone))?.date ?? null;
126
+ }
127
+
128
+ export function dateKeyToTimezoneIso(
129
+ date: string,
130
+ time: string,
131
+ timezone: string,
132
+ ): string {
133
+ return dateTimeInTimezoneToIso(date, time, normalizeTimezone(timezone));
134
+ }
135
+
136
+ export function getCalendarDayBounds(
137
+ day: Date,
138
+ timezone: string,
139
+ ): CalendarDayBounds {
140
+ const normalizedTimezone = normalizeTimezone(timezone);
141
+ const date = dateToCalendarDateKey(day);
142
+ return {
143
+ date,
144
+ start: new Date(dateKeyToTimezoneIso(date, "00:00", normalizedTimezone)),
145
+ end: new Date(
146
+ dateKeyToTimezoneIso(
147
+ addCalendarDays(date, 1),
148
+ "00:00",
149
+ normalizedTimezone,
150
+ ),
151
+ ),
152
+ };
153
+ }
154
+
155
+ export function getViewDateRange(
156
+ viewMode: "month" | "week" | "day",
157
+ selectedDate: Date,
158
+ timezone: string,
159
+ weekStartsOn: 0 | 1 = 0,
160
+ ): { from: string; to: string } {
161
+ const normalizedTimezone = normalizeTimezone(timezone);
162
+ const weekOptions = { weekStartsOn };
163
+ let rangeStart: Date;
164
+ let rangeEndExclusive: Date;
165
+
166
+ if (viewMode === "month") {
167
+ rangeStart = startOfWeek(startOfMonth(selectedDate), weekOptions);
168
+ rangeEndExclusive = addDays(
169
+ endOfWeek(endOfMonth(selectedDate), weekOptions),
170
+ 1,
171
+ );
172
+ } else if (viewMode === "week") {
173
+ rangeStart = startOfWeek(selectedDate, weekOptions);
174
+ rangeEndExclusive = addDays(endOfWeek(selectedDate, weekOptions), 1);
175
+ } else {
176
+ rangeStart = selectedDate;
177
+ rangeEndExclusive = addDays(selectedDate, 1);
178
+ }
179
+
180
+ return {
181
+ from: dateKeyToTimezoneIso(
182
+ dateToCalendarDateKey(rangeStart),
183
+ "00:00",
184
+ normalizedTimezone,
185
+ ),
186
+ to: dateKeyToTimezoneIso(
187
+ dateToCalendarDateKey(rangeEndExclusive),
188
+ "00:00",
189
+ normalizedTimezone,
190
+ ),
191
+ };
192
+ }
193
+
194
+ function dateOnlyPart(value: string | undefined): string | null {
195
+ if (!value) return null;
196
+ if (DATE_ONLY_PATTERN.test(value)) return value;
197
+ return value.slice(0, 10);
198
+ }
199
+
200
+ function eventDateRange(
201
+ event: Pick<CalendarEvent, "start" | "end" | "allDay">,
202
+ timezone: string,
203
+ ) {
204
+ if (event.allDay) {
205
+ const startDate = dateOnlyPart(event.start);
206
+ const endDate =
207
+ dateOnlyPart(event.end) ??
208
+ (startDate ? addCalendarDays(startDate, 1) : null);
209
+ return startDate && endDate ? { startDate, endDate } : null;
210
+ }
211
+
212
+ const start = getDateTimePartsInTimezone(event.start, timezone);
213
+ const end = getDateTimePartsInTimezone(event.end, timezone);
214
+ return start && end ? { startDate: start.date, endDate: end.date } : null;
215
+ }
216
+
217
+ export function getEventDateKey(
218
+ event: Pick<CalendarEvent, "start" | "end" | "allDay">,
219
+ timezone: string,
220
+ ): string | null {
221
+ return eventDateRange(event, timezone)?.startDate ?? null;
222
+ }
223
+
224
+ export function moveEventToCalendarDate(
225
+ event: Pick<CalendarEvent, "start" | "end" | "allDay">,
226
+ targetDate: Date,
227
+ timezone: string,
228
+ ): { start: string; end: string } | null {
229
+ const targetDateKey = dateToCalendarDateKey(targetDate);
230
+ const sourceStartDate = getEventDateKey(event, timezone);
231
+ if (!sourceStartDate) return null;
232
+
233
+ if (event.allDay) {
234
+ const sourceEndDate =
235
+ dateOnlyPart(event.end) ?? addCalendarDays(sourceStartDate, 1);
236
+ const spanDays = Math.max(
237
+ 1,
238
+ Math.round(
239
+ (dateKeyToDate(sourceEndDate).getTime() -
240
+ dateKeyToDate(sourceStartDate).getTime()) /
241
+ (24 * 60 * 60 * 1000),
242
+ ),
243
+ );
244
+ return {
245
+ start: targetDateKey,
246
+ end: addCalendarDays(targetDateKey, spanDays),
247
+ };
248
+ }
249
+
250
+ const start = getDateTimePartsInTimezone(event.start, timezone);
251
+ const end = getDateTimePartsInTimezone(event.end, timezone);
252
+ if (!start || !end) return null;
253
+ const spanDays = Math.max(
254
+ 0,
255
+ Math.round(
256
+ (dateKeyToDate(end.date).getTime() -
257
+ dateKeyToDate(start.date).getTime()) /
258
+ (24 * 60 * 60 * 1000),
259
+ ),
260
+ );
261
+ const startTime = `${String(start.hour).padStart(2, "0")}:${String(
262
+ start.minute,
263
+ ).padStart(2, "0")}`;
264
+ const endTime = `${String(end.hour).padStart(2, "0")}:${String(
265
+ end.minute,
266
+ ).padStart(2, "0")}`;
267
+ return {
268
+ start: dateKeyToTimezoneIso(targetDateKey, startTime, timezone),
269
+ end: dateKeyToTimezoneIso(
270
+ addCalendarDays(targetDateKey, spanDays),
271
+ endTime,
272
+ timezone,
273
+ ),
274
+ };
275
+ }
276
+
277
+ export function eventOverlapsCalendarDay(
278
+ event: Pick<CalendarEvent, "start" | "end" | "allDay">,
279
+ day: Date,
280
+ timezone: string,
281
+ ): boolean {
282
+ const normalizedTimezone = normalizeTimezone(timezone);
283
+ const dayBounds = getCalendarDayBounds(day, normalizedTimezone);
284
+ if (event.allDay) {
285
+ const range = eventDateRange(event, normalizedTimezone);
286
+ const dayDate = dayBounds.date;
287
+ return Boolean(
288
+ range && range.startDate <= dayDate && range.endDate > dayDate,
289
+ );
290
+ }
291
+
292
+ const start = new Date(event.start);
293
+ const end = new Date(event.end);
294
+ return (
295
+ !Number.isNaN(start.getTime()) &&
296
+ !Number.isNaN(end.getTime()) &&
297
+ start < dayBounds.end &&
298
+ end > dayBounds.start
299
+ );
300
+ }
301
+
302
+ function clockMinutes(parts: CalendarDateTimeParts): number {
303
+ return parts.hour * 60 + parts.minute + parts.second / 60;
304
+ }
305
+
306
+ export function getEventSegmentForCalendarDay(
307
+ event: Pick<CalendarEvent, "start" | "end">,
308
+ day: Date,
309
+ timezone: string,
310
+ ): CalendarEventSegment | null {
311
+ const normalizedTimezone = normalizeTimezone(timezone);
312
+ const dayBounds = getCalendarDayBounds(day, normalizedTimezone);
313
+ const rawStart = new Date(event.start);
314
+ const rawEnd = new Date(event.end);
315
+ if (
316
+ Number.isNaN(rawStart.getTime()) ||
317
+ Number.isNaN(rawEnd.getTime()) ||
318
+ rawStart >= dayBounds.end ||
319
+ rawEnd <= dayBounds.start
320
+ ) {
321
+ return null;
322
+ }
323
+
324
+ const start = getDateTimePartsInTimezone(rawStart, normalizedTimezone);
325
+ const end = getDateTimePartsInTimezone(rawEnd, normalizedTimezone);
326
+ if (!start || !end) return null;
327
+
328
+ const startMinutes =
329
+ start.date < dayBounds.date
330
+ ? 0
331
+ : start.date > dayBounds.date
332
+ ? 24 * 60
333
+ : clockMinutes(start);
334
+ const endMinutes =
335
+ end.date > dayBounds.date
336
+ ? 24 * 60
337
+ : end.date < dayBounds.date
338
+ ? 0
339
+ : clockMinutes(end);
340
+ const visibleStart = Math.max(0, Math.min(24 * 60, startMinutes));
341
+ const visibleEnd = Math.max(visibleStart, Math.min(24 * 60, endMinutes));
342
+
343
+ return {
344
+ topMinutes: visibleStart,
345
+ durationMinutes: Math.max(1, visibleEnd - visibleStart),
346
+ startsOnDay: start.date === dayBounds.date,
347
+ endsOnDay: end.date === dayBounds.date,
348
+ startMinutes: visibleStart,
349
+ endMinutes: visibleEnd,
350
+ };
351
+ }
@@ -1,5 +1,9 @@
1
1
  import type { CalendarEvent } from "@shared/api";
2
- import { addDays, parseISO, startOfDay } from "date-fns";
2
+
3
+ import {
4
+ getBrowserTimezone,
5
+ getEventSegmentForCalendarDay,
6
+ } from "@/lib/calendar-timezone";
3
7
 
4
8
  export interface TimedEventLayout {
5
9
  left: number;
@@ -35,21 +39,23 @@ function overlaps(a: EventBounds, b: EventBounds): boolean {
35
39
  export function computeTimedEventLayout(
36
40
  dayEvents: readonly CalendarEvent[],
37
41
  day: Date,
42
+ timezone: string = getBrowserTimezone(),
38
43
  ): Map<string, TimedEventLayout> {
39
44
  const result = new Map<string, TimedEventLayout>();
40
45
  if (dayEvents.length === 0) return result;
41
46
 
42
- const dayStartMs = startOfDay(day).getTime();
43
- const dayEndMs = addDays(startOfDay(day), 1).getTime();
44
47
  const entries = dayEvents.map<EventEntry>((event, inputOrder) => {
45
- const rawStart = parseISO(event.start).getTime();
46
- const rawEnd = parseISO(event.end).getTime();
47
- const start = Math.min(dayEndMs, Math.max(dayStartMs, rawStart));
48
+ const segment = getEventSegmentForCalendarDay(event, day, timezone);
49
+ const start = segment?.startMinutes ?? 0;
48
50
  // Match the card renderer's minimum height so tiny adjacent events get
49
51
  // collision-aware placement even when their raw times barely overlap.
50
52
  const end = Math.min(
51
- dayEndMs,
52
- Math.max(start, rawEnd, start + MIN_VISIBLE_EVENT_MINUTES * 60 * 1000),
53
+ 24 * 60,
54
+ Math.max(
55
+ start,
56
+ segment?.endMinutes ?? start,
57
+ start + MIN_VISIBLE_EVENT_MINUTES,
58
+ ),
53
59
  );
54
60
 
55
61
  return { event, bounds: { start, end }, inputOrder };
@@ -1,13 +1,12 @@
1
1
  import type { CalendarEvent } from "@shared/api";
2
- import {
3
- addDays,
4
- differenceInMinutes,
5
- format,
6
- parseISO,
7
- startOfDay,
8
- } from "date-fns";
9
2
 
10
- import { dateTimeInTimezoneToIso } from "@/lib/event-form-utils";
3
+ import {
4
+ dateKeyToTimezoneIso,
5
+ dateToCalendarDateKey,
6
+ getBrowserTimezone,
7
+ getDateTimePartsInTimezone,
8
+ getEventSegmentForCalendarDay,
9
+ } from "@/lib/calendar-timezone";
11
10
 
12
11
  export interface OutOfOfficeSegment {
13
12
  topMinutes: number;
@@ -22,34 +21,6 @@ export function isOutOfOfficeEvent(
22
21
  return event.eventType === "outOfOffice";
23
22
  }
24
23
 
25
- function localDateTimeParts(value: string, timeZone: string) {
26
- const date = parseISO(value);
27
- if (Number.isNaN(date.getTime())) return null;
28
-
29
- try {
30
- const parts = new Intl.DateTimeFormat("en-US", {
31
- timeZone,
32
- year: "numeric",
33
- month: "2-digit",
34
- day: "2-digit",
35
- hour: "2-digit",
36
- minute: "2-digit",
37
- hourCycle: "h23",
38
- }).formatToParts(date);
39
- const valueFor = (type: Intl.DateTimeFormatPartTypes) =>
40
- parts.find((part) => part.type === type)?.value;
41
- const year = valueFor("year");
42
- const month = valueFor("month");
43
- const day = valueFor("day");
44
- const hour = valueFor("hour");
45
- const minute = valueFor("minute");
46
- if (!year || !month || !day || !hour || !minute) return null;
47
- return { date: `${year}-${month}-${day}`, hour, minute };
48
- } catch {
49
- return null;
50
- }
51
- }
52
-
53
24
  export function isFullDayOutOfOfficeEvent(
54
25
  event: Pick<
55
26
  CalendarEvent,
@@ -68,19 +39,15 @@ export function getFullDayOutOfOfficeDateRange(
68
39
  if (!isOutOfOfficeEvent(event) || event.allDay) return null;
69
40
  const timeZone = event.startTimeZone ?? event.endTimeZone;
70
41
  if (!timeZone) return null;
71
- const start = localDateTimeParts(event.start, timeZone);
72
- const end = localDateTimeParts(event.end, timeZone);
42
+ const start = getDateTimePartsInTimezone(event.start, timeZone);
43
+ const end = getDateTimePartsInTimezone(event.end, timeZone);
73
44
  const isFullDay =
74
45
  start !== null &&
75
46
  end !== null &&
76
47
  new Date(event.start).getTime() ===
77
- new Date(
78
- dateTimeInTimezoneToIso(start.date, "00:00", timeZone),
79
- ).getTime() &&
48
+ new Date(dateKeyToTimezoneIso(start.date, "00:00", timeZone)).getTime() &&
80
49
  new Date(event.end).getTime() ===
81
- new Date(
82
- dateTimeInTimezoneToIso(end.date, "00:00", timeZone),
83
- ).getTime() &&
50
+ new Date(dateKeyToTimezoneIso(end.date, "00:00", timeZone)).getTime() &&
84
51
  end.date > start.date;
85
52
  return isFullDay
86
53
  ? { startDate: start.date, endDateExclusive: end.date }
@@ -96,36 +63,37 @@ export function fullDayOutOfOfficeCoversDate(
96
63
  ): boolean {
97
64
  const range = getFullDayOutOfOfficeDateRange(event);
98
65
  if (!range) return false;
99
- const dateString = format(date, "yyyy-MM-dd");
66
+ const dateString = dateToCalendarDateKey(date);
100
67
  return dateString >= range.startDate && dateString < range.endDateExclusive;
101
68
  }
102
69
 
103
70
  /** Return the portion of a timed out-of-office event visible on one day. */
104
71
  export function getOutOfOfficeSegment(
105
- event: Pick<CalendarEvent, "start" | "end">,
72
+ event: Pick<CalendarEvent, "start" | "end" | "startTimeZone" | "endTimeZone">,
106
73
  day: Date,
74
+ timezone: string = event.startTimeZone ??
75
+ event.endTimeZone ??
76
+ getBrowserTimezone(),
107
77
  ): OutOfOfficeSegment | null {
108
- const eventStart = parseISO(event.start);
109
- const eventEnd = parseISO(event.end);
110
- const dayStart = startOfDay(day);
111
- const dayEnd = addDays(dayStart, 1);
112
-
113
- if (eventStart >= dayEnd || eventEnd <= dayStart) return null;
114
-
115
- const segmentStart = eventStart > dayStart ? eventStart : dayStart;
116
- const segmentEnd = eventEnd < dayEnd ? eventEnd : dayEnd;
78
+ const segment = getEventSegmentForCalendarDay(event, day, timezone);
79
+ if (!segment) return null;
117
80
 
118
81
  return {
119
- topMinutes: Math.max(0, differenceInMinutes(segmentStart, dayStart)),
120
- durationMinutes: Math.max(1, differenceInMinutes(segmentEnd, segmentStart)),
121
- startsOnDay: eventStart >= dayStart && eventStart < dayEnd,
122
- endsOnDay: eventEnd > dayStart && eventEnd <= dayEnd,
82
+ topMinutes: segment.topMinutes,
83
+ durationMinutes: segment.durationMinutes,
84
+ startsOnDay: segment.startsOnDay,
85
+ endsOnDay: segment.endsOnDay,
123
86
  };
124
87
  }
125
88
 
126
89
  export function getFirstVisibleOutOfOfficeDayIndex(
127
- event: Pick<CalendarEvent, "start" | "end">,
90
+ event: Pick<CalendarEvent, "start" | "end" | "startTimeZone" | "endTimeZone">,
128
91
  days: Date[],
92
+ timezone: string = event.startTimeZone ??
93
+ event.endTimeZone ??
94
+ getBrowserTimezone(),
129
95
  ): number {
130
- return days.findIndex((day) => getOutOfOfficeSegment(event, day) !== null);
96
+ return days.findIndex(
97
+ (day) => getOutOfOfficeSegment(event, day, timezone) !== null,
98
+ );
131
99
  }
@@ -1,5 +1,7 @@
1
1
  import type { CalendarEvent, UpdateEventScope } from "@shared/api";
2
2
 
3
+ import { eventOverlapsCalendarDay } from "@/lib/calendar-timezone";
4
+
3
5
  export type WorkingLocationKind =
4
6
  | "homeOffice"
5
7
  | "officeLocation"
@@ -64,14 +66,12 @@ export function getWorkingLocationLabel(
64
66
  properties.officeLocation?.label ||
65
67
  properties.officeLocation?.buildingId ||
66
68
  event.location ||
67
- event.title ||
68
69
  labels.office
69
70
  );
70
71
  }
71
72
  return (
72
73
  properties?.customLocation?.label ||
73
74
  event.location ||
74
- event.title ||
75
75
  labels.workingLocation
76
76
  );
77
77
  }
@@ -147,6 +147,38 @@ export function getWorkingLocationTitle(
147
147
  : event.title;
148
148
  }
149
149
 
150
+ export function isWorkingLocationDraftReadyToCreate(
151
+ event: Pick<
152
+ CalendarEvent,
153
+ "eventType" | "location" | "workingLocationProperties"
154
+ >,
155
+ ): boolean {
156
+ if (!isWorkingLocationEvent(event)) return true;
157
+ if (getWorkingLocationType(event) !== "customLocation") return true;
158
+ return getWorkingLocationEditableLabel(event).trim().length > 0;
159
+ }
160
+
161
+ export function findOwnedWorkingLocationForDay(
162
+ events: readonly CalendarEvent[],
163
+ day: Date,
164
+ timezone: string,
165
+ accountEmail?: string,
166
+ ): CalendarEvent | undefined {
167
+ const matches = events.filter((event) => {
168
+ if (!isWorkingLocationEvent(event)) return false;
169
+ if (event.overlayEmail) return false;
170
+ if (
171
+ accountEmail &&
172
+ event.accountEmail &&
173
+ event.accountEmail !== accountEmail
174
+ ) {
175
+ return false;
176
+ }
177
+ return eventOverlapsCalendarDay(event, day, timezone);
178
+ });
179
+ return matches.find((event) => event.source === "google") ?? matches[0];
180
+ }
181
+
150
182
  export function getWorkingLocationDetail(
151
183
  event: Pick<CalendarEvent, "workingLocationProperties">,
152
184
  labels: WorkingLocationDisplayLabels,