@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
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8263
34
+ - template files: 8273
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import {
4
4
  FeatureNotConfiguredError,
5
5
  getBuilderImageGenerationBaseUrl,
6
- resolveBuilderCredentials,
6
+ resolveBuilderGatewayCredentials,
7
7
  resolveSecret,
8
8
  } from "@agent-native/core/server";
9
9
  import { and, eq, inArray } from "drizzle-orm";
@@ -265,16 +265,20 @@ interface BuilderImageGenerationResponse {
265
265
  export async function generateWithBuilderImageApi(
266
266
  input: GenerateProviderInput,
267
267
  ): Promise<GenerateProviderOutput> {
268
- const builderCredentials = await resolveBuilderCredentials();
268
+ // Gateway lane: a published site paying with Builder credits carries the
269
+ // injected gateway pair and no identity credential at all, and image
270
+ // generation is metered rather than identity-bearing. The resolver still
271
+ // prefers a connected Builder account, so a site that has one is unaffected.
272
+ const builderCredentials = await resolveBuilderGatewayCredentials();
269
273
  if (!builderCredentials.privateKey || !builderCredentials.publicKey) {
270
274
  const detail =
271
275
  !builderCredentials.privateKey && !builderCredentials.publicKey
272
- ? "Builder private and public keys are missing"
276
+ ? "Builder credentials are missing"
273
277
  : !builderCredentials.privateKey
274
- ? "Builder private key is missing"
275
- : "Builder public key is missing";
278
+ ? "Builder token is missing"
279
+ : "Builder space id is missing";
276
280
  throw new BuilderImageGenerationError(
277
- "Builder.io is not fully connected for managed image generation. Reconnect Builder.io (free tier available) so both Builder private and public keys are available.",
281
+ "Builder.io is not connected for managed image generation. Connect Builder.io (free tier available), or publish with Builder credits so the site is issued its own gateway credential.",
278
282
  401,
279
283
  detail,
280
284
  );
@@ -91,7 +91,8 @@ pnpm action create-event \
91
91
  ```
92
92
 
93
93
  Required for ordinary events: `--title`, `--start`, `--end` (ISO datetime
94
- format). Out-of-office events default the title to `Out of office` when it is
94
+ format). Out-of-office events default the title to `Out of office`, and
95
+ working-location events use Google's generated display title when `--title` is
95
96
  omitted.
96
97
  Optional: `--description`, `--location`, `--attendees`, `--addGoogleMeet`, `--addZoom`, `--sendUpdates`, `--accountEmail`.
97
98
 
@@ -156,9 +157,22 @@ pnpm action create-event \
156
157
 
157
158
  Working-location events sync from Google with `workingLocationProperties` and
158
159
  render as native working locations in the UI instead of generic all-day events.
159
- They are transparent/non-blocking for availability. Google allows timed working
160
- locations or single-day all-day working locations; multi-day all-day ranges must
161
- be represented as separate daily working-location events.
160
+ They are transparent/non-blocking for availability. All-day working locations
161
+ use an exclusive `--end` date and can span multiple days; timed working
162
+ locations use ISO datetime start and end values.
163
+
164
+ Creating from a calendar day uses the selected Home, Office, or Other type —
165
+ do not leave the draft as Home and create that instead. Office does not need a
166
+ custom building name; Other does. The Other name is `workingLocationLabel`
167
+ (drafts keep `location` empty), so create must send that label — not an empty
168
+ `location`. Create and update reject a blank Other name instead of storing
169
+ `Working`. If that day already has a working location
170
+ on the same account, update that day's occurrence (`scope: "single"`) instead
171
+ of creating a second event. Timed (not all-day) working locations need a
172
+ summary of Home, Office, or the custom label — never the generated
173
+ `Working location` placeholder. All-day ones omit summary so Google can derive
174
+ the title. Converting a timed location that ends at local midnight back to
175
+ all-day keeps that single day; do not add another exclusive day.
162
176
 
163
177
  `--fullDay true` is semantic only for out-of-office creation. It does not send
164
178
  Google an all-day `date` event, which Google rejects for this event type.
@@ -37,7 +37,9 @@ export default defineAction({
37
37
  title: z
38
38
  .string()
39
39
  .optional()
40
- .describe("Event title. Defaults to 'Out of office' for OOO events."),
40
+ .describe(
41
+ "Event title. Defaults to 'Out of office' for OOO events. Working-location events use Google's generated display title and do not require one.",
42
+ ),
41
43
  start: z
42
44
  .string()
43
45
  .describe(
@@ -46,7 +48,7 @@ export default defineAction({
46
48
  end: z
47
49
  .string()
48
50
  .describe(
49
- "End time in ISO format, or the last inclusive YYYY-MM-DD date for a full-day OOO event.",
51
+ "End time in ISO format, the last inclusive YYYY-MM-DD date for a full-day OOO event, or the exclusive YYYY-MM-DD date for an all-day working-location event.",
50
52
  ),
51
53
  startTimeZone: z
52
54
  .string()
@@ -311,8 +311,17 @@ export function buildStatusEventFields(args: {
311
311
  }
312
312
 
313
313
  const type = args.workingLocationType ?? "customLocation";
314
- const label =
315
- args.workingLocationLabel || args.location || args.title || "Working";
314
+ const label = (
315
+ args.workingLocationLabel ||
316
+ args.location ||
317
+ args.title ||
318
+ ""
319
+ ).trim();
320
+ if (type === "customLocation" && !label) {
321
+ throw new Error(
322
+ "Other working locations require a name. Pass workingLocationLabel.",
323
+ );
324
+ }
316
325
  return {
317
326
  eventType: args.eventType,
318
327
  transparency: "transparent" as const,
@@ -321,7 +330,7 @@ export function buildStatusEventFields(args: {
321
330
  type === "homeOffice"
322
331
  ? { type, homeOffice: {} }
323
332
  : type === "officeLocation"
324
- ? { type, officeLocation: { label } }
333
+ ? { type, officeLocation: label ? { label } : {} }
325
334
  : { type, customLocation: { label } },
326
335
  };
327
336
  }
@@ -359,6 +368,16 @@ function normalizedIso(value: string, label: "start" | "end"): string {
359
368
  return date.toISOString();
360
369
  }
361
370
 
371
+ function normalizeWorkingLocationAllDayDate(value: string): string {
372
+ const date = allDayDatePart(value);
373
+ if (!isValidDateOnly(date)) {
374
+ throw new Error(
375
+ "All-day working location events require valid YYYY-MM-DD dates.",
376
+ );
377
+ }
378
+ return date;
379
+ }
380
+
362
381
  export function normalizeCreateEventInput(args: {
363
382
  title?: string;
364
383
  eventType?: "default" | "outOfOffice" | "focusTime" | "workingLocation";
@@ -376,7 +395,27 @@ export function normalizeCreateEventInput(args: {
376
395
  const title =
377
396
  args.title?.trim() ||
378
397
  (args.eventType === "outOfOffice" ? "Out of office" : "");
379
- if (!title) throw new Error("Event title is required.");
398
+ if (!title && args.eventType !== "workingLocation") {
399
+ throw new Error("Event title is required.");
400
+ }
401
+
402
+ if (args.eventType === "workingLocation" && args.allDay === true) {
403
+ const start = normalizeWorkingLocationAllDayDate(args.start);
404
+ const end = normalizeWorkingLocationAllDayDate(args.end);
405
+ if (end <= start) {
406
+ throw new Error(
407
+ "All-day working location end date must be after its start date.",
408
+ );
409
+ }
410
+ return {
411
+ title,
412
+ start,
413
+ end,
414
+ startTimeZone: undefined,
415
+ endTimeZone: undefined,
416
+ allDay: true,
417
+ };
418
+ }
380
419
 
381
420
  if (args.eventType === "outOfOffice" && args.fullDay === true) {
382
421
  const timezone = requireIanaTimezone(
@@ -435,15 +474,21 @@ export function normalizeCreateEventInput(args: {
435
474
  }
436
475
 
437
476
  function allDayDatePart(value: string): string {
438
- const dateOnlyPattern = /^\d{4}-\d{2}-\d{2}$/;
439
- if (dateOnlyPattern.test(value)) return value;
477
+ if (DATE_ONLY_PATTERN.test(value)) {
478
+ if (isValidDateOnly(value)) return value;
479
+ throw new Error("All-day status events must use valid YYYY-MM-DD dates.");
480
+ }
440
481
  const date = new Date(value);
441
482
  if (Number.isNaN(date.getTime())) {
442
483
  throw new Error(
443
484
  "All-day status events must use valid date or datetime start and end values.",
444
485
  );
445
486
  }
446
- return date.toISOString().slice(0, 10);
487
+ const datePart = date.toISOString().slice(0, 10);
488
+ if (!isValidDateOnly(datePart)) {
489
+ throw new Error("All-day status events must use valid YYYY-MM-DD dates.");
490
+ }
491
+ return datePart;
447
492
  }
448
493
 
449
494
  function allDaySpanDays(start: string, end: string): number {
@@ -477,8 +522,10 @@ export function validateStatusEventTiming(args: {
477
522
 
478
523
  if (args.eventType === "workingLocation" && args.allDay === true) {
479
524
  const days = allDaySpanDays(args.start, args.end);
480
- if (days !== 1) {
481
- throw new Error("All-day working location events must be a single day.");
525
+ if (days < 1) {
526
+ throw new Error(
527
+ "All-day working location end date must be after its start date.",
528
+ );
482
529
  }
483
530
  }
484
531
  }
@@ -412,6 +412,7 @@ export default defineAction({
412
412
  start: args.start ?? existingEvent.start,
413
413
  end: args.end ?? existingEvent.end,
414
414
  });
415
+ updates.allDay = args.allDay ?? existingEvent.allDay;
415
416
  if (
416
417
  existingEvent.eventType === "workingLocation" &&
417
418
  existingEvent.workingLocationProperties
@@ -1,5 +1,6 @@
1
1
  import { defineAgentNativeConfig } from "@agent-native/core/config";
2
2
 
3
3
  export default defineAgentNativeConfig({
4
+ changelog: { enabled: true },
4
5
  harness: true,
5
6
  });
@@ -180,6 +180,10 @@ function dateTimePartsInTimezone(value: string, timezone: string) {
180
180
 
181
181
  function allDayEndDate(end: string | undefined, fallback: string) {
182
182
  if (!end) return fallback;
183
+ if (/^\d{4}-\d{2}-\d{2}$/.test(end)) {
184
+ const previous = addDaysToDateString(end, -1);
185
+ return previous < fallback ? fallback : previous;
186
+ }
183
187
  const parsed = new Date(end);
184
188
  if (Number.isNaN(parsed.getTime())) return fallback;
185
189
  parsed.setDate(parsed.getDate() - 1);
@@ -323,12 +327,14 @@ export function CreateEventPopover({
323
327
 
324
328
  if (draft) {
325
329
  const startParts = draft.start
326
- ? draft.fullDay && /^\d{4}-\d{2}-\d{2}$/.test(draft.start)
330
+ ? (draft.fullDay || draft.allDay) &&
331
+ /^\d{4}-\d{2}-\d{2}$/.test(draft.start)
327
332
  ? { date: draft.start, time: "00:00" }
328
333
  : dateTimePartsInTimezone(draft.start, draftTimezone)
329
334
  : null;
330
335
  const endParts = draft.end
331
- ? draft.fullDay && /^\d{4}-\d{2}-\d{2}$/.test(draft.end)
336
+ ? (draft.fullDay || draft.allDay) &&
337
+ /^\d{4}-\d{2}-\d{2}$/.test(draft.end)
332
338
  ? { date: draft.end, time: "00:00" }
333
339
  : dateTimePartsInTimezone(
334
340
  draft.end,
@@ -1,19 +1,11 @@
1
1
  import { useT } from "@agent-native/core/client/i18n";
2
2
  import type { CalendarEvent } from "@shared/api";
3
- import { IconAlertTriangleFilled, IconMapPin } from "@tabler/icons-react";
4
3
  import {
5
- eachHourOfInterval,
6
- format,
7
- parseISO,
8
- differenceInMinutes,
9
- startOfDay,
10
- isSameDay,
11
- set,
12
- isToday,
13
- addMinutes,
14
- addDays,
15
- min,
16
- } from "date-fns";
4
+ IconAlertTriangleFilled,
5
+ IconMapPin,
6
+ IconPlus,
7
+ } from "@tabler/icons-react";
8
+ import { eachHourOfInterval, format, set, addMinutes } from "date-fns";
17
9
  import { useState, useEffect, useRef, useMemo, useCallback, memo } from "react";
18
10
 
19
11
  import { useCalendarSetters } from "@/components/layout/AppLayout";
@@ -29,6 +21,13 @@ import {
29
21
  type ViewPreferences,
30
22
  } from "@/hooks/use-view-preferences";
31
23
  import { partitionAllDayEvents } from "@/lib/all-day-layout";
24
+ import {
25
+ dateToCalendarDateKey,
26
+ getBrowserTimezone,
27
+ getDateKeyInTimezone,
28
+ getEventDateKey,
29
+ getEventSegmentForCalendarDay,
30
+ } from "@/lib/calendar-timezone";
32
31
  import { getEventDisplayColor, allOtherDeclined } from "@/lib/event-colors";
33
32
  import {
34
33
  computeTimedEventLayout,
@@ -58,6 +57,7 @@ import { OutOfOfficeEvent } from "./OutOfOfficeEvent";
58
57
  interface DayViewProps {
59
58
  events: CalendarEvent[];
60
59
  date: Date;
60
+ timezone?: string;
61
61
  onDeleteEvent: (eventId: string) => void;
62
62
  onEventTimeChange?: (eventId: string, newStart: Date, newEnd: Date) => void;
63
63
  onClickTimeSlot?: (
@@ -66,6 +66,7 @@ interface DayViewProps {
66
66
  endTime: string,
67
67
  options?: { explicitDuration?: boolean },
68
68
  ) => void;
69
+ onCreateWorkingLocation?: (date: Date) => void;
69
70
  quickEditEventId?: string | null;
70
71
  onQuickEditSave?: (
71
72
  eventId: string,
@@ -118,7 +119,7 @@ function minutesToTimeString(totalMinutes: number): string {
118
119
  /** Convert minutes-from-START_HOUR on a given day into a Date, for ghost label formatting */
119
120
  function minutesToDate(date: Date, totalMinutes: number): Date {
120
121
  return addMinutes(
121
- set(startOfDay(date), { hours: START_HOUR, minutes: 0, seconds: 0 }),
122
+ set(date, { hours: START_HOUR, minutes: 0, seconds: 0 }),
122
123
  totalMinutes,
123
124
  );
124
125
  }
@@ -142,18 +143,14 @@ function formatEventTime(start: Date, end: Date): string {
142
143
  return `${startWithAmPm}–${endStr}`;
143
144
  }
144
145
 
145
- function getEventStyleForDate(event: CalendarEvent, date: Date) {
146
- const start = parseISO(event.start);
147
- const end = parseISO(event.end);
148
- const dayStart = set(startOfDay(date), { hours: START_HOUR });
149
- const dayEnd = addDays(startOfDay(date), 1);
150
- const cappedEnd = min([end, dayEnd]);
151
- const segStart = start > dayStart ? start : dayStart;
152
- const topMinutes = Math.max(0, differenceInMinutes(segStart, dayStart));
153
- const durationMinutes = Math.max(
154
- 15,
155
- differenceInMinutes(cappedEnd, segStart),
156
- );
146
+ function getEventStyleForDate(
147
+ event: CalendarEvent,
148
+ date: Date,
149
+ timezone: string,
150
+ ) {
151
+ const segment = getEventSegmentForCalendarDay(event, date, timezone);
152
+ const topMinutes = segment?.topMinutes ?? 0;
153
+ const durationMinutes = Math.max(15, segment?.durationMinutes ?? 15);
157
154
  return {
158
155
  top: `${(topMinutes / 60) * HOUR_HEIGHT}px`,
159
156
  height: `${(durationMinutes / 60) * HOUR_HEIGHT}px`,
@@ -163,6 +160,7 @@ function getEventStyleForDate(event: CalendarEvent, date: Date) {
163
160
  interface DayEventCardProps {
164
161
  event: CalendarEvent;
165
162
  date: Date;
163
+ timezone: string;
166
164
  layout: Map<string, TimedEventLayout>;
167
165
  now: Date;
168
166
  prefs: ViewPreferences;
@@ -204,6 +202,7 @@ interface DayEventCardProps {
204
202
  const DayEventCard = memo(function DayEventCard({
205
203
  event,
206
204
  date,
205
+ timezone,
207
206
  layout,
208
207
  now,
209
208
  prefs,
@@ -246,32 +245,24 @@ const DayEventCard = memo(function DayEventCard({
246
245
  top: `${overrides.top}px`,
247
246
  height: `${overrides.height}px`,
248
247
  }
249
- : getEventStyleForDate(event, date);
248
+ : getEventStyleForDate(event, date, timezone);
250
249
  const color = getEventDisplayColor(event, prefs);
251
- const evStart = parseISO(event.start);
252
- const rawEnd = parseISO(event.end);
253
- const midnight = addDays(startOfDay(date), 1);
254
- const evEnd = min([rawEnd, midnight]);
255
- const isOvernightCapped = rawEnd > midnight;
256
- const isStart = isSameDay(evStart, date);
257
- const isEnd = rawEnd <= midnight;
258
- const dayGridStart = set(startOfDay(date), { hours: START_HOUR });
259
- // For continuation segments (started a prior day), measure visible
260
- // duration from the grid start, not from the event's true start.
261
- const visibleSegStart = isStart ? evStart : dayGridStart;
250
+ const segment = getEventSegmentForCalendarDay(event, date, timezone);
251
+ const isStart =
252
+ getEventDateKey(event, timezone) === dateToCalendarDateKey(date);
253
+ const isOvernightCapped = !(segment?.endsOnDay ?? true);
254
+ const isEnd = segment?.endsOnDay ?? true;
262
255
  const durationMin = overrides
263
256
  ? (overrides.height / HOUR_HEIGHT) * 60
264
- : differenceInMinutes(evEnd, visibleSegStart);
257
+ : (segment?.durationMinutes ?? 15);
265
258
  // Compute display times (use drag overrides if active)
266
259
  const displayStart = overrides
267
- ? addMinutes(dayGridStart, (overrides.top / HOUR_HEIGHT) * 60)
268
- : visibleSegStart;
260
+ ? minutesToDate(date, START_HOUR * 60 + (overrides.top / HOUR_HEIGHT) * 60)
261
+ : minutesToDate(date, segment?.startMinutes ?? 0);
269
262
  const displayEnd = overrides
270
263
  ? addMinutes(displayStart, durationMin)
271
- : isEnd
272
- ? evEnd
273
- : midnight;
274
- const isPast = parseISO(event.end) < now;
264
+ : minutesToDate(date, segment?.endMinutes ?? durationMin);
265
+ const isPast = new Date(event.end) < now;
275
266
  const isDeclined = event.responseStatus === "declined";
276
267
  const allOthersOut = allOtherDeclined(event);
277
268
 
@@ -446,6 +437,7 @@ const DayEventCard = memo(function DayEventCard({
446
437
  return (
447
438
  <EventDetailPopover
448
439
  event={event}
440
+ timezone={timezone}
449
441
  onDelete={onDeleteEvent}
450
442
  isDraft={isDraft}
451
443
  defaultOpen={defaultOpen}
@@ -492,9 +484,11 @@ const DayCreateGhost = memo(function DayCreateGhost({
492
484
  export const DayView = memo(function DayView({
493
485
  events,
494
486
  date,
487
+ timezone = getBrowserTimezone(),
495
488
  onDeleteEvent,
496
489
  onEventTimeChange,
497
490
  onClickTimeSlot,
491
+ onCreateWorkingLocation,
498
492
  quickEditEventId,
499
493
  onQuickEditSave,
500
494
  onQuickEditCancel,
@@ -584,8 +578,8 @@ export const DayView = memo(function DayView({
584
578
  [events],
585
579
  );
586
580
  const layout = useMemo(
587
- () => computeTimedEventLayout(timedEvents, date),
588
- [date, timedEvents],
581
+ () => computeTimedEventLayout(timedEvents, date, timezone),
582
+ [date, timedEvents, timezone],
589
583
  );
590
584
 
591
585
  // Timezone label: prefer the short generic name (e.g. "PT", "ET")
@@ -595,7 +589,10 @@ export const DayView = memo(function DayView({
595
589
  function nameForToken(token: "shortGeneric" | "longGeneric" | "short") {
596
590
  try {
597
591
  return (
598
- new Intl.DateTimeFormat("en-US", { timeZoneName: token })
592
+ new Intl.DateTimeFormat("en-US", {
593
+ timeZone: timezone,
594
+ timeZoneName: token,
595
+ })
599
596
  .formatToParts(now)
600
597
  .find((p) => p.type === "timeZoneName")?.value ?? ""
601
598
  );
@@ -604,10 +601,7 @@ export const DayView = memo(function DayView({
604
601
  }
605
602
  }
606
603
 
607
- let iana = "";
608
- try {
609
- iana = Intl.DateTimeFormat().resolvedOptions().timeZone ?? "";
610
- } catch {}
604
+ const iana = timezone;
611
605
 
612
606
  const longGeneric = nameForToken("longGeneric");
613
607
  let shortGeneric = nameForToken("shortGeneric");
@@ -625,10 +619,26 @@ export const DayView = memo(function DayView({
625
619
  tzLong: longGeneric || iana,
626
620
  tzIana: iana,
627
621
  };
628
- }, []);
622
+ }, [now, timezone]);
629
623
 
630
- const today = isToday(date);
631
- const nowMinutes = (now.getHours() - START_HOUR) * 60 + now.getMinutes();
624
+ const today =
625
+ dateToCalendarDateKey(date) === getDateKeyInTimezone(now, timezone);
626
+ const nowParts = new Intl.DateTimeFormat("en-US", {
627
+ timeZone: timezone,
628
+ hourCycle: "h23",
629
+ hour: "2-digit",
630
+ minute: "2-digit",
631
+ })
632
+ .formatToParts(now)
633
+ .reduce(
634
+ (parts, part) => {
635
+ if (part.type === "hour") parts.hour = Number(part.value);
636
+ if (part.type === "minute") parts.minute = Number(part.value);
637
+ return parts;
638
+ },
639
+ { hour: 0, minute: 0 },
640
+ );
641
+ const nowMinutes = (nowParts.hour - START_HOUR) * 60 + nowParts.minute;
632
642
  const nowTop = (nowMinutes / 60) * HOUR_HEIGHT;
633
643
  const showNowIndicator =
634
644
  today && nowMinutes >= 0 && nowMinutes <= (END_HOUR - START_HOUR) * 60;
@@ -653,6 +663,7 @@ export const DayView = memo(function DayView({
653
663
  scrollContainerRef,
654
664
  onEventTimeChange: handleEventTimeChange,
655
665
  events,
666
+ timezone,
656
667
  });
657
668
 
658
669
  const canDrag = !!onEventTimeChange;
@@ -745,19 +756,38 @@ export const DayView = memo(function DayView({
745
756
  {format(date, "MMMM d, yyyy")}
746
757
  </div>
747
758
  </div>
748
- <Tooltip>
749
- <TooltipTrigger asChild>
750
- <span className="cursor-default truncate px-1 pt-1 text-[11px] font-medium text-muted-foreground">
751
- {tzShort}
752
- </span>
753
- </TooltipTrigger>
754
- <TooltipContent side="bottom">
755
- <p className="text-xs">{tzLong}</p>
756
- {tzIana && tzIana !== tzLong ? (
757
- <p className="text-[10px] text-muted-foreground">{tzIana}</p>
758
- ) : null}
759
- </TooltipContent>
760
- </Tooltip>
759
+ <div className="flex items-start gap-1">
760
+ {onCreateWorkingLocation && (
761
+ <Tooltip>
762
+ <TooltipTrigger asChild>
763
+ <button
764
+ type="button"
765
+ aria-label={t("calendarView.addWorkingLocation")}
766
+ className="flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
767
+ onClick={() => onCreateWorkingLocation(date)}
768
+ >
769
+ <IconPlus aria-hidden="true" className="size-3.5" />
770
+ </button>
771
+ </TooltipTrigger>
772
+ <TooltipContent side="bottom">
773
+ {t("calendarView.addWorkingLocation")}
774
+ </TooltipContent>
775
+ </Tooltip>
776
+ )}
777
+ <Tooltip>
778
+ <TooltipTrigger asChild>
779
+ <span className="cursor-default truncate px-1 pt-1 text-[11px] font-medium text-muted-foreground">
780
+ {tzShort}
781
+ </span>
782
+ </TooltipTrigger>
783
+ <TooltipContent side="bottom">
784
+ <p className="text-xs">{tzLong}</p>
785
+ {tzIana && tzIana !== tzLong ? (
786
+ <p className="text-[10px] text-muted-foreground">{tzIana}</p>
787
+ ) : null}
788
+ </TooltipContent>
789
+ </Tooltip>
790
+ </div>
761
791
  </div>
762
792
 
763
793
  {/* Working locations and ordinary all-day events */}
@@ -776,6 +806,7 @@ export const DayView = memo(function DayView({
776
806
  <EventDetailPopover
777
807
  key={`${event.overlayEmail ?? event.accountEmail ?? "primary"}:${event.id}`}
778
808
  event={event}
809
+ timezone={timezone}
779
810
  onDelete={onDeleteEvent}
780
811
  isDraft={draftEventIds.includes(event.id)}
781
812
  defaultOpen={quickEditEventId === event.id}
@@ -852,6 +883,7 @@ export const DayView = memo(function DayView({
852
883
  <EventDetailPopover
853
884
  key={`${event.overlayEmail ?? event.accountEmail ?? "primary"}:${event.id}`}
854
885
  event={event}
886
+ timezone={timezone}
855
887
  onDelete={onDeleteEvent}
856
888
  isDraft={draftEventIds.includes(event.id)}
857
889
  defaultOpen={quickEditEventId === event.id}
@@ -1017,6 +1049,7 @@ export const DayView = memo(function DayView({
1017
1049
  key={event._tempId ?? event.id}
1018
1050
  event={event}
1019
1051
  day={date}
1052
+ timezone={timezone}
1020
1053
  hourHeight={HOUR_HEIGHT}
1021
1054
  color={
1022
1055
  getEventDisplayColor(event, prefs) ?? "hsl(var(--primary))"
@@ -1087,6 +1120,7 @@ export const DayView = memo(function DayView({
1087
1120
  key={event._tempId ?? event.id}
1088
1121
  event={event}
1089
1122
  date={date}
1123
+ timezone={timezone}
1090
1124
  layout={layout}
1091
1125
  now={now}
1092
1126
  prefs={prefs}