404 lines
14 KiB
TypeScript
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),
|
|
});
|
|
}
|
|
},
|
|
};
|
|
});
|