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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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[] => {
Expand Down Expand Up @@ -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],
Comment thread
cursor[bot] marked this conversation as resolved.
);

const handleRefresh = useCallback(() => {
Expand Down
116 changes: 116 additions & 0 deletions apps/desktop/src/calendar/components/context.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }) => 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<string, unknown>) {
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 <div data-testid="status">{status}</div>;
}

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(
<SyncProvider>
<StatusProbe />
</SyncProvider>,
);

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");
});
});
});
131 changes: 101 additions & 30 deletions apps/desktop/src/calendar/components/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -27,47 +27,118 @@ interface SyncContextValue {
const SyncContext = createContext<SyncContextValue | null>(null);

export function SyncProvider({ children }: { children: React.ReactNode }) {
const scheduleEventSync = useScheduleTaskRunCallback(
CALENDAR_SYNC_TASK_ID,
undefined,
0,
);
const toggleSyncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const [pendingTaskRunId, setPendingTaskRunId] = useState<string | null>(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) {
Expand Down
Loading
Loading