/* ============ Studio store ============ */ /* Single source of truth — every change autosaves to localStorage. Components subscribe via useStore(), mutate via the imperative API. */ const STORAGE_KEY = 'notaryStudio.v1'; const BACKUP_KEY = 'notaryStudio.backups.v1'; const LAST_BACKUP_KEY = 'notaryStudio.lastBackup'; const BACKUP_KEEP = 30; // rolling daily snapshots const todayISO = () => new Date().toISOString().slice(0,10); const nowISO = () => new Date().toISOString(); const uid = () => Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-3); const DEFAULT_SETTINGS = { businessName: 'Middlesex Notary Co', notaryName: '', state: 'MA', defaultFee: 150, mileageRate: 0.67, // IRS 2024 standard baseAddress: '', accent: 'navy', logoUrl: 'assets/middlesex-logo.png', }; const SEED_CLIENTS = [ { id: 'c1', name: 'John Doe', phone: '555-0101', email: 'john@email.com', source: 'Facebook', status: 'Not Contacted', priority: 'Low', revenue: 1000, paid: true, notes: '', createdAt: nowISO() }, { id: 'c2', name: 'Jane Doe', phone: '555-0102', email: 'jane@email.com', source: 'Website', status: 'Contacted', priority: 'Medium', revenue: 5000, paid: false, notes: '', createdAt: nowISO() }, { id: 'c3', name: 'J Doe', phone: '555-0103', email: 'jdoe@email.com', source: 'Networking', status: 'Awaiting Response', priority: 'High', revenue:10000, paid: false, notes: '', createdAt: nowISO() }, ]; const SEED_SIGNINGS = [ { id: 's1', clientId: 'c2', title: 'Loan signing — Refinance', date: todayISO(), time: '14:00', durationMin: 60, location: '123 Market St, San Francisco', docType: 'Refinance', fee: 175, status: 'Scheduled', notes: '', createdAt: nowISO() }, { id: 's2', clientId: 'c1', title: 'POA notarization', date: todayISO(), time: '10:30', durationMin: 30, location: '500 Oak Ave', docType: 'POA', fee: 65, status: 'Completed', notes: '', createdAt: nowISO() }, ]; const SEED_MILEAGE = [ { id: 'm1', date: todayISO(), clientId: 'c2', purpose: 'Loan signing', from: 'Office', to: '123 Market St', miles: 14.2, roundTrip: true, notes: '', createdAt: nowISO() }, { id: 'm2', date: todayISO(), clientId: 'c1', purpose: 'POA delivery', from: 'Office', to: '500 Oak Ave', miles: 8.6, roundTrip: true, notes: '', createdAt: nowISO() }, ]; function loadInitial() { // Try v1 first try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw); const settings = { ...DEFAULT_SETTINGS, ...(parsed.settings || {}) }; // One-time migration: if user is still on the previous app defaults, // promote them to Middlesex Notary Co branding. if (!parsed._brandedMiddlesex) { if (settings.businessName === 'My Notary Practice' || !settings.businessName) { settings.businessName = 'Middlesex Notary Co'; } if (settings.accent === 'blue' || !settings.accent) settings.accent = 'navy'; if (!settings.state) settings.state = 'MA'; settings.logoUrl = 'assets/middlesex-logo.png'; } return { clients: parsed.clients || [], signings: parsed.signings || [], mileage: parsed.mileage || [], settings, _brandedMiddlesex: true, }; } } catch(e) {} // Migrate from legacy 'notaryData' (previous ledger build) try { const legacy = localStorage.getItem('notaryData'); if (legacy) { const arr = JSON.parse(legacy); const clients = (Array.isArray(arr) ? arr : []).map(c => ({ id: 'c' + c.id, name: c.name || '', phone: c.phone || '', email: c.email || '', source: c.source || 'Phone', status: c.status || 'Not Contacted', priority: c.priority || 'Low', revenue: parseFloat(c.revenue) || 0, paid: !!c.paid, notes: '', createdAt: nowISO(), })); return { clients, signings: [], mileage: [], settings: { ...DEFAULT_SETTINGS } }; } } catch(e) {} return { clients: SEED_CLIENTS, signings: SEED_SIGNINGS, mileage: SEED_MILEAGE, settings: { ...DEFAULT_SETTINGS }, }; } const Store = (function() { let state = loadInitial(); let listeners = new Set(); let saveTimer = null; let savingFlag = { saving: false, savedAt: Date.now() }; function notify() { listeners.forEach(fn => fn(state)); } function scheduleSave() { if (saveTimer) clearTimeout(saveTimer); savingFlag = { saving: true, savedAt: savingFlag.savedAt }; notify(); saveTimer = setTimeout(() => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); savingFlag = { saving: false, savedAt: Date.now() }; notify(); } catch(e) { console.warn('save failed', e); } }, 280); } // Save immediately on page hide so we never lose data window.addEventListener('beforeunload', () => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch(e){} try { maybeBackup(true); } catch(e){} }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch(e){} } }); // -------- Daily backup system -------- function loadBackups() { try { return JSON.parse(localStorage.getItem(BACKUP_KEY) || '[]'); } catch(e) { return []; } } function saveBackups(list) { try { localStorage.setItem(BACKUP_KEY, JSON.stringify(list)); } catch(e){} } function maybeBackup(force = false) { const today = todayISO(); const last = localStorage.getItem(LAST_BACKUP_KEY); if (!force && last === today) return false; const list = loadBackups(); const snapshot = { id: today + '-' + Math.random().toString(36).slice(2,6), date: today, createdAt: nowISO(), counts: { clients: state.clients.length, signings: state.signings.length, mileage: state.mileage.length }, data: { clients: state.clients, signings: state.signings, mileage: state.mileage, settings: state.settings }, }; // Replace existing entry for today; otherwise prepend const filtered = list.filter(b => b.date !== today); const next = [snapshot, ...filtered].slice(0, BACKUP_KEEP); saveBackups(next); localStorage.setItem(LAST_BACKUP_KEY, today); return true; } // Snapshot on first load and every hour while the tab is open setTimeout(() => maybeBackup(false), 1500); setInterval(() => maybeBackup(false), 60 * 60 * 1000); function listBackups() { return loadBackups(); } function restoreBackup(id) { const b = loadBackups().find(x => x.id === id); if (!b) return false; commit({ clients: b.data.clients || [], signings: b.data.signings || [], mileage: b.data.mileage || [], settings: { ...DEFAULT_SETTINGS, ...(b.data.settings || {}) }, }); return true; } function deleteBackup(id) { saveBackups(loadBackups().filter(x => x.id !== id)); notify(); } function snapshotNow(label) { const list = loadBackups(); const snapshot = { id: 'manual-' + Date.now().toString(36), date: todayISO(), createdAt: nowISO(), label: label || 'Manual snapshot', counts: { clients: state.clients.length, signings: state.signings.length, mileage: state.mileage.length }, data: { clients: state.clients, signings: state.signings, mileage: state.mileage, settings: state.settings }, }; saveBackups([snapshot, ...list].slice(0, BACKUP_KEEP + 5)); notify(); return snapshot.id; } function getState() { return state; } function getSavingFlag() { return savingFlag; } function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); } function commit(next) { state = next; notify(); scheduleSave(); } // ----- Clients ----- const Clients = { add(c = {}) { const item = { id: uid(), name: '', phone: '', email: '', source: 'Phone', status: 'Not Contacted', priority: 'Low', revenue: 0, paid: false, notes: '', createdAt: nowISO(), ...c, }; commit({ ...state, clients: [item, ...state.clients] }); return item.id; }, update(id, patch) { commit({ ...state, clients: state.clients.map(c => c.id === id ? { ...c, ...patch } : c) }); }, remove(id) { commit({ ...state, clients: state.clients.filter(c => c.id !== id) }); }, }; // ----- Signings ----- const Signings = { add(s = {}) { const item = { id: uid(), clientId: null, title: '', date: todayISO(), time: '10:00', durationMin: 60, location: '', docType: 'Other', fee: state.settings.defaultFee, status: 'Scheduled', notes: '', createdAt: nowISO(), ...s, }; commit({ ...state, signings: [item, ...state.signings] }); return item.id; }, update(id, patch) { commit({ ...state, signings: state.signings.map(s => s.id === id ? { ...s, ...patch } : s) }); }, remove(id) { commit({ ...state, signings: state.signings.filter(s => s.id !== id) }); }, }; // ----- Mileage ----- const Mileage = { add(m = {}) { const item = { id: uid(), date: todayISO(), clientId: null, purpose: '', from: state.settings.baseAddress || 'Office', to: '', miles: 0, roundTrip: true, notes: '', createdAt: nowISO(), ...m, }; commit({ ...state, mileage: [item, ...state.mileage] }); return item.id; }, update(id, patch) { commit({ ...state, mileage: state.mileage.map(m => m.id === id ? { ...m, ...patch } : m) }); }, remove(id) { commit({ ...state, mileage: state.mileage.filter(m => m.id !== id) }); }, }; // ----- Settings ----- const Settings = { update(patch) { commit({ ...state, settings: { ...state.settings, ...patch } }); }, }; // ----- Bulk ----- function importJSON(payload) { if (!payload) return; commit({ clients: payload.clients || [], signings: payload.signings || [], mileage: payload.mileage || [], settings: { ...DEFAULT_SETTINGS, ...(payload.settings || {}) }, }); } function resetAll() { commit({ clients: SEED_CLIENTS, signings: SEED_SIGNINGS, mileage: SEED_MILEAGE, settings: { ...DEFAULT_SETTINGS } }); } return { getState, getSavingFlag, subscribe, Clients, Signings, Mileage, Settings, importJSON, resetAll, listBackups, restoreBackup, deleteBackup, snapshotNow }; })(); function useStore() { const [, setTick] = React.useState(0); React.useEffect(() => Store.subscribe(() => setTick(t => t + 1)), []); return Store.getState(); } function useSavingFlag() { const [, setTick] = React.useState(0); React.useEffect(() => Store.subscribe(() => setTick(t => t + 1)), []); return Store.getSavingFlag(); } /* ============ Formatters ============ */ const fmt = { money: (n, dp = 0) => '$' + (Number(n) || 0).toLocaleString('en-US', { minimumFractionDigits: dp, maximumFractionDigits: dp }), miles: (n) => (Number(n) || 0).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + ' mi', date: (iso) => { try { return new Date(iso + 'T00:00:00').toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' }); } catch(e) { return iso; } }, dateShort: (iso) => { try { return new Date(iso + 'T00:00:00').toLocaleDateString('en-US', { month:'short', day:'numeric' }); } catch(e) { return iso; } }, time: (t) => { if (!t) return ''; const [h,m] = t.split(':').map(Number); const am = h < 12; const hh = h % 12 || 12; return `${hh}:${String(m).padStart(2,'0')} ${am?'AM':'PM'}`; }, relativeTime: (ts) => { const diff = (Date.now() - ts) / 1000; if (diff < 5) return 'just now'; if (diff < 60) return Math.floor(diff) + 's ago'; if (diff < 3600) return Math.floor(diff/60) + 'm ago'; if (diff < 86400) return Math.floor(diff/3600) + 'h ago'; return Math.floor(diff/86400) + 'd ago'; }, }; const STATUS_TONES = { 'Not Contacted': 'gray', 'Contacted': 'blue', 'Awaiting Response': 'orange', 'Won': 'green', 'Lost': 'red', 'Pending': 'purple', 'Scheduled': 'blue', 'Completed': 'green', 'Cancelled': 'red', 'No-show': 'red', }; const PRIORITY_TONES = { Low: 'gray', Medium: 'orange', High: 'red' }; Object.assign(window, { Store, useStore, useSavingFlag, fmt, todayISO, nowISO, uid, STATUS_TONES, PRIORITY_TONES });