ten99timecard/client/src/store/appStore.ts
2026-03-04 21:21:59 -05:00

404 lines
14 KiB
TypeScript

/**
* 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<void>;
/** Unlock existing cookie vault OR create if none exists. */
login: (username: string, password: string) => Promise<void>;
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, 'id' | 'createdAt' | 'updatedAt'>) => WorkEntry;
updateWorkEntry: (id: string, patch: Partial<WorkEntry>) => void;
deleteWorkEntry: (id: string) => void;
// ─── CRUD: Payments ───────────────────────────────────────────────────────
addPayment: (p: Omit<Payment, 'id' | 'createdAt' | 'updatedAt'>) => Payment;
updatePayment: (id: string, patch: Partial<Payment>) => void;
deletePayment: (id: string) => void;
// ─── CRUD: Expenses ───────────────────────────────────────────────────────
addExpense: (e: Omit<Expense, 'id' | 'createdAt' | 'updatedAt'>) => Expense;
updateExpense: (id: string, patch: Partial<Expense>) => void;
deleteExpense: (id: string) => void;
// ─── Tax inputs ───────────────────────────────────────────────────────────
setTaxInputs: (year: number, inputs: Partial<TaxInputs>) => void;
// ─── Dashboard ────────────────────────────────────────────────────────────
addChart: (c?: Partial<ChartConfig>) => void;
updateChart: (id: string, patch: Partial<ChartConfig>) => 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<void>;
importFile: (file: File, password: string) => Promise<void>;
// ─── Persistence ──────────────────────────────────────────────────────────
persist: () => Promise<void>;
}
// ─── Implementation ──────────────────────────────────────────────────────────
export const useAppStore = create<AppStore>((set, get) => {
/** Debounced persist — avoid thrashing PBKDF2 on every keystroke */
let persistTimer: ReturnType<typeof setTimeout> | 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<TaxInputs> = 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),
});
}
},
};
});