Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions templates/calendar/actions/get-settings.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { defineAction } from "@agent-native/core";
import { getRequestUserEmail } from "@agent-native/core/server";
import { getUserSetting } from "@agent-native/core/settings";
import { z } from "zod";

import { normalizeCalendarSettings } from "../shared/settings.js";
import { readCalendarSettings } from "../server/lib/calendar-settings.js";

export default defineAction({
description: "Get calendar settings",
Expand All @@ -12,8 +11,6 @@ export default defineAction({
run: async () => {
const email = getRequestUserEmail();
if (!email) throw new Error("no authenticated user");
return normalizeCalendarSettings(
await getUserSetting(email, "calendar-settings"),
);
return readCalendarSettings(email);
},
});
62 changes: 61 additions & 1 deletion templates/calendar/actions/list-events.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const getRequestTimezoneMock = vi.hoisted(() => vi.fn());
const getRequestUserEmailMock = vi.hoisted(() => vi.fn());
Expand Down Expand Up @@ -252,6 +252,10 @@ describe("list-events inventory contract", () => {
verifyShortLivedTokenMock.mockReturnValue({ ok: true });
});

afterEach(() => {
vi.useRealTimers();
});

it("keeps legacy callers on CalendarEvent arrays", async () => {
const result = await (listEventsAction as any).run(
{ from: "2026-06-17", to: "2026-06-18" },
Expand Down Expand Up @@ -704,6 +708,62 @@ describe("list-events inventory contract", () => {
expect(listGoogleEventsMock).not.toHaveBeenCalled();
});

it("uses the saved timezone for omitted-range inventory cursors", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-17T12:00:00.000Z"));
getRequestTimezoneMock.mockReturnValue("UTC");
getUserSettingMock.mockResolvedValue({ timezone: "America/New_York" });
listGoogleEventsMock.mockResolvedValue({
events: [
{
id: "google-event-1",
googleEventId: "event-1",
title: "First",
description: "",
start: "2026-06-17T16:00:00.000Z",
end: "2026-06-17T16:30:00.000Z",
location: "",
allDay: false,
source: "google",
accountEmail: "steve@example.com",
createdAt: "2026-06-12T10:13:39.746Z",
updatedAt: "2026-06-12T10:13:39.746Z",
},
{
id: "google-event-2",
googleEventId: "event-2",
title: "Second",
description: "",
start: "2026-06-17T17:00:00.000Z",
end: "2026-06-17T17:30:00.000Z",
location: "",
allDay: false,
source: "google",
accountEmail: "steve@example.com",
createdAt: "2026-06-12T10:13:39.746Z",
updatedAt: "2026-06-12T10:13:39.746Z",
},
],
errors: [],
});

const first = await (listEventsAction as any).run(
{ format: "inventory", pageSize: 1, sources: ["google"] },
{ caller: "mcp" },
);
const second = await (listEventsAction as any).run(
{
format: "inventory",
pageSize: 1,
sources: ["google"],
cursor: first.page.nextCursor,
},
{ caller: "mcp" },
);

expect(second.items.map((item: any) => item.id)).toEqual(["event-2"]);
});

it("rejects a malformed inventory cursor before provider reads", async () => {
await expect(
(listEventsAction as any).run(
Expand Down
101 changes: 21 additions & 80 deletions templates/calendar/actions/list-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,16 @@
import { z } from "zod";

import { getDb, schema } from "../server/db/index.js";
import { getCalendarTimezone } from "../server/lib/calendar-settings.js";
import * as googleCalendar from "../server/lib/google-calendar.js";
import { fetchICalEvents } from "../server/lib/ical-fetcher.js";
import type { CalendarEvent, ExternalCalendar } from "../shared/api.js";
import {
addDaysToDateKey,
dateKeyInTimezone,
dateTimeInTimezoneToIso,
isCalendarTimezone,
} from "../shared/timezone.js";
import { calendarEventMatchesQuery } from "./event-search.js";

const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
Expand Down Expand Up @@ -74,6 +81,7 @@
interface ListCalendarEventsOptions {
ownedAccounts?: string[];
range?: CalendarEventRange;
timezone?: string;
}

type CalendarInventorySource = "google" | "bookings" | "ics" | "overlays";
Expand Down Expand Up @@ -156,7 +164,7 @@
function sanitizeError(message: unknown, fallback: string) {
return (
cap(
String(message ?? fallback)

Check warning on line 167 in templates/calendar/actions/list-events.ts

View workflow job for this annotation

GitHub Actions / Lint & format

typescript(no-base-to-string)

'message ?? fallback' will use Object's default stringification format ('[object Object]') when stringified.
.replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
.replace(
/\b(access_token|refresh_token|id_token|token)=([^\s&]+)/gi,
Expand Down Expand Up @@ -346,84 +354,9 @@
};
}

function normalizeTimezone(timezone?: string): string {
if (!timezone) return "UTC";
try {
new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format();
return timezone;
} catch {
return "UTC";
}
}

function datePartsInTimezone(date: Date, timezone: string) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).formatToParts(date);
const get = (type: string) =>
Number(parts.find((part) => part.type === type)?.value ?? "0");
return {
year: get("year"),
month: get("month"),
day: get("day"),
hour: get("hour"),
minute: get("minute"),
second: get("second"),
};
}

function dateOnlyInTimezone(date: Date, timezone: string): string {
const parts = datePartsInTimezone(date, timezone);
return [
String(parts.year).padStart(4, "0"),
String(parts.month).padStart(2, "0"),
String(parts.day).padStart(2, "0"),
].join("-");
}

function addDaysToDateOnly(dateOnly: string, days: number): string {
const [year, month, day] = dateOnly.split("-").map(Number);
const date = new Date(Date.UTC(year, month - 1, day + days));
return [
String(date.getUTCFullYear()).padStart(4, "0"),
String(date.getUTCMonth() + 1).padStart(2, "0"),
String(date.getUTCDate()).padStart(2, "0"),
].join("-");
}

function offsetMsForTimezone(date: Date, timezone: string): number {
const parts = datePartsInTimezone(date, timezone);
const asUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
);
return asUtc - date.getTime();
}

function zonedDateOnlyToUtcIso(dateOnly: string, timezone: string): string {
const [year, month, day] = dateOnly.split("-").map(Number);
const wallClockUtc = Date.UTC(year, month - 1, day, 0, 0, 0);
const firstGuess = new Date(wallClockUtc);
const firstOffset = offsetMsForTimezone(firstGuess, timezone);
const secondGuess = new Date(wallClockUtc - firstOffset);
const secondOffset = offsetMsForTimezone(secondGuess, timezone);
return new Date(wallClockUtc - secondOffset).toISOString();
}

function normalizeDateBound(value: string, timezone: string): string {
if (DATE_ONLY_RE.test(value)) {
return zonedDateOnlyToUtcIso(value, timezone);
return dateTimeInTimezoneToIso(value, "00:00", timezone);
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
Expand All @@ -437,19 +370,20 @@
to?: string;
timezone?: string;
}): CalendarEventRange {
const timezone = normalizeTimezone(args.timezone ?? getRequestTimezone());
const today = dateOnlyInTimezone(new Date(), timezone);
const requested = args.timezone ?? getRequestTimezone();
const timezone = isCalendarTimezone(requested) ? requested : "UTC";
const today = dateKeyInTimezone(new Date(), timezone);
let from = args.from?.trim();
let to = args.to?.trim();
let defaulted = false;

if (!from && !to) {
from = today;
to = addDaysToDateOnly(today, 1);
to = addDaysToDateKey(today, 1);
defaulted = true;
} else if (from && !to) {
if (DATE_ONLY_RE.test(from)) {
to = addDaysToDateOnly(from, 1);
to = addDaysToDateKey(from, 1);
} else {
const start = new Date(from);
if (Number.isNaN(start.getTime())) {
Expand Down Expand Up @@ -578,11 +512,13 @@
): Promise<CalendarEventsResult> {
const email = getRequestUserEmail();
if (!email) throw new Error("no authenticated user");
const timezone = options.timezone ?? (await getCalendarTimezone(email));
const range =
options.range ??
resolveCalendarEventRange({
from: args.from,
to: args.to,
timezone,
});

const sources = resolveInventorySources(args.sources);
Expand Down Expand Up @@ -829,6 +765,9 @@
args.format === "inventory" || (ctx?.caller === "mcp" && !args.format);
const owner = inventory ? getRequestUserEmail() : undefined;
if (inventory && !owner) throw new Error("no authenticated user");
const calendarTimezone = inventory
? await getCalendarTimezone(owner!)
: undefined;

// Reject invalid, expired, owner-bound, and query-bound cursors before any
// provider call. Omitted account filters require the cheap owned-account
Expand All @@ -842,6 +781,7 @@
preparedRange = resolveCalendarEventRange({
from: args.from,
to: args.to,
timezone: calendarTimezone,
});
preparedOwnedAccounts = args.accountEmails
? undefined
Expand All @@ -868,6 +808,7 @@
{
ownedAccounts: preparedOwnedAccounts,
range: preparedRange,
timezone: calendarTimezone,
},
);

Expand Down
27 changes: 11 additions & 16 deletions templates/calendar/actions/update-settings.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { defineAction } from "@agent-native/core";
import { getRequestUserEmail } from "@agent-native/core/server";
import {
getUserSetting,
putUserSetting,
putSetting,
} from "@agent-native/core/settings";
import { z } from "zod";

import { normalizeCalendarSettings } from "../shared/settings.js";
import { saveCalendarSettings } from "../server/lib/calendar-settings.js";
import { isCalendarTimezone } from "../shared/timezone.js";

export default defineAction({
description: "Update calendar settings",
schema: z.object({
timezone: z.string().optional().describe("Timezone"),
timezone: z
.string()
.trim()
.refine(isCalendarTimezone, {
message: "Timezone must be a valid IANA timezone.",
})
.optional()
.describe("IANA timezone, e.g. Europe/Warsaw"),
bookingPageTitle: z.string().optional().describe("Booking page title"),
bookingPageDescription: z
.string()
Expand All @@ -32,14 +35,6 @@ export default defineAction({
run: async (args) => {
const email = getRequestUserEmail();
if (!email) throw new Error("no authenticated user");
const currentSettings = await getUserSetting(email, "calendar-settings");
const settings = normalizeCalendarSettings({
...normalizeCalendarSettings(currentSettings),
...args,
});
const settingsRecord = settings as unknown as Record<string, unknown>;
await putUserSetting(email, "calendar-settings", settingsRecord);
await putSetting("calendar-settings", settingsRecord);
return settings;
return saveCalendarSettings(email, args);
},
});
35 changes: 25 additions & 10 deletions templates/calendar/actions/view-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@

import { getDb, schema } from "../server/db/index.js";
import { rowToBookingLink } from "../server/lib/booking-link-utils.js";
import { getCalendarTimezone } from "../server/lib/calendar-settings.js";
import type { CalendarEvent, CalendarEventDraft } from "../shared/api.js";
import {
CALENDAR_VIEW_PREFERENCES_KEY,
normalizeCalendarViewPreferences,
} from "../shared/calendar-view-preferences.js";
import {
addDaysToDateKey,
dateKeyInTimezone,
dateTimeInTimezoneToIso,
} from "../shared/timezone.js";
import { extractVideoLink } from "./event-action-helpers.js";
import { listCalendarEvents } from "./list-events.js";

Expand Down Expand Up @@ -49,6 +55,11 @@
}
}

function dateKeyFromParts(date: Date): string {

Check warning on line 58 in templates/calendar/actions/view-screen.ts

View workflow job for this annotation

GitHub Actions / Lint & format

eslint(no-unused-vars)

Function 'dateKeyFromParts' is declared but never used.
const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}

export default defineAction({
description:
"See what the user is currently looking at on screen. Returns the current view, date range, and visible events. Always call this first before taking any action.",
Expand All @@ -67,18 +78,22 @@
const nav = navigation as any;

if (nav?.view === "calendar" || !nav?.view) {
const now = new Date();
const viewDate = nav?.date ? new Date(nav.date) : now;

const from = new Date(viewDate);
from.setDate(from.getDate() - from.getDay());
from.setHours(0, 0, 0, 0);
const to = new Date(from);
to.setDate(to.getDate() + 7);
const email = getRequestUserEmail();
if (!email) throw new Error("no authenticated user");
const timezone = await getCalendarTimezone(email);
// Work in calendar days, then resolve the two edges to instants once.
const viewDay = nav?.date ?? dateKeyInTimezone(new Date(), timezone);
// Noon UTC so the weekday can never be shifted by an offset.
const weekday = new Date(`${viewDay}T12:00:00Z`).getUTCDay();
const weekStart = addDaysToDateKey(viewDay, -weekday);

const eventResult = await fetchEventsForRange(
from.toISOString(),
to.toISOString(),
dateTimeInTimezoneToIso(weekStart, "00:00", timezone),
dateTimeInTimezoneToIso(
addDaysToDateKey(weekStart, 7),
"00:00",
timezone,
),
);
const { events } = eventResult;

Expand Down
Loading
Loading