import { create } from "zustand"; import type { AppSettings, BoardSortOrder } from "@/types/settings"; import type { BoardMeta, ColumnWidth } from "@/types/board"; import { loadSettings, saveSettings, listBoards, ensureDataDirs, loadBoard } from "@/lib/storage"; import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification"; export type View = { type: "board-list" } | { type: "board"; boardId: string }; interface AppState { settings: AppSettings; boards: BoardMeta[]; view: View; initialized: boolean; init: () => Promise; setTheme: (theme: AppSettings["theme"]) => void; setAccentColor: (hue: string) => void; setUiZoom: (zoom: number) => void; setDensity: (density: AppSettings["density"]) => void; setDefaultColumnWidth: (width: ColumnWidth) => void; setView: (view: View) => void; refreshBoards: () => Promise; addRecentBoard: (boardId: string) => void; setReduceMotion: (reduceMotion: boolean) => void; setBoardSortOrder: (order: BoardSortOrder) => void; setBoardManualOrder: (ids: string[]) => void; getSortedBoards: () => BoardMeta[]; } function applyTheme(theme: AppSettings["theme"]): void { const root = document.documentElement; if (theme === "system") { const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; root.classList.toggle("dark", prefersDark); } else { root.classList.toggle("dark", theme === "dark"); } } function applyReduceMotion(on: boolean): void { document.documentElement.classList.toggle("reduce-motion", on); } function applyAppearance(settings: AppSettings): void { const root = document.documentElement; root.style.fontSize = `${settings.uiZoom * 16}px`; const hue = settings.accentColor; const isDark = root.classList.contains("dark"); const lightness = isDark ? "60%" : "55%"; root.style.setProperty("--pylon-accent", `oklch(${lightness} 0.12 ${hue})`); const densityMap = { compact: "0.75", comfortable: "1", spacious: "1.25" }; root.style.setProperty("--density-factor", densityMap[settings.density]); } function updateAndSave( get: () => AppState, set: (partial: Partial) => void, patch: Partial ): void { const settings = { ...get().settings, ...patch }; set({ settings }); saveSettings(settings); } export const useAppStore = create((set, get) => ({ settings: { theme: "system", dataDirectory: null, recentBoardIds: [], accentColor: "160", uiZoom: 1, density: "comfortable", defaultColumnWidth: "standard", windowState: null, boardSortOrder: "updated", boardManualOrder: [], lastNotificationCheck: null, reduceMotion: false, }, boards: [], view: { type: "board-list" }, initialized: false, init: async () => { await ensureDataDirs(); const settings = await loadSettings(); const boards = await listBoards(); set({ settings, boards, initialized: true }); applyTheme(settings.theme); applyAppearance(settings); applyReduceMotion(settings.reduceMotion); // Due date notifications (once per hour) const lastCheck = settings.lastNotificationCheck; const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); if (!lastCheck || lastCheck < hourAgo) { try { let granted = await isPermissionGranted(); if (!granted) { const perm = await requestPermission(); granted = perm === "granted"; } if (granted) { let dueToday = 0; let overdue = 0; const today = new Date(); const todayStr = today.toDateString(); for (const meta of boards) { try { const board = await loadBoard(meta.id); for (const card of Object.values(board.cards)) { if (!card.dueDate) continue; const due = new Date(card.dueDate); if (due.toDateString() === todayStr) dueToday++; else if (due < today) overdue++; } } catch { /* skip */ } } if (dueToday > 0) { sendNotification({ title: "OpenPylon", body: `You have ${dueToday} card${dueToday > 1 ? "s" : ""} due today` }); } if (overdue > 0) { sendNotification({ title: "OpenPylon", body: `You have ${overdue} overdue card${overdue > 1 ? "s" : ""}` }); } } updateAndSave(get, set, { lastNotificationCheck: new Date().toISOString() }); } catch { /* notification plugin not available */ } } }, setTheme: (theme) => { updateAndSave(get, set, { theme }); applyTheme(theme); applyAppearance({ ...get().settings, theme }); }, setAccentColor: (accentColor) => { updateAndSave(get, set, { accentColor }); applyAppearance(get().settings); }, setUiZoom: (uiZoom) => { updateAndSave(get, set, { uiZoom }); applyAppearance(get().settings); }, setDensity: (density) => { updateAndSave(get, set, { density }); applyAppearance(get().settings); }, setDefaultColumnWidth: (defaultColumnWidth) => { updateAndSave(get, set, { defaultColumnWidth }); }, setReduceMotion: (reduceMotion) => { updateAndSave(get, set, { reduceMotion }); applyReduceMotion(reduceMotion); }, setView: (view) => set({ view }), refreshBoards: async () => { const boards = await listBoards(); set({ boards }); }, addRecentBoard: (boardId) => { const settings = get().settings; const recent = [ boardId, ...settings.recentBoardIds.filter((id) => id !== boardId), ].slice(0, 10); updateAndSave(get, set, { recentBoardIds: recent }); }, setBoardSortOrder: (boardSortOrder) => { // When switching to manual for the first time, snapshot current order if (boardSortOrder === "manual" && get().settings.boardManualOrder.length === 0) { const currentSorted = get().getSortedBoards(); updateAndSave(get, set, { boardSortOrder, boardManualOrder: currentSorted.map((b) => b.id), }); } else { updateAndSave(get, set, { boardSortOrder }); } }, setBoardManualOrder: (boardManualOrder) => { updateAndSave(get, set, { boardManualOrder }); }, getSortedBoards: () => { const { boards, settings } = get(); const order = settings.boardSortOrder; if (order === "manual") { const manualOrder = settings.boardManualOrder; const orderMap = new Map(manualOrder.map((id, i) => [id, i])); return [...boards].sort((a, b) => { const ai = orderMap.get(a.id) ?? Infinity; const bi = orderMap.get(b.id) ?? Infinity; return ai - bi; }); } if (order === "title") { return [...boards].sort((a, b) => a.title.localeCompare(b.title)); } if (order === "created") { return [...boards].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } // "updated" — default, already sorted from listBoards return boards; }, }));