diff --git a/Cargo.lock b/Cargo.lock index b9bb41f380..dc4f78544d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3199,6 +3199,20 @@ dependencies = [ "specta", ] +[[package]] +name = "calendar-sync" +version = "0.1.0" +dependencies = [ + "calendar-interface", + "chrono", + "futures-util", + "serde", + "specta", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "camino" version = "1.2.2" @@ -18979,15 +18993,23 @@ version = "0.1.0" dependencies = [ "calendar", "calendar-interface", + "calendar-sync", + "chrono", "serde", + "serde_json", "specta", "specta-typescript", + "storage", "tauri", "tauri-plugin", "tauri-plugin-auth", "tauri-plugin-permissions", "tauri-specta", + "tempfile", "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8f32839ab0..4e9e3c9e4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ hypr-cactus = { path = "crates/cactus", package = "cactus" } hypr-cactus-model = { path = "crates/cactus-model", package = "cactus-model" } hypr-calendar = { path = "crates/calendar", package = "calendar" } hypr-calendar-interface = { path = "crates/calendar-interface", package = "calendar-interface" } +hypr-calendar-sync = { path = "crates/calendar-sync", package = "calendar-sync" } hypr-chatwoot = { path = "crates/chatwoot", package = "chatwoot" } hypr-claude = { path = "crates/claude", package = "claude" } hypr-cli-process = { path = "crates/cli-process", package = "cli-process" } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b7e9e29b05..893b60a634 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -162,7 +162,6 @@ "stopword": "^3.1.5", "streamdown": "^2.5.0", "tinybase": "^7.3.5", - "tinytick": "^1.2.8", "tlds": "^1.261.0", "unified": "^11.0.5", "usehooks-ts": "^3.1.1", diff --git a/apps/desktop/src/calendar/components/apple/calendar-selection.tsx b/apps/desktop/src/calendar/components/apple/calendar-selection.tsx index b5e48181af..b77a868050 100644 --- a/apps/desktop/src/calendar/components/apple/calendar-selection.tsx +++ b/apps/desktop/src/calendar/components/apple/calendar-selection.tsx @@ -7,6 +7,7 @@ import { type CalendarItem, CalendarSelection, } from "~/calendar/components/calendar-selection"; +import { useSetCalendarEnabled } from "~/calendar/hooks"; import { useMountEffect } from "~/shared/hooks/useMountEffect"; import * as main from "~/store/tinybase/store/main"; @@ -43,7 +44,7 @@ export function useAppleCalendarSelection() { const { cancelDebouncedSync, status, scheduleDebouncedSync, scheduleSync } = useSync(); - const store = main.UI.useStore(main.STORE_ID); + const setCalendarEnabled = useSetCalendarEnabled(); const calendars = main.UI.useTable("calendars", main.STORE_ID); const groups = useMemo((): CalendarGroup[] => { @@ -77,10 +78,12 @@ export function useAppleCalendarSelection() { const handleToggle = useCallback( (calendar: CalendarItem, enabled: boolean) => { - store?.setPartialRow("calendars", calendar.id, { enabled }); + void setCalendarEnabled(calendar.id, enabled).catch((error) => { + console.error("[apple-calendar-selection] setCalendarEnabled:", error); + }); scheduleDebouncedSync(); }, - [store, scheduleDebouncedSync], + [setCalendarEnabled, scheduleDebouncedSync], ); const handleRefresh = useCallback(() => { diff --git a/apps/desktop/src/calendar/components/context.test.tsx b/apps/desktop/src/calendar/components/context.test.tsx new file mode 100644 index 0000000000..2ef5acb1f6 --- /dev/null +++ b/apps/desktop/src/calendar/components/context.test.tsx @@ -0,0 +1,116 @@ +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const syncMocks = vi.hoisted(() => { + let handler: null | ((event: { payload: Record }) => void) = + null; + + const attach = (nextHandler: typeof handler) => { + handler = nextHandler; + return Promise.resolve(() => { + if (handler === nextHandler) { + handler = null; + } + }); + }; + + return { + getCalendarSyncStatus: vi.fn(), + requestCalendarSync: vi.fn(), + attach, + listen: vi.fn(attach), + emit(payload: Record) { + handler?.({ payload }); + }, + reset() { + handler = null; + }, + }; +}); + +vi.mock("@hypr/plugin-calendar", () => ({ + commands: { + getCalendarSyncStatus: syncMocks.getCalendarSyncStatus, + requestCalendarSync: syncMocks.requestCalendarSync, + }, + events: { + calendarSyncEvent: { + listen: syncMocks.listen, + }, + }, +})); + +import { SyncProvider, useSync } from "./context"; + +function StatusProbe() { + const { status } = useSync(); + return
{status}
; +} + +describe("SyncProvider", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + syncMocks.reset(); + syncMocks.getCalendarSyncStatus.mockReset(); + syncMocks.requestCalendarSync.mockReset(); + syncMocks.listen.mockReset(); + syncMocks.listen.mockImplementation(syncMocks.attach); + syncMocks.requestCalendarSync.mockResolvedValue({ + status: "ok", + data: null, + }); + syncMocks.getCalendarSyncStatus.mockResolvedValue("idle"); + }); + + test("ignores a stale initial status after live sync events arrive", async () => { + let resolveStatus: + | ((value: "idle" | "scheduled" | "running") => void) + | null = null; + syncMocks.getCalendarSyncStatus.mockImplementation( + () => + new Promise((resolve) => { + resolveStatus = resolve; + }), + ); + + render( + + + , + ); + + await waitFor(() => expect(syncMocks.listen).toHaveBeenCalledTimes(1)); + + act(() => { + syncMocks.emit({ + type: "syncStarted", + }); + }); + + await waitFor(() => { + expect(screen.getByTestId("status").textContent).toBe("syncing"); + }); + + act(() => { + syncMocks.emit({ + type: "syncFinished", + data_changed: false, + }); + }); + + await waitFor(() => { + expect(screen.getByTestId("status").textContent).toBe("idle"); + }); + + act(() => { + resolveStatus?.("running"); + }); + + await waitFor(() => { + expect(screen.getByTestId("status").textContent).toBe("idle"); + }); + }); +}); diff --git a/apps/desktop/src/calendar/components/context.tsx b/apps/desktop/src/calendar/components/context.tsx index 97465d3a70..a5a85905bb 100644 --- a/apps/desktop/src/calendar/components/context.tsx +++ b/apps/desktop/src/calendar/components/context.tsx @@ -6,12 +6,12 @@ import { useRef, useState, } from "react"; -import { - useScheduleTaskRunCallback, - useTaskRunRunning, -} from "tinytick/ui-react"; -import { CALENDAR_SYNC_TASK_ID } from "~/services/calendar"; +import { + commands as calendarCommands, + events as calendarEvents, + type CalendarSyncEvent, +} from "@hypr/plugin-calendar"; export const TOGGLE_SYNC_DEBOUNCE_MS = 5000; @@ -27,47 +27,118 @@ interface SyncContextValue { const SyncContext = createContext(null); export function SyncProvider({ children }: { children: React.ReactNode }) { - const scheduleEventSync = useScheduleTaskRunCallback( - CALENDAR_SYNC_TASK_ID, - undefined, - 0, - ); const toggleSyncTimeoutRef = useRef | null>( null, ); - const [pendingTaskRunId, setPendingTaskRunId] = useState(null); + const [workerStatus, setWorkerStatus] = useState< + "idle" | "scheduled" | "running" + >("idle"); const [isDebouncing, setIsDebouncing] = useState(false); - const isTaskRunning = useTaskRunRunning(pendingTaskRunId ?? ""); - const isSyncing = pendingTaskRunId !== null && isTaskRunning === true; - - const status: SyncStatus = isSyncing - ? "syncing" - : isDebouncing - ? "scheduled" - : "idle"; + const logRequestSyncError = useCallback((error: string) => { + console.error(error); + }, []); - useEffect(() => { - if (pendingTaskRunId && isTaskRunning === false) { - setPendingTaskRunId(null); + const refreshWorkerStatus = useCallback(async () => { + try { + const nextStatus = await calendarCommands.getCalendarSyncStatus(); + setWorkerStatus(nextStatus); + } catch (error) { + console.error(error); + setWorkerStatus("idle"); } - }, [pendingTaskRunId, isTaskRunning]); + }, []); + + const status: SyncStatus = + workerStatus === "running" + ? "syncing" + : isDebouncing || workerStatus === "scheduled" + ? "scheduled" + : "idle"; useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | null = null; + // Guards the initial `getCalendarSyncStatus` prime from overwriting a + // fresher status that arrived via the event listener while the invoke + // was in flight — the Rust worker emits status changes from a separate + // thread, so cross-channel ordering with the invoke response is not + // guaranteed. + let sawLiveEvent = false; + + const handleSyncEvent = ({ payload }: { payload: CalendarSyncEvent }) => { + sawLiveEvent = true; + switch (payload.type) { + case "statusChanged": + setWorkerStatus(payload.status); + break; + case "syncStarted": + setWorkerStatus("running"); + break; + case "syncFinished": + case "syncFailed": + setWorkerStatus("idle"); + break; + } + }; + + void (async () => { + try { + const fn = + await calendarEvents.calendarSyncEvent.listen(handleSyncEvent); + if (cancelled) { + fn(); + return; + } + unlisten = fn; + } catch (error) { + console.error(error); + return; + } + + try { + const nextStatus = await calendarCommands.getCalendarSyncStatus(); + if (!cancelled && !sawLiveEvent) { + setWorkerStatus(nextStatus); + } + } catch (error) { + console.error(error); + } + })(); + return () => { + cancelled = true; + unlisten?.(); if (toggleSyncTimeoutRef.current) { clearTimeout(toggleSyncTimeoutRef.current); - scheduleEventSync(); + toggleSyncTimeoutRef.current = null; + void calendarCommands + .requestCalendarSync() + .then((result) => { + if (result.status === "error") { + logRequestSyncError(result.error); + } + }) + .catch(console.error); } }; - }, [scheduleEventSync]); + }, [logRequestSyncError]); const scheduleSync = useCallback(() => { - const taskRunId = scheduleEventSync(); - if (taskRunId) { - setPendingTaskRunId(taskRunId); - } - }, [scheduleEventSync]); + setWorkerStatus((current) => (current === "idle" ? "scheduled" : current)); + void calendarCommands + .requestCalendarSync() + .then((result) => { + if (result.status === "error") { + logRequestSyncError(result.error); + void refreshWorkerStatus(); + } + }) + .catch((error) => { + console.error(error); + void refreshWorkerStatus(); + }); + }, [logRequestSyncError, refreshWorkerStatus]); const scheduleDebouncedSync = useCallback(() => { if (toggleSyncTimeoutRef.current) { diff --git a/apps/desktop/src/calendar/components/oauth/calendar-selection.tsx b/apps/desktop/src/calendar/components/oauth/calendar-selection.tsx index 1736ada955..9f4106cde7 100644 --- a/apps/desktop/src/calendar/components/oauth/calendar-selection.tsx +++ b/apps/desktop/src/calendar/components/oauth/calendar-selection.tsx @@ -9,6 +9,7 @@ import { CalendarSelection, } from "~/calendar/components/calendar-selection"; import type { CalendarProvider } from "~/calendar/components/shared"; +import { useSetCalendarEnabled } from "~/calendar/hooks"; import * as main from "~/store/tinybase/store/main"; export function OAuthCalendarSelection({ @@ -34,7 +35,7 @@ export function OAuthCalendarSelection({ export function useOAuthCalendarSelection(config: CalendarProvider) { const queryClient = useQueryClient(); - const store = main.UI.useStore(main.STORE_ID); + const setCalendarEnabled = useSetCalendarEnabled(); const calendars = main.UI.useTable("calendars", main.STORE_ID); const { cancelDebouncedSync, status, scheduleDebouncedSync, scheduleSync } = useSync(); @@ -109,10 +110,12 @@ export function useOAuthCalendarSelection(config: CalendarProvider) { const handleToggle = useCallback( (calendar: CalendarItem, enabled: boolean) => { - store?.setPartialRow("calendars", calendar.id, { enabled }); + void setCalendarEnabled(calendar.id, enabled).catch((error) => { + console.error("[oauth-calendar-selection] setCalendarEnabled:", error); + }); scheduleDebouncedSync(); }, - [store, scheduleDebouncedSync], + [setCalendarEnabled, scheduleDebouncedSync], ); const handleRefresh = useCallback(() => { diff --git a/apps/desktop/src/calendar/hooks.ts b/apps/desktop/src/calendar/hooks.ts index 81805dac9a..e470e00e98 100644 --- a/apps/desktop/src/calendar/hooks.ts +++ b/apps/desktop/src/calendar/hooks.ts @@ -1,6 +1,7 @@ import { format } from "date-fns"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { commands as calendarCommands } from "@hypr/plugin-calendar"; import { safeParseDate } from "@hypr/utils"; import { TZDate } from "@hypr/utils"; @@ -81,6 +82,29 @@ export function useCalendar(id: string | null | undefined): Calendar | null { }, [id, row]); } +/** + * Toggle a calendar's `enabled` flag through the Rust plugin. The command is + * the single-writer path for `calendars.json` — never call + * `store.setPartialRow("calendars", ...)` directly, since the TinyBase + * persister for this table is load-only (see + * `apps/desktop/src/store/tinybase/persister/calendar/persister.ts`) and the + * Rust sync worker would clobber any UI-side writes on its next pass. + */ +export function useSetCalendarEnabled() { + return useCallback( + async (calendarId: string, enabled: boolean): Promise => { + const result = await calendarCommands.setCalendarEnabled( + calendarId, + enabled, + ); + if (result.status === "error") { + throw new Error(result.error); + } + }, + [], + ); +} + export type EnabledCalendar = { id: string; provider: string }; export function useEnabledCalendars(): EnabledCalendar[] { diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index d09d7a99b7..b8692395f4 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -6,11 +6,6 @@ import { createRouter, RouterProvider } from "@tanstack/react-router"; import { StrictMode, useMemo } from "react"; import ReactDOM from "react-dom/client"; import { Provider as TinyBaseProvider, useStores } from "tinybase/ui-react"; -import { createManager } from "tinytick"; -import { - Provider as TinyTickProvider, - useCreateManager, -} from "tinytick/ui-react"; import { getCurrentWebviewWindowLabel, @@ -22,8 +17,9 @@ import "@hypr/ui/globals.css"; import { createToolRegistry } from "./contexts/tool-registry/core"; import { env } from "./env"; import { routeTree } from "./routeTree.gen"; +import { CalendarSyncReconciler } from "./services/calendar-sync-reconciler"; import { EventListeners } from "./services/event-listeners"; -import { TaskManager } from "./services/task-manager"; +import { EventNotificationManager } from "./services/event-notification-manager"; import { ErrorComponent, NotFoundComponent } from "./shared/control"; import { type Store, @@ -99,24 +95,20 @@ if (env.VITE_SENTRY_DSN) { }); } -function AppWithTiny() { - const manager = useCreateManager(() => { - return createManager().start(); - }); +function AppRoot() { const isMainWindow = getCurrentWebviewWindowLabel() === "main"; return ( - - - - - - {isMainWindow ? : null} - {isMainWindow ? : null} - - - + + + + + {isMainWindow ? : null} + {isMainWindow ? : null} + {isMainWindow ? : null} + + ); } @@ -128,7 +120,7 @@ if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement); root.render( - + , ); } diff --git a/apps/desktop/src/services/calendar-sync-reconciler.test.tsx b/apps/desktop/src/services/calendar-sync-reconciler.test.tsx new file mode 100644 index 0000000000..d6d23a0776 --- /dev/null +++ b/apps/desktop/src/services/calendar-sync-reconciler.test.tsx @@ -0,0 +1,107 @@ +import { render } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { CalendarSyncReconciler } from "./calendar-sync-reconciler"; + +const { useStoreMock, reconcileCalendarSessionsMock } = vi.hoisted(() => ({ + useStoreMock: vi.fn(), + reconcileCalendarSessionsMock: vi.fn(), +})); + +vi.mock("~/store/tinybase/store/main", () => ({ + STORE_ID: "main-store", + UI: { + useStore: useStoreMock, + }, +})); + +vi.mock("./calendar/reconcile", () => ({ + reconcileCalendarSessions: reconcileCalendarSessionsMock, +})); + +type TableName = "events" | "calendars"; +type Listener = () => void; + +function createStore() { + const listeners = new Map>([ + ["events", new Map()], + ["calendars", new Map()], + ]); + let nextListenerId = 1; + + return { + addTableListener: vi.fn((tableName: TableName, listener: Listener) => { + const id = String(nextListenerId++); + listeners.get(tableName)?.set(id, listener); + return id; + }), + delListener: vi.fn((listenerId: string) => { + for (const tableListeners of listeners.values()) { + tableListeners.delete(listenerId); + } + }), + emit(tableName: TableName) { + for (const listener of listeners.get(tableName)?.values() ?? []) { + listener(); + } + }, + }; +} + +describe("CalendarSyncReconciler", () => { + beforeEach(() => { + vi.useFakeTimers(); + useStoreMock.mockReset(); + reconcileCalendarSessionsMock.mockReset(); + }); + + test("reconciles once on mount after the debounce window", () => { + const store = createStore(); + useStoreMock.mockReturnValue(store); + + render(); + + expect(reconcileCalendarSessionsMock).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(50); + + expect(reconcileCalendarSessionsMock).toHaveBeenCalledTimes(1); + expect(reconcileCalendarSessionsMock).toHaveBeenCalledWith(store); + }); + + test("reconciles after TinyBase applies events table changes", () => { + const store = createStore(); + useStoreMock.mockReturnValue(store); + + render(); + vi.advanceTimersByTime(50); + reconcileCalendarSessionsMock.mockClear(); + + store.emit("events"); + vi.advanceTimersByTime(49); + expect(reconcileCalendarSessionsMock).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(reconcileCalendarSessionsMock).toHaveBeenCalledTimes(1); + expect(reconcileCalendarSessionsMock).toHaveBeenCalledWith(store); + }); + + test("debounces near-simultaneous events and calendars reloads", () => { + const store = createStore(); + useStoreMock.mockReturnValue(store); + + render(); + vi.advanceTimersByTime(50); + reconcileCalendarSessionsMock.mockClear(); + + store.emit("events"); + vi.advanceTimersByTime(25); + store.emit("calendars"); + vi.advanceTimersByTime(49); + + expect(reconcileCalendarSessionsMock).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(reconcileCalendarSessionsMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/services/calendar-sync-reconciler.tsx b/apps/desktop/src/services/calendar-sync-reconciler.tsx new file mode 100644 index 0000000000..72c89024c6 --- /dev/null +++ b/apps/desktop/src/services/calendar-sync-reconciler.tsx @@ -0,0 +1,54 @@ +import { useEffect } from "react"; + +import { reconcileCalendarSessions } from "./calendar/reconcile"; + +import * as main from "~/store/tinybase/store/main"; + +const RECONCILE_DEBOUNCE_MS = 50; + +/** + * Keeps `sessions.event_json` / `events.participants_json` in sync with + * Rust-owned calendar data. Reconcile after TinyBase actually ingests updated + * `events` / `calendars` rows so we don't race the file-backed persisters. + * + * The file-backed tables can land in separate callbacks, so debounce to collapse + * near-simultaneous `events.json` / `calendars.json` reloads into a single pass. + */ +export function CalendarSyncReconciler() { + const store = main.UI.useStore(main.STORE_ID); + + useEffect(() => { + if (!store) { + return; + } + + let timeout: ReturnType | null = null; + const scheduleReconcile = () => { + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(() => { + timeout = null; + reconcileCalendarSessions(store as main.Store); + }, RECONCILE_DEBOUNCE_MS); + }; + + const listenerIds = [ + store.addTableListener("events", scheduleReconcile), + store.addTableListener("calendars", scheduleReconcile), + ]; + + scheduleReconcile(); + + return () => { + if (timeout) { + clearTimeout(timeout); + } + for (const listenerId of listenerIds) { + store.delListener(listenerId); + } + }; + }, [store]); + + return null; +} diff --git a/apps/desktop/src/services/calendar/AGENTS.md b/apps/desktop/src/services/calendar/AGENTS.md new file mode 100644 index 0000000000..00e6d912bf --- /dev/null +++ b/apps/desktop/src/services/calendar/AGENTS.md @@ -0,0 +1,7 @@ +# Calendar Reconcile Policy + +- `sessions.event_json` and auto participant mappings are authoritative only for successfully observed, in-horizon calendar events. +- In the current TinyBase bridge, `calendar-sync` keeps out-of-scope events in the cache. For a session-linked `tracking_id`, a missing cached row therefore means a positive delete, not "unknown". +- Positive deletes include provider removal, disabled calendars, and disconnected accounts: clear `sessions.event_json` and remove `source: "auto"` participant mappings. +- `source: "excluded"` participant mappings stay sticky. +- If future Rust/SQLite work starts evicting out-of-scope rows, do not infer deletes from cache absence. Move this policy to an explicit `Observed` / `Deleted` / `Untouched` sync outcome instead. diff --git a/apps/desktop/src/services/calendar/ctx.test.ts b/apps/desktop/src/services/calendar/ctx.test.ts deleted file mode 100644 index 7ac5416e37..0000000000 --- a/apps/desktop/src/services/calendar/ctx.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { createMergeableStore } from "tinybase/with-schemas"; -import { beforeEach, describe, expect, test, vi } from "vitest"; - -import { SCHEMA } from "@hypr/store"; - -const pluginCalendar = vi.hoisted(() => ({ - listCalendars: vi.fn(), -})); - -vi.mock("@hypr/plugin-calendar", () => ({ - commands: { - listCalendars: pluginCalendar.listCalendars, - }, -})); - -import { syncCalendars } from "./ctx"; - -function createStore() { - const store = createMergeableStore() - .setTablesSchema(SCHEMA.table) - .setValuesSchema(SCHEMA.value); - - store.setValue("user_id", "user-1"); - - return store; -} - -function getCalendarsByConnection( - store: ReturnType, - provider: string, -) { - return store - .getRowIds("calendars") - .map((rowId) => ({ id: rowId, ...store.getRow("calendars", rowId) })) - .filter((calendar) => calendar.provider === provider); -} - -describe("syncCalendars", () => { - beforeEach(() => { - pluginCalendar.listCalendars.mockReset(); - }); - - test("keeps Google calendars isolated per connection when ids overlap", async () => { - const store = createStore(); - - store.setRow("calendars", "john-row", { - user_id: "user-1", - created_at: "2026-03-25T00:00:00.000Z", - tracking_id_calendar: "primary", - name: "John (Char)", - enabled: true, - provider: "google", - source: "john@char.com", - color: "#4285f4", - connection_id: "conn-john", - }); - - pluginCalendar.listCalendars.mockImplementation( - async (_provider: string, connectionId: string) => { - if (connectionId === "conn-john") { - return { - status: "success", - data: [ - { - id: "primary", - title: "John (Char)", - source: "john@char.com", - color: "#4285f4", - }, - ], - }; - } - - if (connectionId === "conn-gmail") { - return { - status: "success", - data: [ - { - id: "primary", - title: "Personal", - source: "jeeheontransformers@gmail.com", - color: "#a142f4", - }, - ], - }; - } - - return { status: "error" }; - }, - ); - - await syncCalendars(store, [ - { - provider: "google", - connection_ids: ["conn-john", "conn-gmail"], - }, - ]); - - const calendars = getCalendarsByConnection(store, "google"); - - expect(calendars).toHaveLength(2); - expect( - calendars.find((calendar) => calendar.connection_id === "conn-john"), - ).toMatchObject({ - tracking_id_calendar: "primary", - name: "John (Char)", - enabled: true, - source: "john@char.com", - }); - expect( - calendars.find((calendar) => calendar.connection_id === "conn-gmail"), - ).toMatchObject({ - tracking_id_calendar: "primary", - name: "Personal", - enabled: false, - source: "jeeheontransformers@gmail.com", - }); - }); - - test("removes calendars for disconnected accounts even when ids overlap", async () => { - const store = createStore(); - - store.setRow("calendars", "john-row", { - user_id: "user-1", - created_at: "2026-03-25T00:00:00.000Z", - tracking_id_calendar: "primary", - name: "John (Char)", - enabled: true, - provider: "google", - source: "john@char.com", - color: "#4285f4", - connection_id: "conn-john", - }); - store.setRow("calendars", "gmail-row", { - user_id: "user-1", - created_at: "2026-03-25T00:00:00.000Z", - tracking_id_calendar: "primary", - name: "Personal", - enabled: false, - provider: "google", - source: "jeeheontransformers@gmail.com", - color: "#a142f4", - connection_id: "conn-gmail", - }); - - pluginCalendar.listCalendars.mockResolvedValue({ - status: "success", - data: [ - { - id: "primary", - title: "Personal", - source: "jeeheontransformers@gmail.com", - color: "#a142f4", - }, - ], - }); - - await syncCalendars(store, [ - { - provider: "google", - connection_ids: ["conn-gmail"], - }, - ]); - - const calendars = getCalendarsByConnection(store, "google"); - - expect(calendars).toHaveLength(1); - expect(calendars[0]).toMatchObject({ - connection_id: "conn-gmail", - name: "Personal", - }); - }); -}); diff --git a/apps/desktop/src/services/calendar/ctx.ts b/apps/desktop/src/services/calendar/ctx.ts deleted file mode 100644 index 1421b035ea..0000000000 --- a/apps/desktop/src/services/calendar/ctx.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { Queries } from "tinybase/with-schemas"; - -import { commands as calendarCommands } from "@hypr/plugin-calendar"; -import type { - CalendarListItem, - CalendarProviderType, - ProviderConnectionIds, -} from "@hypr/plugin-calendar"; - -import { - findCalendarByTrackingId, - getCalendarTrackingKey, -} from "~/calendar/utils"; -import { QUERIES, type Schemas, type Store } from "~/store/tinybase/store/main"; - -// --- - -export interface Ctx { - store: Store; - provider: CalendarProviderType; - connectionId: string; - userId: string; - from: Date; - to: Date; - calendarIds: Set; - calendarTrackingIdToId: Map; -} - -// --- - -export function createCtx( - store: Store, - queries: Queries, - provider: CalendarProviderType, - connectionId: string, -): Ctx | null { - const resultTable = queries.getResultTable(QUERIES.enabledCalendars); - - const calendarIds = new Set(); - const calendarTrackingIdToId = new Map(); - - for (const calendarId of Object.keys(resultTable)) { - const calendar = store.getRow("calendars", calendarId); - if ( - calendar?.provider !== provider || - calendar?.connection_id !== connectionId - ) { - continue; - } - - calendarIds.add(calendarId); - - const trackingId = calendar?.tracking_id_calendar as string | undefined; - if (trackingId) { - calendarTrackingIdToId.set(trackingId, calendarId); - } - } - - // We can't do this because we need a ctx to delete - // left-over events from old calendars in sync - // if (calendarTrackingIdToId.size === 0) { - // return null; - // } - - const userId = store.getValue("user_id"); - if (!userId) { - return null; - } - - const { from, to } = getRange(); - - return { - store, - provider, - connectionId, - userId: String(userId), - from, - to, - calendarIds, - calendarTrackingIdToId, - }; -} - -// --- - -export async function getProviderConnections(): Promise< - ProviderConnectionIds[] -> { - const result = await calendarCommands.listConnectionIds(); - if (result.status === "error") return []; - return result.data; -} - -export async function syncCalendars( - store: Store, - providerConnections: ProviderConnectionIds[], -): Promise { - const userId = store.getValue("user_id"); - if (!userId) return; - - for (const { provider, connection_ids } of providerConnections) { - const perConnection: { - connectionId: string; - calendars: CalendarListItem[]; - }[] = []; - - for (const connectionId of connection_ids) { - const result = await calendarCommands.listCalendars( - provider, - connectionId, - ); - if (result.status === "error") continue; - perConnection.push({ connectionId, calendars: result.data }); - } - - const requestedConnectionIds = new Set(connection_ids); - const successfulConnectionIds = new Set( - perConnection.map(({ connectionId }) => connectionId), - ); - - const incomingKeys = new Set( - perConnection.flatMap(({ connectionId, calendars }) => - calendars.map((cal) => - getCalendarTrackingKey({ - provider, - connectionId, - trackingId: cal.id, - }), - ), - ), - ); - - store.transaction(() => { - const disabledCalendarIds = new Set(); - - for (const rowId of store.getRowIds("calendars")) { - const row = store.getRow("calendars", rowId); - if ( - row.provider === provider && - (!requestedConnectionIds.has(row.connection_id as string) || - (successfulConnectionIds.has(row.connection_id as string) && - !incomingKeys.has( - getCalendarTrackingKey({ - provider: row.provider as string | undefined, - connectionId: row.connection_id as string | undefined, - trackingId: row.tracking_id_calendar as string | undefined, - }), - ))) - ) { - disabledCalendarIds.add(rowId); - store.delRow("calendars", rowId); - } else if (row.provider === provider && !row.enabled) { - disabledCalendarIds.add(rowId); - } - } - - if (disabledCalendarIds.size > 0) { - for (const eventId of store.getRowIds("events")) { - const event = store.getRow("events", eventId); - if (event.calendar_id && disabledCalendarIds.has(event.calendar_id)) { - store.delRow("events", eventId); - } - } - } - - for (const { connectionId, calendars } of perConnection) { - for (const cal of calendars) { - const existingRowId = findCalendarByTrackingId(store, { - provider, - connectionId, - trackingId: cal.id, - }); - const rowId = existingRowId ?? crypto.randomUUID(); - const existing = existingRowId - ? store.getRow("calendars", existingRowId) - : null; - - store.setRow("calendars", rowId, { - user_id: String(userId), - created_at: existing?.created_at || new Date().toISOString(), - tracking_id_calendar: cal.id, - name: cal.title, - enabled: existing?.enabled ?? false, - provider, - source: cal.source ?? undefined, - color: cal.color ?? "#888", - connection_id: connectionId, - }); - } - } - }); - } -} - -// --- - -const getRange = () => { - const now = new Date(); - const from = new Date(now); - from.setDate(from.getDate() - 7); - const to = new Date(now); - to.setDate(to.getDate() + 30); - return { from, to }; -}; diff --git a/apps/desktop/src/services/calendar/fetch/existing.ts b/apps/desktop/src/services/calendar/fetch/existing.ts deleted file mode 100644 index 6d22346022..0000000000 --- a/apps/desktop/src/services/calendar/fetch/existing.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { Ctx } from "../ctx"; -import type { ExistingEvent } from "./types"; - -function isEventInRange( - startedAt: string, - endedAt: string | undefined, - from: Date, - to: Date, -): boolean { - const eventStart = new Date(startedAt); - const eventEnd = endedAt ? new Date(endedAt) : eventStart; - - return eventStart <= to && eventEnd >= from; -} - -export function fetchExistingEvents(ctx: Ctx): ExistingEvent[] { - const events: ExistingEvent[] = []; - - ctx.store.forEachRow("events", (rowId, _forEachCell) => { - const event = ctx.store.getRow("events", rowId); - if (!event) return; - - const calendarId = event.calendar_id; - if (!calendarId) { - return; - } - - if (!ctx.calendarIds.has(calendarId)) { - return; - } - - const startedAt = event.started_at; - if (!startedAt) return; - - const endedAt = event.ended_at; - if (isEventInRange(startedAt, endedAt, ctx.from, ctx.to)) { - events.push({ - id: rowId, - tracking_id_event: event.tracking_id_event, - user_id: event.user_id, - created_at: event.created_at, - calendar_id: calendarId, - title: event.title, - started_at: startedAt, - ended_at: endedAt, - location: event.location, - meeting_link: event.meeting_link, - description: event.description, - note: event.note, - recurrence_series_id: event.recurrence_series_id, - has_recurrence_rules: event.has_recurrence_rules, - provider: event.provider, - }); - } - }); - - return events; -} diff --git a/apps/desktop/src/services/calendar/fetch/incoming.ts b/apps/desktop/src/services/calendar/fetch/incoming.ts deleted file mode 100644 index b87829710c..0000000000 --- a/apps/desktop/src/services/calendar/fetch/incoming.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { commands as calendarCommands } from "@hypr/plugin-calendar"; -import type { CalendarEvent } from "@hypr/plugin-calendar"; - -import type { Ctx } from "../ctx"; -import type { - EventParticipant, - IncomingEvent, - IncomingParticipants, -} from "./types"; - -export class CalendarFetchError extends Error { - constructor( - public readonly calendarTrackingId: string, - public readonly cause: string, - ) { - super( - `Failed to fetch events for calendar ${calendarTrackingId}: ${cause}`, - ); - this.name = "CalendarFetchError"; - } -} - -export async function fetchIncomingEvents(ctx: Ctx): Promise<{ - events: IncomingEvent[]; - participants: IncomingParticipants; -}> { - const trackingIds = Array.from(ctx.calendarTrackingIdToId.keys()); - - const results = await Promise.all( - trackingIds.map(async (trackingId) => { - const result = await calendarCommands.listEvents( - ctx.provider, - ctx.connectionId, - { - calendar_tracking_id: trackingId, - from: ctx.from.toISOString(), - to: ctx.to.toISOString(), - }, - ); - - if (result.status === "error") { - throw new CalendarFetchError(trackingId, result.error); - } - - return result.data; - }), - ); - - const calendarEvents = results.flat(); - const events: IncomingEvent[] = []; - const participants: IncomingParticipants = new Map(); - - for (const calendarEvent of calendarEvents) { - if ( - calendarEvent.attendees.find( - (attendee) => - attendee.is_current_user && attendee.status === "declined", - ) - ) { - continue; - } - const { event, eventParticipants } = - await normalizeCalendarEvent(calendarEvent); - events.push(event); - if (eventParticipants.length > 0) { - participants.set(event.tracking_id_event, eventParticipants); - } - } - - return { events, participants }; -} - -async function normalizeCalendarEvent(calendarEvent: CalendarEvent): Promise<{ - event: IncomingEvent; - eventParticipants: EventParticipant[]; -}> { - const meetingLink = - calendarEvent.meeting_link ?? - (await extractMeetingLink( - calendarEvent.description, - calendarEvent.location, - )); - - const eventParticipants: EventParticipant[] = []; - - if (calendarEvent.organizer) { - eventParticipants.push({ - name: calendarEvent.organizer.name ?? undefined, - email: calendarEvent.organizer.email ?? undefined, - is_organizer: true, - is_current_user: calendarEvent.organizer.is_current_user, - }); - } - - const organizerEmail = calendarEvent.organizer?.email?.toLowerCase(); - - for (const attendee of calendarEvent.attendees) { - if (attendee.role === "nonparticipant") continue; - if (organizerEmail && attendee.email?.toLowerCase() === organizerEmail) - continue; - eventParticipants.push({ - name: attendee.name ?? undefined, - email: attendee.email ?? undefined, - is_organizer: false, - is_current_user: attendee.is_current_user, - }); - } - - return { - event: { - tracking_id_event: calendarEvent.id, - tracking_id_calendar: calendarEvent.calendar_id, - title: calendarEvent.title, - started_at: calendarEvent.started_at, - ended_at: calendarEvent.ended_at, - location: calendarEvent.location ?? undefined, - meeting_link: meetingLink ?? undefined, - description: calendarEvent.description ?? undefined, - recurrence_series_id: calendarEvent.recurring_event_id ?? undefined, - has_recurrence_rules: calendarEvent.has_recurrence_rules, - is_all_day: calendarEvent.is_all_day, - }, - eventParticipants, - }; -} - -async function extractMeetingLink( - ...texts: (string | undefined | null)[] -): Promise { - for (const text of texts) { - if (!text) continue; - const result = await calendarCommands.parseMeetingLink(text); - if (result) return result; - } - return undefined; -} diff --git a/apps/desktop/src/services/calendar/fetch/index.ts b/apps/desktop/src/services/calendar/fetch/index.ts deleted file mode 100644 index d67a3c8cb1..0000000000 --- a/apps/desktop/src/services/calendar/fetch/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { fetchExistingEvents } from "./existing"; -export { CalendarFetchError, fetchIncomingEvents } from "./incoming"; -export type { ExistingEvent, IncomingEvent } from "./types"; diff --git a/apps/desktop/src/services/calendar/fetch/types.ts b/apps/desktop/src/services/calendar/fetch/types.ts deleted file mode 100644 index c2aace456c..0000000000 --- a/apps/desktop/src/services/calendar/fetch/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { EventParticipant, EventStorage } from "@hypr/store"; - -export type { EventParticipant }; - -export type IncomingEvent = { - tracking_id_event: string; - tracking_id_calendar: string; - title?: string; - started_at?: string; - ended_at?: string; - location?: string; - meeting_link?: string; - description?: string; - recurrence_series_id?: string; - has_recurrence_rules: boolean; - is_all_day: boolean; -}; - -export type IncomingParticipants = Map; - -export type ExistingEvent = { - id: string; - tracking_id_event?: string; - has_recurrence_rules?: boolean; -} & EventStorage; diff --git a/apps/desktop/src/services/calendar/index.ts b/apps/desktop/src/services/calendar/index.ts deleted file mode 100644 index b9df71f8aa..0000000000 --- a/apps/desktop/src/services/calendar/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { Queries } from "tinybase/with-schemas"; - -import type { CalendarProviderType } from "@hypr/plugin-calendar"; - -import { createCtx, getProviderConnections, syncCalendars } from "./ctx"; -import { - CalendarFetchError, - fetchExistingEvents, - fetchIncomingEvents, -} from "./fetch"; -import { - executeForEventsSync, - executeForParticipantsSync, - syncEvents, - syncSessionEmbeddedEvents, - syncSessionParticipants, -} from "./process"; - -import type { Schemas, Store } from "~/store/tinybase/store/main"; - -export const CALENDAR_SYNC_TASK_ID = "calendarSync"; - -export async function syncCalendarEvents( - store: Store, - queries: Queries, -): Promise { - await Promise.all([ - new Promise((resolve) => setTimeout(resolve, 250)), - run(store, queries), - ]); -} - -async function run(store: Store, queries: Queries) { - const providerConnections = await getProviderConnections(); - await syncCalendars(store, providerConnections); - for (const { provider, connection_ids } of providerConnections) { - for (const connectionId of connection_ids) { - try { - await runForConnection(store, queries, provider, connectionId); - } catch (error) { - console.error( - `[calendar-sync] Error syncing ${provider} (${connectionId}): ${error}`, - ); - } - } - } -} - -async function runForConnection( - store: Store, - queries: Queries, - provider: CalendarProviderType, - connectionId: string, -) { - const ctx = createCtx(store, queries, provider, connectionId); - if (!ctx) { - return; - } - - let incoming; - let incomingParticipants; - - try { - const result = await fetchIncomingEvents(ctx); - incoming = result.events; - incomingParticipants = result.participants; - } catch (error) { - if (error instanceof CalendarFetchError) { - console.error( - `[calendar-sync] Aborting ${provider} sync due to fetch error: ${error.message}`, - ); - return; - } - throw error; - } - - const existing = fetchExistingEvents(ctx); - - const eventsOut = syncEvents(ctx, { - incoming, - existing, - incomingParticipants, - }); - executeForEventsSync(ctx, eventsOut); - syncSessionEmbeddedEvents(ctx, incoming); - - const participantsOut = syncSessionParticipants(ctx, { - incomingParticipants, - }); - executeForParticipantsSync(ctx, participantsOut); -} diff --git a/apps/desktop/src/services/calendar/process/events/execute.test.ts b/apps/desktop/src/services/calendar/process/events/execute.test.ts deleted file mode 100644 index b6c285526d..0000000000 --- a/apps/desktop/src/services/calendar/process/events/execute.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import type { SessionEvent } from "@hypr/store"; - -import type { Ctx } from "../../ctx"; -import type { IncomingEvent } from "../../fetch/types"; -import { syncSessionEmbeddedEvents } from "./execute"; - -type MockStoreData = { - sessions: Record>; - events: Record>; - values: Record; -}; - -function createMockStore(data: MockStoreData) { - return { - getRow: (table: string, id: string) => { - if (table === "sessions") return data.sessions[id] ?? {}; - if (table === "events") return data.events[id] ?? {}; - return {}; - }, - forEachRow: ( - table: string, - callback: (id: string, forEachCell: unknown) => void, - ) => { - const tableData = - table === "sessions" - ? data.sessions - : table === "events" - ? data.events - : {}; - for (const id of Object.keys(tableData)) { - callback(id, () => {}); - } - }, - setPartialRow: ( - table: string, - id: string, - row: Record, - ) => { - if (table === "sessions") { - data.sessions[id] = { ...data.sessions[id], ...row }; - } - }, - transaction: (fn: () => void) => fn(), - getValue: (key: string) => data.values[key], - setValue: (key: string, value: string) => { - data.values[key] = value; - }, - } as unknown as Ctx["store"]; -} - -function createMockCtx( - storeData: MockStoreData, - overrides: Partial = {}, -): Ctx { - return { - store: createMockStore(storeData), - provider: "apple" as const, - connectionId: "apple", - userId: "user-1", - from: new Date("2024-01-01"), - to: new Date("2024-02-01"), - calendarIds: new Set(["cal-1"]), - calendarTrackingIdToId: new Map([["tracking-cal-1", "cal-1"]]), - ...overrides, - }; -} - -function makeSessionEvent(overrides: Partial = {}): SessionEvent { - return { - tracking_id: "track-1", - calendar_id: "cal-1", - title: "Old Title", - started_at: "2024-01-15T10:00:00Z", - ended_at: "2024-01-15T11:00:00Z", - is_all_day: false, - has_recurrence_rules: false, - ...overrides, - }; -} - -function makeIncomingEvent( - overrides: Partial = {}, -): IncomingEvent { - return { - tracking_id_event: "track-1", - tracking_id_calendar: "tracking-cal-1", - title: "Updated Title", - started_at: "2024-01-15T10:00:00Z", - ended_at: "2024-01-15T11:00:00Z", - has_recurrence_rules: false, - is_all_day: false, - ...overrides, - }; -} - -describe("syncSessionEmbeddedEvents", () => { - test("updates session embedded event for non-recurring event", () => { - const storeData: MockStoreData = { - sessions: { - "session-1": { - event_json: JSON.stringify(makeSessionEvent()), - }, - }, - events: {}, - values: {}, - }; - const ctx = createMockCtx(storeData); - - syncSessionEmbeddedEvents(ctx, [ - makeIncomingEvent({ title: "Updated Title" }), - ]); - - const updated = JSON.parse( - storeData.sessions["session-1"].event_json as string, - ); - expect(updated.title).toBe("Updated Title"); - expect(updated.tracking_id).toBe("track-1"); - }); - - test("matches recurring events by unique tracking_id per occurrence", () => { - const storeData: MockStoreData = { - sessions: { - "session-jan15": { - event_json: JSON.stringify( - makeSessionEvent({ - tracking_id: "recurring-1:2024-01-15", - has_recurrence_rules: true, - started_at: "2024-01-15T10:00:00Z", - }), - ), - }, - "session-jan22": { - event_json: JSON.stringify( - makeSessionEvent({ - tracking_id: "recurring-1:2024-01-22", - has_recurrence_rules: true, - started_at: "2024-01-22T10:00:00Z", - }), - ), - }, - }, - events: {}, - values: {}, - }; - const ctx = createMockCtx(storeData); - - syncSessionEmbeddedEvents(ctx, [ - makeIncomingEvent({ - tracking_id_event: "recurring-1:2024-01-15", - has_recurrence_rules: true, - started_at: "2024-01-15T10:00:00Z", - title: "Updated Jan 15", - }), - ]); - - const jan15 = JSON.parse( - storeData.sessions["session-jan15"].event_json as string, - ); - expect(jan15.title).toBe("Updated Jan 15"); - - const jan22 = JSON.parse( - storeData.sessions["session-jan22"].event_json as string, - ); - expect(jan22.title).toBe("Old Title"); - }); - - test("skips sessions without embedded events", () => { - const storeData: MockStoreData = { - sessions: { - "session-1": { title: "No Event" }, - }, - events: {}, - values: {}, - }; - const ctx = createMockCtx(storeData); - - syncSessionEmbeddedEvents(ctx, [makeIncomingEvent()]); - - expect(storeData.sessions["session-1"].event_json).toBeUndefined(); - }); - - test("does nothing when incoming events is empty", () => { - const original = makeSessionEvent(); - const storeData: MockStoreData = { - sessions: { - "session-1": { - event_json: JSON.stringify(original), - }, - }, - events: {}, - values: {}, - }; - const ctx = createMockCtx(storeData); - - syncSessionEmbeddedEvents(ctx, []); - - const result = JSON.parse( - storeData.sessions["session-1"].event_json as string, - ); - expect(result.title).toBe("Old Title"); - }); - - test("resolves calendar_id from calendarTrackingIdToId map", () => { - const storeData: MockStoreData = { - sessions: { - "session-1": { - event_json: JSON.stringify( - makeSessionEvent({ calendar_id: "old-cal" }), - ), - }, - }, - events: {}, - values: {}, - }; - const ctx = createMockCtx(storeData, { - calendarTrackingIdToId: new Map([["tracking-cal-new", "cal-new"]]), - }); - - syncSessionEmbeddedEvents(ctx, [ - makeIncomingEvent({ tracking_id_calendar: "tracking-cal-new" }), - ]); - - const result = JSON.parse( - storeData.sessions["session-1"].event_json as string, - ); - expect(result.calendar_id).toBe("cal-new"); - }); -}); diff --git a/apps/desktop/src/services/calendar/process/events/execute.ts b/apps/desktop/src/services/calendar/process/events/execute.ts deleted file mode 100644 index ac199fb9ef..0000000000 --- a/apps/desktop/src/services/calendar/process/events/execute.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { EventStorage, SessionEvent } from "@hypr/store"; - -import type { Ctx } from "../../ctx"; -import type { IncomingEvent } from "../../fetch/types"; -import type { EventsSyncOutput } from "./types"; - -import { getSessionEventById } from "~/session/utils"; -import { id } from "~/shared/utils"; - -export function executeForEventsSync(ctx: Ctx, out: EventsSyncOutput): void { - const userId = ctx.store.getValue("user_id"); - if (!userId) { - throw new Error("user_id is not set"); - } - - const now = new Date().toISOString(); - - ctx.store.transaction(() => { - for (const eventId of out.toDelete) { - ctx.store.delRow("events", eventId); - } - - for (const event of out.toUpdate) { - ctx.store.setPartialRow("events", event.id, { - tracking_id_event: event.tracking_id_event, - calendar_id: event.calendar_id, - title: event.title, - started_at: event.started_at, - ended_at: event.ended_at, - location: event.location, - meeting_link: event.meeting_link, - description: event.description, - recurrence_series_id: event.recurrence_series_id, - has_recurrence_rules: event.has_recurrence_rules, - is_all_day: event.is_all_day, - provider: ctx.provider, - participants_json: - event.participants.length > 0 - ? JSON.stringify(event.participants) - : undefined, - }); - } - - for (const eventToAdd of out.toAdd) { - const calendarId = ctx.calendarTrackingIdToId.get( - eventToAdd.tracking_id_calendar, - ); - if (!calendarId) { - continue; - } - - const eventId = id(); - - ctx.store.setRow("events", eventId, { - user_id: userId, - created_at: now, - tracking_id_event: eventToAdd.tracking_id_event, - calendar_id: calendarId, - title: eventToAdd.title ?? "", - started_at: eventToAdd.started_at ?? "", - ended_at: eventToAdd.ended_at ?? "", - location: eventToAdd.location, - meeting_link: eventToAdd.meeting_link, - description: eventToAdd.description, - recurrence_series_id: eventToAdd.recurrence_series_id, - has_recurrence_rules: eventToAdd.has_recurrence_rules, - is_all_day: eventToAdd.is_all_day, - provider: ctx.provider, - participants_json: - eventToAdd.participants.length > 0 - ? JSON.stringify(eventToAdd.participants) - : undefined, - } satisfies EventStorage); - } - }); -} - -export function syncSessionEmbeddedEvents( - ctx: Ctx, - incoming: IncomingEvent[], -): void { - const incomingByTrackingId = new Map(); - for (const event of incoming) { - incomingByTrackingId.set(event.tracking_id_event, event); - } - - ctx.store.transaction(() => { - ctx.store.forEachRow("sessions", (sessionId, _forEachCell) => { - const sessionEvent = getSessionEventById(ctx.store, sessionId); - if (!sessionEvent) return; - - const incomingEvent = incomingByTrackingId.get(sessionEvent.tracking_id); - if (!incomingEvent) return; - - const calendarId = - ctx.calendarTrackingIdToId.get(incomingEvent.tracking_id_calendar) ?? - ""; - - const updated: SessionEvent = { - tracking_id: incomingEvent.tracking_id_event, - calendar_id: calendarId, - title: incomingEvent.title ?? "", - started_at: incomingEvent.started_at ?? "", - ended_at: incomingEvent.ended_at ?? "", - is_all_day: incomingEvent.is_all_day, - has_recurrence_rules: incomingEvent.has_recurrence_rules, - location: incomingEvent.location, - meeting_link: incomingEvent.meeting_link, - description: incomingEvent.description, - recurrence_series_id: incomingEvent.recurrence_series_id, - }; - - ctx.store.setPartialRow("sessions", sessionId, { - event_json: JSON.stringify(updated), - }); - }); - }); -} diff --git a/apps/desktop/src/services/calendar/process/events/index.ts b/apps/desktop/src/services/calendar/process/events/index.ts deleted file mode 100644 index 76a3899fb6..0000000000 --- a/apps/desktop/src/services/calendar/process/events/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { executeForEventsSync, syncSessionEmbeddedEvents } from "./execute"; -export { syncEvents } from "./sync"; -export type { - EventId, - EventsSyncInput, - EventsSyncOutput, - EventToAdd, - EventToUpdate, -} from "./types"; diff --git a/apps/desktop/src/services/calendar/process/events/sync.test.ts b/apps/desktop/src/services/calendar/process/events/sync.test.ts deleted file mode 100644 index 9126702765..0000000000 --- a/apps/desktop/src/services/calendar/process/events/sync.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import type { Ctx } from "../../ctx"; -import type { ExistingEvent, IncomingEvent } from "../../fetch/types"; -import { syncEvents } from "./sync"; -import type { EventsSyncInput } from "./types"; - -function createMockStore(config: { - eventToSession?: Map; - nonEmptySessions?: Set; -}) { - const eventToSession = config.eventToSession ?? new Map(); - const nonEmptySessions = config.nonEmptySessions ?? new Set(); - - const sessionToEvent = new Map(); - for (const [eventId, sessionId] of eventToSession) { - sessionToEvent.set(sessionId, eventId); - } - - return { - getRow: (table: string, id: string) => { - if (table === "sessions") { - const eventId = sessionToEvent.get(id); - if (!eventId) return {}; - const hasContent = nonEmptySessions.has(id); - return { - event_id: eventId, - raw_md: hasContent ? "some content" : "", - }; - } - return {}; - }, - forEachRow: (table: string, callback: (rowId: string) => void) => { - if (table === "sessions") { - for (const sessionId of sessionToEvent.keys()) { - callback(sessionId); - } - } - }, - } as unknown as Ctx["store"]; -} - -function createMockCtx( - overrides: Partial & { - eventToSession?: Map; - nonEmptySessions?: Set; - } = {}, -): Ctx { - const store = createMockStore({ - eventToSession: overrides.eventToSession, - nonEmptySessions: overrides.nonEmptySessions, - }); - - return { - provider: "apple" as const, - connectionId: "apple", - userId: "user-1", - from: new Date("2024-01-01"), - to: new Date("2024-02-01"), - calendarIds: overrides.calendarIds ?? new Set(["cal-1"]), - calendarTrackingIdToId: - overrides.calendarTrackingIdToId ?? - new Map([["tracking-cal-1", "cal-1"]]), - store, - ...overrides, - }; -} - -function createIncomingEvent( - overrides: Partial = {}, -): IncomingEvent { - return { - tracking_id_event: "incoming-1", - tracking_id_calendar: "tracking-cal-1", - title: "Test Event", - started_at: "2024-01-15T10:00:00Z", - ended_at: "2024-01-15T11:00:00Z", - has_recurrence_rules: false, - is_all_day: false, - ...overrides, - }; -} - -function createExistingEvent( - overrides: Partial = {}, -): ExistingEvent { - return { - id: "event-1", - tracking_id_event: "existing-1", - calendar_id: "cal-1", - user_id: "user-1", - created_at: "2024-01-01T00:00:00Z", - title: "Existing Event", - started_at: "2024-01-15T10:00:00Z", - ended_at: "2024-01-15T11:00:00Z", - provider: "apple", - ...overrides, - }; -} - -function syncInput(overrides: Partial = {}): EventsSyncInput { - return { - incoming: [], - existing: [], - incomingParticipants: new Map(), - ...overrides, - }; -} - -describe("syncEvents", () => { - test("adds new incoming events", () => { - const ctx = createMockCtx(); - const result = syncEvents( - ctx, - syncInput({ - incoming: [createIncomingEvent()], - }), - ); - - expect(result.toAdd).toHaveLength(1); - expect(result.toDelete).toHaveLength(0); - expect(result.toUpdate).toHaveLength(0); - }); - - test("updates existing events with matching tracking id", () => { - const ctx = createMockCtx(); - const result = syncEvents( - ctx, - syncInput({ - incoming: [createIncomingEvent({ tracking_id_event: "existing-1" })], - existing: [createExistingEvent()], - }), - ); - - expect(result.toUpdate).toHaveLength(1); - expect(result.toAdd).toHaveLength(0); - expect(result.toDelete).toHaveLength(0); - }); - - test("deletes orphaned events without matching incoming", () => { - const ctx = createMockCtx(); - const result = syncEvents( - ctx, - syncInput({ - existing: [createExistingEvent()], - }), - ); - - expect(result.toDelete).toContain("event-1"); - }); - - describe("removed calendar cleanup", () => { - test("deletes events when calendar removed from Apple Calendar (no incoming events)", () => { - const ctx = createMockCtx({ - calendarIds: new Set(["cal-1"]), - calendarTrackingIdToId: new Map([["tracking-cal-1", "cal-1"]]), - }); - - const result = syncEvents( - ctx, - syncInput({ - existing: [ - createExistingEvent({ - id: "event-1", - tracking_id_event: "track-1", - }), - createExistingEvent({ - id: "event-2", - tracking_id_event: "track-2", - }), - ], - }), - ); - - expect(result.toDelete).toContain("event-1"); - expect(result.toDelete).toContain("event-2"); - expect(result.toDelete).toHaveLength(2); - }); - - test("deletes events regardless of non-empty sessions when calendar removed", () => { - const ctx = createMockCtx({ - calendarIds: new Set(["cal-1"]), - eventToSession: new Map([["event-1", "session-1"]]), - nonEmptySessions: new Set(["session-1"]), - }); - - const result = syncEvents( - ctx, - syncInput({ - existing: [ - createExistingEvent({ - id: "event-1", - tracking_id_event: "track-1", - }), - createExistingEvent({ - id: "event-2", - tracking_id_event: "track-2", - }), - ], - }), - ); - - expect(result.toDelete).toContain("event-1"); - expect(result.toDelete).toContain("event-2"); - }); - - test("deletes events with empty sessions when calendar removed", () => { - const ctx = createMockCtx({ - calendarIds: new Set(["cal-1"]), - eventToSession: new Map([["event-1", "session-1"]]), - nonEmptySessions: new Set(), - }); - - const result = syncEvents( - ctx, - syncInput({ - existing: [createExistingEvent({ id: "event-1" })], - }), - ); - - expect(result.toDelete).toContain("event-1"); - }); - - test("only deletes events from removed calendar, keeps events from active calendars", () => { - const ctx = createMockCtx({ - calendarIds: new Set(["cal-1", "cal-2"]), - calendarTrackingIdToId: new Map([ - ["tracking-cal-1", "cal-1"], - ["tracking-cal-2", "cal-2"], - ]), - }); - - const result = syncEvents( - ctx, - syncInput({ - incoming: [ - createIncomingEvent({ - tracking_id_event: "track-2", - tracking_id_calendar: "tracking-cal-2", - }), - ], - existing: [ - createExistingEvent({ - id: "event-1", - calendar_id: "cal-1", - tracking_id_event: "track-1", - }), - createExistingEvent({ - id: "event-2", - calendar_id: "cal-2", - tracking_id_event: "track-2", - }), - ], - }), - ); - - expect(result.toDelete).toContain("event-1"); - expect(result.toDelete).not.toContain("event-2"); - expect(result.toUpdate).toHaveLength(1); - }); - }); - - describe("participants", () => { - test("attaches participants to added events", () => { - const ctx = createMockCtx(); - const participants = [ - { email: "alice@example.com", name: "Alice", is_organizer: true }, - { email: "bob@example.com", name: "Bob" }, - ]; - const result = syncEvents( - ctx, - syncInput({ - incoming: [createIncomingEvent()], - incomingParticipants: new Map([["incoming-1", participants]]), - }), - ); - - expect(result.toAdd).toHaveLength(1); - expect(result.toAdd[0].participants).toEqual(participants); - }); - - test("attaches participants to updated events", () => { - const ctx = createMockCtx(); - const participants = [{ email: "alice@example.com", name: "Alice" }]; - const result = syncEvents( - ctx, - syncInput({ - incoming: [createIncomingEvent({ tracking_id_event: "existing-1" })], - existing: [createExistingEvent()], - incomingParticipants: new Map([["existing-1", participants]]), - }), - ); - - expect(result.toUpdate).toHaveLength(1); - expect(result.toUpdate[0].participants).toEqual(participants); - }); - - test("defaults to empty participants when no match in incomingParticipants", () => { - const ctx = createMockCtx(); - const result = syncEvents( - ctx, - syncInput({ - incoming: [createIncomingEvent()], - incomingParticipants: new Map(), - }), - ); - - expect(result.toAdd).toHaveLength(1); - expect(result.toAdd[0].participants).toEqual([]); - }); - - test("matches participants by tracking_id_event for recurring events", () => { - const ctx = createMockCtx(); - const participants = [{ email: "alice@example.com", name: "Alice" }]; - const result = syncEvents( - ctx, - syncInput({ - incoming: [ - createIncomingEvent({ - tracking_id_event: "recurring-1", - has_recurrence_rules: true, - started_at: "2024-01-15T10:00:00Z", - }), - ], - incomingParticipants: new Map([["recurring-1", participants]]), - }), - ); - - expect(result.toAdd).toHaveLength(1); - expect(result.toAdd[0].participants).toEqual(participants); - }); - }); -}); diff --git a/apps/desktop/src/services/calendar/process/events/sync.ts b/apps/desktop/src/services/calendar/process/events/sync.ts deleted file mode 100644 index f46138d633..0000000000 --- a/apps/desktop/src/services/calendar/process/events/sync.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { Ctx } from "../../ctx"; -import type { EventsSyncInput, EventsSyncOutput } from "./types"; - -export function syncEvents( - _ctx: Ctx, - { incoming, existing, incomingParticipants }: EventsSyncInput, -): EventsSyncOutput { - const out: EventsSyncOutput = { - toDelete: [], - toUpdate: [], - toAdd: [], - }; - - const incomingByTrackingId = new Map( - incoming.map((e) => [e.tracking_id_event, e]), - ); - const handledTrackingIds = new Set(); - - for (const storeEvent of existing) { - const trackingId = storeEvent.tracking_id_event; - const matchingIncomingEvent = trackingId - ? incomingByTrackingId.get(trackingId) - : undefined; - - if (matchingIncomingEvent && trackingId) { - out.toUpdate.push({ - ...storeEvent, - ...matchingIncomingEvent, - id: storeEvent.id, - tracking_id_event: trackingId, - user_id: storeEvent.user_id, - created_at: storeEvent.created_at, - calendar_id: storeEvent.calendar_id, - has_recurrence_rules: matchingIncomingEvent.has_recurrence_rules, - participants: incomingParticipants.get(trackingId) ?? [], - }); - handledTrackingIds.add(trackingId); - continue; - } - - out.toDelete.push(storeEvent.id); - } - - for (const incomingEvent of incoming) { - if (!handledTrackingIds.has(incomingEvent.tracking_id_event)) { - out.toAdd.push({ - ...incomingEvent, - participants: - incomingParticipants.get(incomingEvent.tracking_id_event) ?? [], - }); - } - } - - return out; -} diff --git a/apps/desktop/src/services/calendar/process/events/types.ts b/apps/desktop/src/services/calendar/process/events/types.ts deleted file mode 100644 index e944e6046b..0000000000 --- a/apps/desktop/src/services/calendar/process/events/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { EventParticipant } from "@hypr/store"; - -import type { - ExistingEvent, - IncomingEvent, - IncomingParticipants, -} from "../../fetch/types"; - -export type EventId = string; - -export type EventsSyncInput = { - incoming: IncomingEvent[]; - existing: ExistingEvent[]; - incomingParticipants: IncomingParticipants; -}; - -export type EventToAdd = IncomingEvent & { - participants: EventParticipant[]; -}; - -export type EventToUpdate = ExistingEvent & - Omit & { - participants: EventParticipant[]; - }; - -export type EventsSyncOutput = { - toDelete: EventId[]; - toUpdate: EventToUpdate[]; - toAdd: EventToAdd[]; -}; diff --git a/apps/desktop/src/services/calendar/process/index.ts b/apps/desktop/src/services/calendar/process/index.ts deleted file mode 100644 index 731455e805..0000000000 --- a/apps/desktop/src/services/calendar/process/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - executeForEventsSync, - syncEvents, - syncSessionEmbeddedEvents, -} from "./events"; -export { - executeForParticipantsSync, - syncSessionParticipants, -} from "./participants"; diff --git a/apps/desktop/src/services/calendar/process/participants/execute.ts b/apps/desktop/src/services/calendar/process/participants/execute.ts index 7625f4bcdc..ed2644259b 100644 --- a/apps/desktop/src/services/calendar/process/participants/execute.ts +++ b/apps/desktop/src/services/calendar/process/participants/execute.ts @@ -3,13 +3,13 @@ import type { MappingSessionParticipantStorage, } from "@hypr/store"; -import type { Ctx } from "../../ctx"; +import type { ReconcileCtx } from "../../types"; import type { ParticipantsSyncOutput } from "./types"; import { id } from "~/shared/utils"; export function executeForParticipantsSync( - ctx: Ctx, + ctx: ReconcileCtx, out: ParticipantsSyncOutput, ): void { const userId = ctx.store.getValue("user_id"); diff --git a/apps/desktop/src/services/calendar/process/participants/sync.test.ts b/apps/desktop/src/services/calendar/process/participants/sync.test.ts index 856bd2d850..011c5ec392 100644 --- a/apps/desktop/src/services/calendar/process/participants/sync.test.ts +++ b/apps/desktop/src/services/calendar/process/participants/sync.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import type { Ctx } from "../../ctx"; +import type { ReconcileCtx } from "../../types"; import { syncSessionParticipants } from "./sync"; type MockStoreData = { @@ -26,19 +26,12 @@ function createMockStore(data: MockStoreData) { callback(id, () => {}); } }, - } as unknown as Ctx["store"]; + } as unknown as ReconcileCtx["store"]; } -function createMockCtx(store: Ctx["store"]): Ctx { +function createMockCtx(store: ReconcileCtx["store"]): ReconcileCtx { return { store, - provider: "apple" as const, - connectionId: "apple", - userId: "user-1", - from: new Date("2024-01-01"), - to: new Date("2024-02-01"), - calendarIds: new Set(["cal-1"]), - calendarTrackingIdToId: new Map([["tracking-cal-1", "cal-1"]]), }; } @@ -72,7 +65,13 @@ describe("syncParticipants", () => { const result = syncSessionParticipants(ctx, { incomingParticipants: new Map([ - ["tracking-1", [{ email: "test@example.com", name: "Test" }]], + [ + "tracking-1", + { + type: "observed", + participants: [{ email: "test@example.com", name: "Test" }], + }, + ], ]), }); @@ -95,7 +94,13 @@ describe("syncParticipants", () => { const result = syncSessionParticipants(ctx, { incomingParticipants: new Map([ - ["tracking-1", [{ email: "new@example.com", name: "New Person" }]], + [ + "tracking-1", + { + type: "observed", + participants: [{ email: "new@example.com", name: "New Person" }], + }, + ], ]), }); @@ -119,7 +124,13 @@ describe("syncParticipants", () => { const result = syncSessionParticipants(ctx, { incomingParticipants: new Map([ - ["tracking-1", [{ email: "existing@example.com", name: "Existing" }]], + [ + "tracking-1", + { + type: "observed", + participants: [{ email: "existing@example.com", name: "Existing" }], + }, + ], ]), }); @@ -148,7 +159,35 @@ describe("syncParticipants", () => { const ctx = createMockCtx(store); const result = syncSessionParticipants(ctx, { - incomingParticipants: new Map([["tracking-1", []]]), + incomingParticipants: new Map([ + ["tracking-1", { type: "observed", participants: [] }], + ]), + }); + + expect(result.toDelete).toContain("mapping-1"); + }); + + test("deletes auto-source mappings when event was deleted", () => { + const store = createMockStore({ + humans: { "human-1": { email: "removed@example.com" } }, + sessions: { + "session-1": { + event_json: JSON.stringify({ tracking_id: "tracking-1" }), + }, + }, + events: {}, + mapping_session_participant: { + "mapping-1": { + session_id: "session-1", + human_id: "human-1", + source: "auto", + }, + }, + }); + const ctx = createMockCtx(store); + + const result = syncSessionParticipants(ctx, { + incomingParticipants: new Map([["tracking-1", { type: "deleted" }]]), }); expect(result.toDelete).toContain("mapping-1"); @@ -174,7 +213,7 @@ describe("syncParticipants", () => { const ctx = createMockCtx(store); const result = syncSessionParticipants(ctx, { - incomingParticipants: new Map([["tracking-1", []]]), + incomingParticipants: new Map([["tracking-1", { type: "deleted" }]]), }); expect(result.toDelete).not.toContain("mapping-1"); diff --git a/apps/desktop/src/services/calendar/process/participants/sync.ts b/apps/desktop/src/services/calendar/process/participants/sync.ts index 268516894f..bff007ece7 100644 --- a/apps/desktop/src/services/calendar/process/participants/sync.ts +++ b/apps/desktop/src/services/calendar/process/participants/sync.ts @@ -1,5 +1,4 @@ -import type { Ctx } from "../../ctx"; -import type { EventParticipant } from "../../fetch/types"; +import type { IncomingParticipantState, ReconcileCtx } from "../../types"; import type { HumanToCreate, ParticipantMappingToAdd, @@ -12,7 +11,7 @@ import { id } from "~/shared/utils"; import type { Store } from "~/store/tinybase/store/main"; export function syncSessionParticipants( - ctx: Ctx, + ctx: ReconcileCtx, input: ParticipantsSyncInput, ): ParticipantsSyncOutput { const output: ParticipantsSyncOutput = { @@ -24,7 +23,7 @@ export function syncSessionParticipants( const humansByEmail = buildHumansByEmailIndex(ctx.store); const humansToCreateMap = new Map(); - for (const [trackingId, participants] of input.incomingParticipants) { + for (const [trackingId, participantState] of input.incomingParticipants) { const sessionId = findSessionByTrackingId(ctx.store, trackingId); if (!sessionId) { continue; @@ -33,7 +32,7 @@ export function syncSessionParticipants( const sessionOutput = computeSessionParticipantChanges( ctx.store, sessionId, - participants, + participantState, humansByEmail, humansToCreateMap, ); @@ -64,10 +63,12 @@ function buildHumansByEmailIndex(store: Store): Map { function computeSessionParticipantChanges( store: Store, sessionId: string, - eventParticipants: EventParticipant[], + participantState: IncomingParticipantState, humansByEmail: Map, humansToCreateMap: Map, ): { toDelete: string[]; toAdd: ParticipantMappingToAdd[] } { + const eventParticipants = + participantState.type === "observed" ? participantState.participants : []; const eventHumanIds = new Set(); for (const participant of eventParticipants) { if (!participant.email) { diff --git a/apps/desktop/src/services/calendar/process/participants/types.ts b/apps/desktop/src/services/calendar/process/participants/types.ts index 4df4fc8eb1..8d828f3840 100644 --- a/apps/desktop/src/services/calendar/process/participants/types.ts +++ b/apps/desktop/src/services/calendar/process/participants/types.ts @@ -1,4 +1,4 @@ -import type { IncomingParticipants } from "../../fetch/types"; +import type { IncomingParticipants } from "../../types"; export type ParticipantMappingId = string; diff --git a/apps/desktop/src/services/calendar/reconcile.test.ts b/apps/desktop/src/services/calendar/reconcile.test.ts new file mode 100644 index 0000000000..fc813f347a --- /dev/null +++ b/apps/desktop/src/services/calendar/reconcile.test.ts @@ -0,0 +1,111 @@ +import { createMergeableStore } from "tinybase/with-schemas"; +import { describe, expect, test } from "vitest"; + +import { SCHEMA, type SessionEvent } from "@hypr/store"; + +import { reconcileCalendarSessions } from "./reconcile"; + +import type { Store } from "~/store/tinybase/store/main"; + +function createStore() { + return createMergeableStore() + .setTablesSchema(SCHEMA.table) + .setValuesSchema(SCHEMA.value) as Store; +} + +function makeSessionEvent(overrides: Partial = {}): SessionEvent { + return { + tracking_id: "event-1", + calendar_id: "old-calendar-id", + title: "Standup", + started_at: "2026-04-15T09:00:00Z", + ended_at: "2026-04-15T09:30:00Z", + is_all_day: false, + has_recurrence_rules: false, + ...overrides, + }; +} + +describe("reconcileCalendarSessions", () => { + test("keeps session calendar ids tied to the source event row when a row id matches another tracking id", () => { + const store = createStore(); + + store.setValue("user_id", "user-1"); + + store.setRow("calendars", "tracking-cal-2", { + user_id: "user-1", + created_at: "2026-04-15T00:00:00Z", + tracking_id_calendar: "tracking-cal-1", + name: "John (Char)", + enabled: true, + provider: "google", + source: "john@char.com", + color: "#4285f4", + connection_id: "conn-john", + }); + store.setRow("calendars", "gmail-row", { + user_id: "user-1", + created_at: "2026-04-15T00:00:00Z", + tracking_id_calendar: "tracking-cal-2", + name: "Personal", + enabled: true, + provider: "google", + source: "person@example.com", + color: "#a142f4", + connection_id: "conn-gmail", + }); + + store.setRow("events", "event-row", { + user_id: "user-1", + created_at: "2026-04-15T00:00:00Z", + tracking_id_event: "event-1", + calendar_id: "tracking-cal-2", + title: "Standup", + started_at: "2026-04-15T09:00:00Z", + ended_at: "2026-04-15T09:30:00Z", + provider: "google", + }); + store.setRow("sessions", "session-1", { + user_id: "user-1", + created_at: "2026-04-15T00:00:00Z", + title: "Standup notes", + raw_md: "", + event_json: JSON.stringify( + makeSessionEvent({ + tracking_id: "event-1", + }), + ), + }); + + reconcileCalendarSessions(store); + + const session = store.getRow("sessions", "session-1"); + const embeddedEvent = JSON.parse( + String(session?.event_json ?? ""), + ) as SessionEvent; + + expect(embeddedEvent.calendar_id).toBe("tracking-cal-2"); + }); + + test("clears session event_json when the backing synced event disappears", () => { + const store = createStore(); + + store.setValue("user_id", "user-1"); + store.setRow("sessions", "session-1", { + user_id: "user-1", + created_at: "2026-04-15T00:00:00Z", + title: "Standup notes", + raw_md: "", + event_json: JSON.stringify( + makeSessionEvent({ + tracking_id: "event-1", + }), + ), + }); + + reconcileCalendarSessions(store); + + const session = store.getRow("sessions", "session-1"); + expect(session?.event_json).toBe(""); + }); +}); diff --git a/apps/desktop/src/services/calendar/reconcile.ts b/apps/desktop/src/services/calendar/reconcile.ts new file mode 100644 index 0000000000..640e32e1dd --- /dev/null +++ b/apps/desktop/src/services/calendar/reconcile.ts @@ -0,0 +1,140 @@ +import type { EventParticipant } from "@hypr/store"; + +import { + executeForParticipantsSync, + syncSessionParticipants, +} from "./process/participants"; +import type { + IncomingParticipants, + ReconcileCtx, + ReconcileSessionEventState, +} from "./types"; + +import { getSessionEventById } from "~/session/utils"; +import type { Store } from "~/store/tinybase/store/main"; + +export function reconcileCalendarSessions(store: Store) { + const ctx: ReconcileCtx = { store }; + const incomingEventStates = new Map(); + const incomingParticipants: IncomingParticipants = new Map(); + + store.forEachRow("events", (eventId, _forEachCell) => { + const event = store.getRow("events", eventId); + if (!event?.tracking_id_event) { + return; + } + + const trackingId = String(event.tracking_id_event); + const calendarId = String(event.calendar_id ?? ""); + incomingEventStates.set(trackingId, { + type: "observed", + event: { + tracking_id_event: trackingId, + calendar_id: calendarId, + title: asOptionalString(event.title), + started_at: asOptionalString(event.started_at), + ended_at: asOptionalString(event.ended_at), + location: asOptionalString(event.location), + meeting_link: asOptionalString(event.meeting_link), + description: asOptionalString(event.description), + recurrence_series_id: asOptionalString(event.recurrence_series_id), + has_recurrence_rules: Boolean(event.has_recurrence_rules), + is_all_day: Boolean(event.is_all_day), + }, + }); + + incomingParticipants.set(trackingId, { + type: "observed", + participants: parseParticipants(event.participants_json), + }); + }); + + markDeletedSessionEvents(store, incomingEventStates, incomingParticipants); + + reconcileSessionEmbeddedEvents(store, incomingEventStates); + + const participantsOut = syncSessionParticipants(ctx, { + incomingParticipants, + }); + executeForParticipantsSync(ctx, participantsOut); +} + +function reconcileSessionEmbeddedEvents( + store: Store, + incomingEventStates: Map, +) { + store.transaction(() => { + store.forEachRow("sessions", (sessionId, _forEachCell) => { + const sessionEvent = getSessionEventById(store, sessionId); + if (!sessionEvent) return; + if (!sessionEvent.tracking_id) return; + + const nextState = incomingEventStates.get(sessionEvent.tracking_id); + if (!nextState) { + return; + } + + if (nextState.type === "deleted") { + store.setPartialRow("sessions", sessionId, { + event_json: "", + }); + return; + } + + const incomingEvent = nextState.event; + store.setPartialRow("sessions", sessionId, { + event_json: JSON.stringify({ + tracking_id: incomingEvent.tracking_id_event, + calendar_id: incomingEvent.calendar_id, + title: incomingEvent.title ?? "", + started_at: incomingEvent.started_at ?? "", + ended_at: incomingEvent.ended_at ?? "", + is_all_day: incomingEvent.is_all_day, + has_recurrence_rules: incomingEvent.has_recurrence_rules, + location: incomingEvent.location, + meeting_link: incomingEvent.meeting_link, + description: incomingEvent.description, + recurrence_series_id: incomingEvent.recurrence_series_id, + }), + }); + }); + }); +} + +function markDeletedSessionEvents( + store: Store, + incomingEventStates: Map, + incomingParticipants: IncomingParticipants, +) { + store.forEachRow("sessions", (sessionId, _forEachCell) => { + const sessionEvent = getSessionEventById(store, sessionId); + if (!sessionEvent?.tracking_id) { + return; + } + if (incomingEventStates.has(sessionEvent.tracking_id)) { + return; + } + + // During the TinyBase bridge, calendar-sync keeps out-of-range events in + // the cache, so a missing row is a positive delete. + incomingEventStates.set(sessionEvent.tracking_id, { type: "deleted" }); + incomingParticipants.set(sessionEvent.tracking_id, { type: "deleted" }); + }); +} + +function asOptionalString(value: unknown) { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function parseParticipants(value: unknown): EventParticipant[] { + if (typeof value !== "string" || !value) { + return []; + } + + try { + const parsed = JSON.parse(value) as EventParticipant[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} diff --git a/apps/desktop/src/services/calendar/types.ts b/apps/desktop/src/services/calendar/types.ts new file mode 100644 index 0000000000..5ad0a231b7 --- /dev/null +++ b/apps/desktop/src/services/calendar/types.ts @@ -0,0 +1,43 @@ +import type { EventParticipant } from "@hypr/store"; + +import type { Store } from "~/store/tinybase/store/main"; + +export type { EventParticipant }; + +export interface ReconcileCtx { + store: Store; +} + +export type ReconcileIncomingEvent = { + tracking_id_event: string; + calendar_id: string; + title?: string; + started_at?: string; + ended_at?: string; + location?: string; + meeting_link?: string; + description?: string; + recurrence_series_id?: string; + has_recurrence_rules: boolean; + is_all_day: boolean; +}; + +export type ReconcileSessionEventState = + | { + type: "observed"; + event: ReconcileIncomingEvent; + } + | { + type: "deleted"; + }; + +export type IncomingParticipantState = + | { + type: "observed"; + participants: EventParticipant[]; + } + | { + type: "deleted"; + }; + +export type IncomingParticipants = Map; diff --git a/apps/desktop/src/services/event-notification-manager.tsx b/apps/desktop/src/services/event-notification-manager.tsx new file mode 100644 index 0000000000..5f1a53e4f4 --- /dev/null +++ b/apps/desktop/src/services/event-notification-manager.tsx @@ -0,0 +1,37 @@ +import { useEffect, useRef } from "react"; + +import { + checkEventNotifications, + type NotifiedEventsMap, +} from "./event-notification"; + +import * as main from "~/store/tinybase/store/main"; +import * as settings from "~/store/tinybase/store/settings"; + +export function EventNotificationManager() { + const store = main.UI.useStore(main.STORE_ID); + const settingsStore = settings.UI.useStore(settings.STORE_ID); + const notifiedEventsRef = useRef(new Map()); + + useEffect(() => { + if (!store || !settingsStore) { + return; + } + + const run = () => { + checkEventNotifications( + store as main.Store, + settingsStore as settings.Store, + notifiedEventsRef.current, + ); + }; + + run(); + const interval = window.setInterval(run, 30 * 1000); + return () => { + window.clearInterval(interval); + }; + }, [store, settingsStore]); + + return null; +} diff --git a/apps/desktop/src/services/task-manager.tsx b/apps/desktop/src/services/task-manager.tsx deleted file mode 100644 index f983d00837..0000000000 --- a/apps/desktop/src/services/task-manager.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useEffect, useRef } from "react"; -import type { Queries } from "tinybase/with-schemas"; -import { - useScheduleTaskRun, - useScheduleTaskRunCallback, - useSetTask, -} from "tinytick/ui-react"; - -import { events as appleCalendarEvents } from "@hypr/plugin-calendar"; - -import { CALENDAR_SYNC_TASK_ID, syncCalendarEvents } from "./calendar"; -import { - checkEventNotifications, - EVENT_NOTIFICATION_INTERVAL, - EVENT_NOTIFICATION_TASK_ID, - type NotifiedEventsMap, -} from "./event-notification"; - -import * as main from "~/store/tinybase/store/main"; -import * as settings from "~/store/tinybase/store/settings"; - -const CALENDAR_SYNC_INTERVAL = 60 * 1000; // 60 sec - -export function TaskManager() { - const store = main.UI.useStore(main.STORE_ID); - const queries = main.UI.useQueries(main.STORE_ID); - - const settingsStore = settings.UI.useStore(settings.STORE_ID); - const notifiedEventsRef = useRef(new Map()); - - useSetTask(CALENDAR_SYNC_TASK_ID, async () => { - await syncCalendarEvents( - store as main.Store, - queries as Queries, - ); - }, [store, queries, settingsStore]); - - useScheduleTaskRun(CALENDAR_SYNC_TASK_ID, undefined, 0, { - repeatDelay: CALENDAR_SYNC_INTERVAL, - }); - - const scheduleCalendarSync = useScheduleTaskRunCallback( - CALENDAR_SYNC_TASK_ID, - undefined, - 0, - ); - - useEffect(() => { - const unlisten = appleCalendarEvents.calendarChangedEvent.listen(() => { - scheduleCalendarSync(); - }); - - return () => { - unlisten.then((fn) => fn()); - }; - }, [scheduleCalendarSync]); - - useSetTask(EVENT_NOTIFICATION_TASK_ID, async () => { - if (!store || !settingsStore) return; - checkEventNotifications( - store as main.Store, - settingsStore as settings.Store, - notifiedEventsRef.current, - ); - }, [store, settingsStore]); - - useScheduleTaskRun(EVENT_NOTIFICATION_TASK_ID, undefined, 0, { - repeatDelay: EVENT_NOTIFICATION_INTERVAL, - }); - - return null; -} diff --git a/apps/desktop/src/shared/hooks/useDeeplinkHandler.ts b/apps/desktop/src/shared/hooks/useDeeplinkHandler.ts index 9954ef2e9c..37c4af63f2 100644 --- a/apps/desktop/src/shared/hooks/useDeeplinkHandler.ts +++ b/apps/desktop/src/shared/hooks/useDeeplinkHandler.ts @@ -1,24 +1,18 @@ import { useQueryClient } from "@tanstack/react-query"; import { isTauri } from "@tauri-apps/api/core"; import { useEffect } from "react"; -import { useScheduleTaskRunCallback } from "tinytick/ui-react"; +import { commands as calendarCommands } from "@hypr/plugin-calendar"; import { events as deeplink2Events } from "@hypr/plugin-deeplink2"; import { dismissInstruction } from "@hypr/plugin-windows"; import { useAuth } from "~/auth"; -import { CALENDAR_SYNC_TASK_ID } from "~/services/calendar"; import { useTabs } from "~/store/zustand/tabs"; export function useDeeplinkHandler() { const auth = useAuth(); const queryClient = useQueryClient(); const openNew = useTabs((state) => state.openNew); - const scheduleCalendarSync = useScheduleTaskRunCallback( - CALENDAR_SYNC_TASK_ID, - undefined, - 0, - ); useEffect(() => { if (!isTauri()) { @@ -30,7 +24,11 @@ export function useDeeplinkHandler() { void queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === "integration-status", }); - scheduleCalendarSync(); + void calendarCommands.requestCalendarSync().then((result) => { + if (result.status === "error") { + console.error(result.error); + } + }); }; const unlisten = deeplink2Events.deepLinkEvent.listen(({ payload }) => { @@ -76,5 +74,5 @@ export function useDeeplinkHandler() { } void unlisten.then((fn) => fn()); }; - }, [auth, openNew, queryClient, scheduleCalendarSync]); + }, [auth, openNew, queryClient]); } diff --git a/apps/desktop/src/store/tinybase/persister/calendar/index.ts b/apps/desktop/src/store/tinybase/persister/calendar/index.ts index b101bf0170..a7db45e98f 100644 --- a/apps/desktop/src/store/tinybase/persister/calendar/index.ts +++ b/apps/desktop/src/store/tinybase/persister/calendar/index.ts @@ -1,6 +1,5 @@ import * as _UI from "tinybase/ui-react/with-schemas"; -import { getCurrentWebviewWindowLabel } from "@hypr/plugin-windows"; import { type Schemas } from "@hypr/store"; import { createCalendarPersister } from "./persister"; @@ -14,11 +13,7 @@ export function useCalendarPersister(store: Store) { store, async (store) => { const persister = createCalendarPersister(store as Store); - if (getCurrentWebviewWindowLabel() === "main") { - await persister.startAutoPersisting(); - } else { - await persister.startAutoLoad(); - } + await persister.startAutoLoad(); return persister; }, [], diff --git a/apps/desktop/src/store/tinybase/persister/calendar/persister.ts b/apps/desktop/src/store/tinybase/persister/calendar/persister.ts index 0344dc95f8..9e2bf2d319 100644 --- a/apps/desktop/src/store/tinybase/persister/calendar/persister.ts +++ b/apps/desktop/src/store/tinybase/persister/calendar/persister.ts @@ -2,6 +2,10 @@ import { createJsonFilePersister } from "~/store/tinybase/persister/factories"; import type { Store } from "~/store/tinybase/store/main"; export function createCalendarPersister(store: Store) { + // Load-only: the Rust calendar-sync worker is the sole writer for + // `calendars.json`. UI toggles go through `useSetCalendarEnabled` → + // `calendarCommands.setCalendarEnabled` and arrive back here via the + // file-changed listener. See `plugins/calendar/src/sync/json.rs`. return createJsonFilePersister(store, { tableName: "calendars", filename: "calendars.json", diff --git a/apps/desktop/src/store/tinybase/persister/events/index.ts b/apps/desktop/src/store/tinybase/persister/events/index.ts index 200d52f211..92850b6c76 100644 --- a/apps/desktop/src/store/tinybase/persister/events/index.ts +++ b/apps/desktop/src/store/tinybase/persister/events/index.ts @@ -1,6 +1,5 @@ import * as _UI from "tinybase/ui-react/with-schemas"; -import { getCurrentWebviewWindowLabel } from "@hypr/plugin-windows"; import { type Schemas } from "@hypr/store"; import { createEventPersister } from "./persister"; @@ -14,11 +13,7 @@ export function useEventsPersister(store: Store) { store, async (store) => { const persister = createEventPersister(store as Store); - if (getCurrentWebviewWindowLabel() === "main") { - await persister.startAutoPersisting(); - } else { - await persister.startAutoLoad(); - } + await persister.startAutoLoad(); return persister; }, [], diff --git a/apps/desktop/src/store/tinybase/persister/events/persister.ts b/apps/desktop/src/store/tinybase/persister/events/persister.ts index a5c33c1b5f..276e970b17 100644 --- a/apps/desktop/src/store/tinybase/persister/events/persister.ts +++ b/apps/desktop/src/store/tinybase/persister/events/persister.ts @@ -2,6 +2,9 @@ import { createJsonFilePersister } from "~/store/tinybase/persister/factories"; import type { Store } from "~/store/tinybase/store/main"; export function createEventPersister(store: Store) { + // Load-only: the Rust calendar-sync worker is the sole writer for + // `events.json`. See `plugins/calendar/src/sync/source.rs` and + // `plugins/calendar/src/sync/json.rs`. return createJsonFilePersister(store, { tableName: "events", filename: "events.json", diff --git a/apps/desktop/src/store/tinybase/persister/factories/json-file.ts b/apps/desktop/src/store/tinybase/persister/factories/json-file.ts index 3c62979a70..0b285680f6 100644 --- a/apps/desktop/src/store/tinybase/persister/factories/json-file.ts +++ b/apps/desktop/src/store/tinybase/persister/factories/json-file.ts @@ -58,15 +58,16 @@ export function createJsonFilePersister< return createCustomPersister( store, async () => loadContent(filename, tableName, label, jsonFields), - async (_, changes) => - saveContent( + async (_, changes) => { + await saveContent( store, changes, tableName, filename, label, jsonFields, - ), + ); + }, (listener) => addListener( listener, diff --git a/crates/calendar-interface/Cargo.toml b/crates/calendar-interface/Cargo.toml index 9d6839876a..c689c46872 100644 --- a/crates/calendar-interface/Cargo.toml +++ b/crates/calendar-interface/Cargo.toml @@ -6,4 +6,4 @@ edition = "2024" [dependencies] chrono = { workspace = true, features = ["serde"] } serde = { workspace = true, features = ["derive"] } -specta = { workspace = true, features = ["derive"] } +specta = { workspace = true, features = ["chrono", "derive"] } diff --git a/crates/calendar-sync/AGENTS.md b/crates/calendar-sync/AGENTS.md new file mode 100644 index 0000000000..bea650e487 --- /dev/null +++ b/crates/calendar-sync/AGENTS.md @@ -0,0 +1,5 @@ +# Invariant + +- `crates/calendar-sync` is a pure sync engine. Do not add Tauri, auth, HTTP/provider fetching, or plugin record-schema details here. +- The crate may own scheduler, sync plans, minimal `Incoming*` types, and narrow traits over fetch/store/runtime boundaries. +- Normalization of provider data and concrete persistence formats belong in the plugin. diff --git a/crates/calendar-sync/Cargo.toml b/crates/calendar-sync/Cargo.toml new file mode 100644 index 0000000000..870cfe6ed2 --- /dev/null +++ b/crates/calendar-sync/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "calendar-sync" +version = "0.1.0" +edition = "2024" + +[dependencies] +chrono = { workspace = true } +futures-util = { workspace = true } +hypr-calendar-interface = { workspace = true } +serde = { workspace = true, features = ["derive"] } +specta = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } +tracing = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "sync", "test-util", "time"] } diff --git a/crates/calendar-sync/src/bootstrap.rs b/crates/calendar-sync/src/bootstrap.rs new file mode 100644 index 0000000000..8da6fafcf0 --- /dev/null +++ b/crates/calendar-sync/src/bootstrap.rs @@ -0,0 +1,54 @@ +use std::sync::{Arc, Mutex}; + +use tokio::sync::mpsc; + +use crate::config::Config; +use crate::handle::CalendarSyncHandle; +use crate::panic_utils::panic_message; +use crate::runtime::{CalendarSyncRuntime, SyncStatus}; +use crate::source::CalendarSyncSource; +use crate::store::CalendarSyncStore; +use crate::worker::SyncWorker; + +pub fn start(source: S, store: Arc, runtime: R, config: Config) -> CalendarSyncHandle +where + S: CalendarSyncSource, + T: CalendarSyncStore, + R: CalendarSyncRuntime, +{ + let source = Arc::new(source); + let runtime = Arc::new(runtime); + let status = Arc::new(Mutex::new(SyncStatus::Idle)); + let (tx, rx) = mpsc::unbounded_channel(); + let worker = SyncWorker::new( + source, + store, + runtime, + status.clone(), + rx, + config.interval, + config.sync_timeout, + ); + + std::thread::Builder::new() + .name("calendar-sync-worker".to_string()) + .spawn(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to create calendar sync runtime"); + runtime.block_on(worker.run()); + })); + + if let Err(payload) = result { + tracing::error!( + panic = %panic_message(payload.as_ref()), + "calendar sync worker thread panicked" + ); + } + }) + .expect("failed to spawn calendar sync worker thread"); + + CalendarSyncHandle::new(tx, status) +} diff --git a/crates/calendar-sync/src/config.rs b/crates/calendar-sync/src/config.rs new file mode 100644 index 0000000000..c7e1d0664f --- /dev/null +++ b/crates/calendar-sync/src/config.rs @@ -0,0 +1,25 @@ +use std::time::Duration; + +#[derive(Clone)] +pub struct Config { + pub interval: Duration, + pub sync_timeout: Duration, +} + +impl Config { + pub fn every(interval: Duration) -> Self { + assert!( + !interval.is_zero(), + "calendar sync interval must be greater than zero" + ); + + Self { + interval, + sync_timeout: Duration::from_secs(30), + } + } + + pub fn every_minute() -> Self { + Self::every(Duration::from_secs(60)) + } +} diff --git a/crates/calendar-sync/src/error.rs b/crates/calendar-sync/src/error.rs new file mode 100644 index 0000000000..fa6e30e61a --- /dev/null +++ b/crates/calendar-sync/src/error.rs @@ -0,0 +1,5 @@ +use thiserror::Error; + +#[derive(Debug, Clone, Copy, Error)] +#[error("calendar sync worker is not accepting requests")] +pub struct RequestSyncError; diff --git a/crates/calendar-sync/src/handle.rs b/crates/calendar-sync/src/handle.rs new file mode 100644 index 0000000000..608e1a6da5 --- /dev/null +++ b/crates/calendar-sync/src/handle.rs @@ -0,0 +1,31 @@ +use std::sync::{Arc, Mutex}; + +use tokio::sync::mpsc; + +use crate::{error::RequestSyncError, runtime::SyncStatus}; + +#[derive(Clone)] +pub struct CalendarSyncHandle { + tx: mpsc::UnboundedSender<()>, + status: Arc>, +} + +impl CalendarSyncHandle { + pub(crate) fn new(tx: mpsc::UnboundedSender<()>, status: Arc>) -> Self { + Self { tx, status } + } + + pub fn request_sync(&self) -> Result<(), RequestSyncError> { + tracing::info!("calendar sync requested"); + if let Err(error) = self.tx.send(()) { + tracing::error!(?error, "calendar sync worker is not accepting requests"); + return Err(RequestSyncError); + } + + Ok(()) + } + + pub fn status(&self) -> SyncStatus { + *self.status.lock().unwrap() + } +} diff --git a/crates/calendar-sync/src/lib.rs b/crates/calendar-sync/src/lib.rs new file mode 100644 index 0000000000..094a45541b --- /dev/null +++ b/crates/calendar-sync/src/lib.rs @@ -0,0 +1,24 @@ +mod bootstrap; +mod config; +mod error; +mod handle; +mod panic_utils; +mod plan; +mod runtime; +mod source; +mod store; +mod types; +mod worker; + +pub use bootstrap::start; +pub use config::Config; +pub use error::RequestSyncError; +pub use handle::CalendarSyncHandle; +pub use plan::{CalendarOp, CalendarPlan, EventOp, EventPlan, plan_calendars, plan_events}; +pub use runtime::{CalendarSyncRuntime, CalendarSyncWorkerEvent, SyncStatus}; +pub use source::{BoxError, CalendarSyncSource, IncomingSnapshot, SyncOutcome}; +pub use store::CalendarSyncStore; +pub use types::{ + CalendarKey, CalendarPayload, ConnectionKey, EventPayload, IncomingCalendar, IncomingEvent, + IncomingParticipant, PersistedCalendar, PersistedEvent, SyncRange, +}; diff --git a/crates/calendar-sync/src/panic_utils.rs b/crates/calendar-sync/src/panic_utils.rs new file mode 100644 index 0000000000..39e562ed6e --- /dev/null +++ b/crates/calendar-sync/src/panic_utils.rs @@ -0,0 +1,11 @@ +use std::any::Any; + +pub(crate) fn panic_message(payload: &(dyn Any + Send)) -> String { + if let Some(message) = payload.downcast_ref::<&'static str>() { + (*message).to_string() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "unknown panic".to_string() + } +} diff --git a/crates/calendar-sync/src/plan.rs b/crates/calendar-sync/src/plan.rs new file mode 100644 index 0000000000..6a796d0c90 --- /dev/null +++ b/crates/calendar-sync/src/plan.rs @@ -0,0 +1,600 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::{DateTime, FixedOffset, Utc}; + +use crate::types::{ + CalendarKey, ConnectionKey, IncomingCalendar, IncomingEvent, PersistedCalendar, PersistedEvent, + SyncRange, +}; + +#[derive(Debug, Clone)] +pub enum CalendarOp<'a> { + Delete { + id: String, + }, + Upsert { + existing_id: Option, + incoming: &'a IncomingCalendar, + }, +} + +#[derive(Debug, Clone)] +pub struct CalendarPlan<'a> { + pub ops: Vec>, + pub enabled_calendar_ids: BTreeSet, + pub enabled_calendar_keys: BTreeMap, + pub disabled_calendar_ids: BTreeSet, +} + +#[derive(Debug, Clone)] +pub enum EventOp<'a> { + Delete { + id: String, + }, + Update { + id: String, + incoming: &'a IncomingEvent, + }, + Insert { + incoming: &'a IncomingEvent, + }, +} + +#[derive(Debug, Clone)] +pub struct EventPlan<'a> { + pub ops: Vec>, +} + +pub fn plan_calendars<'a, C: PersistedCalendar>( + existing: &'a [C], + incoming: &'a [IncomingCalendar], + requested_connections: &'a BTreeSet, + successful_calendar_connections: &'a BTreeSet, +) -> CalendarPlan<'a> { + debug_assert!(incoming.iter().all(|calendar| { + successful_calendar_connections.contains(&calendar.key.connection_key()) + })); + + let incoming_by_key: BTreeMap<_, _> = + incoming.iter().map(|row| (row.key.clone(), row)).collect(); + let existing_by_key: BTreeMap<_, _> = existing.iter().map(|row| (row.key(), row)).collect(); + let mut ops = Vec::new(); + let mut enabled_calendar_ids = BTreeSet::new(); + let mut enabled_calendar_keys = BTreeMap::new(); + let mut disabled_calendar_ids = BTreeSet::new(); + + for calendar in existing { + let key = calendar.key(); + let connection_key = key.connection_key(); + let existing_id = calendar.id().to_string(); + + let should_remove = !requested_connections.contains(&connection_key) + || (successful_calendar_connections.contains(&connection_key) + && !incoming_by_key.contains_key(&key)); + + if should_remove { + disabled_calendar_ids.insert(existing_id.clone()); + ops.push(CalendarOp::Delete { id: existing_id }); + continue; + } + + if calendar.enabled() { + enabled_calendar_ids.insert(existing_id.clone()); + enabled_calendar_keys.insert(key.clone(), existing_id.clone()); + } else { + disabled_calendar_ids.insert(existing_id.clone()); + } + + if let Some(incoming_calendar) = incoming_by_key.get(&key) { + ops.push(CalendarOp::Upsert { + existing_id: Some(existing_id), + incoming: incoming_calendar, + }); + } + } + + for (key, incoming_calendar) in &incoming_by_key { + if !existing_by_key.contains_key(key) { + ops.push(CalendarOp::Upsert { + existing_id: None, + incoming: incoming_calendar, + }); + } + } + + CalendarPlan { + ops, + enabled_calendar_ids, + enabled_calendar_keys, + disabled_calendar_ids, + } +} + +pub fn plan_events<'a, 'b, C: PersistedCalendar, E: PersistedEvent>( + existing_calendars: &'a [C], + existing_events: &'a [E], + incoming: &'a [IncomingEvent], + successful_event_connections: &'a BTreeSet, + calendar_plan: &'b CalendarPlan<'a>, + range: SyncRange, +) -> EventPlan<'a> { + debug_assert!(incoming.iter().all(|event| { + successful_event_connections.contains(&event.calendar_key.connection_key()) + })); + + let existing_calendars_by_id: BTreeMap<_, _> = existing_calendars + .iter() + .map(|calendar| (calendar.id().to_string(), calendar)) + .collect(); + let mut incoming_by_identity = BTreeMap::new(); + for event in incoming { + let identity = event_identity(&event.calendar_key, &event.tracking_id_event); + if incoming_by_identity.insert(identity, event).is_some() { + tracing::debug!( + provider = ?event.calendar_key.provider, + connection_id = %event.calendar_key.connection_id, + tracking_id_event = %event.tracking_id_event, + "collapsing duplicate incoming event identity" + ); + } + } + + let mut ops = Vec::new(); + let mut handled_identities = BTreeSet::new(); + + for event in existing_events { + if calendar_plan + .disabled_calendar_ids + .contains(event.calendar_id()) + { + ops.push(EventOp::Delete { + id: event.id().to_string(), + }); + continue; + } + + if !calendar_plan + .enabled_calendar_ids + .contains(event.calendar_id()) + { + continue; + } + + let Some(calendar) = existing_calendars_by_id.get(event.calendar_id()) else { + continue; + }; + let connection_key = calendar.key().connection_key(); + if !successful_event_connections.contains(&connection_key) { + continue; + } + + if !is_event_in_range(event.started_at(), event.ended_at(), range) { + continue; + } + + let Some(tracking_id) = event.tracking_id_event() else { + ops.push(EventOp::Delete { + id: event.id().to_string(), + }); + continue; + }; + let identity = event_identity(&calendar.key(), tracking_id); + + if let Some(incoming_event) = incoming_by_identity.get(&identity) { + ops.push(EventOp::Update { + id: event.id().to_string(), + incoming: incoming_event, + }); + handled_identities.insert(identity); + continue; + } + + ops.push(EventOp::Delete { + id: event.id().to_string(), + }); + } + + for (identity, incoming_event) in incoming_by_identity { + if handled_identities.contains(&identity) { + continue; + } + if !successful_event_connections.contains(&incoming_event.calendar_key.connection_key()) { + continue; + } + if !calendar_plan + .enabled_calendar_keys + .contains_key(&incoming_event.calendar_key) + { + continue; + } + + ops.push(EventOp::Insert { + incoming: incoming_event, + }); + } + + EventPlan { ops } +} + +fn is_event_in_range(started_at: &str, ended_at: Option<&str>, range: SyncRange) -> bool { + let Ok(event_start) = parse_rfc3339(started_at) else { + return false; + }; + let event_end = ended_at + .and_then(|value| parse_rfc3339(value).ok()) + .unwrap_or(event_start); + + event_start <= range.to && event_end >= range.from +} + +fn parse_rfc3339(value: &str) -> Result, chrono::ParseError> { + DateTime::parse_from_rfc3339(value) + .map(|parsed: DateTime| parsed.with_timezone(&Utc)) +} + +fn event_identity(calendar_key: &CalendarKey, tracking_id_event: &str) -> (ConnectionKey, String) { + (calendar_key.connection_key(), tracking_id_event.to_string()) +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + use hypr_calendar_interface::CalendarProviderType; + + use super::*; + use crate::types::{EventPayload, IncomingParticipant}; + + #[derive(Clone)] + struct TestCalendar { + id: String, + key: CalendarKey, + enabled: bool, + } + + impl PersistedCalendar for TestCalendar { + fn id(&self) -> &str { + &self.id + } + + fn key(&self) -> CalendarKey { + self.key.clone() + } + + fn enabled(&self) -> bool { + self.enabled + } + } + + #[derive(Clone)] + struct TestEvent { + id: String, + tracking_id_event: Option, + calendar_id: String, + started_at: String, + ended_at: Option, + } + + impl PersistedEvent for TestEvent { + fn id(&self) -> &str { + &self.id + } + + fn tracking_id_event(&self) -> Option<&str> { + self.tracking_id_event.as_deref() + } + + fn calendar_id(&self) -> &str { + &self.calendar_id + } + + fn started_at(&self) -> &str { + &self.started_at + } + + fn ended_at(&self) -> Option<&str> { + self.ended_at.as_deref() + } + } + + #[test] + fn overlapping_event_ids_update_within_their_connection() { + let calendars = vec![ + test_calendar("cal-john", "conn-john", "primary", true), + test_calendar("cal-gmail", "conn-gmail", "primary", true), + ]; + let incoming_calendars = vec![ + test_incoming_calendar("conn-john", "primary"), + test_incoming_calendar("conn-gmail", "primary"), + ]; + let requested_connections = BTreeSet::from([ + ConnectionKey::new(CalendarProviderType::Google, "conn-john"), + ConnectionKey::new(CalendarProviderType::Google, "conn-gmail"), + ]); + let calendar_plan = plan_calendars( + &calendars, + &incoming_calendars, + &requested_connections, + &requested_connections, + ); + let events = vec![ + test_event("event-john", Some("evt-1"), "cal-john"), + test_event("event-gmail", Some("evt-1"), "cal-gmail"), + ]; + let incoming = vec![ + test_incoming_event("conn-john", "primary", "evt-1"), + test_incoming_event("conn-gmail", "primary", "evt-1"), + ]; + + let plan = plan_events( + &calendars, + &events, + &incoming, + &requested_connections, + &calendar_plan, + test_range(), + ); + + assert_eq!(plan.ops.len(), 2); + match &plan.ops[0] { + EventOp::Update { id, incoming } => { + assert_eq!(id, "event-john"); + assert_eq!(incoming.calendar_key.connection_id, "conn-john"); + } + other => panic!("expected update for john, got {other:?}"), + } + match &plan.ops[1] { + EventOp::Update { id, incoming } => { + assert_eq!(id, "event-gmail"); + assert_eq!(incoming.calendar_key.connection_id, "conn-gmail"); + } + other => panic!("expected update for gmail, got {other:?}"), + } + } + + #[test] + fn missing_event_deletes_only_for_successful_connection() { + let calendars = vec![ + test_calendar("cal-john", "conn-john", "primary", true), + test_calendar("cal-gmail", "conn-gmail", "primary", true), + ]; + let incoming_calendars = vec![ + test_incoming_calendar("conn-john", "primary"), + test_incoming_calendar("conn-gmail", "primary"), + ]; + let requested_connections = BTreeSet::from([ + ConnectionKey::new(CalendarProviderType::Google, "conn-john"), + ConnectionKey::new(CalendarProviderType::Google, "conn-gmail"), + ]); + let successful_event_connections = BTreeSet::from([ConnectionKey::new( + CalendarProviderType::Google, + "conn-john", + )]); + let calendar_plan = plan_calendars( + &calendars, + &incoming_calendars, + &requested_connections, + &requested_connections, + ); + let events = vec![ + test_event("event-john", Some("evt-1"), "cal-john"), + test_event("event-gmail", Some("evt-1"), "cal-gmail"), + ]; + + let plan = plan_events( + &calendars, + &events, + &[], + &successful_event_connections, + &calendar_plan, + test_range(), + ); + + assert_eq!(plan.ops.len(), 1); + match &plan.ops[0] { + EventOp::Delete { id } => assert_eq!(id, "event-john"), + other => panic!("expected delete, got {other:?}"), + } + } + + #[test] + fn inserts_only_for_matching_enabled_connection() { + let calendars = vec![ + test_calendar("cal-john", "conn-john", "primary", true), + test_calendar("cal-gmail", "conn-gmail", "primary", false), + ]; + let incoming_calendars = vec![ + test_incoming_calendar("conn-john", "primary"), + test_incoming_calendar("conn-gmail", "primary"), + ]; + let requested_connections = BTreeSet::from([ + ConnectionKey::new(CalendarProviderType::Google, "conn-john"), + ConnectionKey::new(CalendarProviderType::Google, "conn-gmail"), + ]); + let calendar_plan = plan_calendars( + &calendars, + &incoming_calendars, + &requested_connections, + &requested_connections, + ); + let incoming = vec![ + test_incoming_event("conn-john", "primary", "evt-1"), + test_incoming_event("conn-gmail", "primary", "evt-2"), + ]; + let events = Vec::::new(); + + let plan = plan_events( + &calendars, + &events, + &incoming, + &requested_connections, + &calendar_plan, + test_range(), + ); + + assert_eq!(plan.ops.len(), 1); + match &plan.ops[0] { + EventOp::Insert { incoming } => { + assert_eq!(incoming.calendar_key.connection_id, "conn-john"); + assert_eq!(incoming.tracking_id_event, "evt-1"); + } + other => panic!("expected insert, got {other:?}"), + } + } + + #[test] + fn duplicate_incoming_identity_inserts_once() { + let calendars = vec![test_calendar("cal-john", "conn-john", "primary", true)]; + let incoming_calendars = vec![test_incoming_calendar("conn-john", "primary")]; + let requested_connections = BTreeSet::from([ConnectionKey::new( + CalendarProviderType::Google, + "conn-john", + )]); + let calendar_plan = plan_calendars( + &calendars, + &incoming_calendars, + &requested_connections, + &requested_connections, + ); + let incoming = vec![ + test_incoming_event("conn-john", "primary", "evt-1"), + IncomingEvent { + payload: EventPayload { + title: Some("latest title wins".to_string()), + ..EventPayload::default() + }, + ..test_incoming_event("conn-john", "primary", "evt-1") + }, + ]; + let events = Vec::::new(); + + let plan = plan_events( + &calendars, + &events, + &incoming, + &requested_connections, + &calendar_plan, + test_range(), + ); + + assert_eq!(plan.ops.len(), 1); + match &plan.ops[0] { + EventOp::Insert { incoming } => { + assert_eq!(incoming.calendar_key.connection_id, "conn-john"); + assert_eq!(incoming.tracking_id_event, "evt-1"); + assert_eq!(incoming.payload.title.as_deref(), Some("latest title wins")); + } + other => panic!("expected insert, got {other:?}"), + } + } + + #[cfg(debug_assertions)] + #[test] + #[should_panic] + fn incoming_calendars_must_belong_to_successful_connections() { + let requested_connections = BTreeSet::from([ConnectionKey::new( + CalendarProviderType::Google, + "conn-john", + )]); + + let existing = Vec::::new(); + + let _ = plan_calendars( + &existing, + &[test_incoming_calendar("conn-john", "primary")], + &requested_connections, + &BTreeSet::new(), + ); + } + + #[cfg(debug_assertions)] + #[test] + #[should_panic] + fn incoming_events_must_belong_to_successful_connections() { + let calendars = vec![test_calendar("cal-john", "conn-john", "primary", true)]; + let requested_connections = BTreeSet::from([ConnectionKey::new( + CalendarProviderType::Google, + "conn-john", + )]); + let incoming_calendars = vec![test_incoming_calendar("conn-john", "primary")]; + let calendar_plan = plan_calendars( + &calendars, + &incoming_calendars, + &requested_connections, + &requested_connections, + ); + + let events = Vec::::new(); + + let _ = plan_events( + &calendars, + &events, + &[test_incoming_event("conn-john", "primary", "evt-1")], + &BTreeSet::new(), + &calendar_plan, + test_range(), + ); + } + + fn test_calendar( + id: &str, + connection_id: &str, + tracking_id: &str, + enabled: bool, + ) -> TestCalendar { + TestCalendar { + id: id.to_string(), + key: CalendarKey::new(CalendarProviderType::Google, connection_id, tracking_id), + enabled, + } + } + + fn test_event(id: &str, tracking_id_event: Option<&str>, calendar_id: &str) -> TestEvent { + TestEvent { + id: id.to_string(), + tracking_id_event: tracking_id_event.map(ToString::to_string), + calendar_id: calendar_id.to_string(), + started_at: "2026-04-10T10:00:00Z".to_string(), + ended_at: Some("2026-04-10T11:00:00Z".to_string()), + } + } + + fn test_incoming_calendar(connection_id: &str, tracking_id: &str) -> IncomingCalendar { + IncomingCalendar { + key: CalendarKey::new(CalendarProviderType::Google, connection_id, tracking_id), + payload: crate::types::CalendarPayload { + name: tracking_id.to_string(), + source: connection_id.to_string(), + color: "#888".to_string(), + }, + } + } + + fn test_incoming_event( + connection_id: &str, + calendar_tracking_id: &str, + tracking_id_event: &str, + ) -> IncomingEvent { + IncomingEvent { + calendar_key: CalendarKey::new( + CalendarProviderType::Google, + connection_id, + calendar_tracking_id, + ), + tracking_id_event: tracking_id_event.to_string(), + started_at: "2026-04-10T10:00:00Z".to_string(), + ended_at: Some("2026-04-10T11:00:00Z".to_string()), + recurrence_series_id: None, + has_recurrence_rules: false, + is_all_day: false, + participants: Vec::::new(), + payload: EventPayload::default(), + } + } + + fn test_range() -> SyncRange { + SyncRange { + from: Utc.with_ymd_and_hms(2026, 4, 1, 0, 0, 0).unwrap(), + to: Utc.with_ymd_and_hms(2026, 4, 30, 23, 59, 59).unwrap(), + } + } +} diff --git a/crates/calendar-sync/src/runtime.rs b/crates/calendar-sync/src/runtime.rs new file mode 100644 index 0000000000..dafa5cdfa9 --- /dev/null +++ b/crates/calendar-sync/src/runtime.rs @@ -0,0 +1,19 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(rename_all = "lowercase")] +pub enum SyncStatus { + Idle, + Scheduled, + Running, +} + +#[derive(Debug, Clone)] +pub enum CalendarSyncWorkerEvent { + StatusChanged { status: SyncStatus }, + SyncStarted, + SyncFinished { data_changed: bool }, + SyncFailed { error: String }, +} + +pub trait CalendarSyncRuntime: Send + Sync + 'static { + fn emit(&self, event: CalendarSyncWorkerEvent); +} diff --git a/crates/calendar-sync/src/source.rs b/crates/calendar-sync/src/source.rs new file mode 100644 index 0000000000..02734305ef --- /dev/null +++ b/crates/calendar-sync/src/source.rs @@ -0,0 +1,28 @@ +use std::collections::BTreeSet; +use std::future::Future; +use std::pin::Pin; + +use crate::types::{ConnectionKey, IncomingCalendar, IncomingEvent, SyncRange}; + +pub type BoxError = Box; + +#[derive(Debug, Clone, Copy, Default)] +pub struct SyncOutcome { + pub data_changed: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct IncomingSnapshot { + pub requested_connections: BTreeSet, + pub successful_calendar_connections: BTreeSet, + pub successful_event_connections: BTreeSet, + pub calendars: Vec, + pub events: Vec, +} + +pub trait CalendarSyncSource: Send + Sync + 'static { + fn fetch( + &self, + range: SyncRange, + ) -> Pin> + Send + '_>>; +} diff --git a/crates/calendar-sync/src/store.rs b/crates/calendar-sync/src/store.rs new file mode 100644 index 0000000000..a8ed50e7e5 --- /dev/null +++ b/crates/calendar-sync/src/store.rs @@ -0,0 +1,27 @@ +use std::future::Future; +use std::pin::Pin; + +use crate::plan::{CalendarPlan, EventPlan}; +use crate::source::BoxError; +use crate::types::{PersistedCalendar, PersistedEvent}; + +pub trait CalendarSyncStore: Send + Sync + 'static { + type Calendar: PersistedCalendar; + type Event: PersistedEvent; + + fn read( + &self, + ) -> Pin< + Box< + dyn Future, Vec), BoxError>> + + Send + + '_, + >, + >; + + fn apply<'a>( + &'a self, + calendar_plan: CalendarPlan<'a>, + event_plan: EventPlan<'a>, + ) -> Pin> + Send + 'a>>; +} diff --git a/crates/calendar-sync/src/types.rs b/crates/calendar-sync/src/types.rs new file mode 100644 index 0000000000..e2746f4e3a --- /dev/null +++ b/crates/calendar-sync/src/types.rs @@ -0,0 +1,146 @@ +use std::cmp::Ordering; + +use chrono::{DateTime, Utc}; +use hypr_calendar_interface::CalendarProviderType; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConnectionKey { + pub provider: CalendarProviderType, + pub connection_id: String, +} + +impl ConnectionKey { + pub fn new(provider: CalendarProviderType, connection_id: impl Into) -> Self { + Self { + provider, + connection_id: connection_id.into(), + } + } +} + +impl PartialOrd for ConnectionKey { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for ConnectionKey { + fn cmp(&self, other: &Self) -> Ordering { + provider_tag(self.provider) + .cmp(provider_tag(other.provider)) + .then_with(|| self.connection_id.cmp(&other.connection_id)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CalendarKey { + pub provider: CalendarProviderType, + pub connection_id: String, + pub tracking_id: String, +} + +impl PartialOrd for CalendarKey { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for CalendarKey { + fn cmp(&self, other: &Self) -> Ordering { + provider_tag(self.provider) + .cmp(provider_tag(other.provider)) + .then_with(|| self.connection_id.cmp(&other.connection_id)) + .then_with(|| self.tracking_id.cmp(&other.tracking_id)) + } +} + +impl CalendarKey { + pub fn new( + provider: CalendarProviderType, + connection_id: impl Into, + tracking_id: impl Into, + ) -> Self { + Self { + provider, + connection_id: connection_id.into(), + tracking_id: tracking_id.into(), + } + } + + pub fn connection_key(&self) -> ConnectionKey { + ConnectionKey { + provider: self.provider, + connection_id: self.connection_id.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CalendarPayload { + pub name: String, + pub source: String, + pub color: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IncomingCalendar { + pub key: CalendarKey, + pub payload: CalendarPayload, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IncomingParticipant { + pub name: Option, + pub email: Option, + pub is_organizer: bool, + pub is_current_user: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct EventPayload { + pub title: Option, + pub location: Option, + pub meeting_link: Option, + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IncomingEvent { + pub calendar_key: CalendarKey, + pub tracking_id_event: String, + pub started_at: String, + pub ended_at: Option, + pub recurrence_series_id: Option, + pub has_recurrence_rules: bool, + pub is_all_day: bool, + pub participants: Vec, + pub payload: EventPayload, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SyncRange { + pub from: DateTime, + pub to: DateTime, +} + +pub trait PersistedCalendar { + fn id(&self) -> &str; + fn key(&self) -> CalendarKey; + fn enabled(&self) -> bool; +} + +pub trait PersistedEvent { + fn id(&self) -> &str; + fn tracking_id_event(&self) -> Option<&str>; + fn calendar_id(&self) -> &str; + fn started_at(&self) -> &str; + fn ended_at(&self) -> Option<&str>; +} + +fn provider_tag(provider: CalendarProviderType) -> &'static str { + match provider { + CalendarProviderType::Apple => "apple", + CalendarProviderType::Google => "google", + CalendarProviderType::Outlook => "outlook", + } +} diff --git a/crates/calendar-sync/src/worker.rs b/crates/calendar-sync/src/worker.rs new file mode 100644 index 0000000000..b5013adf21 --- /dev/null +++ b/crates/calendar-sync/src/worker.rs @@ -0,0 +1,492 @@ +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use chrono::{Duration as ChronoDuration, Utc}; +use futures_util::FutureExt; +use tokio::sync::mpsc::{UnboundedReceiver, error::TryRecvError}; +use tokio::time::{Instant, sleep_until, timeout}; + +use crate::panic_utils::panic_message; +use crate::plan::{plan_calendars, plan_events}; +use crate::runtime::{CalendarSyncRuntime, CalendarSyncWorkerEvent, SyncStatus}; +use crate::source::{CalendarSyncSource, SyncOutcome}; +use crate::store::CalendarSyncStore; +use crate::types::SyncRange; + +pub(crate) struct SyncWorker { + source: Arc, + store: Arc, + runtime: Arc, + status: Arc>, + rx: UnboundedReceiver<()>, + interval: Duration, + sync_timeout: Duration, +} + +impl SyncWorker +where + S: CalendarSyncSource, + T: CalendarSyncStore, + R: CalendarSyncRuntime, +{ + pub(crate) fn new( + source: Arc, + store: Arc, + runtime: Arc, + status: Arc>, + rx: UnboundedReceiver<()>, + interval: Duration, + sync_timeout: Duration, + ) -> Self { + Self { + source, + store, + runtime, + status, + rx, + interval, + sync_timeout, + } + } + + pub(crate) async fn run(mut self) { + tracing::info!("calendar sync worker started"); + let mut last_attempt = Instant::now(); + + loop { + let next_interval = last_attempt + self.interval; + + tokio::select! { + biased; + maybe_request = self.rx.recv() => { + let Some(()) = maybe_request else { + break; + }; + + self.prepare_run(); + self.run_once().await; + last_attempt = Instant::now(); + } + _ = sleep_until(next_interval) => { + self.prepare_run(); + self.run_once().await; + last_attempt = Instant::now(); + } + } + } + + tracing::warn!("calendar sync worker stopped"); + } + + fn drain_pending(&mut self) { + loop { + match self.rx.try_recv() { + Ok(()) => {} + Err(TryRecvError::Empty | TryRecvError::Disconnected) => break, + } + } + } + + fn prepare_run(&mut self) { + self.set_status(SyncStatus::Scheduled); + self.drain_pending(); + } + + async fn run_once(&self) { + let outcome = AssertUnwindSafe(async { + self.set_status(SyncStatus::Running); + tracing::info!("calendar sync started"); + self.safe_emit(CalendarSyncWorkerEvent::SyncStarted); + + match timeout(self.sync_timeout, self.sync_once()).await { + Ok(Ok(outcome)) => { + tracing::info!( + data_changed = outcome.data_changed, + "calendar sync finished" + ); + self.safe_emit(CalendarSyncWorkerEvent::SyncFinished { + data_changed: outcome.data_changed, + }); + } + Ok(Err(error)) => { + tracing::error!(error = %error, "calendar sync failed"); + self.safe_emit(CalendarSyncWorkerEvent::SyncFailed { + error: error.to_string(), + }); + } + Err(_) => { + tracing::error!( + timeout_secs = self.sync_timeout.as_secs(), + "calendar sync timed out" + ); + self.safe_emit(CalendarSyncWorkerEvent::SyncFailed { + error: format!( + "calendar sync timed out after {}s", + self.sync_timeout.as_secs() + ), + }); + } + } + }) + .catch_unwind() + .await; + + if let Err(panic_payload) = outcome { + let panic_message = panic_message(panic_payload.as_ref()); + tracing::error!(panic = %panic_message, "calendar sync panicked"); + self.safe_emit(CalendarSyncWorkerEvent::SyncFailed { + error: format!("calendar sync panicked: {panic_message}"), + }); + } + + self.set_status(SyncStatus::Idle); + } + + async fn sync_once(&self) -> Result { + let range = sync_range(); + let snapshot = self.source.fetch(range).await?; + let (calendars, events) = self.store.read().await?; + let calendar_plan = plan_calendars( + &calendars, + &snapshot.calendars, + &snapshot.requested_connections, + &snapshot.successful_calendar_connections, + ); + let event_plan = plan_events( + &calendars, + &events, + &snapshot.events, + &snapshot.successful_event_connections, + &calendar_plan, + range, + ); + let data_changed = self.store.apply(calendar_plan, event_plan).await?; + Ok(SyncOutcome { data_changed }) + } + + fn set_status(&self, next: SyncStatus) { + let should_emit = { + let mut status = self.status.lock().unwrap(); + if *status == next { + false + } else { + *status = next; + true + } + }; + + if should_emit { + self.safe_emit(CalendarSyncWorkerEvent::StatusChanged { status: next }); + } + } + + fn safe_emit(&self, event: CalendarSyncWorkerEvent) { + if let Err(panic_payload) = catch_unwind(AssertUnwindSafe(|| self.runtime.emit(event))) { + tracing::error!( + panic = %panic_message(panic_payload.as_ref()), + "calendar sync runtime emit panicked" + ); + } + } +} + +fn sync_range() -> SyncRange { + let now = Utc::now(); + SyncRange { + from: now - ChronoDuration::days(7), + to: now + ChronoDuration::days(30), + } +} + +#[cfg(test)] +mod tests { + use std::pin::Pin; + use std::sync::atomic::{AtomicBool, Ordering}; + + use tokio::sync::Notify; + + use super::*; + use crate::plan::{CalendarPlan, EventPlan}; + use crate::source::{BoxError, IncomingSnapshot}; + use crate::types::{CalendarKey, PersistedCalendar, PersistedEvent}; + + #[derive(Clone, Default)] + struct MockRuntime; + + impl CalendarSyncRuntime for MockRuntime { + fn emit(&self, _event: CalendarSyncWorkerEvent) {} + } + + #[derive(Clone, Default)] + struct RecordingRuntime { + events: Arc>>, + } + + impl RecordingRuntime { + fn recorded_events(&self) -> Vec { + self.events.lock().unwrap().clone() + } + } + + impl CalendarSyncRuntime for RecordingRuntime { + fn emit(&self, event: CalendarSyncWorkerEvent) { + self.events.lock().unwrap().push(event); + } + } + + #[derive(Clone)] + struct MockSource { + calls: Arc>, + calls_changed: Arc, + block_first_call: Arc, + release_first_call: Arc, + } + + impl MockSource { + fn new(block_first_call: bool) -> Self { + Self { + calls: Arc::new(Mutex::new(0)), + calls_changed: Arc::new(Notify::new()), + block_first_call: Arc::new(AtomicBool::new(block_first_call)), + release_first_call: Arc::new(Notify::new()), + } + } + + fn recorded_calls(&self) -> usize { + *self.calls.lock().unwrap() + } + + async fn wait_for_calls(&self, count: usize) { + loop { + if self.recorded_calls() >= count { + return; + } + + self.calls_changed.notified().await; + } + } + + fn release_blocked_call(&self) { + self.release_first_call.notify_waiters(); + } + } + + impl CalendarSyncSource for MockSource { + fn fetch( + &self, + _range: SyncRange, + ) -> Pin< + Box> + Send + '_>, + > { + let calls = self.calls.clone(); + let calls_changed = self.calls_changed.clone(); + let block_first_call = self.block_first_call.clone(); + let release_first_call = self.release_first_call.clone(); + + Box::pin(async move { + *calls.lock().unwrap() += 1; + calls_changed.notify_waiters(); + + if block_first_call.swap(false, Ordering::SeqCst) { + release_first_call.notified().await; + } + + Ok(IncomingSnapshot::default()) + }) + } + } + + #[derive(Clone, Default)] + struct MockStore; + + #[derive(Clone)] + struct TestCalendar { + id: String, + key: CalendarKey, + enabled: bool, + } + + impl PersistedCalendar for TestCalendar { + fn id(&self) -> &str { + &self.id + } + + fn key(&self) -> CalendarKey { + self.key.clone() + } + + fn enabled(&self) -> bool { + self.enabled + } + } + + #[derive(Clone)] + struct TestEvent { + id: String, + tracking_id_event: Option, + calendar_id: String, + started_at: String, + ended_at: Option, + } + + impl PersistedEvent for TestEvent { + fn id(&self) -> &str { + &self.id + } + + fn tracking_id_event(&self) -> Option<&str> { + self.tracking_id_event.as_deref() + } + + fn calendar_id(&self) -> &str { + &self.calendar_id + } + + fn started_at(&self) -> &str { + &self.started_at + } + + fn ended_at(&self) -> Option<&str> { + self.ended_at.as_deref() + } + } + + impl CalendarSyncStore for MockStore { + type Calendar = TestCalendar; + type Event = TestEvent; + + fn read( + &self, + ) -> Pin< + Box< + dyn std::future::Future< + Output = Result<(Vec, Vec), BoxError>, + > + Send + + '_, + >, + > { + Box::pin(async move { Ok((Vec::new(), Vec::new())) }) + } + + fn apply<'a>( + &'a self, + _calendar_plan: CalendarPlan<'a>, + _event_plan: EventPlan<'a>, + ) -> Pin> + Send + 'a>> + { + Box::pin(async move { Ok(false) }) + } + } + + #[tokio::test(start_paused = true)] + async fn manual_sync_resets_interval_timer() { + let source = MockSource::new(false); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let worker = SyncWorker::new( + Arc::new(source.clone()), + Arc::new(MockStore), + Arc::new(MockRuntime), + Arc::new(Mutex::new(SyncStatus::Idle)), + rx, + Duration::from_secs(60), + Duration::from_secs(5), + ); + + let task = tokio::spawn(worker.run()); + + tokio::time::advance(Duration::from_secs(30)).await; + tokio::task::yield_now().await; + assert_eq!(source.recorded_calls(), 0); + + tx.send(()).unwrap(); + source.wait_for_calls(1).await; + assert_eq!(source.recorded_calls(), 1); + + tokio::time::advance(Duration::from_secs(59)).await; + tokio::task::yield_now().await; + assert_eq!(source.recorded_calls(), 1); + + tokio::time::advance(Duration::from_secs(1)).await; + source.wait_for_calls(2).await; + assert_eq!(source.recorded_calls(), 2); + + drop(tx); + task.abort(); + } + + #[tokio::test(start_paused = true)] + async fn queued_requests_are_coalesced_between_runs() { + let source = MockSource::new(true); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let worker = SyncWorker::new( + Arc::new(source.clone()), + Arc::new(MockStore), + Arc::new(MockRuntime), + Arc::new(Mutex::new(SyncStatus::Idle)), + rx, + Duration::from_secs(60 * 60), + Duration::from_secs(5), + ); + + let task = tokio::spawn(worker.run()); + + tx.send(()).unwrap(); + source.wait_for_calls(1).await; + + tx.send(()).unwrap(); + tx.send(()).unwrap(); + tx.send(()).unwrap(); + source.release_blocked_call(); + source.wait_for_calls(2).await; + + assert_eq!(source.recorded_calls(), 2); + + drop(tx); + task.abort(); + } + + #[tokio::test(start_paused = true)] + async fn timer_driven_sync_emits_scheduled_before_running() { + let source = MockSource::new(false); + let runtime = RecordingRuntime::default(); + let (_tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let worker = SyncWorker::new( + Arc::new(source.clone()), + Arc::new(MockStore), + Arc::new(runtime.clone()), + Arc::new(Mutex::new(SyncStatus::Idle)), + rx, + Duration::from_secs(60), + Duration::from_secs(5), + ); + + let task = tokio::spawn(worker.run()); + + tokio::time::advance(Duration::from_secs(60)).await; + source.wait_for_calls(1).await; + + let events = runtime.recorded_events(); + assert!( + matches!( + events.as_slice(), + [ + CalendarSyncWorkerEvent::StatusChanged { + status: SyncStatus::Scheduled + }, + CalendarSyncWorkerEvent::StatusChanged { + status: SyncStatus::Running + }, + CalendarSyncWorkerEvent::SyncStarted, + .., + CalendarSyncWorkerEvent::StatusChanged { + status: SyncStatus::Idle + } + ] + ), + "unexpected event sequence: {events:?}" + ); + + task.abort(); + } +} diff --git a/crates/calendar-sync/tests/start.rs b/crates/calendar-sync/tests/start.rs new file mode 100644 index 0000000000..fbfbb1f98e --- /dev/null +++ b/crates/calendar-sync/tests/start.rs @@ -0,0 +1,252 @@ +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use calendar_sync::{ + BoxError, CalendarKey, CalendarPlan, CalendarSyncHandle, CalendarSyncRuntime, + CalendarSyncSource, CalendarSyncStore, CalendarSyncWorkerEvent, Config, EventPlan, + IncomingSnapshot, PersistedCalendar, PersistedEvent, SyncRange, SyncStatus, +}; +use tokio::sync::Notify; + +#[derive(Clone, Default)] +struct RecordingRuntime { + events: Arc>>, + finished: Arc, + failure: Arc>>, +} + +impl RecordingRuntime { + fn recorded_events(&self) -> Vec { + self.events.lock().unwrap().clone() + } + + fn failure(&self) -> Option { + self.failure.lock().unwrap().clone() + } + + async fn wait_for_finish(&self) { + self.finished.notified().await; + } +} + +impl CalendarSyncRuntime for RecordingRuntime { + fn emit(&self, event: CalendarSyncWorkerEvent) { + match &event { + CalendarSyncWorkerEvent::SyncFinished { .. } => self.finished.notify_waiters(), + CalendarSyncWorkerEvent::SyncFailed { error } => { + *self.failure.lock().unwrap() = Some(error.clone()); + self.finished.notify_waiters(); + } + _ => {} + } + + self.events.lock().unwrap().push(event); + } +} + +#[derive(Clone)] +struct BlockingSource { + calls: Arc>, + calls_changed: Arc, + block_first_call: Arc, + release_first_call: Arc, +} + +impl BlockingSource { + fn new(block_first_call: bool) -> Self { + Self { + calls: Arc::new(Mutex::new(0)), + calls_changed: Arc::new(Notify::new()), + block_first_call: Arc::new(AtomicBool::new(block_first_call)), + release_first_call: Arc::new(Notify::new()), + } + } + + async fn wait_for_calls(&self, count: usize) { + loop { + if *self.calls.lock().unwrap() >= count { + return; + } + + self.calls_changed.notified().await; + } + } + + fn release_blocked_call(&self) { + self.release_first_call.notify_waiters(); + } +} + +impl CalendarSyncSource for BlockingSource { + fn fetch( + &self, + _range: SyncRange, + ) -> Pin> + Send + '_>> + { + let calls = self.calls.clone(); + let calls_changed = self.calls_changed.clone(); + let block_first_call = self.block_first_call.clone(); + let release_first_call = self.release_first_call.clone(); + + Box::pin(async move { + *calls.lock().unwrap() += 1; + calls_changed.notify_waiters(); + + if block_first_call.swap(false, Ordering::SeqCst) { + release_first_call.notified().await; + } + + Ok(IncomingSnapshot::default()) + }) + } +} + +#[derive(Clone, Default)] +struct MockStore; + +#[derive(Clone)] +struct TestCalendar { + id: String, + key: CalendarKey, + enabled: bool, +} + +impl PersistedCalendar for TestCalendar { + fn id(&self) -> &str { + &self.id + } + + fn key(&self) -> CalendarKey { + self.key.clone() + } + + fn enabled(&self) -> bool { + self.enabled + } +} + +#[derive(Clone)] +struct TestEvent { + id: String, + tracking_id_event: Option, + calendar_id: String, + started_at: String, + ended_at: Option, +} + +impl PersistedEvent for TestEvent { + fn id(&self) -> &str { + &self.id + } + + fn tracking_id_event(&self) -> Option<&str> { + self.tracking_id_event.as_deref() + } + + fn calendar_id(&self) -> &str { + &self.calendar_id + } + + fn started_at(&self) -> &str { + &self.started_at + } + + fn ended_at(&self) -> Option<&str> { + self.ended_at.as_deref() + } +} + +impl CalendarSyncStore for MockStore { + type Calendar = TestCalendar; + type Event = TestEvent; + + fn read( + &self, + ) -> Pin< + Box< + dyn std::future::Future< + Output = Result<(Vec, Vec), BoxError>, + > + Send + + '_, + >, + > { + Box::pin(async move { Ok((Vec::new(), Vec::new())) }) + } + + fn apply<'a>( + &'a self, + _calendar_plan: CalendarPlan<'a>, + _event_plan: EventPlan<'a>, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { Ok(false) }) + } +} + +#[tokio::test] +async fn start_requests_sync_and_reports_lifecycle() { + let source = BlockingSource::new(true); + let runtime = RecordingRuntime::default(); + let handle = calendar_sync::start( + source.clone(), + Arc::new(MockStore), + runtime.clone(), + Config { + interval: Duration::from_secs(60 * 60), + sync_timeout: Duration::from_secs(5), + }, + ); + + assert_eq!(handle.status(), SyncStatus::Idle); + + handle.request_sync().unwrap(); + source.wait_for_calls(1).await; + wait_for_status(&handle, SyncStatus::Running).await; + + source.release_blocked_call(); + tokio::time::timeout(Duration::from_secs(1), runtime.wait_for_finish()) + .await + .expect("sync should finish"); + + wait_for_status(&handle, SyncStatus::Idle).await; + assert_eq!(runtime.failure(), None, "sync should not fail"); + + let events = runtime.recorded_events(); + assert!( + matches!( + events.as_slice(), + [ + CalendarSyncWorkerEvent::StatusChanged { + status: SyncStatus::Scheduled + }, + CalendarSyncWorkerEvent::StatusChanged { + status: SyncStatus::Running + }, + CalendarSyncWorkerEvent::SyncStarted, + .., + CalendarSyncWorkerEvent::SyncFinished { + data_changed: false + }, + CalendarSyncWorkerEvent::StatusChanged { + status: SyncStatus::Idle + } + ] + ), + "unexpected event sequence: {events:?}" + ); +} + +async fn wait_for_status(handle: &CalendarSyncHandle, expected: SyncStatus) { + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if handle.status() == expected { + return; + } + + tokio::task::yield_now().await; + } + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for status {expected:?}")); +} diff --git a/crates/calendar/src/lib.rs b/crates/calendar/src/lib.rs index b5678f006c..9f11fa998c 100644 --- a/crates/calendar/src/lib.rs +++ b/crates/calendar/src/lib.rs @@ -8,7 +8,7 @@ pub use hypr_calendar_interface::{ CalendarEvent, CalendarListItem, CalendarProviderType, CreateEventInput, EventFilter, }; -pub fn start(runtime: impl runtime::CalendarRuntime) { +pub fn watch_apple_changes(runtime: impl runtime::CalendarRuntime) { #[cfg(target_os = "macos")] { use std::sync::Arc; diff --git a/plugins/calendar/Cargo.toml b/plugins/calendar/Cargo.toml index 01f0809737..5441c90f0e 100644 --- a/plugins/calendar/Cargo.toml +++ b/plugins/calendar/Cargo.toml @@ -10,18 +10,27 @@ description = "" [dependencies] hypr-calendar = { workspace = true, features = ["specta"] } hypr-calendar-interface = { workspace = true } +hypr-calendar-sync = { workspace = true } +hypr-storage = { workspace = true } tauri = { workspace = true, features = ["test"] } tauri-plugin-auth = { workspace = true } tauri-plugin-permissions = { workspace = true } tauri-specta = { workspace = true, features = ["derive", "typescript"] } +chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } specta = { workspace = true, features = ["chrono"] } thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs"] } +tracing = { workspace = true } +uuid = { workspace = true, features = ["v4"] } [build-dependencies] tauri-plugin = { workspace = true, features = ["build"] } [dev-dependencies] specta-typescript = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["fs", "macros", "rt", "rt-multi-thread", "sync", "time"] } diff --git a/plugins/calendar/build.rs b/plugins/calendar/build.rs index 35dee98231..d915b1291b 100644 --- a/plugins/calendar/build.rs +++ b/plugins/calendar/build.rs @@ -7,6 +7,9 @@ const COMMANDS: &[&str] = &[ "open_calendar", "create_event", "parse_meeting_link", + "request_calendar_sync", + "get_calendar_sync_status", + "set_calendar_enabled", ]; fn main() { diff --git a/plugins/calendar/js/bindings.gen.ts b/plugins/calendar/js/bindings.gen.ts index ba9610d8fc..11e02b9263 100644 --- a/plugins/calendar/js/bindings.gen.ts +++ b/plugins/calendar/js/bindings.gen.ts @@ -1,223 +1,341 @@ // @ts-nocheck - // This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. /** user-defined commands **/ - export const commands = { -async availableProviders() : Promise { + async availableProviders(): Promise { return await TAURI_INVOKE("plugin:calendar|available_providers"); -}, -async isProviderEnabled(provider: CalendarProviderType) : Promise> { + }, + async isProviderEnabled( + provider: CalendarProviderType, + ): Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("plugin:calendar|is_provider_enabled", { provider }) }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, -async listConnectionIds() : Promise> { + return { + status: "ok", + data: await TAURI_INVOKE("plugin:calendar|is_provider_enabled", { + provider, + }), + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, + async listConnectionIds(): Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("plugin:calendar|list_connection_ids") }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, -async listCalendars(provider: CalendarProviderType, connectionId: string) : Promise> { + return { + status: "ok", + data: await TAURI_INVOKE("plugin:calendar|list_connection_ids"), + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, + async listCalendars( + provider: CalendarProviderType, + connectionId: string, + ): Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("plugin:calendar|list_calendars", { provider, connectionId }) }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, -async listEvents(provider: CalendarProviderType, connectionId: string, filter: EventFilter) : Promise> { + return { + status: "ok", + data: await TAURI_INVOKE("plugin:calendar|list_calendars", { + provider, + connectionId, + }), + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, + async listEvents( + provider: CalendarProviderType, + connectionId: string, + filter: EventFilter, + ): Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("plugin:calendar|list_events", { provider, connectionId, filter }) }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, -async openCalendar(provider: CalendarProviderType) : Promise> { + return { + status: "ok", + data: await TAURI_INVOKE("plugin:calendar|list_events", { + provider, + connectionId, + filter, + }), + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, + async openCalendar( + provider: CalendarProviderType, + ): Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("plugin:calendar|open_calendar", { provider }) }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, -async createEvent(provider: CalendarProviderType, input: CreateEventInput) : Promise> { + return { + status: "ok", + data: await TAURI_INVOKE("plugin:calendar|open_calendar", { provider }), + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, + async createEvent( + provider: CalendarProviderType, + input: CreateEventInput, + ): Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("plugin:calendar|create_event", { provider, input }) }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, -async parseMeetingLink(text: string) : Promise { + return { + status: "ok", + data: await TAURI_INVOKE("plugin:calendar|create_event", { + provider, + input, + }), + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, + async parseMeetingLink(text: string): Promise { return await TAURI_INVOKE("plugin:calendar|parse_meeting_link", { text }); -} -} + }, + async requestCalendarSync(): Promise> { + try { + return { + status: "ok", + data: await TAURI_INVOKE("plugin:calendar|request_calendar_sync"), + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, + async getCalendarSyncStatus(): Promise { + return await TAURI_INVOKE("plugin:calendar|get_calendar_sync_status"); + }, + async setCalendarEnabled( + calendarId: string, + enabled: boolean, + ): Promise> { + try { + return { + status: "ok", + data: await TAURI_INVOKE("plugin:calendar|set_calendar_enabled", { + calendarId, + enabled, + }), + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, +}; /** user-defined events **/ - export const events = __makeEvents__<{ -calendarChangedEvent: CalendarChangedEvent + calendarChangedEvent: CalendarChangedEvent; + calendarSyncEvent: CalendarSyncEvent; }>({ -calendarChangedEvent: "plugin:calendar:calendar-changed-event" -}) + calendarChangedEvent: "plugin:calendar:calendar-changed-event", + calendarSyncEvent: "plugin:calendar:calendar-sync-event", +}); /** user-defined constants **/ - - /** user-defined types **/ -export type AttendeeRole = "chair" | "required" | "optional" | "nonparticipant" -export type AttendeeStatus = "pending" | "accepted" | "tentative" | "declined" -export type CalendarChangedEvent = null -export type CalendarEvent = { provider: CalendarProviderType; -/** - * Unique between events. Synthesized for Apple events (eventIdentifier:YYYY-MM-DD for recurring). - */ -id: string; -/** - * Calendar id. - */ -calendar_id: string; -/** - * iCal identifier used for deduplication. - * Apple: calendarItemExternalIdentifier, Google: iCalUID. - */ -external_id: string; title: string; description: string | null; location: string | null; url: string | null; -/** - * Parsed from notes for Apple, Google provides url directly. - */ -meeting_link: string | null; -/** - * ISO 8601. For Google, start of day for all day events (Apple already does that). - */ -started_at: string; -/** - * ISO 8601. For Google, end of day for all day events (Apple already does that). - */ -ended_at: string; timezone: string | null; is_all_day: boolean; -/** - * Apple: None | Confirmed | Tentative | Canceled -> map None to Confirmed. - * Google: confirmed | tentative | cancelled. - */ -status: EventStatus; organizer: EventPerson | null; attendees: EventAttendee[]; has_recurrence_rules: boolean; -/** - * Google's approach: for an instance of a recurring event, this is the id of the recurring - * event to which this instance belongs. For Apple, this is the recurrence's series_identifier - * (same across all occurrences of a recurring event). - */ -recurring_event_id: string | null; -/** - * Raw data. JSON for both Apple and Google. - */ -raw: string } -export type CalendarListItem = { provider: CalendarProviderType; id: string; title: string; source: string | null; color: string | null; is_primary: boolean | null; can_edit: boolean | null; raw: string } -export type CalendarProviderType = "apple" | "google" | "outlook" -export type CreateEventInput = { calendar_tracking_id: string; title: string; started_at: string; ended_at: string; is_all_day: boolean | null; location: string | null; notes: string | null; url: string | null } -export type EventAttendee = { name: string | null; -/** - * Apple calendar events only provide a contact entry, which can possibly not have an email. - */ -email: string | null; -/** - * Apple: participant.isCurrentUser, Google: attendee.self. - */ -is_current_user: boolean; -/** - * Apple: EKParticipantStatus (Unknown | Pending | Accepted | Declined | Tentative | Delegated | Completed | InProgress). - * Google: needsAction | declined | tentative | accepted. - * Normalize: unknown/needsAction -> Pending, delegated/completed/inProgress -> Accepted. - */ -status: AttendeeStatus; -/** - * Apple: EKParticipantRole (Unknown | Required | Optional | Chair | NonParticipant). - * Google: attendee.optional and attendee.organizer. - * For Apple, normalize unknown as required (see RFC 5545 3.2.16). - * For Google: organizer -> Chair, !organizer & !optional -> Required, !organizer & optional -> Optional. - */ -role: AttendeeRole } -export type EventFilter = { from: string; to: string; calendar_tracking_id: string } +export type AttendeeRole = "chair" | "required" | "optional" | "nonparticipant"; +export type AttendeeStatus = "pending" | "accepted" | "tentative" | "declined"; +export type CalendarChangedEvent = null; +export type CalendarEvent = { + provider: CalendarProviderType; + /** + * Unique between events. Synthesized for Apple events (eventIdentifier:YYYY-MM-DD for recurring). + */ + id: string; + /** + * Calendar id. + */ + calendar_id: string; + /** + * iCal identifier used for deduplication. + * Apple: calendarItemExternalIdentifier, Google: iCalUID. + */ + external_id: string; + title: string; + description: string | null; + location: string | null; + url: string | null; + /** + * Parsed from notes for Apple, Google provides url directly. + */ + meeting_link: string | null; + /** + * ISO 8601. For Google, start of day for all day events (Apple already does that). + */ + started_at: string; + /** + * ISO 8601. For Google, end of day for all day events (Apple already does that). + */ + ended_at: string; + timezone: string | null; + is_all_day: boolean; + /** + * Apple: None | Confirmed | Tentative | Canceled -> map None to Confirmed. + * Google: confirmed | tentative | cancelled. + */ + status: EventStatus; + organizer: EventPerson | null; + attendees: EventAttendee[]; + has_recurrence_rules: boolean; + /** + * Google's approach: for an instance of a recurring event, this is the id of the recurring + * event to which this instance belongs. For Apple, this is the recurrence's series_identifier + * (same across all occurrences of a recurring event). + */ + recurring_event_id: string | null; + /** + * Raw data. JSON for both Apple and Google. + */ + raw: string; +}; +export type CalendarListItem = { + provider: CalendarProviderType; + id: string; + title: string; + source: string | null; + color: string | null; + is_primary: boolean | null; + can_edit: boolean | null; + raw: string; +}; +export type CalendarProviderType = "apple" | "google" | "outlook"; +export type CalendarSyncEvent = + | { type: "statusChanged"; status: SyncStatus } + | { type: "syncStarted" } + | { type: "syncFinished"; data_changed: boolean } + | { type: "syncFailed"; error: string }; +export type CreateEventInput = { + calendar_tracking_id: string; + title: string; + started_at: string; + ended_at: string; + is_all_day: boolean | null; + location: string | null; + notes: string | null; + url: string | null; +}; +export type EventAttendee = { + name: string | null; + /** + * Apple calendar events only provide a contact entry, which can possibly not have an email. + */ + email: string | null; + /** + * Apple: participant.isCurrentUser, Google: attendee.self. + */ + is_current_user: boolean; + /** + * Apple: EKParticipantStatus (Unknown | Pending | Accepted | Declined | Tentative | Delegated | Completed | InProgress). + * Google: needsAction | declined | tentative | accepted. + * Normalize: unknown/needsAction -> Pending, delegated/completed/inProgress -> Accepted. + */ + status: AttendeeStatus; + /** + * Apple: EKParticipantRole (Unknown | Required | Optional | Chair | NonParticipant). + * Google: attendee.optional and attendee.organizer. + * For Apple, normalize unknown as required (see RFC 5545 3.2.16). + * For Google: organizer -> Chair, !organizer & !optional -> Required, !organizer & optional -> Optional. + */ + role: AttendeeRole; +}; +export type EventFilter = { + from: string; + to: string; + calendar_tracking_id: string; +}; /** * Apple: {name, email, isCurrentUser, ...}, Google: {id, email, displayName, self}. */ -export type EventPerson = { name: string | null; -/** - * Apple calendar events only provide a contact entry, which can possibly not have an email. - */ -email: string | null; -/** - * Apple: participant.isCurrentUser, Google: organizer.self. - */ -is_current_user: boolean } -export type EventStatus = "confirmed" | "tentative" | "cancelled" -export type ProviderConnectionIds = { provider: CalendarProviderType; connection_ids: string[] } +export type EventPerson = { + name: string | null; + /** + * Apple calendar events only provide a contact entry, which can possibly not have an email. + */ + email: string | null; + /** + * Apple: participant.isCurrentUser, Google: organizer.self. + */ + is_current_user: boolean; +}; +export type EventStatus = "confirmed" | "tentative" | "cancelled"; +export type ProviderConnectionIds = { + provider: CalendarProviderType; + connection_ids: string[]; +}; +export type SyncStatus = "idle" | "scheduled" | "running"; /** tauri-specta globals **/ import { - invoke as TAURI_INVOKE, - Channel as TAURI_CHANNEL, + invoke as TAURI_INVOKE, + Channel as TAURI_CHANNEL, } from "@tauri-apps/api/core"; import * as TAURI_API_EVENT from "@tauri-apps/api/event"; import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; type __EventObj__ = { - listen: ( - cb: TAURI_API_EVENT.EventCallback, - ) => ReturnType>; - once: ( - cb: TAURI_API_EVENT.EventCallback, - ) => ReturnType>; - emit: null extends T - ? (payload?: T) => ReturnType - : (payload: T) => ReturnType; + listen: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + once: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + emit: null extends T + ? (payload?: T) => ReturnType + : (payload: T) => ReturnType; }; export type Result = - | { status: "ok"; data: T } - | { status: "error"; error: E }; + | { status: "ok"; data: T } + | { status: "error"; error: E }; function __makeEvents__>( - mappings: Record, + mappings: Record, ) { - return new Proxy( - {} as unknown as { - [K in keyof T]: __EventObj__ & { - (handle: __WebviewWindow__): __EventObj__; - }; - }, - { - get: (_, event) => { - const name = mappings[event as keyof T]; + return new Proxy( + {} as unknown as { + [K in keyof T]: __EventObj__ & { + (handle: __WebviewWindow__): __EventObj__; + }; + }, + { + get: (_, event) => { + const name = mappings[event as keyof T]; - return new Proxy((() => {}) as any, { - apply: (_, __, [window]: [__WebviewWindow__]) => ({ - listen: (arg: any) => window.listen(name, arg), - once: (arg: any) => window.once(name, arg), - emit: (arg: any) => window.emit(name, arg), - }), - get: (_, command: keyof __EventObj__) => { - switch (command) { - case "listen": - return (arg: any) => TAURI_API_EVENT.listen(name, arg); - case "once": - return (arg: any) => TAURI_API_EVENT.once(name, arg); - case "emit": - return (arg: any) => TAURI_API_EVENT.emit(name, arg); - } - }, - }); - }, - }, - ); + return new Proxy((() => {}) as any, { + apply: (_, __, [window]: [__WebviewWindow__]) => ({ + listen: (arg: any) => window.listen(name, arg), + once: (arg: any) => window.once(name, arg), + emit: (arg: any) => window.emit(name, arg), + }), + get: (_, command: keyof __EventObj__) => { + switch (command) { + case "listen": + return (arg: any) => TAURI_API_EVENT.listen(name, arg); + case "once": + return (arg: any) => TAURI_API_EVENT.once(name, arg); + case "emit": + return (arg: any) => TAURI_API_EVENT.emit(name, arg); + } + }, + }); + }, + }, + ); } diff --git a/plugins/calendar/permissions/autogenerated/commands/get_calendar_sync_status.toml b/plugins/calendar/permissions/autogenerated/commands/get_calendar_sync_status.toml new file mode 100644 index 0000000000..3c88c2794e --- /dev/null +++ b/plugins/calendar/permissions/autogenerated/commands/get_calendar_sync_status.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-calendar-sync-status" +description = "Enables the get_calendar_sync_status command without any pre-configured scope." +commands.allow = ["get_calendar_sync_status"] + +[[permission]] +identifier = "deny-get-calendar-sync-status" +description = "Denies the get_calendar_sync_status command without any pre-configured scope." +commands.deny = ["get_calendar_sync_status"] diff --git a/plugins/calendar/permissions/autogenerated/commands/request_calendar_sync.toml b/plugins/calendar/permissions/autogenerated/commands/request_calendar_sync.toml new file mode 100644 index 0000000000..17d1263f09 --- /dev/null +++ b/plugins/calendar/permissions/autogenerated/commands/request_calendar_sync.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-request-calendar-sync" +description = "Enables the request_calendar_sync command without any pre-configured scope." +commands.allow = ["request_calendar_sync"] + +[[permission]] +identifier = "deny-request-calendar-sync" +description = "Denies the request_calendar_sync command without any pre-configured scope." +commands.deny = ["request_calendar_sync"] diff --git a/plugins/calendar/permissions/autogenerated/commands/set_calendar_enabled.toml b/plugins/calendar/permissions/autogenerated/commands/set_calendar_enabled.toml new file mode 100644 index 0000000000..27753f429a --- /dev/null +++ b/plugins/calendar/permissions/autogenerated/commands/set_calendar_enabled.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-set-calendar-enabled" +description = "Enables the set_calendar_enabled command without any pre-configured scope." +commands.allow = ["set_calendar_enabled"] + +[[permission]] +identifier = "deny-set-calendar-enabled" +description = "Denies the set_calendar_enabled command without any pre-configured scope." +commands.deny = ["set_calendar_enabled"] diff --git a/plugins/calendar/permissions/autogenerated/reference.md b/plugins/calendar/permissions/autogenerated/reference.md index 254fee4e92..6ae7b6b0eb 100644 --- a/plugins/calendar/permissions/autogenerated/reference.md +++ b/plugins/calendar/permissions/autogenerated/reference.md @@ -12,6 +12,9 @@ Default permissions for the plugin - `allow-open-calendar` - `allow-create-event` - `allow-parse-meeting-link` +- `allow-request-calendar-sync` +- `allow-get-calendar-sync-status` +- `allow-set-calendar-enabled` ## Permission Table @@ -77,6 +80,32 @@ Denies the create_event command without any pre-configured scope. +`calendar:allow-get-calendar-sync-status` + + + + +Enables the get_calendar_sync_status command without any pre-configured scope. + + + + + + + +`calendar:deny-get-calendar-sync-status` + + + + +Denies the get_calendar_sync_status command without any pre-configured scope. + + + + + + + `calendar:allow-is-provider-enabled` @@ -227,6 +256,58 @@ Enables the parse_meeting_link command without any pre-configured scope. Denies the parse_meeting_link command without any pre-configured scope. + + + + + + +`calendar:allow-request-calendar-sync` + + + + +Enables the request_calendar_sync command without any pre-configured scope. + + + + + + + +`calendar:deny-request-calendar-sync` + + + + +Denies the request_calendar_sync command without any pre-configured scope. + + + + + + + +`calendar:allow-set-calendar-enabled` + + + + +Enables the set_calendar_enabled command without any pre-configured scope. + + + + + + + +`calendar:deny-set-calendar-enabled` + + + + +Denies the set_calendar_enabled command without any pre-configured scope. + diff --git a/plugins/calendar/permissions/default.toml b/plugins/calendar/permissions/default.toml index 3d469c323d..91c98ecf68 100644 --- a/plugins/calendar/permissions/default.toml +++ b/plugins/calendar/permissions/default.toml @@ -9,4 +9,7 @@ permissions = [ "allow-open-calendar", "allow-create-event", "allow-parse-meeting-link", + "allow-request-calendar-sync", + "allow-get-calendar-sync-status", + "allow-set-calendar-enabled", ] diff --git a/plugins/calendar/permissions/schemas/schema.json b/plugins/calendar/permissions/schemas/schema.json index 5e94f41f48..d6ab6d4581 100644 --- a/plugins/calendar/permissions/schemas/schema.json +++ b/plugins/calendar/permissions/schemas/schema.json @@ -318,6 +318,18 @@ "const": "deny-create-event", "markdownDescription": "Denies the create_event command without any pre-configured scope." }, + { + "description": "Enables the get_calendar_sync_status command without any pre-configured scope.", + "type": "string", + "const": "allow-get-calendar-sync-status", + "markdownDescription": "Enables the get_calendar_sync_status command without any pre-configured scope." + }, + { + "description": "Denies the get_calendar_sync_status command without any pre-configured scope.", + "type": "string", + "const": "deny-get-calendar-sync-status", + "markdownDescription": "Denies the get_calendar_sync_status command without any pre-configured scope." + }, { "description": "Enables the is_provider_enabled command without any pre-configured scope.", "type": "string", @@ -391,10 +403,34 @@ "markdownDescription": "Denies the parse_meeting_link command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-available-providers`\n- `allow-is-provider-enabled`\n- `allow-list-connection-ids`\n- `allow-list-calendars`\n- `allow-list-events`\n- `allow-open-calendar`\n- `allow-create-event`\n- `allow-parse-meeting-link`", + "description": "Enables the request_calendar_sync command without any pre-configured scope.", + "type": "string", + "const": "allow-request-calendar-sync", + "markdownDescription": "Enables the request_calendar_sync command without any pre-configured scope." + }, + { + "description": "Denies the request_calendar_sync command without any pre-configured scope.", + "type": "string", + "const": "deny-request-calendar-sync", + "markdownDescription": "Denies the request_calendar_sync command without any pre-configured scope." + }, + { + "description": "Enables the set_calendar_enabled command without any pre-configured scope.", + "type": "string", + "const": "allow-set-calendar-enabled", + "markdownDescription": "Enables the set_calendar_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_calendar_enabled command without any pre-configured scope.", + "type": "string", + "const": "deny-set-calendar-enabled", + "markdownDescription": "Denies the set_calendar_enabled command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-available-providers`\n- `allow-is-provider-enabled`\n- `allow-list-connection-ids`\n- `allow-list-calendars`\n- `allow-list-events`\n- `allow-open-calendar`\n- `allow-create-event`\n- `allow-parse-meeting-link`\n- `allow-request-calendar-sync`\n- `allow-get-calendar-sync-status`\n- `allow-set-calendar-enabled`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-available-providers`\n- `allow-is-provider-enabled`\n- `allow-list-connection-ids`\n- `allow-list-calendars`\n- `allow-list-events`\n- `allow-open-calendar`\n- `allow-create-event`\n- `allow-parse-meeting-link`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-available-providers`\n- `allow-is-provider-enabled`\n- `allow-list-connection-ids`\n- `allow-list-calendars`\n- `allow-list-events`\n- `allow-open-calendar`\n- `allow-create-event`\n- `allow-parse-meeting-link`\n- `allow-request-calendar-sync`\n- `allow-get-calendar-sync-status`\n- `allow-set-calendar-enabled`" } ] } diff --git a/plugins/calendar/src/auth.rs b/plugins/calendar/src/auth.rs new file mode 100644 index 0000000000..a0b4c936fa --- /dev/null +++ b/plugins/calendar/src/auth.rs @@ -0,0 +1,39 @@ +use tauri_plugin_auth::AuthPluginExt; +use tauri_plugin_permissions::PermissionsPluginExt; + +use crate::error::Error; + +pub fn access_token(app: &tauri::AppHandle) -> Option { + app.access_token().ok().flatten().filter(|t| !t.is_empty()) +} + +pub fn require_access_token(app: &tauri::AppHandle) -> Result { + let token = app.access_token().map_err(|e| Error::Auth(e.to_string()))?; + match token { + Some(t) if !t.is_empty() => Ok(t), + _ => Err(hypr_calendar::Error::NotAuthenticated.into()), + } +} + +pub async fn is_apple_authorized( + app: &tauri::AppHandle, +) -> Result { + #[cfg(target_os = "macos")] + { + let status = app + .permissions() + .check(tauri_plugin_permissions::Permission::Calendar) + .await + .map_err(|e| hypr_calendar::Error::Api(e.to_string()))?; + Ok(matches!( + status, + tauri_plugin_permissions::PermissionStatus::Authorized + )) + } + + #[cfg(not(target_os = "macos"))] + { + let _ = app; + Ok(false) + } +} diff --git a/plugins/calendar/src/commands.rs b/plugins/calendar/src/commands.rs index fc1e3cc801..cc9979883c 100644 --- a/plugins/calendar/src/commands.rs +++ b/plugins/calendar/src/commands.rs @@ -1,11 +1,21 @@ +use std::sync::Arc; + use hypr_calendar_interface::{ CalendarEvent, CalendarListItem, CalendarProviderType, CreateEventInput, EventFilter, }; use tauri::Manager; -use tauri_plugin_auth::AuthPluginExt; -use tauri_plugin_permissions::PermissionsPluginExt; +use crate::auth::{access_token, is_apple_authorized, require_access_token}; use crate::error::Error; +use crate::sync::JsonCalendarSyncStore; +use crate::sync::json::CalendarSyncSnapshot; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SetCalendarEnabledOutcome { + Updated, + Unchanged, + NotFound, +} #[tauri::command] #[specta::specta] @@ -106,35 +116,118 @@ pub fn parse_meeting_link(text: String) -> Option { hypr_calendar::parse_meeting_link(&text) } -fn access_token(app: &tauri::AppHandle) -> Option { - app.access_token().ok().flatten().filter(|t| !t.is_empty()) +#[tauri::command] +#[specta::specta] +pub fn request_calendar_sync(app: tauri::AppHandle) -> Result<(), Error> { + app.state::() + .request_sync() + .map_err(|error| Error::Sync(error.to_string())) +} + +#[tauri::command] +#[specta::specta] +pub fn get_calendar_sync_status( + app: tauri::AppHandle, +) -> hypr_calendar_sync::SyncStatus { + app.state::() + .status() +} + +#[tauri::command] +#[specta::specta] +pub async fn set_calendar_enabled( + app: tauri::AppHandle, + calendar_id: String, + enabled: bool, +) -> Result<(), Error> { + let store = app.state::>().inner().clone(); + let outcome = store + .mutate_with_result(|snap| { + let outcome = set_calendar_enabled_in_snapshot(snap, &calendar_id, enabled); + ( + matches!(outcome, SetCalendarEnabledOutcome::Updated), + outcome, + ) + }) + .await + .map_err(|error| Error::Store(error.to_string()))?; + + match outcome { + SetCalendarEnabledOutcome::Updated | SetCalendarEnabledOutcome::Unchanged => Ok(()), + SetCalendarEnabledOutcome::NotFound => Err(Error::CalendarNotFound(calendar_id)), + } } -fn require_access_token(app: &tauri::AppHandle) -> Result { - let token = app.access_token().map_err(|e| Error::Auth(e.to_string()))?; - match token { - Some(t) if !t.is_empty() => Ok(t), - _ => Err(hypr_calendar::Error::NotAuthenticated.into()), +fn set_calendar_enabled_in_snapshot( + snapshot: &mut CalendarSyncSnapshot, + calendar_id: &str, + enabled: bool, +) -> SetCalendarEnabledOutcome { + let Some(calendar) = snapshot.calendars.get_mut(calendar_id) else { + return SetCalendarEnabledOutcome::NotFound; + }; + + if calendar.enabled == enabled { + return SetCalendarEnabledOutcome::Unchanged; } + + calendar.enabled = enabled; + SetCalendarEnabledOutcome::Updated } -async fn is_apple_authorized(app: &tauri::AppHandle) -> Result { - #[cfg(target_os = "macos")] - { - let status = app - .permissions() - .check(tauri_plugin_permissions::Permission::Calendar) - .await - .map_err(|e| hypr_calendar::Error::Api(e.to_string()))?; - Ok(matches!( - status, - tauri_plugin_permissions::PermissionStatus::Authorized - )) +#[cfg(test)] +mod tests { + use hypr_calendar_interface::CalendarProviderType; + + use super::*; + use crate::sync::store::CalendarRecord; + + #[test] + fn set_calendar_enabled_updates_existing_calendar() { + let mut snapshot = CalendarSyncSnapshot::default(); + snapshot + .calendars + .insert("cal-1".to_string(), sample_calendar(false)); + + let outcome = set_calendar_enabled_in_snapshot(&mut snapshot, "cal-1", true); + + assert_eq!(outcome, SetCalendarEnabledOutcome::Updated); + assert_eq!(snapshot.calendars.get("cal-1").unwrap().enabled, true); + } + + #[test] + fn set_calendar_enabled_reports_missing_calendar() { + let mut snapshot = CalendarSyncSnapshot::default(); + + let outcome = set_calendar_enabled_in_snapshot(&mut snapshot, "missing", true); + + assert_eq!(outcome, SetCalendarEnabledOutcome::NotFound); + } + + #[test] + fn set_calendar_enabled_leaves_matching_value_unchanged() { + let mut snapshot = CalendarSyncSnapshot::default(); + snapshot + .calendars + .insert("cal-1".to_string(), sample_calendar(true)); + + let outcome = set_calendar_enabled_in_snapshot(&mut snapshot, "cal-1", true); + + assert_eq!(outcome, SetCalendarEnabledOutcome::Unchanged); + assert_eq!(snapshot.calendars.get("cal-1").unwrap().enabled, true); } - #[cfg(not(target_os = "macos"))] - { - let _ = app; - Ok(false) + fn sample_calendar(enabled: bool) -> CalendarRecord { + CalendarRecord { + user_id: "user-1".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + tracking_id_calendar: "tracking-cal-1".to_string(), + name: "Primary".to_string(), + enabled, + provider: CalendarProviderType::Google, + source: "test".to_string(), + color: "#4285f4".to_string(), + connection_id: "conn-1".to_string(), + } } } diff --git a/plugins/calendar/src/error.rs b/plugins/calendar/src/error.rs index 41a2451c06..3479bfe7e4 100644 --- a/plugins/calendar/src/error.rs +++ b/plugins/calendar/src/error.rs @@ -4,6 +4,12 @@ pub enum Error { Calendar(#[from] hypr_calendar::Error), #[error("auth error: {0}")] Auth(String), + #[error("calendar not found: {0}")] + CalendarNotFound(String), + #[error("calendar sync unavailable: {0}")] + Sync(String), + #[error("calendar sync store error: {0}")] + Store(String), } impl serde::Serialize for Error { diff --git a/plugins/calendar/src/events.rs b/plugins/calendar/src/events.rs index f37cc4b159..c2946ebad5 100644 --- a/plugins/calendar/src/events.rs +++ b/plugins/calendar/src/events.rs @@ -1,2 +1,17 @@ #[derive(serde::Serialize, Clone, specta::Type, tauri_specta::Event)] pub struct CalendarChangedEvent; + +#[derive(serde::Serialize, Clone, specta::Type, tauri_specta::Event)] +#[serde(tag = "type")] +pub enum CalendarSyncEvent { + #[serde(rename = "statusChanged")] + StatusChanged { + status: hypr_calendar_sync::SyncStatus, + }, + #[serde(rename = "syncStarted")] + SyncStarted, + #[serde(rename = "syncFinished")] + SyncFinished { data_changed: bool }, + #[serde(rename = "syncFailed")] + SyncFailed { error: String }, +} diff --git a/plugins/calendar/src/lib.rs b/plugins/calendar/src/lib.rs index 2aa30fdf89..f72db8f977 100644 --- a/plugins/calendar/src/lib.rs +++ b/plugins/calendar/src/lib.rs @@ -1,7 +1,9 @@ +mod auth; mod commands; mod error; mod events; mod runtime; +mod sync; pub use error::Error; pub use events::*; @@ -25,8 +27,14 @@ fn make_specta_builder() -> tauri_specta::Builder { commands::open_calendar::, commands::create_event::, commands::parse_meeting_link, + commands::request_calendar_sync::, + commands::get_calendar_sync_status::, + commands::set_calendar_enabled::, + ]) + .events(tauri_specta::collect_events![ + CalendarChangedEvent, + CalendarSyncEvent ]) - .events(tauri_specta::collect_events![CalendarChangedEvent]) .error_handling(tauri_specta::ErrorHandlingMode::Result) } @@ -39,12 +47,44 @@ pub fn init() -> tauri::plugin::TauriPlugin { .setup(move |app, _api| { specta_builder.mount_events(app); - hypr_calendar::start(runtime::TauriCalendarRuntime(app.app_handle().clone())); - + use std::sync::Arc; use tauri::Manager; + app.manage(PluginConfig { api_base_url }); + + let store = Arc::new( + sync::JsonCalendarSyncStore::for_app(app.app_handle()).map_err( + |error| -> Box { + tracing::error!(%error, "failed to initialize calendar sync store"); + error.to_string().into() + }, + )?, + ); + app.manage(store.clone()); + + let handle = hypr_calendar_sync::start( + sync::PluginCalendarSyncSource::new(app.app_handle().clone(), store.clone()), + store.clone(), + runtime::TauriCalendarSyncRuntime(app.app_handle().clone()), + hypr_calendar_sync::Config::every_minute(), + ); + app.manage(handle); + + hypr_calendar::watch_apple_changes(runtime::TauriCalendarRuntime( + app.app_handle().clone(), + )); Ok(()) }) + .on_event(|app, event| { + if matches!(event, tauri::RunEvent::Ready) { + use tauri::Manager; + if let Some(handle) = app.try_state::() { + if let Err(error) = handle.request_sync() { + tracing::error!(%error, "failed to queue startup calendar sync"); + } + } + } + }) .build() } diff --git a/plugins/calendar/src/runtime.rs b/plugins/calendar/src/runtime.rs index ba2b3a8fea..b17ea2ff6b 100644 --- a/plugins/calendar/src/runtime.rs +++ b/plugins/calendar/src/runtime.rs @@ -1,12 +1,39 @@ use hypr_calendar::runtime::CalendarRuntime; +use tauri::Manager; use tauri_specta::Event as _; -use crate::events::CalendarChangedEvent; +use crate::events::{CalendarChangedEvent, CalendarSyncEvent}; pub struct TauriCalendarRuntime(pub tauri::AppHandle); +pub struct TauriCalendarSyncRuntime(pub tauri::AppHandle); impl CalendarRuntime for TauriCalendarRuntime { fn emit_changed(&self) { let _ = CalendarChangedEvent.emit(&self.0); + if let Some(handle) = self.0.try_state::() { + if let Err(error) = handle.request_sync() { + tracing::error!(%error, "failed to queue calendar sync after Apple calendar change"); + } + } + } +} + +impl hypr_calendar_sync::CalendarSyncRuntime for TauriCalendarSyncRuntime { + fn emit(&self, event: hypr_calendar_sync::CalendarSyncWorkerEvent) { + let event = match event { + hypr_calendar_sync::CalendarSyncWorkerEvent::StatusChanged { status } => { + CalendarSyncEvent::StatusChanged { status } + } + hypr_calendar_sync::CalendarSyncWorkerEvent::SyncStarted => { + CalendarSyncEvent::SyncStarted + } + hypr_calendar_sync::CalendarSyncWorkerEvent::SyncFinished { data_changed } => { + CalendarSyncEvent::SyncFinished { data_changed } + } + hypr_calendar_sync::CalendarSyncWorkerEvent::SyncFailed { error } => { + CalendarSyncEvent::SyncFailed { error } + } + }; + let _ = event.emit(&self.0); } } diff --git a/plugins/calendar/src/sync/json.rs b/plugins/calendar/src/sync/json.rs new file mode 100644 index 0000000000..5447d0a534 --- /dev/null +++ b/plugins/calendar/src/sync/json.rs @@ -0,0 +1,967 @@ +//! File-backed calendar sync store. + +use std::collections::BTreeMap; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; + +use chrono::Utc; +use hypr_calendar_sync::{BoxError, CalendarOp, CalendarPlan, EventOp, EventPlan}; + +use super::store::{ + CalendarRecord, EventRecord, ParticipantRecord, StoredCalendarRecord, StoredEventRecord, + default_user_id, +}; + +const CALENDARS_FILENAME: &str = "calendars.json"; +const EVENTS_FILENAME: &str = "events.json"; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CalendarSyncSnapshot { + pub calendars: BTreeMap, + pub events: BTreeMap, +} + +pub struct JsonCalendarSyncStore { + base_path: PathBuf, + write_lock: tokio::sync::Mutex<()>, +} + +impl JsonCalendarSyncStore { + pub fn from_base_path(base_path: PathBuf) -> Self { + Self { + base_path, + write_lock: tokio::sync::Mutex::new(()), + } + } + + pub fn for_app(app: &tauri::AppHandle) -> Result { + Ok(Self::from_base_path(resolve_vault_base(app)?)) + } + + pub async fn load_snapshot(&self) -> Result { + self.read_snapshot().await + } + + pub(crate) async fn mutate_with_result(&self, mutator: F) -> Result + where + F: FnOnce(&mut CalendarSyncSnapshot) -> (bool, T) + Send, + T: Send, + { + let _guard = self.write_lock.lock().await; + let mut snapshot = self.read_snapshot().await?; + let (should_persist, result) = mutator(&mut snapshot); + if should_persist { + self.write_snapshot(&snapshot).await?; + } + Ok(result) + } + + async fn read_snapshot(&self) -> Result { + let calendars = + read_json_map::(&self.base_path.join(CALENDARS_FILENAME)) + .await? + .into_iter() + .map(|(id, record)| (id, record.into())) + .collect(); + let events = read_json_map::(&self.base_path.join(EVENTS_FILENAME)) + .await? + .into_iter() + .map(|(id, record)| (id, record.into())) + .collect(); + Ok(CalendarSyncSnapshot { calendars, events }) + } + + async fn write_snapshot(&self, snapshot: &CalendarSyncSnapshot) -> Result<(), BoxError> { + let calendars = snapshot + .calendars + .iter() + .map(|(id, record)| (id.clone(), JsonCalendarRecord::from(record))) + .collect(); + let events = snapshot + .events + .iter() + .map(|(id, record)| (id.clone(), JsonEventRecord::from(record))) + .collect(); + write_json_map(&self.base_path.join(CALENDARS_FILENAME), &calendars).await?; + write_json_map(&self.base_path.join(EVENTS_FILENAME), &events).await?; + Ok(()) + } +} + +impl hypr_calendar_sync::CalendarSyncStore for JsonCalendarSyncStore { + type Calendar = StoredCalendarRecord; + type Event = StoredEventRecord; + + fn read( + &self, + ) -> Pin< + Box< + dyn Future, Vec), BoxError>> + + Send + + '_, + >, + > { + Box::pin(async move { + let snapshot = self.load_snapshot().await?; + Ok(( + snapshot + .calendars + .into_iter() + .map(|(id, record)| StoredCalendarRecord { id, record }) + .collect(), + snapshot + .events + .into_iter() + .map(|(id, record)| StoredEventRecord { id, record }) + .collect(), + )) + }) + } + + fn apply<'a>( + &'a self, + calendar_plan: CalendarPlan<'a>, + event_plan: EventPlan<'a>, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let _guard = self.write_lock.lock().await; + let mut snapshot = self.read_snapshot().await?; + let calendar_changed = apply_calendar_plan(&mut snapshot, calendar_plan); + let event_changed = apply_event_plan(&mut snapshot, event_plan); + let changed = calendar_changed || event_changed; + if changed { + self.write_snapshot(&snapshot).await?; + } + Ok(changed) + }) + } +} + +fn apply_calendar_plan(snapshot: &mut CalendarSyncSnapshot, plan: CalendarPlan<'_>) -> bool { + let mut changed = false; + + for op in plan.ops { + match op { + CalendarOp::Delete { id } => { + changed |= snapshot.calendars.remove(&id).is_some(); + } + CalendarOp::Upsert { + existing_id, + incoming, + } => { + let row_id = existing_id.unwrap_or_else(new_id); + let existing = snapshot.calendars.get(&row_id); + let next = CalendarRecord { + user_id: existing + .map(|row| row.user_id.clone()) + .unwrap_or_else(default_user_id), + created_at: existing + .map(|row| row.created_at.clone()) + .unwrap_or_else(now_iso), + tracking_id_calendar: incoming.key.tracking_id.clone(), + name: incoming.payload.name.clone(), + enabled: existing.map(|row| row.enabled).unwrap_or(false), + provider: incoming.key.provider, + source: incoming.payload.source.clone(), + color: incoming.payload.color.clone(), + connection_id: incoming.key.connection_id.clone(), + }; + if existing != Some(&next) { + snapshot.calendars.insert(row_id, next); + changed = true; + } + } + } + } + + changed +} + +fn apply_event_plan(snapshot: &mut CalendarSyncSnapshot, plan: EventPlan<'_>) -> bool { + let calendar_ids_by_key = snapshot + .calendars + .iter() + .map(|(id, record)| { + ( + hypr_calendar_sync::CalendarKey::new( + record.provider, + record.connection_id.clone(), + record.tracking_id_calendar.clone(), + ), + id.clone(), + ) + }) + .collect::>(); + let mut changed = false; + + for op in plan.ops { + match op { + EventOp::Delete { id } => { + changed |= snapshot.events.remove(&id).is_some(); + } + EventOp::Update { id, incoming } => { + let Some(existing) = snapshot.events.get(&id).cloned() else { + tracing::error!( + event_id = %id, + tracking_id_event = %incoming.tracking_id_event, + "calendar sync update skipped because the persisted event row is missing" + ); + continue; + }; + let next = event_record_from_incoming( + incoming, + existing.calendar_id.clone(), + Some(&existing), + ); + if existing != next { + snapshot.events.insert(id, next); + changed = true; + } + } + EventOp::Insert { incoming } => { + let Some(calendar_id) = calendar_ids_by_key.get(&incoming.calendar_key).cloned() + else { + tracing::error!( + provider = ?incoming.calendar_key.provider, + connection_id = %incoming.calendar_key.connection_id, + tracking_id_calendar = %incoming.calendar_key.tracking_id, + tracking_id_event = %incoming.tracking_id_event, + "calendar sync insert skipped because the target calendar row is missing" + ); + continue; + }; + snapshot.events.insert( + new_id(), + event_record_from_incoming(incoming, calendar_id, None), + ); + changed = true; + } + } + } + + changed +} + +async fn read_json_map(path: &Path) -> Result, BoxError> +where + T: for<'de> serde::Deserialize<'de>, +{ + let content = match tokio::fs::read_to_string(path).await { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()), + Err(error) => return Err(Box::new(error)), + }; + + Ok(serde_json::from_str(&content)?) +} + +async fn write_json_map(path: &Path, value: &BTreeMap) -> Result<(), BoxError> +where + T: serde::Serialize, +{ + let content = serde_json::to_string(value)?; + hypr_storage::fs::atomic_write_async(path, &content).await?; + Ok(()) +} + +fn resolve_vault_base(app: &tauri::AppHandle) -> Result { + let bundle_id: &str = app.config().identifier.as_ref(); + let settings_base = hypr_storage::global::compute_default_base(bundle_id) + .ok_or_else(|| std::io::Error::other("settings base unavailable"))?; + std::fs::create_dir_all(&settings_base)?; + // Calendar sync follows the same startup vault resolution as settings and + // legacy import: the global config lives under `settings_base`, and the + // default vault base is also `settings_base` until the user opts into a + // custom vault location. + Ok(hypr_storage::vault::resolve_base( + &settings_base, + &settings_base, + )) +} + +fn now_iso() -> String { + Utc::now().to_rfc3339() +} + +fn new_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +fn event_record_from_incoming( + incoming: &hypr_calendar_sync::IncomingEvent, + calendar_id: String, + existing: Option<&EventRecord>, +) -> EventRecord { + EventRecord { + user_id: existing + .map(|row| row.user_id.clone()) + .unwrap_or_else(default_user_id), + created_at: existing + .map(|row| row.created_at.clone()) + .unwrap_or_else(now_iso), + tracking_id_event: Some(incoming.tracking_id_event.clone()), + calendar_id, + title: incoming.payload.title.clone().unwrap_or_default(), + started_at: incoming.started_at.clone(), + ended_at: incoming.ended_at.clone(), + location: incoming.payload.location.clone(), + meeting_link: incoming.payload.meeting_link.clone(), + description: incoming.payload.description.clone(), + note: existing.and_then(|row| row.note.clone()), + recurrence_series_id: incoming.recurrence_series_id.clone(), + has_recurrence_rules: incoming.has_recurrence_rules, + is_all_day: incoming.is_all_day, + provider: incoming.calendar_key.provider, + participants: incoming + .participants + .iter() + .map(|participant| ParticipantRecord { + name: participant.name.clone(), + email: participant.email.clone(), + is_organizer: participant.is_organizer, + is_current_user: participant.is_current_user, + }) + .collect(), + } +} + +// On-disk shape note: the JSON files were historically written by the old TS +// persister, which omitted any field it never set (e.g. `source` on a Google +// holidays calendar, `name` on a participant). Every field below — other than +// `provider`, where a missing value means data corruption — is `#[serde(default)]` +// so real user files still load. Do not relax this without a regression test. +// See `tests::tolerates_legacy_ts_written_shape`. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +struct JsonCalendarRecord { + #[serde(default = "default_user_id")] + user_id: String, + #[serde(default)] + created_at: String, + #[serde(default)] + tracking_id_calendar: String, + #[serde(default)] + name: String, + #[serde(default)] + enabled: bool, + provider: hypr_calendar::CalendarProviderType, + #[serde(default)] + source: String, + #[serde(default)] + color: String, + #[serde(default)] + connection_id: String, +} + +impl From for CalendarRecord { + fn from(value: JsonCalendarRecord) -> Self { + Self { + user_id: value.user_id, + created_at: value.created_at, + tracking_id_calendar: value.tracking_id_calendar, + name: value.name, + enabled: value.enabled, + provider: value.provider, + source: value.source, + color: value.color, + connection_id: value.connection_id, + } + } +} + +impl From<&CalendarRecord> for JsonCalendarRecord { + fn from(value: &CalendarRecord) -> Self { + Self { + user_id: value.user_id.clone(), + created_at: value.created_at.clone(), + tracking_id_calendar: value.tracking_id_calendar.clone(), + name: value.name.clone(), + enabled: value.enabled, + provider: value.provider, + source: value.source.clone(), + color: value.color.clone(), + connection_id: value.connection_id.clone(), + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +struct JsonParticipantRecord { + #[serde(default)] + name: Option, + #[serde(default)] + email: Option, + #[serde(default)] + is_organizer: bool, + #[serde(default)] + is_current_user: bool, +} + +impl From for ParticipantRecord { + fn from(value: JsonParticipantRecord) -> Self { + Self { + name: value.name, + email: value.email, + is_organizer: value.is_organizer, + is_current_user: value.is_current_user, + } + } +} + +impl From<&ParticipantRecord> for JsonParticipantRecord { + fn from(value: &ParticipantRecord) -> Self { + Self { + name: value.name.clone(), + email: value.email.clone(), + is_organizer: value.is_organizer, + is_current_user: value.is_current_user, + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +struct JsonEventRecord { + #[serde(default = "default_user_id")] + user_id: String, + #[serde(default)] + created_at: String, + #[serde(default)] + tracking_id_event: Option, + #[serde(default)] + calendar_id: String, + #[serde(default)] + title: String, + #[serde(default)] + started_at: String, + #[serde(default)] + ended_at: Option, + #[serde(default)] + location: Option, + #[serde(default)] + meeting_link: Option, + #[serde(default)] + description: Option, + #[serde(default)] + note: Option, + #[serde(default)] + recurrence_series_id: Option, + #[serde(default)] + has_recurrence_rules: bool, + #[serde(default)] + is_all_day: bool, + provider: hypr_calendar::CalendarProviderType, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + participants: Vec, +} + +impl From for EventRecord { + fn from(value: JsonEventRecord) -> Self { + Self { + user_id: value.user_id, + created_at: value.created_at, + tracking_id_event: value.tracking_id_event, + calendar_id: value.calendar_id, + title: value.title, + started_at: value.started_at, + ended_at: value.ended_at, + location: value.location, + meeting_link: value.meeting_link, + description: value.description, + note: value.note, + recurrence_series_id: value.recurrence_series_id, + has_recurrence_rules: value.has_recurrence_rules, + is_all_day: value.is_all_day, + provider: value.provider, + participants: value.participants.into_iter().map(Into::into).collect(), + } + } +} + +impl From<&EventRecord> for JsonEventRecord { + fn from(value: &EventRecord) -> Self { + Self { + user_id: value.user_id.clone(), + created_at: value.created_at.clone(), + tracking_id_event: value.tracking_id_event.clone(), + calendar_id: value.calendar_id.clone(), + title: value.title.clone(), + started_at: value.started_at.clone(), + ended_at: value.ended_at.clone(), + location: value.location.clone(), + meeting_link: value.meeting_link.clone(), + description: value.description.clone(), + note: value.note.clone(), + recurrence_series_id: value.recurrence_series_id.clone(), + has_recurrence_rules: value.has_recurrence_rules, + is_all_day: value.is_all_day, + provider: value.provider, + participants: value.participants.iter().map(Into::into).collect(), + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, BTreeSet}; + use std::future::Future; + use std::pin::Pin; + use std::sync::Arc; + use std::time::Duration; + + use hypr_calendar::CalendarProviderType; + use hypr_calendar_sync::{ + BoxError, CalendarKey, CalendarPayload, CalendarSyncRuntime, CalendarSyncSource, + CalendarSyncWorkerEvent, ConnectionKey, EventPayload, IncomingCalendar, IncomingEvent, + IncomingParticipant, IncomingSnapshot, SyncRange, + }; + use tokio::sync::Notify; + + use super::*; + + fn sample_calendar(name: &str) -> CalendarRecord { + CalendarRecord { + user_id: default_user_id(), + created_at: "2026-04-15T00:00:00Z".to_string(), + tracking_id_calendar: name.to_string(), + name: name.to_string(), + enabled: false, + provider: CalendarProviderType::Apple, + source: "apple".to_string(), + color: "#888".to_string(), + connection_id: "conn".to_string(), + } + } + + fn sample_event(calendar_id: &str) -> EventRecord { + EventRecord { + user_id: default_user_id(), + created_at: "2026-04-15T00:00:00Z".to_string(), + tracking_id_event: Some("evt-1".to_string()), + calendar_id: calendar_id.to_string(), + title: "Standup".to_string(), + started_at: "2026-04-15T09:00:00Z".to_string(), + ended_at: Some("2026-04-15T09:30:00Z".to_string()), + location: Some("Room 1".to_string()), + meeting_link: Some("https://meet.google.com/abc-defg-hij".to_string()), + description: Some("Daily standup".to_string()), + note: Some("keep me".to_string()), + recurrence_series_id: None, + has_recurrence_rules: false, + is_all_day: false, + provider: CalendarProviderType::Google, + participants: vec![ParticipantRecord { + name: Some("Alice".to_string()), + email: Some("alice@example.com".to_string()), + is_organizer: true, + is_current_user: true, + }], + } + } + + #[derive(Clone)] + struct StaticSource { + snapshot: IncomingSnapshot, + } + + impl CalendarSyncSource for StaticSource { + fn fetch( + &self, + _range: SyncRange, + ) -> Pin> + Send + '_>> { + let snapshot = self.snapshot.clone(); + Box::pin(async move { Ok(snapshot) }) + } + } + + #[derive(Clone, Default)] + struct RecordingRuntime { + finished: Arc, + failure: Arc>>, + } + + impl RecordingRuntime { + fn failure(&self) -> Option { + self.failure.lock().unwrap().clone() + } + } + + impl CalendarSyncRuntime for RecordingRuntime { + fn emit(&self, event: CalendarSyncWorkerEvent) { + match event { + CalendarSyncWorkerEvent::SyncFinished { .. } => self.finished.notify_waiters(), + CalendarSyncWorkerEvent::SyncFailed { error } => { + *self.failure.lock().unwrap() = Some(error); + self.finished.notify_waiters(); + } + _ => {} + } + } + } + + #[tokio::test] + async fn mutate_skips_write_when_closure_returns_false() { + let dir = tempfile::tempdir().unwrap(); + let store = JsonCalendarSyncStore::from_base_path(dir.path().to_path_buf()); + + let persisted = store + .mutate_with_result(|snap| { + snap.calendars + .insert("cal-1".to_string(), sample_calendar("cal-1")); + (false, false) + }) + .await + .unwrap(); + + assert!(!persisted); + assert!( + store.load_snapshot().await.unwrap().calendars.is_empty(), + "discarded mutation must not touch disk", + ); + } + + /// Regression: the previous schema required a literal `source` field on + /// every calendar row, but the historical TS persister omitted it for + /// rows where the provider didn't supply one (e.g. a Google "holidays" + /// calendar). Loading such a file panicked with `missing field + /// 'source'` and nuked the whole sync pass. Same deal for participants + /// without a `name`. + /// + /// This fixture mirrors what was observed on a real user's disk — + /// update it, don't relax it, if you change the shape. + #[tokio::test] + async fn tolerates_legacy_ts_written_shape() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write( + dir.path().join(CALENDARS_FILENAME), + r##"{ + "holiday-row": { + "color": "#16a765", + "connection_id": "conn-1", + "created_at": "2026-04-13T04:37:19.109Z", + "enabled": false, + "name": "Holidays", + "provider": "google", + "tracking_id_calendar": "holiday@group.v.calendar.google.com", + "user_id": "00000000-0000-0000-0000-000000000000" + }, + "primary-row": { + "color": "#9fe1e7", + "connection_id": "conn-1", + "created_at": "2026-04-13T04:37:19.109Z", + "enabled": true, + "name": "me@example.com", + "provider": "google", + "source": "me@example.com", + "tracking_id_calendar": "me@example.com", + "user_id": "00000000-0000-0000-0000-000000000000" + } + }"##, + ) + .await + .unwrap(); + tokio::fs::write( + dir.path().join(EVENTS_FILENAME), + r##"{ + "ev-1": { + "calendar_id": "primary-row", + "created_at": "2026-04-13T04:37:41.864Z", + "ended_at": "2026-04-07T20:15:00+09:00", + "has_recurrence_rules": false, + "is_all_day": false, + "participants": [ + { + "email": "me@example.com", + "is_current_user": true, + "is_organizer": true + } + ], + "provider": "google", + "started_at": "2026-04-07T20:00:00+09:00", + "title": "standup", + "tracking_id_event": "evt-abc", + "user_id": "00000000-0000-0000-0000-000000000000" + } + }"##, + ) + .await + .unwrap(); + + let store = JsonCalendarSyncStore::from_base_path(dir.path().to_path_buf()); + let snapshot = store + .load_snapshot() + .await + .expect("must tolerate legacy shape"); + + let holiday = snapshot + .calendars + .get("holiday-row") + .expect("holiday row must load"); + assert_eq!(holiday.source, "", "missing source must default, not error"); + assert_eq!(holiday.enabled, false); + + let event = snapshot.events.get("ev-1").expect("ev-1 must load"); + let participant = event.participants.first().expect("one participant"); + assert_eq!( + participant.name, None, + "missing participant name must default to None" + ); + assert_eq!(participant.email.as_deref(), Some("me@example.com")); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_mutations_never_lose_updates() { + let dir = tempfile::tempdir().unwrap(); + let store = Arc::new(JsonCalendarSyncStore::from_base_path( + dir.path().to_path_buf(), + )); + + const PER_TASK: usize = 25; + + let mut handles = Vec::with_capacity(2); + for task_id in 0..2 { + let store = store.clone(); + handles.push(tokio::spawn(async move { + for i in 0..PER_TASK { + let key = format!("task-{task_id}-cal-{i}"); + store + .mutate_with_result(move |snap| { + snap.calendars.insert(key.clone(), sample_calendar(&key)); + (true, ()) + }) + .await + .expect("mutate ok"); + } + })); + } + + for handle in handles { + handle.await.unwrap(); + } + + let snapshot = store.load_snapshot().await.unwrap(); + assert_eq!( + snapshot.calendars.len(), + PER_TASK * 2, + "all concurrent mutations must be preserved" + ); + } + + #[tokio::test] + async fn apply_returns_false_for_noop_plans() { + let dir = tempfile::tempdir().unwrap(); + let store = JsonCalendarSyncStore::from_base_path(dir.path().to_path_buf()); + + store + .mutate_with_result(|snapshot| { + snapshot + .calendars + .insert("cal-1".to_string(), sample_calendar("primary")); + snapshot + .events + .insert("event-1".to_string(), sample_event("cal-1")); + (true, ()) + }) + .await + .unwrap(); + + let incoming_calendars = vec![IncomingCalendar { + key: CalendarKey::new(CalendarProviderType::Apple, "conn", "primary"), + payload: CalendarPayload { + name: "primary".to_string(), + source: "apple".to_string(), + color: "#888".to_string(), + }, + }]; + let incoming_events = vec![IncomingEvent { + calendar_key: CalendarKey::new(CalendarProviderType::Google, "conn-1", "primary"), + tracking_id_event: "evt-1".to_string(), + started_at: "2026-04-15T09:00:00Z".to_string(), + ended_at: Some("2026-04-15T09:30:00Z".to_string()), + recurrence_series_id: None, + has_recurrence_rules: false, + is_all_day: false, + participants: vec![IncomingParticipant { + name: Some("Alice".to_string()), + email: Some("alice@example.com".to_string()), + is_organizer: true, + is_current_user: true, + }], + payload: EventPayload { + title: Some("Standup".to_string()), + location: Some("Room 1".to_string()), + meeting_link: Some("https://meet.google.com/abc-defg-hij".to_string()), + description: Some("Daily standup".to_string()), + }, + }]; + let existing_calendars = vec![StoredCalendarRecord { + id: "cal-1".to_string(), + record: sample_calendar("primary"), + }]; + let requested_calendar_connections = + BTreeSet::from([ConnectionKey::new(CalendarProviderType::Apple, "conn")]); + let calendar_plan = hypr_calendar_sync::plan_calendars( + &existing_calendars, + &incoming_calendars, + &requested_calendar_connections, + &requested_calendar_connections, + ); + let existing_event_calendars = vec![StoredCalendarRecord { + id: "cal-1".to_string(), + record: CalendarRecord { + provider: CalendarProviderType::Google, + source: "google".to_string(), + color: "#4285f4".to_string(), + connection_id: "conn-1".to_string(), + ..sample_calendar("primary") + }, + }]; + let existing_events = vec![StoredEventRecord { + id: "event-1".to_string(), + record: sample_event("cal-1"), + }]; + let successful_event_connections = + BTreeSet::from([ConnectionKey::new(CalendarProviderType::Google, "conn-1")]); + let existing_event_calendar_plan = hypr_calendar_sync::CalendarPlan { + ops: Vec::new(), + enabled_calendar_ids: BTreeSet::from(["cal-1".to_string()]), + enabled_calendar_keys: BTreeMap::from([( + CalendarKey::new(CalendarProviderType::Google, "conn-1", "primary"), + "cal-1".to_string(), + )]), + disabled_calendar_ids: BTreeSet::new(), + }; + let event_plan = hypr_calendar_sync::plan_events( + &existing_event_calendars, + &existing_events, + &incoming_events, + &successful_event_connections, + &existing_event_calendar_plan, + SyncRange { + from: chrono::DateTime::parse_from_rfc3339("2026-04-01T00:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc), + to: chrono::DateTime::parse_from_rfc3339("2026-04-30T23:59:59Z") + .unwrap() + .with_timezone(&chrono::Utc), + }, + ); + + let changed = ::apply( + &store, + calendar_plan, + event_plan, + ) + .await + .unwrap(); + + assert!(!changed, "noop plans should not rewrite the snapshot"); + } + + #[tokio::test] + async fn worker_persists_snapshot_end_to_end() { + let dir = tempfile::tempdir().unwrap(); + let store = Arc::new(JsonCalendarSyncStore::from_base_path( + dir.path().to_path_buf(), + )); + store + .mutate_with_result(|snapshot| { + snapshot.calendars.insert( + "cal-primary".to_string(), + CalendarRecord { + user_id: default_user_id(), + created_at: "2026-04-15T00:00:00Z".to_string(), + tracking_id_calendar: "primary".to_string(), + name: "Primary".to_string(), + enabled: true, + provider: CalendarProviderType::Google, + source: "old@example.com".to_string(), + color: "#888".to_string(), + connection_id: "conn-1".to_string(), + }, + ); + (true, ()) + }) + .await + .unwrap(); + + let connection = ConnectionKey::new(CalendarProviderType::Google, "conn-1"); + let runtime = RecordingRuntime::default(); + let handle = hypr_calendar_sync::start( + StaticSource { + snapshot: IncomingSnapshot { + requested_connections: BTreeSet::from([connection.clone()]), + successful_calendar_connections: BTreeSet::from([connection.clone()]), + successful_event_connections: BTreeSet::from([connection.clone()]), + calendars: vec![IncomingCalendar { + key: CalendarKey::new(CalendarProviderType::Google, "conn-1", "primary"), + payload: CalendarPayload { + name: "Primary Renamed".to_string(), + source: "me@example.com".to_string(), + color: "#4285f4".to_string(), + }, + }], + events: vec![IncomingEvent { + calendar_key: CalendarKey::new( + CalendarProviderType::Google, + "conn-1", + "primary", + ), + tracking_id_event: "evt-1".to_string(), + started_at: "2026-04-15T09:00:00Z".to_string(), + ended_at: Some("2026-04-15T09:30:00Z".to_string()), + recurrence_series_id: None, + has_recurrence_rules: false, + is_all_day: false, + participants: vec![IncomingParticipant { + name: Some("Alice".to_string()), + email: Some("alice@example.com".to_string()), + is_organizer: true, + is_current_user: true, + }], + payload: EventPayload { + title: Some("Standup".to_string()), + location: Some("Room 1".to_string()), + meeting_link: Some("https://meet.google.com/abc-defg-hij".to_string()), + description: Some("Daily standup".to_string()), + }, + }], + }, + }, + store.clone(), + runtime.clone(), + hypr_calendar_sync::Config { + interval: Duration::from_secs(60 * 60), + sync_timeout: Duration::from_secs(5), + }, + ); + + handle.request_sync().unwrap(); + tokio::time::timeout(Duration::from_secs(5), runtime.finished.notified()) + .await + .expect("worker should finish a requested sync"); + + assert_eq!( + runtime.failure(), + None, + "worker should not emit sync failures" + ); + + let snapshot = store.load_snapshot().await.unwrap(); + let calendar = snapshot + .calendars + .get("cal-primary") + .expect("existing calendar row should be updated"); + assert_eq!(calendar.name, "Primary Renamed"); + assert_eq!(calendar.source, "me@example.com"); + + let event = snapshot + .events + .values() + .next() + .expect("worker should persist one event"); + assert_eq!(event.calendar_id, "cal-primary"); + assert_eq!(event.title, "Standup"); + assert_eq!(event.note.as_deref(), None); + assert_eq!(event.participants.len(), 1); + } +} diff --git a/plugins/calendar/src/sync/mod.rs b/plugins/calendar/src/sync/mod.rs new file mode 100644 index 0000000000..2449281da5 --- /dev/null +++ b/plugins/calendar/src/sync/mod.rs @@ -0,0 +1,8 @@ +//! Calendar sync plugin glue. + +pub mod json; +pub mod source; +pub mod store; + +pub use json::JsonCalendarSyncStore; +pub use source::PluginCalendarSyncSource; diff --git a/plugins/calendar/src/sync/source.rs b/plugins/calendar/src/sync/source.rs new file mode 100644 index 0000000000..d7e1bd490e --- /dev/null +++ b/plugins/calendar/src/sync/source.rs @@ -0,0 +1,498 @@ +use std::collections::BTreeSet; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use hypr_calendar::CalendarProviderType; +use hypr_calendar_interface::{AttendeeRole, CalendarEvent}; +use hypr_calendar_sync::{ + BoxError, CalendarKey, CalendarPayload, ConnectionKey, EventPayload, IncomingCalendar, + IncomingEvent, IncomingParticipant, IncomingSnapshot, SyncRange, +}; +use tauri::Manager; + +use crate::auth::{access_token, is_apple_authorized, require_access_token}; + +use super::json::JsonCalendarSyncStore; + +#[derive(Clone)] +pub struct PluginCalendarSyncSource { + app: tauri::AppHandle, + store: Arc, +} + +impl PluginCalendarSyncSource { + pub fn new(app: tauri::AppHandle, store: Arc) -> Self { + Self { app, store } + } + + async fn fetch_snapshot(&self, range: SyncRange) -> Result { + let api_base_url = self.app.state::().api_base_url.clone(); + tracing::info!("calendar sync source: starting sync pass"); + let apple_authorized = is_apple_authorized(&self.app) + .await + .map_err(|error| Box::new(error) as BoxError)?; + let provider_connections = hypr_calendar::list_connection_ids( + &api_base_url, + access_token(&self.app).as_deref(), + apple_authorized, + ) + .await?; + tracing::info!( + providers = provider_connections.len(), + "calendar sync source: fetched provider connections" + ); + let stored_snapshot = self.store.load_snapshot().await?; + let enabled_calendar_keys = enabled_calendar_keys(&stored_snapshot); + + let requested_connections = provider_connections + .iter() + .flat_map(|provider_connection_ids| { + provider_connection_ids + .connection_ids + .iter() + .map(|connection_id| { + ConnectionKey::new(provider_connection_ids.provider, connection_id.clone()) + }) + }) + .collect(); + + let mut successful_calendar_connections = BTreeSet::new(); + let mut successful_event_connections = BTreeSet::new(); + let mut calendars = Vec::new(); + let mut events = Vec::new(); + + for provider_connection_ids in &provider_connections { + for connection_id in &provider_connection_ids.connection_ids { + match list_calendars( + &self.app, + &api_base_url, + provider_connection_ids.provider, + connection_id, + ) + .await + { + Ok(connection_calendars) => { + successful_calendar_connections.insert(ConnectionKey::new( + provider_connection_ids.provider, + connection_id.clone(), + )); + + for calendar in &connection_calendars { + calendars.push(IncomingCalendar { + key: CalendarKey::new( + provider_connection_ids.provider, + connection_id.clone(), + calendar.id.clone(), + ), + payload: CalendarPayload { + name: calendar.title.clone(), + source: calendar.source.clone().unwrap_or_default(), + color: calendar + .color + .clone() + .unwrap_or_else(|| "#888".to_string()), + }, + }); + } + + match fetch_events_for_connection( + &self.app, + &api_base_url, + provider_connection_ids.provider, + connection_id, + &connection_calendars, + &enabled_calendar_keys, + range, + ) + .await + { + Ok(connection_events) => { + successful_event_connections.insert(ConnectionKey::new( + provider_connection_ids.provider, + connection_id.clone(), + )); + events.extend(connection_events); + } + Err(error) => { + tracing::error!( + provider = %provider_str(provider_connection_ids.provider), + connection_id, + "calendar sync failed for connection: {error}" + ); + } + } + } + Err(error) => { + tracing::warn!( + provider = %provider_str(provider_connection_ids.provider), + connection_id, + "failed to list calendars: {error}" + ); + } + } + } + } + + Ok(IncomingSnapshot { + requested_connections, + successful_calendar_connections, + successful_event_connections, + calendars, + events, + }) + } +} + +impl hypr_calendar_sync::CalendarSyncSource for PluginCalendarSyncSource { + fn fetch( + &self, + range: SyncRange, + ) -> Pin< + Box< + dyn Future< + Output = Result< + hypr_calendar_sync::IncomingSnapshot, + hypr_calendar_sync::BoxError, + >, + > + Send + + '_, + >, + > { + Box::pin(async move { self.fetch_snapshot(range).await }) + } +} + +async fn fetch_events_for_connection( + app: &tauri::AppHandle, + api_base_url: &str, + provider: CalendarProviderType, + connection_id: &str, + calendars: &[hypr_calendar::CalendarListItem], + enabled_calendar_keys: &BTreeSet, + range: SyncRange, +) -> Result, BoxError> { + let mut incoming_events = Vec::new(); + + for calendar in + filter_fetchable_calendars(provider, connection_id, calendars, enabled_calendar_keys) + { + let filter = hypr_calendar::EventFilter { + from: range.from, + to: range.to, + calendar_tracking_id: calendar.id.clone(), + }; + let fetched = list_events(app, api_base_url, provider, connection_id, filter).await?; + for calendar_event in fetched { + if should_skip_event(&calendar_event) { + continue; + } + incoming_events.push(normalize_event(provider, connection_id, &calendar_event)); + } + } + + Ok(incoming_events) +} + +fn enabled_calendar_keys(snapshot: &super::json::CalendarSyncSnapshot) -> BTreeSet { + snapshot + .calendars + .values() + .filter(|calendar| calendar.enabled) + .map(|calendar| { + CalendarKey::new( + calendar.provider, + calendar.connection_id.clone(), + calendar.tracking_id_calendar.clone(), + ) + }) + .collect() +} + +fn filter_fetchable_calendars<'a>( + provider: CalendarProviderType, + connection_id: &str, + calendars: &'a [hypr_calendar::CalendarListItem], + enabled_calendar_keys: &BTreeSet, +) -> Vec<&'a hypr_calendar::CalendarListItem> { + calendars + .iter() + .filter(|calendar| { + enabled_calendar_keys.contains(&CalendarKey::new( + provider, + connection_id, + calendar.id.clone(), + )) + }) + .collect() +} + +async fn list_calendars( + app: &tauri::AppHandle, + api_base_url: &str, + provider: CalendarProviderType, + connection_id: &str, +) -> Result, BoxError> { + let token = match provider { + CalendarProviderType::Apple => access_token(app).unwrap_or_default(), + _ => require_access_token(app).map_err(|error| Box::new(error) as BoxError)?, + }; + Ok(hypr_calendar::list_calendars(api_base_url, &token, provider, connection_id).await?) +} + +async fn list_events( + app: &tauri::AppHandle, + api_base_url: &str, + provider: CalendarProviderType, + connection_id: &str, + filter: hypr_calendar::EventFilter, +) -> Result, BoxError> { + let token = match provider { + CalendarProviderType::Apple => access_token(app).unwrap_or_default(), + _ => require_access_token(app).map_err(|error| Box::new(error) as BoxError)?, + }; + Ok(hypr_calendar::list_events(api_base_url, &token, provider, connection_id, filter).await?) +} + +fn normalize_event( + provider: CalendarProviderType, + connection_id: &str, + event: &CalendarEvent, +) -> IncomingEvent { + let mut participants = Vec::new(); + if let Some(organizer) = &event.organizer { + participants.push(IncomingParticipant { + name: organizer.name.clone(), + email: organizer.email.clone(), + is_organizer: true, + is_current_user: organizer.is_current_user, + }); + } + + let organizer_email = event + .organizer + .as_ref() + .and_then(|organizer| organizer.email.as_ref()) + .map(|email| email.to_lowercase()); + for attendee in &event.attendees { + if attendee.role == AttendeeRole::NonParticipant { + continue; + } + if organizer_email.as_ref().is_some_and(|email| { + attendee.email.as_ref().map(|value| value.to_lowercase()) == Some(email.clone()) + }) { + continue; + } + participants.push(IncomingParticipant { + name: attendee.name.clone(), + email: attendee.email.clone(), + is_organizer: false, + is_current_user: attendee.is_current_user, + }); + } + + IncomingEvent { + calendar_key: CalendarKey::new( + provider, + connection_id.to_string(), + event.calendar_id.clone(), + ), + tracking_id_event: event.id.clone(), + started_at: event.started_at.clone(), + ended_at: Some(event.ended_at.clone()), + recurrence_series_id: event.recurring_event_id.clone(), + has_recurrence_rules: event.has_recurrence_rules, + is_all_day: event.is_all_day, + participants, + payload: EventPayload { + title: Some(event.title.clone()), + location: event.location.clone(), + meeting_link: event + .meeting_link + .clone() + .or_else(|| { + event + .description + .as_deref() + .and_then(hypr_calendar::parse_meeting_link) + }) + .or_else(|| { + event + .location + .as_deref() + .and_then(hypr_calendar::parse_meeting_link) + }), + description: event.description.clone(), + }, + } +} + +fn should_skip_event(event: &CalendarEvent) -> bool { + event.attendees.iter().any(|attendee| { + attendee.is_current_user + && matches!( + attendee.status, + hypr_calendar_interface::AttendeeStatus::Declined + ) + }) +} + +fn provider_str(provider: CalendarProviderType) -> &'static str { + match provider { + CalendarProviderType::Apple => "apple", + CalendarProviderType::Google => "google", + CalendarProviderType::Outlook => "outlook", + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::sync::json::CalendarSyncSnapshot; + use crate::sync::store::CalendarRecord; + + #[test] + fn disabled_calendars_stay_in_snapshot_but_are_not_fetchable() { + let provider = CalendarProviderType::Google; + let connection_id = "conn-1"; + let snapshot = CalendarSyncSnapshot { + calendars: BTreeMap::from([ + ( + "cal-enabled".to_string(), + CalendarRecord { + user_id: "user".to_string(), + created_at: "2026-04-15T00:00:00Z".to_string(), + tracking_id_calendar: "primary".to_string(), + name: "Primary".to_string(), + enabled: true, + provider, + source: "me@example.com".to_string(), + color: "#4285f4".to_string(), + connection_id: connection_id.to_string(), + }, + ), + ( + "cal-disabled".to_string(), + CalendarRecord { + user_id: "user".to_string(), + created_at: "2026-04-15T00:00:00Z".to_string(), + tracking_id_calendar: "holidays".to_string(), + name: "Holidays".to_string(), + enabled: false, + provider, + source: "holidays".to_string(), + color: "#16a765".to_string(), + connection_id: connection_id.to_string(), + }, + ), + ]), + events: BTreeMap::new(), + }; + let provider_calendars = vec![ + test_calendar_list_item(provider, "primary", "Primary"), + test_calendar_list_item(provider, "holidays", "Holidays"), + ]; + + let enabled_keys = enabled_calendar_keys(&snapshot); + let fetchable = + filter_fetchable_calendars(provider, connection_id, &provider_calendars, &enabled_keys); + + assert_eq!( + snapshot.calendars.len(), + 2, + "both calendars remain in snapshot" + ); + assert_eq!(fetchable.len(), 1); + assert_eq!(fetchable[0].id, "primary"); + } + + #[test] + fn reenabled_calendar_becomes_fetchable() { + let provider = CalendarProviderType::Google; + let connection_id = "conn-1"; + let provider_calendars = vec![test_calendar_list_item(provider, "primary", "Primary")]; + let disabled_snapshot = CalendarSyncSnapshot { + calendars: BTreeMap::from([( + "cal-primary".to_string(), + CalendarRecord { + user_id: "user".to_string(), + created_at: "2026-04-15T00:00:00Z".to_string(), + tracking_id_calendar: "primary".to_string(), + name: "Primary".to_string(), + enabled: false, + provider, + source: "me@example.com".to_string(), + color: "#4285f4".to_string(), + connection_id: connection_id.to_string(), + }, + )]), + events: BTreeMap::new(), + }; + let enabled_snapshot = CalendarSyncSnapshot { + calendars: BTreeMap::from([( + "cal-primary".to_string(), + CalendarRecord { + enabled: true, + ..disabled_snapshot.calendars["cal-primary"].clone() + }, + )]), + events: BTreeMap::new(), + }; + + let disabled_fetchable = filter_fetchable_calendars( + provider, + connection_id, + &provider_calendars, + &enabled_calendar_keys(&disabled_snapshot), + ); + let enabled_fetchable = filter_fetchable_calendars( + provider, + connection_id, + &provider_calendars, + &enabled_calendar_keys(&enabled_snapshot), + ); + + assert!(disabled_fetchable.is_empty()); + assert_eq!(enabled_fetchable.len(), 1); + assert_eq!(enabled_fetchable[0].id, "primary"); + } + + #[test] + fn brand_new_calendar_stays_unfetchable_until_it_exists_in_snapshot() { + let provider = CalendarProviderType::Google; + let connection_id = "conn-1"; + let empty_snapshot = CalendarSyncSnapshot::default(); + let provider_calendars = vec![test_calendar_list_item(provider, "primary", "Primary")]; + + let fetchable = filter_fetchable_calendars( + provider, + connection_id, + &provider_calendars, + &enabled_calendar_keys(&empty_snapshot), + ); + + assert!( + fetchable.is_empty(), + "new calendars should not fetch events before the store has an enabled row" + ); + } + + fn test_calendar_list_item( + provider: CalendarProviderType, + id: &str, + title: &str, + ) -> hypr_calendar::CalendarListItem { + hypr_calendar::CalendarListItem { + provider, + id: id.to_string(), + title: title.to_string(), + source: None, + color: None, + is_primary: None, + can_edit: None, + raw: "{}".to_string(), + } + } +} diff --git a/plugins/calendar/src/sync/store.rs b/plugins/calendar/src/sync/store.rs new file mode 100644 index 0000000000..7fc0d712b0 --- /dev/null +++ b/plugins/calendar/src/sync/store.rs @@ -0,0 +1,100 @@ +use hypr_calendar_interface::CalendarProviderType; + +const DEFAULT_USER_ID: &str = "00000000-0000-0000-0000-000000000000"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CalendarRecord { + pub user_id: String, + pub created_at: String, + pub tracking_id_calendar: String, + pub name: String, + pub enabled: bool, + pub provider: CalendarProviderType, + pub source: String, + pub color: String, + pub connection_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParticipantRecord { + pub name: Option, + pub email: Option, + pub is_organizer: bool, + pub is_current_user: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EventRecord { + pub user_id: String, + pub created_at: String, + pub tracking_id_event: Option, + pub calendar_id: String, + pub title: String, + pub started_at: String, + pub ended_at: Option, + pub location: Option, + pub meeting_link: Option, + pub description: Option, + pub note: Option, + pub recurrence_series_id: Option, + pub has_recurrence_rules: bool, + pub is_all_day: bool, + pub provider: CalendarProviderType, + pub participants: Vec, +} + +pub(crate) fn default_user_id() -> String { + DEFAULT_USER_ID.to_string() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoredCalendarRecord { + pub id: String, + pub record: CalendarRecord, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoredEventRecord { + pub id: String, + pub record: EventRecord, +} + +impl hypr_calendar_sync::PersistedCalendar for StoredCalendarRecord { + fn id(&self) -> &str { + &self.id + } + + fn key(&self) -> hypr_calendar_sync::CalendarKey { + hypr_calendar_sync::CalendarKey::new( + self.record.provider, + self.record.connection_id.clone(), + self.record.tracking_id_calendar.clone(), + ) + } + + fn enabled(&self) -> bool { + self.record.enabled + } +} + +impl hypr_calendar_sync::PersistedEvent for StoredEventRecord { + fn id(&self) -> &str { + &self.id + } + + fn tracking_id_event(&self) -> Option<&str> { + self.record.tracking_id_event.as_deref() + } + + fn calendar_id(&self) -> &str { + &self.record.calendar_id + } + + fn started_at(&self) -> &str { + &self.record.started_at + } + + fn ended_at(&self) -> Option<&str> { + self.record.ended_at.as_deref() + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 096cc16168..e2b254f46e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -400,7 +400,7 @@ importers: version: 0.13.11(typescript@5.8.3)(zod@4.3.6) '@tanstack/react-form': specifier: ^1.29.0 - version: 1.29.0(@tanstack/react-start@1.167.33(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.25.12)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.29.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-query': specifier: ^5.99.0 version: 5.99.0(react@19.2.5) @@ -578,9 +578,6 @@ importers: tinybase: specifier: ^7.3.5 version: 7.3.5(@electric-sql/pglite@0.3.16)(@sinclair/typebox@0.34.49)(effect@3.21.0)(postgres@3.4.9)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(ws@8.20.0)(zod@4.3.6) - tinytick: - specifier: ^1.2.8 - version: 1.2.8 tlds: specifier: ^1.261.0 version: 1.261.0 @@ -17970,9 +17967,6 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - tinytick@1.2.8: - resolution: {integrity: sha512-tT7/2EIfUcQN4yjREOx7xwL85pooPYg9vahoPcY9/sGdfurR/cp/bgfxlAAKQnIoXjcD8peYhqa6NS3em2PioA==} - tlds@1.261.0: resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} hasBin: true @@ -19712,7 +19706,7 @@ snapshots: '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -20327,7 +20321,7 @@ snapshots: '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -27218,6 +27212,14 @@ snapshots: transitivePeerDependencies: - react-dom + '@tanstack/react-form@1.29.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@tanstack/form-core': 1.29.0 + '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + transitivePeerDependencies: + - react-dom + '@tanstack/react-query-devtools@5.99.0(@tanstack/react-query@5.99.0(react@19.2.5))(react@19.2.5)': dependencies: '@tanstack/query-devtools': 5.99.0 @@ -30890,6 +30892,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 @@ -32790,7 +32796,7 @@ snapshots: happy-dom@20.8.9: dependencies: - '@types/node': 25.6.0 + '@types/node': 24.12.2 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -33137,7 +33143,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -33177,7 +33183,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -35654,7 +35660,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.13 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -39351,8 +39357,6 @@ snapshots: tinyrainbow@3.1.0: {} - tinytick@1.2.8: {} - tlds@1.261.0: {} tldts-core@6.1.86: {}