/** * Zustand store — single source of truth for all app data. * Persistence is manual (we call vault.save() after mutations) so we can * route through the encryption layer and chosen storage adapter. */ import { create } from 'zustand'; import type { AppData, WorkEntry, Payment, Expense, TaxInputs, Settings, DashboardConfig, ChartConfig, ThemeName, ThemeMode, StorageMode, CloudConfig, LocalAuthState, CloudAuthState, } from '@/types'; import { uid } from '@/lib/id'; import { Vault, cookieDataExists } from '@/lib/storage/vault'; import { todayISO } from '@/lib/format'; // ─── Defaults ──────────────────────────────────────────────────────────────── const defaultDashboard = (): DashboardConfig => ({ charts: [ { id: uid(), type: 'area', metrics: ['payments', 'expenses'], granularity: 'month', rangeStart: null, rangeEnd: null, yMin: null, yMax: null, title: 'Income vs Expenses', }, ], widgets: [ 'ytdPayments', 'ytdNet', 'nextQuarterlyDue', 'projectedAnnualTax', ], }); const defaultSettings = (): Settings => ({ theme: 'standard', mode: 'dark', storageMode: 'cookie', defaultRate: 50, }); const defaultData = (): AppData => ({ workEntries: [], payments: [], expenses: [], taxInputs: {}, dashboard: defaultDashboard(), settings: defaultSettings(), version: 1, }); // ─── Store shape ───────────────────────────────────────────────────────────── interface AppStore { // Data data: AppData; // Auth localAuth: LocalAuthState; cloudAuth: CloudAuthState; // Internals vault: Vault | null; saving: boolean; lastSaveError: string | null; // ─── Auth actions ───────────────────────────────────────────────────────── /** Create a new local vault (cookie mode). Fails if username already taken. */ register: (username: string, password: string) => Promise; /** Unlock existing cookie vault OR create if none exists. */ login: (username: string, password: string) => Promise; logout: () => void; /** Cloud auth — sets JWT + switches to cloud mode */ setCloudAuth: (token: string, email: string, provider: 'email' | 'google') => void; // ─── CRUD: Work ─────────────────────────────────────────────────────────── addWorkEntry: (e: Omit) => WorkEntry; updateWorkEntry: (id: string, patch: Partial) => void; deleteWorkEntry: (id: string) => void; // ─── CRUD: Payments ─────────────────────────────────────────────────────── addPayment: (p: Omit) => Payment; updatePayment: (id: string, patch: Partial) => void; deletePayment: (id: string) => void; // ─── CRUD: Expenses ─────────────────────────────────────────────────────── addExpense: (e: Omit) => Expense; updateExpense: (id: string, patch: Partial) => void; deleteExpense: (id: string) => void; // ─── Tax inputs ─────────────────────────────────────────────────────────── setTaxInputs: (year: number, inputs: Partial) => void; // ─── Dashboard ──────────────────────────────────────────────────────────── addChart: (c?: Partial) => void; updateChart: (id: string, patch: Partial) => void; removeChart: (id: string) => void; setDashboardWidgets: (w: DashboardConfig['widgets']) => void; // ─── Settings ───────────────────────────────────────────────────────────── setTheme: (theme: ThemeName, mode: ThemeMode) => void; setStorageMode: (mode: StorageMode, cloudConfig?: CloudConfig) => void; setDefaultRate: (rate: number) => void; // ─── File import/export ─────────────────────────────────────────────────── exportFile: () => Promise; importFile: (file: File, password: string) => Promise; // ─── Persistence ────────────────────────────────────────────────────────── persist: () => Promise; } // ─── Implementation ────────────────────────────────────────────────────────── export const useAppStore = create((set, get) => { /** Debounced persist — avoid thrashing PBKDF2 on every keystroke */ let persistTimer: ReturnType | null = null; const schedulePersist = () => { if (persistTimer) clearTimeout(persistTimer); persistTimer = setTimeout(() => get().persist(), 400); }; const mutate = (fn: (d: AppData) => void) => { set((s) => { const next = structuredClone(s.data); fn(next); next.version += 1; return { data: next }; }); schedulePersist(); }; return { data: defaultData(), localAuth: { unlocked: false, username: null }, cloudAuth: { token: null, email: null, provider: null }, vault: null, saving: false, lastSaveError: null, // ─── Auth ─────────────────────────────────────────────────────────────── register: async (username, password) => { if (await cookieDataExists(username)) { throw new Error('That username already has a local vault. Try logging in instead.'); } const vault = new Vault({ mode: 'cookie', username, password }); const fresh = defaultData(); await vault.save(fresh); set({ vault, data: fresh, localAuth: { unlocked: true, username }, }); }, login: async (username, password) => { const exists = await cookieDataExists(username); const vault = new Vault({ mode: 'cookie', username, password }); if (!exists) { // First time — create vault const fresh = defaultData(); await vault.save(fresh); set({ vault, data: fresh, localAuth: { unlocked: true, username }, }); return; } // Existing — try to decrypt. Wrong password throws. let data: AppData; try { data = (await vault.load()) ?? defaultData(); } catch { throw new Error('Wrong password'); } set({ vault, data, localAuth: { unlocked: true, username }, }); }, logout: () => { set({ vault: null, data: defaultData(), localAuth: { unlocked: false, username: null }, cloudAuth: { token: null, email: null, provider: null }, }); }, setCloudAuth: (token, email, provider) => { set({ cloudAuth: { token, email, provider } }); }, // ─── Work CRUD ────────────────────────────────────────────────────────── addWorkEntry: (e) => { const now = Date.now(); const entry: WorkEntry = { ...e, id: uid(), createdAt: now, updatedAt: now }; mutate((d) => d.workEntries.push(entry)); return entry; }, updateWorkEntry: (id, patch) => { mutate((d) => { const i = d.workEntries.findIndex((w) => w.id === id); if (i >= 0) d.workEntries[i] = { ...d.workEntries[i], ...patch, updatedAt: Date.now() }; }); }, deleteWorkEntry: (id) => { mutate((d) => { d.workEntries = d.workEntries.filter((w) => w.id !== id); }); }, // ─── Payment CRUD ─────────────────────────────────────────────────────── addPayment: (p) => { const now = Date.now(); const payment: Payment = { ...p, id: uid(), createdAt: now, updatedAt: now }; mutate((d) => d.payments.push(payment)); return payment; }, updatePayment: (id, patch) => { mutate((d) => { const i = d.payments.findIndex((p) => p.id === id); if (i >= 0) d.payments[i] = { ...d.payments[i], ...patch, updatedAt: Date.now() }; }); }, deletePayment: (id) => { mutate((d) => { d.payments = d.payments.filter((p) => p.id !== id); }); }, // ─── Expense CRUD ─────────────────────────────────────────────────────── addExpense: (e) => { const now = Date.now(); const expense: Expense = { ...e, id: uid(), createdAt: now, updatedAt: now }; mutate((d) => d.expenses.push(expense)); return expense; }, updateExpense: (id, patch) => { mutate((d) => { const i = d.expenses.findIndex((x) => x.id === id); if (i >= 0) d.expenses[i] = { ...d.expenses[i], ...patch, updatedAt: Date.now() }; }); }, deleteExpense: (id) => { mutate((d) => { d.expenses = d.expenses.filter((x) => x.id !== id); }); }, // ─── Tax inputs ───────────────────────────────────────────────────────── setTaxInputs: (year, inputs) => { mutate((d) => { const existing: Partial = d.taxInputs[year] ?? {}; d.taxInputs[year] = { ...existing, ...inputs, taxYear: year, filingStatus: inputs.filingStatus ?? existing.filingStatus ?? 'single', }; }); }, // ─── Dashboard ────────────────────────────────────────────────────────── addChart: (c) => { mutate((d) => { d.dashboard.charts.push({ id: uid(), type: 'line', metrics: ['payments'], granularity: 'month', rangeStart: null, rangeEnd: null, yMin: null, yMax: null, ...c, }); }); }, updateChart: (id, patch) => { mutate((d) => { const i = d.dashboard.charts.findIndex((c) => c.id === id); if (i >= 0) d.dashboard.charts[i] = { ...d.dashboard.charts[i], ...patch }; }); }, removeChart: (id) => { mutate((d) => { d.dashboard.charts = d.dashboard.charts.filter((c) => c.id !== id); }); }, setDashboardWidgets: (w) => { mutate((d) => { d.dashboard.widgets = w; }); }, // ─── Settings ─────────────────────────────────────────────────────────── setTheme: (theme, mode) => { mutate((d) => { d.settings.theme = theme; d.settings.mode = mode; }); }, setStorageMode: (mode, cloudConfig) => { const { localAuth, cloudAuth } = get(); if (!localAuth.username) throw new Error('Must be logged in'); mutate((d) => { d.settings.storageMode = mode; if (cloudConfig) d.settings.cloudConfig = cloudConfig; }); // Rebuild vault with new adapter const password = prompt('Re-enter your password to switch storage mode:'); if (!password) return; const vault = new Vault({ mode, username: localAuth.username, password, apiUrl: cloudConfig?.apiUrl, getCloudToken: () => cloudAuth.token, }); set({ vault }); schedulePersist(); }, setDefaultRate: (rate) => { mutate((d) => { d.settings.defaultRate = rate; }); }, // ─── File import/export ───────────────────────────────────────────────── exportFile: async () => { const { vault, data } = get(); if (!vault) throw new Error('Not logged in'); await vault.exportToFile(data); }, importFile: async (file, password) => { const tmpVault = new Vault({ mode: 'file', username: 'import', password, }); tmpVault.fileAdapter.setFile(file); const data = await tmpVault.load(); if (!data) throw new Error('File empty or unreadable'); set({ data }); schedulePersist(); }, // ─── Persist ──────────────────────────────────────────────────────────── persist: async () => { const { vault, data } = get(); if (!vault) return; set({ saving: true, lastSaveError: null }); try { await vault.save(data); set({ saving: false }); } catch (err) { set({ saving: false, lastSaveError: err instanceof Error ? err.message : String(err), }); } }, }; });