diff --git a/Cargo.lock b/Cargo.lock index 3ac382e42a..065be98b06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2918,6 +2918,23 @@ dependencies = [ "specta", ] +[[package]] +name = "calendar-worker" +version = "0.1.0" +dependencies = [ + "apalis", + "apalis-cron", + "chrono", + "cron", + "serde", + "serde_json", + "specta", + "tauri-specta", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "camino" version = "1.2.2" @@ -11516,23 +11533,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "notification-worker" -version = "0.1.0" -dependencies = [ - "apalis", - "apalis-cron", - "chrono", - "cron", - "serde", - "serde_json", - "specta", - "tauri-specta", - "thiserror 2.0.18", - "tokio", - "tracing", -] - [[package]] name = "notify" version = "8.2.0" @@ -18101,6 +18101,8 @@ version = "0.1.0" dependencies = [ "calendar", "calendar-interface", + "calendar-worker", + "chrono", "serde", "specta", "specta-typescript", @@ -18110,6 +18112,7 @@ dependencies = [ "tauri-plugin-permissions", "tauri-specta", "thiserror 2.0.18", + "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index faea101bc2..c596cb02f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,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-worker = { path = "crates/calendar-worker", package = "calendar-worker" } hypr-chatwoot = { path = "crates/chatwoot", package = "chatwoot" } hypr-claude = { path = "crates/claude", package = "claude" } hypr-cloudsync = { path = "crates/cloudsync", package = "cloudsync" } diff --git a/apps/desktop/src/services/calendar-auto-start.test.ts b/apps/desktop/src/services/calendar-auto-start.test.ts new file mode 100644 index 0000000000..5d038d412c --- /dev/null +++ b/apps/desktop/src/services/calendar-auto-start.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test, vi } from "vitest"; + +import { + consumePendingCalendarAutoStarts, + resolveCalendarAutoStartEvent, +} from "./calendar-auto-start"; + +type MockStoreData = { + events: Record>; + values: Record; +}; + +function createStore(data: MockStoreData) { + return { + forEachRow: ( + tableId: "events", + callback: (rowId: string, forEachCell: unknown) => void, + ) => { + if (tableId !== "events") { + return; + } + + for (const rowId of Object.keys(data.events)) { + callback(rowId, () => {}); + } + }, + getRow: (tableId: "events", rowId: string) => { + if (tableId !== "events") { + return null; + } + + return data.events[rowId] ?? null; + }, + getValue: (valueId: string) => data.values[valueId], + }; +} + +describe("resolveCalendarAutoStartEvent", () => { + test("returns pending when the synced event row is not available yet", () => { + const result = resolveCalendarAutoStartEvent( + createStore({ + events: {}, + values: {}, + }), + "tracking-1", + ); + + expect(result).toEqual({ status: "pending" }); + }); + + test("returns ignored when the event tracking id is ignored", () => { + const result = resolveCalendarAutoStartEvent( + createStore({ + events: { + "event-row-1": { tracking_id_event: "tracking-1" }, + }, + values: { + ignored_events: JSON.stringify([{ tracking_id: "tracking-1" }]), + }, + }), + "tracking-1", + ); + + expect(result).toEqual({ status: "ignored" }); + }); + + test("returns ignored when the recurring series is ignored", () => { + const result = resolveCalendarAutoStartEvent( + createStore({ + events: { + "event-row-1": { + tracking_id_event: "tracking-1", + recurrence_series_id: "series-1", + }, + }, + values: { + ignored_recurring_series: JSON.stringify([{ id: "series-1" }]), + }, + }), + "tracking-1", + ); + + expect(result).toEqual({ status: "ignored" }); + }); + + test("returns the local event row id when the event is ready", () => { + const result = resolveCalendarAutoStartEvent( + createStore({ + events: { + "event-row-1": { tracking_id_event: "tracking-1" }, + }, + values: {}, + }), + "tracking-1", + ); + + expect(result).toEqual({ + status: "ready", + eventRowId: "event-row-1", + }); + }); +}); + +describe("consumePendingCalendarAutoStarts", () => { + test("keeps unresolved tracking ids queued until sync catches up", () => { + const pending = new Set(["tracking-1"]); + const onReady = vi.fn(); + + consumePendingCalendarAutoStarts( + createStore({ + events: {}, + values: {}, + }), + pending, + onReady, + ); + + expect(onReady).not.toHaveBeenCalled(); + expect([...pending]).toEqual(["tracking-1"]); + }); + + test("consumes ready and ignored tracking ids without dropping future retries", () => { + const pending = new Set(["tracking-ready", "tracking-ignored"]); + const onReady = vi.fn(); + + consumePendingCalendarAutoStarts( + createStore({ + events: { + "event-row-1": { tracking_id_event: "tracking-ready" }, + "event-row-2": { tracking_id_event: "tracking-ignored" }, + }, + values: { + ignored_events: JSON.stringify([{ tracking_id: "tracking-ignored" }]), + }, + }), + pending, + onReady, + ); + + expect(onReady).toHaveBeenCalledWith("event-row-1"); + expect([...pending]).toEqual([]); + }); +}); diff --git a/apps/desktop/src/services/calendar-auto-start.ts b/apps/desktop/src/services/calendar-auto-start.ts new file mode 100644 index 0000000000..8a68196960 --- /dev/null +++ b/apps/desktop/src/services/calendar-auto-start.ts @@ -0,0 +1,93 @@ +type StoreLike = { + forEachRow: ( + tableId: "events", + callback: (rowId: string, forEachCell: unknown) => void, + ) => void; + getRow: (tableId: "events", rowId: string) => Record | null; + getValue: (valueId: "ignored_events" | "ignored_recurring_series") => unknown; +}; + +export type CalendarAutoStartResolution = + | { status: "pending" } + | { status: "ignored" } + | { status: "ready"; eventRowId: string }; + +export function resolveCalendarAutoStartEvent( + store: StoreLike, + trackingId: string, +): CalendarAutoStartResolution { + let eventRowId: string | null = null; + + store.forEachRow("events", (rowId, _forEachCell) => { + if (eventRowId) return; + const row = store.getRow("events", rowId); + if (row?.tracking_id_event === trackingId) { + eventRowId = rowId; + } + }); + + if (!eventRowId) { + return { status: "pending" }; + } + + const eventRow = store.getRow("events", eventRowId); + const seriesId = eventRow?.recurrence_series_id as string | undefined; + + if (isTrackingIdIgnored(store, trackingId)) { + return { status: "ignored" }; + } + + if (seriesId && isRecurringSeriesIgnored(store, seriesId)) { + return { status: "ignored" }; + } + + return { status: "ready", eventRowId }; +} + +export function consumePendingCalendarAutoStarts( + store: StoreLike, + pendingTrackingIds: Set, + onReady: (eventRowId: string) => void, +): void { + for (const trackingId of [...pendingTrackingIds]) { + const resolution = resolveCalendarAutoStartEvent(store, trackingId); + + if (resolution.status === "pending") { + continue; + } + + pendingTrackingIds.delete(trackingId); + + if (resolution.status === "ready") { + onReady(resolution.eventRowId); + } + } +} + +function isTrackingIdIgnored(store: StoreLike, trackingId: string): boolean { + try { + const raw = store.getValue("ignored_events"); + if (!raw || typeof raw !== "string") { + return false; + } + + const ignored = JSON.parse(raw) as Array<{ tracking_id: string }>; + return ignored.some((event) => event.tracking_id === trackingId); + } catch { + return false; + } +} + +function isRecurringSeriesIgnored(store: StoreLike, seriesId: string): boolean { + try { + const raw = store.getValue("ignored_recurring_series"); + if (!raw || typeof raw !== "string") { + return false; + } + + const ignored = JSON.parse(raw) as Array<{ id: string }>; + return ignored.some((series) => series.id === seriesId); + } catch { + return false; + } +} diff --git a/apps/desktop/src/services/event-listeners.tsx b/apps/desktop/src/services/event-listeners.tsx index 069fa18c17..87f951c4da 100644 --- a/apps/desktop/src/services/event-listeners.tsx +++ b/apps/desktop/src/services/event-listeners.tsx @@ -1,6 +1,7 @@ import { type UnlistenFn } from "@tauri-apps/api/event"; import { useEffect, useRef } from "react"; +import { events as calendarEvents } from "@hypr/plugin-calendar"; import { events as notificationEvents } from "@hypr/plugin-notification"; import { commands as updaterCommands, @@ -8,11 +9,17 @@ import { } from "@hypr/plugin-updater2"; import { getCurrentWebviewWindowLabel } from "@hypr/plugin-windows"; +import { + consumePendingCalendarAutoStarts, + resolveCalendarAutoStartEvent, +} from "./calendar-auto-start"; + import * as main from "~/store/tinybase/store/main"; import { createSession, getOrCreateSessionForEventId, } from "~/store/tinybase/store/sessions"; +import { listenerStore } from "~/store/zustand/listener/instance"; import { useTabs } from "~/store/zustand/tabs"; function useUpdaterEvents() { @@ -46,7 +53,6 @@ function useUpdaterEvents() { function useNotificationEvents() { const store = main.UI.useStore(main.STORE_ID); const openNew = useTabs((state) => state.openNew); - const pendingAutoStart = useRef<{ eventId: string | null } | null>(null); const storeRef = useRef(store); const openNewRef = useRef(openNew); @@ -55,21 +61,6 @@ function useNotificationEvents() { openNewRef.current = openNew; }, [store, openNew]); - useEffect(() => { - if (pendingAutoStart.current && store) { - const { eventId } = pendingAutoStart.current; - pendingAutoStart.current = null; - const sessionId = eventId - ? getOrCreateSessionForEventId(store, eventId) - : createSession(store); - openNew({ - type: "sessions", - id: sessionId, - state: { view: null, autoStart: true }, - }); - } - }, [store, openNew]); - useEffect(() => { if (getCurrentWebviewWindowLabel() !== "main") { return; @@ -89,17 +80,15 @@ function useNotificationEvents() { ? payload.source.event_id : null; const currentStore = storeRef.current; - if (!currentStore) { - pendingAutoStart.current = { eventId }; - return; - } + if (!currentStore) return; const sessionId = eventId ? getOrCreateSessionForEventId(currentStore, eventId) : createSession(currentStore); + listenerStore.getState().requestAutoStart(sessionId); openNewRef.current({ type: "sessions", id: sessionId, - state: { view: null, autoStart: true }, + state: { view: null }, }); } else if (payload.type === "notification_option_selected") { const currentStore = storeRef.current; @@ -119,10 +108,11 @@ function useNotificationEvents() { ) : createSession(currentStore); + listenerStore.getState().requestAutoStart(sessionId); openNewRef.current({ type: "sessions", id: sessionId, - state: { view: null, autoStart: true }, + state: { view: null }, }); } }) @@ -141,9 +131,110 @@ function useNotificationEvents() { }, []); } +function useCalendarStartEvents() { + const store = main.UI.useStore(main.STORE_ID); + const openNew = useTabs((state) => state.openNew); + const pendingAutoStartTrackingIds = useRef(new Set()); + const storeRef = useRef(store); + const openNewRef = useRef(openNew); + + useEffect(() => { + storeRef.current = store; + }, [store]); + useEffect(() => { + openNewRef.current = openNew; + }, [openNew]); + + const openCalendarAutoStart = ( + currentStore: NonNullable, + eventRowId: string, + ) => { + const sessionId = getOrCreateSessionForEventId(currentStore, eventRowId); + listenerStore.getState().requestAutoStart(sessionId); + openNewRef.current({ + type: "sessions", + id: sessionId, + state: { view: null }, + }); + }; + + const consumePendingAutoStarts = ( + currentStore: NonNullable, + ) => { + consumePendingCalendarAutoStarts( + currentStore, + pendingAutoStartTrackingIds.current, + (eventRowId) => { + openCalendarAutoStart(currentStore, eventRowId); + }, + ); + }; + + useEffect(() => { + if (getCurrentWebviewWindowLabel() !== "main" || !store) return; + + consumePendingAutoStarts(store); + + const listenerId = store.addRowListener("events", null, () => { + consumePendingAutoStarts(store); + }); + + return () => { + store.delListener(listenerId); + }; + }, [store]); + + useEffect(() => { + if (getCurrentWebviewWindowLabel() !== "main") return; + + let unlisten: UnlistenFn | null = null; + let cancelled = false; + + void calendarEvents.notificationWorkerEvent + .listen(({ payload }) => { + if (payload.type !== "eventStarted") return; + + const currentStore = storeRef.current; + if (!currentStore) { + pendingAutoStartTrackingIds.current.add(payload.event_id); + return; + } + + const resolution = resolveCalendarAutoStartEvent( + currentStore, + payload.event_id, + ); + + if (resolution.status === "pending") { + pendingAutoStartTrackingIds.current.add(payload.event_id); + return; + } + + pendingAutoStartTrackingIds.current.delete(payload.event_id); + + if (resolution.status === "ready") { + openCalendarAutoStart(currentStore, resolution.eventRowId); + } + }) + .then((f) => { + if (cancelled) { + f(); + } else { + unlisten = f; + } + }); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); +} + export function EventListeners() { useUpdaterEvents(); useNotificationEvents(); + useCalendarStartEvents(); return null; } diff --git a/apps/desktop/src/session/hooks/useEventCountdown.ts b/apps/desktop/src/session/hooks/useEventCountdown.ts index 7f3092c02b..6f25ef4e7b 100644 --- a/apps/desktop/src/session/hooks/useEventCountdown.ts +++ b/apps/desktop/src/session/hooks/useEventCountdown.ts @@ -1,18 +1,12 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useSessionEvent } from "~/store/tinybase/hooks"; const FIVE_MINUTES = 5 * 60 * 1000; -export function useEventCountdown( - sessionId: string, - { onExpire }: { onExpire?: () => void } = {}, -) { +export function useEventCountdown(sessionId: string) { const sessionEvent = useSessionEvent(sessionId); const startedAt = sessionEvent?.started_at; - const onExpireRef = useRef(onExpire); - onExpireRef.current = onExpire; - const [label, setLabel] = useState(null); useEffect(() => { @@ -22,8 +16,6 @@ export function useEventCountdown( } const eventStart = new Date(startedAt).getTime(); - let fired = false; - let interval: ReturnType; const update = () => { @@ -32,10 +24,6 @@ export function useEventCountdown( if (diff <= 0) { setLabel(null); clearInterval(interval); - if (!fired) { - fired = true; - onExpireRef.current?.(); - } return; } diff --git a/apps/desktop/src/session/index.tsx b/apps/desktop/src/session/index.tsx index b3980583be..2face60eaa 100644 --- a/apps/desktop/src/session/index.tsx +++ b/apps/desktop/src/session/index.tsx @@ -116,10 +116,11 @@ export function TabContentNote({ }) { const listenerStatus = useListener((state) => state.live.status); const sessionMode = useListener((state) => state.getSessionMode(tab.id)); + const pendingAutoStart = useListener((state) => state.pendingAutoStart); + const clearAutoStart = useListener((state) => state.clearAutoStart); const updateSessionTabState = useTabs((state) => state.updateSessionTabState); const { conn } = useSTTConnection(); const startListening = useStartListening(tab.id); - const hasAttemptedAutoStart = useRef(false); useEffect(() => { if ( @@ -134,16 +135,12 @@ export function TabContentNote({ }, [sessionMode, tab, updateSessionTabState]); useEffect(() => { - if (!tab.state.autoStart) { - hasAttemptedAutoStart.current = false; - return; - } - - if (hasAttemptedAutoStart.current) { + if (pendingAutoStart !== tab.id) { return; } if (listenerStatus !== "inactive") { + clearAutoStart(); return; } @@ -151,17 +148,15 @@ export function TabContentNote({ return; } - hasAttemptedAutoStart.current = true; startListening(); - updateSessionTabState(tab, { ...tab.state, autoStart: null }); + clearAutoStart(); }, [ + pendingAutoStart, tab.id, - tab.state, - tab.state.autoStart, listenerStatus, conn, startListening, - updateSessionTabState, + clearAutoStart, ]); const { data: audioUrl } = useQuery({ diff --git a/apps/desktop/src/shared/main/useNewNote.ts b/apps/desktop/src/shared/main/useNewNote.ts index 759814bb54..66b01a4f39 100644 --- a/apps/desktop/src/shared/main/useNewNote.ts +++ b/apps/desktop/src/shared/main/useNewNote.ts @@ -7,6 +7,7 @@ import { useShallow } from "zustand/shallow"; import { commands as analyticsCommands } from "@hypr/plugin-analytics"; import { id } from "~/shared/utils"; +import { listenerStore } from "~/store/zustand/listener/instance"; import { useTabs } from "~/store/zustand/tabs"; import { useListener } from "~/stt/contexts"; import { setPendingUpload } from "~/stt/pending-upload"; @@ -89,11 +90,8 @@ export function useNewNoteAndListen({ }); const ff = behavior === "new" ? openNew : openCurrent; - ff({ - type: "sessions", - id: sessionId, - state: { view: null, autoStart: true }, - }); + listenerStore.getState().requestAutoStart(sessionId); + ff({ type: "sessions", id: sessionId, state: { view: null } }); }, [ status, liveSessionId, @@ -152,7 +150,7 @@ export function useNewNoteAndUpload() { openNew({ type: "sessions", id: sessionId, - state: { view: { type: "transcript" }, autoStart: null }, + state: { view: { type: "transcript" } }, }); }, [persistedStore, internalStore, openNew], diff --git a/apps/desktop/src/store/zustand/listener/general-shared.ts b/apps/desktop/src/store/zustand/listener/general-shared.ts index 9af8f75c7c..14521e4f35 100644 --- a/apps/desktop/src/store/zustand/listener/general-shared.ts +++ b/apps/desktop/src/store/zustand/listener/general-shared.ts @@ -20,6 +20,7 @@ export type LoadingPhase = | "connected"; export type GeneralState = { + pendingAutoStart: string | null; live: { eventUnlisteners?: (() => void)[]; loading: boolean; @@ -58,6 +59,7 @@ const initialLiveState: LiveState = { }; export const initialGeneralState: GeneralState = { + pendingAutoStart: null, live: initialLiveState, }; diff --git a/apps/desktop/src/store/zustand/listener/general.ts b/apps/desktop/src/store/zustand/listener/general.ts index fd84e846ff..bb81f2146f 100644 --- a/apps/desktop/src/store/zustand/listener/general.ts +++ b/apps/desktop/src/store/zustand/listener/general.ts @@ -42,6 +42,8 @@ export type GeneralActions = { options?: { handlePersist?: BatchPersistCallback }, ) => Promise; getSessionMode: (sessionId: string) => SessionMode; + requestAutoStart: (sessionId: string) => void; + clearAutoStart: () => void; }; export const createGeneralSlice = < @@ -148,6 +150,12 @@ export const createGeneralSlice = < await runBatchSession(get, sessionId, params); }, + requestAutoStart: (sessionId) => { + set({ pendingAutoStart: sessionId } as Partial); + }, + clearAutoStart: () => { + set({ pendingAutoStart: null } as Partial); + }, getSessionMode: (sessionId) => { if (!sessionId) { return "inactive"; diff --git a/apps/desktop/src/store/zustand/tabs/schema.ts b/apps/desktop/src/store/zustand/tabs/schema.ts index e4faccf09d..a83b1cf275 100644 --- a/apps/desktop/src/store/zustand/tabs/schema.ts +++ b/apps/desktop/src/store/zustand/tabs/schema.ts @@ -142,7 +142,7 @@ export const getDefaultState = (tab: TabInput): Tab => { ...base, type: "sessions", id: tab.id, - state: tab.state ?? { view: null, autoStart: null }, + state: tab.state ?? { view: null }, }; case "contacts": return { diff --git a/apps/desktop/src/store/zustand/tabs/state.test.ts b/apps/desktop/src/store/zustand/tabs/state.test.ts index c987be0354..e040ea6e8c 100644 --- a/apps/desktop/src/store/zustand/tabs/state.test.ts +++ b/apps/desktop/src/store/zustand/tabs/state.test.ts @@ -27,14 +27,14 @@ describe("State Updater Actions", () => { const state = useTabs.getState(); expect(state.tabs[0]).toMatchObject({ id: tab.id, - state: { view: { type: "enhanced", id: "note-1" }, autoStart: null }, + state: { view: { type: "enhanced", id: "note-1" } }, }); expect(useTabs.getState()).toHaveCurrentTab({ id: tab.id, - state: { view: { type: "enhanced", id: "note-1" }, autoStart: null }, + state: { view: { type: "enhanced", id: "note-1" } }, }); expect(useTabs.getState()).toHaveLastHistoryEntry({ - state: { view: { type: "enhanced", id: "note-1" }, autoStart: null }, + state: { view: { type: "enhanced", id: "note-1" } }, }); }); @@ -56,11 +56,11 @@ describe("State Updater Actions", () => { }); expect(state.tabs[1]).toMatchObject({ id: active.id, - state: { view: null, autoStart: null }, + state: { view: null }, }); expect(useTabs.getState()).toHaveLastHistoryEntry({ id: active.id, - state: { view: null, autoStart: null }, + state: { view: null }, }); }); @@ -77,7 +77,7 @@ describe("State Updater Actions", () => { const state = useTabs.getState(); expect(state.tabs[0]).toMatchObject({ id: session.id, - state: { view: null, autoStart: null }, + state: { view: null }, }); expect(state.tabs[1]).toMatchObject({ type: "contacts" }); }); @@ -115,7 +115,7 @@ describe("State Updater Actions", () => { const state = useTabs.getState(); expect(state.tabs[0]).toMatchObject({ state: newContactsState }); expect(state.tabs[1]).toMatchObject({ - state: { view: null, autoStart: null }, + state: { view: null }, }); expect(useTabs.getState()).toHaveLastHistoryEntry({ id: session.id, diff --git a/apps/desktop/src/store/zustand/tabs/test-utils.ts b/apps/desktop/src/store/zustand/tabs/test-utils.ts index 1c83acc782..8d396fb5c9 100644 --- a/apps/desktop/src/store/zustand/tabs/test-utils.ts +++ b/apps/desktop/src/store/zustand/tabs/test-utils.ts @@ -29,7 +29,7 @@ export const createSessionTab = ( slotId: id(), state: { view: null, - autoStart: null, + ...overrides.state, }, }); diff --git a/crates/notification-worker/Cargo.toml b/crates/calendar-worker/Cargo.toml similarity index 95% rename from crates/notification-worker/Cargo.toml rename to crates/calendar-worker/Cargo.toml index f5842df3e3..dcc6e58179 100644 --- a/crates/notification-worker/Cargo.toml +++ b/crates/calendar-worker/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "notification-worker" +name = "calendar-worker" version = "0.1.0" edition = "2024" diff --git a/crates/notification-worker/src/error.rs b/crates/calendar-worker/src/error.rs similarity index 100% rename from crates/notification-worker/src/error.rs rename to crates/calendar-worker/src/error.rs diff --git a/crates/notification-worker/src/lib.rs b/crates/calendar-worker/src/lib.rs similarity index 90% rename from crates/notification-worker/src/lib.rs rename to crates/calendar-worker/src/lib.rs index dd8140399e..8d8a1d8dac 100644 --- a/crates/notification-worker/src/lib.rs +++ b/crates/calendar-worker/src/lib.rs @@ -34,10 +34,10 @@ pub async fn run( }; let schedule = Schedule::from_str("0 * * * * *")?; - let worker = WorkerBuilder::new("notification-worker") + let worker = WorkerBuilder::new("calendar-worker") .backend(CronStream::new(schedule)) .data(state) - .build(worker::check_upcoming); + .build(worker::check_events); worker.run().await?; diff --git a/crates/notification-worker/src/runtime.rs b/crates/calendar-worker/src/runtime.rs similarity index 82% rename from crates/notification-worker/src/runtime.rs rename to crates/calendar-worker/src/runtime.rs index c78913b829..3778cc6d2f 100644 --- a/crates/notification-worker/src/runtime.rs +++ b/crates/calendar-worker/src/runtime.rs @@ -2,12 +2,11 @@ #[cfg_attr(feature = "tauri-event", derive(tauri_specta::Event))] #[serde(tag = "type")] pub enum NotificationWorkerEvent { - #[serde(rename = "upcomingEvent")] - UpcomingEvent { + #[serde(rename = "eventStarted")] + EventStarted { event_id: String, title: String, started_at: String, - minutes_until: i64, participants: Vec, }, } diff --git a/crates/notification-worker/src/source/fs.rs b/crates/calendar-worker/src/source/fs.rs similarity index 100% rename from crates/notification-worker/src/source/fs.rs rename to crates/calendar-worker/src/source/fs.rs diff --git a/crates/notification-worker/src/source/mod.rs b/crates/calendar-worker/src/source/mod.rs similarity index 100% rename from crates/notification-worker/src/source/mod.rs rename to crates/calendar-worker/src/source/mod.rs diff --git a/crates/calendar-worker/src/worker.rs b/crates/calendar-worker/src/worker.rs new file mode 100644 index 0000000000..4eb210fb36 --- /dev/null +++ b/crates/calendar-worker/src/worker.rs @@ -0,0 +1,79 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use apalis::prelude::Data; +use apalis_cron::Tick; +use chrono::{DateTime, Duration, Utc}; + +use crate::runtime::{NotificationWorkerEvent, NotificationWorkerRuntime}; +use crate::source::EventSource; + +const DEDUP_TTL_MINUTES: i64 = 30; + +// Emit EventStarted when a meeting is within this window around its start time. +// Wide enough to survive a full cron interval on either side. +const START_WINDOW_BEFORE_SECS: i64 = 60; +const START_WINDOW_AFTER_SECS: i64 = 120; + +#[derive(Clone)] +pub struct WorkerState { + pub source: Arc, + pub runtime: Arc, + pub lookahead: Duration, + pub notified: Arc>>>, +} + +pub async fn do_check(state: &WorkerState) { + let events = match state.source.upcoming_events(state.lookahead).await { + Ok(e) => e, + Err(e) => { + tracing::warn!("calendar-worker: fetch failed: {e}"); + return; + } + }; + + let now = Utc::now(); + + { + let mut notified = state.notified.lock().unwrap(); + notified + .retain(|_, ts| now.signed_duration_since(*ts) < Duration::minutes(DEDUP_TTL_MINUTES)); + } + + for event in events { + let secs = event.started_at.signed_duration_since(now).num_seconds(); + + if secs > START_WINDOW_BEFORE_SECS || secs < -START_WINDOW_AFTER_SECS { + continue; + } + + let already = state.notified.lock().unwrap().contains_key(&event.event_id); + if already { + continue; + } + + tracing::info!( + event_id = %event.event_id, + title = %event.title, + secs_until = secs, + "emitting event started" + ); + + state.runtime.emit(NotificationWorkerEvent::EventStarted { + event_id: event.event_id.clone(), + title: event.title, + started_at: event.started_at.to_rfc3339(), + participants: event.participants, + }); + + state.notified.lock().unwrap().insert(event.event_id, now); + } +} + +pub async fn check_events( + _tick: Tick, + ctx: Data, +) -> Result<(), Box> { + do_check(&ctx).await; + Ok(()) +} diff --git a/crates/notification-worker/src/worker.rs b/crates/notification-worker/src/worker.rs deleted file mode 100644 index 7547bd7b4f..0000000000 --- a/crates/notification-worker/src/worker.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -use apalis::prelude::Data; -use apalis_cron::Tick; -use chrono::{Duration, Utc}; - -use crate::runtime::{NotificationWorkerEvent, NotificationWorkerRuntime}; -use crate::source::EventSource; - -const DEDUP_TTL_MINUTES: i64 = 30; - -#[derive(Clone)] -pub struct WorkerState { - pub source: Arc, - pub runtime: Arc, - pub lookahead: Duration, - pub notified: Arc>>>, -} - -pub async fn check_upcoming( - _tick: Tick, - ctx: Data, -) -> Result<(), Box> { - let events = ctx.source.upcoming_events(ctx.lookahead).await?; - - let now = Utc::now(); - - { - let mut notified = ctx.notified.lock().unwrap(); - notified - .retain(|_, ts| now.signed_duration_since(*ts) < Duration::minutes(DEDUP_TTL_MINUTES)); - } - - for event in events { - let already = { - let notified = ctx.notified.lock().unwrap(); - notified.contains_key(&event.event_id) - }; - - if already { - continue; - } - - let minutes_until = event.started_at.signed_duration_since(now).num_minutes(); - - tracing::info!( - event_id = %event.event_id, - title = %event.title, - minutes_until, - "emitting upcoming event notification" - ); - - ctx.runtime.emit(NotificationWorkerEvent::UpcomingEvent { - event_id: event.event_id.clone(), - title: event.title, - started_at: event.started_at.to_rfc3339(), - minutes_until, - participants: event.participants, - }); - - ctx.notified.lock().unwrap().insert(event.event_id, now); - } - - Ok(()) -} diff --git a/plugins/calendar/Cargo.toml b/plugins/calendar/Cargo.toml index 01f0809737..a661a4802e 100644 --- a/plugins/calendar/Cargo.toml +++ b/plugins/calendar/Cargo.toml @@ -10,15 +10,18 @@ description = "" [dependencies] hypr-calendar = { workspace = true, features = ["specta"] } hypr-calendar-interface = { workspace = true } +hypr-calendar-worker = { workspace = true, features = ["tauri-event"] } 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"] } specta = { workspace = true, features = ["chrono"] } thiserror = { workspace = true } +tracing = { workspace = true } [build-dependencies] tauri-plugin = { workspace = true, features = ["build"] } diff --git a/plugins/calendar/js/bindings.gen.ts b/plugins/calendar/js/bindings.gen.ts index ba9610d8fc..a055eeb704 100644 --- a/plugins/calendar/js/bindings.gen.ts +++ b/plugins/calendar/js/bindings.gen.ts @@ -66,9 +66,11 @@ async parseMeetingLink(text: string) : Promise { export const events = __makeEvents__<{ -calendarChangedEvent: CalendarChangedEvent +calendarChangedEvent: CalendarChangedEvent, +notificationWorkerEvent: NotificationWorkerEvent }>({ -calendarChangedEvent: "plugin:calendar:calendar-changed-event" +calendarChangedEvent: "plugin:calendar:calendar-changed-event", +notificationWorkerEvent: "plugin:calendar:notification-worker-event" }) /** user-defined constants **/ @@ -160,6 +162,7 @@ email: string | null; */ is_current_user: boolean } export type EventStatus = "confirmed" | "tentative" | "cancelled" +export type NotificationWorkerEvent = { type: "eventStarted"; event_id: string; title: string; started_at: string; participants: string[] } export type ProviderConnectionIds = { provider: CalendarProviderType; connection_ids: string[] } /** tauri-specta globals **/ diff --git a/plugins/calendar/src/lib.rs b/plugins/calendar/src/lib.rs index 2aa30fdf89..c6970b72ca 100644 --- a/plugins/calendar/src/lib.rs +++ b/plugins/calendar/src/lib.rs @@ -2,6 +2,7 @@ mod commands; mod error; mod events; mod runtime; +mod source; pub use error::Error; pub use events::*; @@ -26,7 +27,10 @@ fn make_specta_builder() -> tauri_specta::Builder { commands::create_event::, commands::parse_meeting_link, ]) - .events(tauri_specta::collect_events![CalendarChangedEvent]) + .events(tauri_specta::collect_events![ + CalendarChangedEvent, + hypr_calendar_worker::runtime::NotificationWorkerEvent + ]) .error_handling(tauri_specta::ErrorHandlingMode::Result) } @@ -43,6 +47,20 @@ pub fn init() -> tauri::plugin::TauriPlugin { use tauri::Manager; app.manage(PluginConfig { api_base_url }); + + let worker_source = source::CalendarEventSource(app.app_handle().clone()); + let worker_runtime = runtime::TauriNotificationWorkerRuntime(app.app_handle().clone()); + tauri::async_runtime::spawn(async move { + if let Err(e) = hypr_calendar_worker::run( + worker_source, + worker_runtime, + chrono::Duration::minutes(10), + ) + .await + { + tracing::error!("calendar-worker error: {e}"); + } + }); Ok(()) }) .build() diff --git a/plugins/calendar/src/runtime.rs b/plugins/calendar/src/runtime.rs index ba2b3a8fea..eae9006038 100644 --- a/plugins/calendar/src/runtime.rs +++ b/plugins/calendar/src/runtime.rs @@ -1,6 +1,8 @@ -use hypr_calendar::runtime::CalendarRuntime; use tauri_specta::Event as _; +use hypr_calendar::runtime::CalendarRuntime; +use hypr_calendar_worker::runtime::{NotificationWorkerEvent, NotificationWorkerRuntime}; + use crate::events::CalendarChangedEvent; pub struct TauriCalendarRuntime(pub tauri::AppHandle); @@ -10,3 +12,11 @@ impl CalendarRuntime for TauriCalendarRuntime { let _ = CalendarChangedEvent.emit(&self.0); } } + +pub struct TauriNotificationWorkerRuntime(pub tauri::AppHandle); + +impl NotificationWorkerRuntime for TauriNotificationWorkerRuntime { + fn emit(&self, event: NotificationWorkerEvent) { + let _ = event.emit(&self.0); + } +} diff --git a/plugins/calendar/src/source.rs b/plugins/calendar/src/source.rs new file mode 100644 index 0000000000..8df98d209c --- /dev/null +++ b/plugins/calendar/src/source.rs @@ -0,0 +1,144 @@ +use std::pin::Pin; + +use chrono::{DateTime, Duration, Utc}; +use hypr_calendar_interface::EventFilter; +use hypr_calendar_worker::source::{EventSource, UpcomingEvent}; +use tauri::Manager; +use tauri_plugin_auth::AuthPluginExt; + +pub struct CalendarEventSource(pub tauri::AppHandle); + +impl EventSource for CalendarEventSource { + fn upcoming_events( + &self, + within: Duration, + ) -> Pin< + Box< + dyn std::future::Future< + Output = Result, hypr_calendar_worker::Error>, + > + Send + + '_, + >, + > { + let app = self.0.clone(); + Box::pin(async move { + let config = app.state::(); + let token = app.access_token().ok().flatten().filter(|t| !t.is_empty()); + + let apple_authorized = { + #[cfg(target_os = "macos")] + { + use tauri_plugin_permissions::{ + Permission, PermissionStatus, PermissionsPluginExt, + }; + app.permissions() + .check(Permission::Calendar) + .await + .map(|s| matches!(s, PermissionStatus::Authorized)) + .unwrap_or(false) + } + #[cfg(not(target_os = "macos"))] + { + false + } + }; + + let connection_ids = match hypr_calendar::list_connection_ids( + &config.api_base_url, + token.as_deref(), + apple_authorized, + ) + .await + { + Ok(ids) => ids, + Err(e) => { + tracing::warn!("calendar-worker: failed to list connection ids: {e}"); + return Ok(Vec::new()); + } + }; + + let now = Utc::now(); + // Look back 2 minutes so a cold start catches meetings that just began. + let from = now - chrono::Duration::minutes(2); + let to = now + within; + let mut upcoming = Vec::new(); + + for provider_conn in connection_ids { + let token_str = token.clone().unwrap_or_default(); + + for connection_id in &provider_conn.connection_ids { + let calendars = match hypr_calendar::list_calendars( + &config.api_base_url, + &token_str, + provider_conn.provider, + connection_id, + ) + .await + { + Ok(c) => c, + Err(e) => { + tracing::warn!("calendar-worker: failed to list calendars: {e}"); + continue; + } + }; + + for calendar in calendars { + let filter = EventFilter { + from, + to, + calendar_tracking_id: calendar.id, + }; + + let events = match hypr_calendar::list_events( + &config.api_base_url, + &token_str, + provider_conn.provider, + connection_id, + filter, + ) + .await + { + Ok(e) => e, + Err(e) => { + tracing::warn!("calendar-worker: failed to list events: {e}"); + continue; + } + }; + + for event in events { + if event.is_all_day { + continue; + } + + let Ok(started_at) = DateTime::parse_from_rfc3339(&event.started_at) + .map(|dt| dt.with_timezone(&Utc)) + else { + continue; + }; + + let ended_at = DateTime::parse_from_rfc3339(&event.ended_at) + .map(|dt| dt.with_timezone(&Utc)) + .ok(); + + let participants = event + .attendees + .iter() + .filter_map(|a| a.name.clone().or_else(|| a.email.clone())) + .collect(); + + upcoming.push(UpcomingEvent { + event_id: event.id, + title: event.title, + started_at, + ended_at, + participants, + }); + } + } + } + } + + Ok(upcoming) + }) + } +} diff --git a/plugins/tray/src/menu_items/tray_start.rs b/plugins/tray/src/menu_items/tray_start.rs index ec481704a3..454290c61d 100644 --- a/plugins/tray/src/menu_items/tray_start.rs +++ b/plugins/tray/src/menu_items/tray_start.rs @@ -25,7 +25,6 @@ impl MenuItemHandler for TrayStart { id: "new".to_string(), state: Some(SessionsState { view: Default::default(), - auto_start: Some(true), }), }, }; diff --git a/plugins/windows/js/bindings.gen.ts b/plugins/windows/js/bindings.gen.ts index eb16397685..160ecd86c5 100644 --- a/plugins/windows/js/bindings.gen.ts +++ b/plugins/windows/js/bindings.gen.ts @@ -97,7 +97,7 @@ export type JsonValue = null | boolean | number | string | JsonValue[] | Partial export type Navigate = { path: string; search: Partial<{ [key in string]: JsonValue }> | null } export type OpenTab = { tab: TabInput } export type PromptsState = { selectedTask: string | null } -export type SessionsState = { view: EditorView | null; autoStart: boolean | null } +export type SessionsState = { view: EditorView | null } export type SettingsState = { tab: string | null } export type TabInput = { type: "sessions"; id: string; state?: SessionsState | null } | { type: "contacts"; state?: ContactsState | null } | { type: "templates"; state?: TemplatesState | null } | { type: "prompts"; state?: PromptsState | null } | { type: "chat_shortcuts"; state?: ChatShortcutsState | null } | { type: "extensions"; state?: ExtensionsState | null } | { type: "humans"; id: string } | { type: "organizations"; id: string } | { type: "folders"; id: string | null } | { type: "empty" } | { type: "extension"; extensionId: string; state?: Partial<{ [key in string]: JsonValue }> | null } | { type: "calendar" } | { type: "changelog"; state: ChangelogState } | { type: "settings"; state?: SettingsState | null } | { type: "chat_support"; state?: ChatState | null } | { type: "onboarding" } | { type: "daily" } | { type: "edit"; requestId: string } export type TemplatesState = { showHomepage: boolean | null; isWebMode: boolean | null; selectedMineId: string | null; selectedWebIndex: number | null } diff --git a/plugins/windows/src/tab/state.rs b/plugins/windows/src/tab/state.rs index 8dd51c3ff7..d2685daf34 100644 --- a/plugins/windows/src/tab/state.rs +++ b/plugins/windows/src/tab/state.rs @@ -15,7 +15,6 @@ crate::common_derives! { crate::common_derives! { pub struct SessionsState { pub view: Option, - pub auto_start: Option, } }