initial code commit
This commit is contained in:
commit
27bb45f7df
56 changed files with 15106 additions and 0 deletions
404
client/src/store/appStore.ts
Normal file
404
client/src/store/appStore.ts
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
/**
|
||||
* 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),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue