onyx-tasks/apps/tauri/src/lib/stores/app.svelte.ts
Claude 67ac43e527
Add Vitest suite covering the smoke-test fixes
Extracts two pure helpers out of Svelte components so they can be
exercised without the reactive runtime, and adds component tests for
ConfirmDialog's Escape-handling behavior.

- apps/tauri/src/lib/grouping.ts (new): `groupTasksByDate` lifted out of
  the `groupedPendingTasks` $derived in the app store.
- apps/tauri/src/lib/paths.ts (new): `workspaceNameFromPath` lifted out
  of SetupScreen.handleOpen.
- apps/tauri/src/lib/grouping.test.ts: 8 cases — "No Date" placed last
  (regression), full bucket ordering, empty input, within-bucket
  stable sort, earlier-today stays in Today, multi-task same-day,
  No Date preserves insertion order.
- apps/tauri/src/lib/paths.test.ts: 8 cases — POSIX/Windows/mixed
  separators, trailing slash regression ("…/Tasks/" → "Tasks"), empty
  and root-only fallback, names with spaces.
- apps/tauri/src/lib/components/ConfirmDialog.test.ts: 6 cases —
  renders message/detail/custom confirm text, Cancel/Confirm fire the
  right callbacks, Escape calls oncancel and does NOT reach an outer
  window listener (regression), non-Escape keys are ignored, and the
  module-level open-count increments/decrements correctly (including
  when two dialogs are mounted at once).

Test harness: Vitest + jsdom + @testing-library/svelte. `npm test`
runs the suite; `resolve.conditions` is set to "browser" under VITEST
so Svelte resolves its client entry and mount() works.

23/23 tests pass. cargo check, cargo test -p onyx-core (162/162),
and npm run build all still green.
2026-04-17 14:33:12 +00:00

655 lines
18 KiB
TypeScript

import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type {
AppConfig,
Task,
TaskList,
Screen,
SyncResult,
} from "../types";
import { groupTasksByDate, type TaskGroup } from "../grouping";
// Listen for file system changes from the backend watcher.
listen("fs-changed", () => {
loadLists();
// Debounced sync for WebDAV workspaces on local file changes
if (isSyncedWorkspace) debouncedSync();
});
// ── Reactive state ───────────────────────────────────────────────────
const LS_DECORATIONS_KEY = "windowDecorations";
let windowDecorations = $state<"custom" | "none" | "system">(
(localStorage.getItem(LS_DECORATIONS_KEY) as "custom" | "none" | "system") ?? "custom"
);
if (windowDecorations === "system") getCurrentWindow().setDecorations(true);
if (windowDecorations === "none") document.documentElement.classList.add("decorations-none");
let screen = $state<Screen>("setup");
let config = $state<AppConfig | null>(null);
let lists = $state<TaskList[]>([]);
let activeListId = $state<string | null>(null);
let tasks = $state<Task[]>([]);
let osDark = globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
let syncing = $state(false);
let initialSync = $state(false);
let syncStatus = $state<"idle" | "synced" | "error" | "offline">("idle");
let lastSyncResult = $state<SyncResult | null>(null);
let error = $state<string | null>(null);
let missingWorkspace = $state<string | null>(null);
let lastSyncTime = 0;
let _syncInterval: ReturnType<typeof setInterval> | null = null;
let _syncDebounce: ReturnType<typeof setTimeout> | null = null;
let _focusUnlisten: (() => void) | null = null;
const DEFAULT_SYNC_INTERVAL_SECS = 60;
const DEFAULT_SYNC_INTERVAL_UNFOCUSED_SECS = 600;
const SYNC_DEBOUNCE_MS = 5_000;
let _appFocused = true;
// ── Derived ──────────────────────────────────────────────────────────
let activeList = $derived(lists.find((l) => l.id === activeListId) ?? null);
let pendingTasks = $derived(tasks.filter((t) => t.status === "backlog" && !t.parent_id));
let completedTasks = $derived(tasks.filter((t) => t.status === "completed" && !t.parent_id));
let groupedPendingTasks = $derived.by((): TaskGroup[] | null => {
if (!activeList?.group_by_date) return null;
return groupTasksByDate(pendingTasks);
});
// Build a map of parent_id -> children for subtask hierarchy
let childrenMap = $derived.by(() => {
const map = new Map<string, Task[]>();
for (const t of tasks) {
if (t.parent_id) {
const siblings = map.get(t.parent_id);
if (siblings) siblings.push(t);
else map.set(t.parent_id, [t]);
}
}
return map;
});
function getSubtasks(parentId: string): Task[] {
return childrenMap.get(parentId) ?? [];
}
let hasWorkspace = $derived(
config !== null &&
config.current_workspace !== null &&
Object.keys(config.workspaces).length > 0,
);
const DARK_THEMES = new Set(["dark", "nord", "dracula", "solarized", "onyx"]);
let currentTheme = $derived(
config?.current_workspace
? config.workspaces[config.current_workspace]?.theme ?? null
: null,
);
let isDark = $derived(
currentTheme ? DARK_THEMES.has(currentTheme) : osDark,
);
let isWebdav = $derived(
config?.current_workspace
? config.workspaces[config.current_workspace]?.mode === "webdav"
: false,
);
let isGoogleTasks = $derived(
config?.current_workspace
? config.workspaces[config.current_workspace]?.mode === "googletasks"
: false,
);
let isSyncedWorkspace = $derived(isWebdav || isGoogleTasks);
let syncIntervalSecs = $derived(
config?.current_workspace
? config.workspaces[config.current_workspace]?.sync_interval_secs ?? DEFAULT_SYNC_INTERVAL_SECS
: DEFAULT_SYNC_INTERVAL_SECS,
);
let syncIntervalUnfocusedSecs = $derived(
config?.current_workspace
? config.workspaces[config.current_workspace]?.sync_interval_unfocused_secs ?? DEFAULT_SYNC_INTERVAL_UNFOCUSED_SECS
: DEFAULT_SYNC_INTERVAL_UNFOCUSED_SECS,
);
// ── Actions ──────────────────────────────────────────────────────────
async function loadConfig() {
try {
config = await invoke<AppConfig>("get_config");
if (hasWorkspace) {
// Try loading lists — if the workspace path is gone, get_lists will fail
lists = [];
try {
lists = await invoke<TaskList[]>("get_lists");
} catch {
missingWorkspace = config!.current_workspace;
screen = "missing";
return;
}
if (lists.length > 0 && !activeListId) activeListId = lists[0].id;
if (activeListId) await loadTasks();
screen = "tasks";
if (isSyncedWorkspace) startAutoSync();
} else {
screen = "setup";
}
} catch (e) {
config = { workspaces: {}, current_workspace: null };
screen = "setup";
}
}
async function addWorkspace(name: string, path: string) {
try {
await invoke("init_workspace", { path });
await invoke("add_workspace", { name, path });
config = await invoke<AppConfig>("get_config");
await loadLists();
invoke("watch_workspace", { path }).catch((e) => console.warn("File watcher failed:", e));
screen = "tasks";
error = null;
} catch (e) {
error = String(e);
}
}
async function switchWorkspace(id: string) {
try {
await invoke("set_current_workspace", { id });
config = await invoke<AppConfig>("get_config");
activeListId = null;
tasks = [];
await loadLists();
const ws = config?.workspaces[id];
if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e));
if (isSyncedWorkspace) startAutoSync(); else stopAutoSync();
error = null;
} catch (e) {
error = String(e);
}
}
async function renameWorkspace(id: string, newName: string) {
try {
await invoke("rename_workspace", { id, newName });
config = await invoke<AppConfig>("get_config");
error = null;
} catch (e) {
error = String(e);
}
}
async function removeWorkspace(id: string) {
stopAutoSync();
try {
await invoke("remove_workspace", { id });
config = await invoke<AppConfig>("get_config");
if (!hasWorkspace) {
screen = "setup";
lists = [];
tasks = [];
activeListId = null;
}
} catch (e) {
error = String(e);
}
}
async function loadLists() {
try {
lists = await invoke<TaskList[]>("get_lists");
if (lists.length > 0 && !activeListId) {
activeListId = lists[0].id;
}
if (activeListId) await loadTasks();
} catch (e) {
error = String(e);
}
}
async function loadTasks() {
if (!activeListId) return;
try {
const loaded = await invoke<Task[]>("list_tasks", { listId: activeListId });
// Deduplicate by task ID — sync conflicts can produce files with the same UUID
const seen = new Set<string>();
tasks = loaded.filter((t) => {
if (seen.has(t.id)) return false;
seen.add(t.id);
return true;
});
} catch (e) {
error = String(e);
}
}
async function selectList(id: string) {
activeListId = id;
tasks = [];
await loadTasks();
}
async function createList(name: string) {
try {
const list = await invoke<TaskList>("create_list", { name });
lists = [...lists, list];
activeListId = list.id;
tasks = [];
error = null;
} catch (e) {
error = String(e);
}
}
async function deleteList(id: string) {
try {
await invoke("delete_list", { listId: id });
lists = lists.filter((l) => l.id !== id);
if (activeListId === id) {
activeListId = lists.length > 0 ? lists[0].id : null;
if (activeListId) await loadTasks();
else tasks = [];
}
} catch (e) {
error = String(e);
}
}
async function createTask(title: string, description?: string, parentId?: string): Promise<Task | null> {
if (!activeListId) return null;
try {
const task = await invoke<Task>("create_task", {
listId: activeListId,
title,
description: description ?? "",
parentId: parentId ?? null,
});
tasks = parentId ? [task, ...tasks] : [...tasks, task];
error = null;
return task;
} catch (e) {
error = String(e);
return null;
}
}
async function toggleTask(taskId: string) {
if (!activeListId) return;
try {
const updated = await invoke<Task>("toggle_task", {
listId: activeListId,
taskId,
});
// Move to top of list locally, then persist order in background
if (updated.status === "backlog") {
tasks = [updated, ...tasks.filter((t) => t.id !== taskId)];
invoke("reorder_task", { listId: activeListId, taskId, newPosition: 0 }).catch((e) => { error = String(e); });
} else {
tasks = tasks.map((t) => (t.id === taskId ? updated : t));
}
} catch (e) {
error = String(e);
}
}
async function updateTask(task: Task) {
if (!activeListId) return;
try {
await invoke("update_task", { listId: activeListId, task });
tasks = tasks.map((t) => (t.id === task.id ? task : t));
} catch (e) {
error = String(e);
}
}
async function reorderTask(taskId: string, newPosition: number) {
if (!activeListId) return;
try {
await invoke("reorder_task", { listId: activeListId, taskId, newPosition });
await loadTasks();
} catch (e) {
error = String(e);
}
}
async function deleteTask(taskId: string): Promise<boolean> {
if (!activeListId) return false;
try {
await invoke("delete_task", { listId: activeListId, taskId });
tasks = tasks.filter((t) => t.id !== taskId);
return true;
} catch (e) {
error = String(e);
return false;
}
}
async function moveTask(taskId: string, targetListId: string) {
if (!activeListId) return;
try {
await invoke("move_task", {
fromListId: activeListId,
toListId: targetListId,
taskId,
});
tasks = tasks.filter((t) => t.id !== taskId);
} catch (e) {
error = String(e);
}
}
async function renameList(listId: string, newName: string) {
try {
await invoke("rename_list", { listId, newName });
lists = lists.map((l) =>
l.id === listId ? { ...l, title: newName } : l,
);
} catch (e) {
error = String(e);
}
}
async function setGroupByDate(listId: string, enabled: boolean) {
try {
await invoke("set_group_by_date", { listId, enabled });
lists = lists.map((l) =>
l.id === listId ? { ...l, group_by_date: enabled } : l,
);
if (listId === activeListId) await loadTasks();
} catch (e) {
error = String(e);
}
}
async function triggerSync() {
if (!config?.current_workspace || syncing) return;
syncing = true;
try {
const result = isGoogleTasks
? await invoke<SyncResult>("sync_google_tasks_workspace", {
workspaceId: config.current_workspace,
})
: await invoke<SyncResult>("sync_workspace", {
workspaceId: config.current_workspace,
mode: "full",
});
lastSyncResult = result;
lastSyncTime = Date.now();
syncStatus = result.errors.length > 0 ? "error" : "synced";
if (result.errors.length > 0) error = result.errors.join("; ");
config = await invoke<AppConfig>("get_config");
await loadLists();
} catch (e) {
const msg = String(e);
const isTransient = /timeout|connect|network|unreachable|refused/i.test(msg);
syncStatus = isTransient ? "offline" : "error";
// Only show the error banner for non-transient failures; connectivity issues just update the status dot
if (!isTransient) error = msg;
} finally {
syncing = false;
}
}
function debouncedSync() {
if (_syncDebounce) clearTimeout(_syncDebounce);
_syncDebounce = setTimeout(() => { _syncDebounce = null; triggerSync(); }, SYNC_DEBOUNCE_MS);
}
function restartSyncInterval() {
if (_syncInterval) clearInterval(_syncInterval);
var secs = _appFocused ? syncIntervalSecs : syncIntervalUnfocusedSecs;
_syncInterval = setInterval(triggerSync, secs * 1000);
}
function startAutoSync() {
stopAutoSync();
_appFocused = true;
triggerSync();
restartSyncInterval();
getCurrentWindow().onFocusChanged(({ payload: focused }) => {
// Sync on re-focus if stale beyond the focused interval
if (focused && !_appFocused && Date.now() - lastSyncTime > syncIntervalSecs * 1000)
triggerSync();
_appFocused = focused;
restartSyncInterval();
}).then((unlisten) => {
if (!_syncInterval) unlisten();
else _focusUnlisten = unlisten;
}).catch((e) => {
console.warn("Failed to set up focus listener:", e);
});
}
function stopAutoSync() {
if (_syncInterval) { clearInterval(_syncInterval); _syncInterval = null; }
if (_syncDebounce) { clearTimeout(_syncDebounce); _syncDebounce = null; }
if (_focusUnlisten) { _focusUnlisten(); _focusUnlisten = null; }
}
async function setSyncInterval(secs: number | null) {
if (!config?.current_workspace) return;
try {
await invoke("set_sync_interval", {
workspaceId: config.current_workspace,
intervalSecs: secs,
});
config = await invoke<AppConfig>("get_config");
if (isSyncedWorkspace) startAutoSync();
} catch (e) {
error = String(e);
}
}
async function setSyncIntervalUnfocused(secs: number | null) {
if (!config?.current_workspace) return;
try {
await invoke("set_sync_interval_unfocused", {
workspaceId: config.current_workspace,
intervalSecs: secs,
});
config = await invoke<AppConfig>("get_config");
if (isSyncedWorkspace) startAutoSync();
} catch (e) {
error = String(e);
}
}
function setWindowDecorations(value: "custom" | "none" | "system") {
windowDecorations = value;
localStorage.setItem(LS_DECORATIONS_KEY, value);
getCurrentWindow().setDecorations(value === "system");
document.documentElement.classList.toggle("decorations-none", value === "none");
}
async function setTheme(theme: string | null) {
if (!config?.current_workspace) return;
try {
await invoke("set_workspace_theme", {
workspaceId: config.current_workspace,
theme,
});
config = await invoke<AppConfig>("get_config");
} catch (e) {
error = String(e);
}
}
async function addWebdavWorkspace(name: string, webdavUrl: string, webdavPath: string, username: string, password: string) {
try {
await invoke("add_webdav_workspace", { name, webdavUrl, webdavPath, username, password });
config = await invoke<AppConfig>("get_config");
screen = "tasks";
error = null;
// Run initial sync before showing content so the workspace isn't empty
initialSync = true;
try {
await triggerSync();
} finally {
initialSync = false;
}
await loadLists();
if (config?.current_workspace) {
const ws = config.workspaces[config.current_workspace];
if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e));
}
if (isSyncedWorkspace) startAutoSync();
} catch (e) {
initialSync = false;
error = String(e);
}
}
async function addGoogleTasksWorkspace(
name: string,
accessToken: string,
refreshToken: string,
account: string,
) {
try {
await invoke("add_google_tasks_workspace", { name, accessToken, refreshToken, account });
config = await invoke<AppConfig>("get_config");
screen = "tasks";
error = null;
await loadLists();
startAutoSync();
} catch (e) {
error = String(e);
}
}
async function forgetMissingWorkspace() {
if (!missingWorkspace) return;
await removeWorkspace(missingWorkspace);
missingWorkspace = null;
config = await invoke<AppConfig>("get_config");
if (hasWorkspace) {
// Switch to the next available workspace
const nextName = Object.keys(config!.workspaces)[0];
if (nextName) {
await switchWorkspace(nextName);
screen = "tasks";
return;
}
}
screen = "setup";
lists = [];
tasks = [];
activeListId = null;
}
function setScreen(s: Screen) {
screen = s;
}
function clearError() {
error = null;
}
// ── Exports ──────────────────────────────────────────────────────────
export const app = {
get screen() {
return screen;
},
get config() {
return config;
},
get lists() {
return lists;
},
get activeListId() {
return activeListId;
},
get activeList() {
return activeList;
},
get tasks() {
return tasks;
},
get pendingTasks() {
return pendingTasks;
},
get groupedPendingTasks() {
return groupedPendingTasks;
},
get completedTasks() {
return completedTasks;
},
get currentTheme() {
return currentTheme;
},
get isDark() {
return isDark;
},
get syncing() {
return syncing;
},
get initialSync() {
return initialSync;
},
get syncStatus() {
return syncStatus;
},
get isWebdav() {
return isWebdav;
},
get isGoogleTasks() {
return isGoogleTasks;
},
get isSyncedWorkspace() {
return isSyncedWorkspace;
},
get syncIntervalSecs() {
return syncIntervalSecs;
},
get syncIntervalUnfocusedSecs() {
return syncIntervalUnfocusedSecs;
},
get lastSyncResult() {
return lastSyncResult;
},
get windowDecorations() {
return windowDecorations;
},
get error() {
return error;
},
get hasWorkspace() {
return hasWorkspace;
},
get missingWorkspace() {
return missingWorkspace;
},
getSubtasks,
loadConfig,
addWorkspace,
switchWorkspace,
renameWorkspace,
removeWorkspace,
loadLists,
loadTasks,
selectList,
createList,
deleteList,
createTask,
toggleTask,
updateTask,
reorderTask,
deleteTask,
moveTask,
renameList,
setGroupByDate,
triggerSync,
startAutoSync,
stopAutoSync,
setSyncInterval,
setSyncIntervalUnfocused,
setWindowDecorations,
setTheme,
addWebdavWorkspace,
addGoogleTasksWorkspace,
forgetMissingWorkspace,
setScreen,
clearError,
};