// Global state for the MC Command Center. // Persisted to Supabase via window._sbClient (initialised in index.html). // Components consume via useStore(). Nothing here knows about the DB schema — // it just reads/writes a single JSON blob per user. // Seed — Michelle Cheri's actual founder context (plausible mock shown on // first launch before real data is entered). const SEED = () => { // dates relative to today const today = new Date(); const inDays = (n) => { const d = new Date(today); d.setDate(d.getDate() + n); return d.toISOString().slice(0, 10); }; const agoDays = (n) => inDays(-n); return { user: { name: 'Michelle Cheri', initials: 'MC' }, week: weekNumber(today), weekStart: 'Monday', energy: 'strong', // spent | steady | strong | wired priorities: [ { id: 'p1', title: 'Pitch deck v3 — investor meeting Friday', tag: 'bdm', urgency: 'crit', transferred: false, rollovers: 0, extended: false, done: false, tasks: [ { id: 't11', label: 'Rewrite traction slide (numbers from Stripe)', done: true }, { id: 't12', label: 'Get Marcus feedback on positioning', done: true }, { id: 't13', label: 'Re-do market sizing — bottoms-up', done: false }, { id: 't14', label: 'Practice run with Sara at 4pm', done: false }, ], }, { id: 'p2', title: 'Close senior designer hire', tag: 'ops', urgency: 'soon', transferred: false, rollovers: 0, extended: false, done: false, tasks: [ { id: 't21', label: 'Final call with Liesel — Wed 14:00', done: false }, { id: 't22', label: 'Reference checks (3)', done: true }, { id: 't23', label: 'Send offer letter', done: false }, ], }, { id: 'p3', title: 'Q3 board update — written + numbers', tag: 'admin', urgency: 'warn', transferred: true, rollovers: 1, extended: false, done: false, tasks: [ { id: 't31', label: 'Pull Q3 metrics from data team', done: false }, { id: 't32', label: 'Outline narrative (one page)', done: false }, { id: 't33', label: 'Draft + share with Anders for review', done: false }, ], }, { id: 'p4', title: 'Sign Nordlys MSA — kickoff Monday', tag: 'bdm', urgency: 'soon', transferred: false, rollovers: 0, extended: false, done: false, tasks: [ { id: 't41', label: 'Redline contract w/ lawyer', done: true }, { id: 't42', label: 'Send signed PDF', done: false }, ], }, ], braindump: [ { id: 'b1', text: 'Founder roundtable idea — quarterly, 8 people, no agenda', kind: 'idea', flagged: false, done: false, at: agoDays(0) }, { id: 'b2', text: 'Ask Petra about her ops playbook', kind: 'dump', flagged: true, done: false, at: agoDays(0) }, { id: 'b3', text: 'Newsletter: "the transfer rule" essay', kind: 'idea', flagged: false, done: false, at: agoDays(1) }, { id: 'b4', text: 'Cancel Loom subscription, we use Tella now', kind: 'dump', flagged: false, done: true, at: agoDays(1) }, { id: 'b5', text: 'New pricing: maybe seat-based for enterprise?', kind: 'idea', flagged: true, done: false, at: agoDays(2) }, { id: 'b6', text: 'Book Lisbon flights for Web Summit', kind: 'dump', flagged: false, done: false, at: agoDays(2) }, ], deadlines: [ { id: 'd1', name: 'Investor meeting', ctx: 'with Marcus + partners', due: inDays(3), bucket: 'crit' }, { id: 'd2', name: 'Nordlys signed MSA', ctx: 'kickoff depends on this', due: inDays(5), bucket: 'soon' }, { id: 'd3', name: 'Q3 board update', ctx: 'board reads sent Sunday', due: inDays(8), bucket: 'soon' }, { id: 'd4', name: 'VAT Q2 filing', ctx: 'auto-prep by accountant', due: inDays(12), bucket: 'warn' }, { id: 'd5', name: 'Series A target close',ctx: 'soft deadline', due: inDays(38), bucket: 'soon' }, ], admin: [ { id: 'a1', kind: 'invoice-overdue', count: 1, when: '18 days overdue', do: 'Send reminder', who: 'Acme · €4,200', sev: 'crit' }, { id: 'a2', kind: 'invoice-send', count: 2, when: 'ready to send', do: 'Send now', who: 'Nordlys + Saber', sev: 'warn' }, { id: 'a3', kind: 'vat', count: 1, when: 'due in 12 days', do: 'Review & file', who: 'Q2 / NL', sev: 'warn' }, { id: 'a4', kind: 'expenses', count: 14, when: 'this month', do: 'Triage receipts',who: 'auto-imported', sev: 'calm' }, { id: 'a5', kind: 'contract', count: 1, when: 'awaiting signature', do: 'Resend', who: 'Nordlys MSA', sev: 'warn' }, { id: 'a6', kind: 'subscriptions', count: 3, when: 'unused 60+ days', do: 'Review & cut', who: '€127/mo', sev: 'calm' }, { id: 'a7', kind: 'inbox', count: 47, when: '3 flagged important', do: 'Triage 10 min', who: '', sev: 'ok' }, { id: 'a8', kind: 'tax-q3', count: 1, when: 'estimate due Sep 15', do: 'Review', who: 'income tax', sev: 'calm' }, ], money: { inMonth: 34200, outMonth: 18400, outstanding: 6050, currency: '€', inSpark: [12, 19, 14, 22, 16, 25, 30], outSpark: [16, 12, 18, 14, 19, 15, 13], }, wins: [ { id: 'w1', text: 'Sent investor shortlist to Marcus', when: 'Yesterday' }, { id: 'w2', text: 'Closed dev contract with Saber', when: 'Mon' }, { id: 'w3', text: 'Q2 books reconciled (clean)', when: '4d ago' }, { id: 'w4', text: 'Hired fractional CFO', when: '2w ago' }, { id: 'w5', text: 'Series Seed extension closed', when: '3w ago' }, ], pipeline: { Opportunity: [{ n: 'Ovaro', v: '€60k', hot: false }, { n: 'Tessera', v: '€140k', hot: true }, { n: 'Brink', v: '€45k', hot: false }], Discovery: [{ n: 'Halden', v: '€80k', hot: true }, { n: 'Marlow', v: '€55k', hot: false }], Proposal: [{ n: 'Nordlys', v: '€220k', hot: true }], Negotiation: [{ n: 'Acme', v: '€180k', hot: true }], Won: [{ n: 'Saber', v: '€95k', hot: false }, { n: 'Lumen', v: '€60k', hot: false }], }, burst: { active: false, taskId: null, priorityId: null, length: 30 * 60, // seconds remaining: 30 * 60, phase: 'focus', // 'focus' | 'break' running: false, completedToday: 3, streakDays: 4, }, nextWeek: [], lastBurstPriorityId: null, cleanseStreak: 2, wrapStreak: 5, wrap: { active: false, priorityId: null, taskId: null, taskLabel: '', loops: [], win: '', }, }; }; function weekNumber(d) { const date = new Date(d.getTime()); date.setHours(0, 0, 0, 0); date.setDate(date.getDate() + 4 - (date.getDay() || 7)); const yearStart = new Date(date.getFullYear(), 0, 1); return Math.ceil(((date - yearStart) / 86400000 + 1) / 7); } // ------ context + hook ------ const StoreCtx = React.createContext(null); function StoreProvider({ children, seedOverride }) { const [state, _setState] = React.useState(() => seedOverride ? { ...SEED(), ...seedOverride } : SEED() ); // 'loading' → waiting for DB | 'signed-out' → no session | 'saving' → write in flight | 'synced' → all good const [syncState, setSyncState] = React.useState('loading'); const saveTimer = React.useRef(null); React.useEffect(() => { const sb = window._sbClient; if (!sb) { // No Supabase client available — fall back to localStorage so the app // still works (dev without credentials, etc.) try { const raw = localStorage.getItem('mc-command-center-v1'); if (raw) _setState(prev => ({ ...SEED(), ...JSON.parse(raw) })); } catch (e) {} setSyncState('synced'); return; } let mounted = true; // Load the user's data from Supabase, migrating from localStorage if needed. const loadFromDB = async (userId) => { try { const { data: existing } = await sb .from('command_center_state') .select('data') .eq('user_id', userId) .maybeSingle(); if (!mounted) return; if (existing?.data) { _setState(seedOverride ? { ...SEED(), ...existing.data, ...seedOverride } : { ...SEED(), ...existing.data } ); } else { // One-time migration: carry over any localStorage data. const legacy = localStorage.getItem('mc-command-center-v1'); if (legacy) { try { const parsed = JSON.parse(legacy); await sb.from('command_center_state') .insert({ user_id: userId, data: parsed }); if (mounted) _setState(seedOverride ? { ...SEED(), ...parsed, ...seedOverride } : { ...SEED(), ...parsed } ); } catch (e) { if (mounted) _setState(seedOverride ? { ...SEED(), ...seedOverride } : SEED()); } } else { _setState(seedOverride ? { ...SEED(), ...seedOverride } : SEED()); } } } catch (e) { console.warn('Failed to load from Supabase:', e); if (mounted) _setState(seedOverride ? { ...SEED(), ...seedOverride } : SEED()); } if (mounted) setSyncState('synced'); }; // Use onAuthStateChange as single source of truth for session state. // INITIAL_SESSION fires immediately on mount (with session or null). // SIGNED_IN fires after a magic link redirect. const { data: { subscription } } = sb.auth.onAuthStateChange((event, session) => { if (!mounted) return; if (event === 'INITIAL_SESSION' || event === 'SIGNED_IN') { if (session?.user) { setSyncState('loading'); loadFromDB(session.user.id); } else { // INITIAL_SESSION with no session = not signed in setSyncState('signed-out'); } } else if (event === 'SIGNED_OUT') { _setState(SEED()); setSyncState('signed-out'); } // TOKEN_REFRESHED: session is still valid, no data reload needed. }); return () => { mounted = false; subscription.unsubscribe(); }; }, []); const setState = React.useCallback((updater, opts) => { _setState((prev) => { const next = typeof updater === 'function' ? updater(prev) : updater; // Quiet updates skip the persist write AND the "saving…" indicator. // Used for ephemeral state like the pomodoro tick (1Hz would otherwise // flicker the sync indicator constantly). if (opts?.quiet) return next; setSyncState('saving'); if (saveTimer.current) clearTimeout(saveTimer.current); saveTimer.current = setTimeout(async () => { const sb = window._sbClient; if (!sb) { // Fallback: localStorage try { localStorage.setItem('mc-command-center-v1', JSON.stringify(next)); } catch (e) {} setSyncState('synced'); return; } try { const { data: { user } } = await sb.auth.getUser(); if (!user) { setSyncState('synced'); return; } await sb.from('command_center_state') .upsert({ user_id: user.id, data: next, updated_at: new Date().toISOString() }); } catch (e) { console.warn('Save failed:', e); } setSyncState('synced'); }, 400); return next; }); }, []); // ---- mutators (closures so children can call) ---- const api = React.useMemo(() => ({ state, syncState, setEnergy: (e) => setState((s) => ({ ...s, energy: e })), toggleTask: (priorityId, taskId) => setState((s) => ({ ...s, priorities: s.priorities.map((p) => p.id !== priorityId ? p : { ...p, tasks: p.tasks.map((t) => t.id !== taskId ? t : { ...t, done: !t.done }), done: p.tasks.every((t) => t.id === taskId ? !t.done : t.done) && p.tasks.length > 0 ? true : p.done, }), })), addTask: (priorityId, label) => setState((s) => ({ ...s, priorities: s.priorities.map((p) => p.id !== priorityId ? p : { ...p, tasks: [...p.tasks, { id: 't' + Date.now(), label, done: false }], }), })), deletePriority: (id) => setState((s) => ({ ...s, priorities: s.priorities.filter((p) => p.id !== id) })), addPriority: (title) => setState((s) => ({ ...s, priorities: [...s.priorities, { id: 'p' + Date.now(), title, tag: 'ops', urgency: 'soon', transferred: false, rollovers: 0, extended: false, done: false, tasks: [], }], })), renamePriority: (id, title) => setState((s) => ({ ...s, priorities: s.priorities.map((p) => p.id === id ? { ...p, title } : p), })), setPriorityCategory: (id, tag) => setState((s) => ({ ...s, priorities: s.priorities.map((p) => p.id === id ? { ...p, tag } : p), })), transferPriority: (id) => setState((s) => ({ ...s, priorities: s.priorities.map((p) => p.id === id ? { ...p, transferred: true, rollovers: Math.min(((p.rollovers || 0) + 1), 2) } : p), })), grantExtension: (id) => setState((s) => ({ ...s, priorities: s.priorities.map((p) => p.id === id ? { ...p, extended: true } : p), })), completePriority: (id) => setState((s) => { const p = s.priorities.find((x) => x.id === id); if (!p) return s; return { ...s, priorities: s.priorities.filter((x) => x.id !== id), wins: [{ id: 'w' + Date.now(), text: `✓ ${p.title}`, when: 'just now' }, ...s.wins], }; }), bumpCleanseStreak: () => setState((s) => ({ ...s, cleanseStreak: (s.cleanseStreak || 0) + 1 })), // ---- wrap-up ritual ---- startWrap: (priorityId, taskId) => setState((s) => { const p = s.priorities.find((x) => x.id === priorityId); const t = p?.tasks.find((x) => x.id === taskId); return { ...s, wrap: { active: true, priorityId: priorityId || null, taskId: taskId || null, taskLabel: t?.label || p?.title || 'this burst', loops: [], win: '', }, }; }), addLoop: (text) => setState((s) => ({ ...s, wrap: { ...s.wrap, loops: [...s.wrap.loops, { id: 'l' + Date.now() + Math.random(), text, routed: null }] }, })), routeLoop: (loopId, destination) => setState((s) => { const loop = s.wrap.loops.find((l) => l.id === loopId); if (!loop) return s; let extra = {}; if (destination === 'zip') { extra.braindump = [{ id: 'b' + Date.now() + Math.random(), text: loop.text, kind: 'dump', flagged: false, done: false, at: new Date().toISOString().slice(0, 10) }, ...s.braindump]; } else if (destination === 'idea') { extra.braindump = [{ id: 'b' + Date.now() + Math.random(), text: loop.text, kind: 'idea', flagged: false, done: false, at: new Date().toISOString().slice(0, 10) }, ...s.braindump]; } else if (destination?.startsWith('priority:')) { const pid = destination.slice('priority:'.length); extra.priorities = s.priorities.map((p) => p.id !== pid ? p : { ...p, tasks: [...p.tasks, { id: 't' + Date.now() + Math.random(), label: loop.text, done: false }], }); } return { ...s, ...extra, wrap: { ...s.wrap, loops: s.wrap.loops.map((l) => l.id === loopId ? { ...l, routed: destination } : l) }, }; }), setWrapWin: (text) => setState((s) => ({ ...s, wrap: { ...s.wrap, win: text } })), completeWrap: ({ markTaskDone = false } = {}) => setState((s) => { let wins = s.wins; const w = s.wrap.win?.trim(); if (w) wins = [{ id: 'w' + Date.now(), text: w, when: 'just now' }, ...wins]; let priorities = s.priorities; if (s.wrap.priorityId && s.wrap.taskId) { priorities = priorities.map((p) => p.id !== s.wrap.priorityId ? p : { ...p, tasks: p.tasks.map((t) => t.id !== s.wrap.taskId ? t : { ...t, done: markTaskDone }), }); } return { ...s, wins, priorities, wrapStreak: (s.wrapStreak || 0) + 1, wrap: { ...s.wrap, active: false }, }; }), dismissWrap: () => setState((s) => ({ ...s, wrap: { ...s.wrap, active: false } })), addBrainItem: (text, kind) => setState((s) => ({ ...s, braindump: [{ id: 'b' + Date.now(), text, kind, flagged: false, done: false, at: new Date().toISOString().slice(0, 10) }, ...s.braindump], })), deleteBrainItem: (id) => setState((s) => ({ ...s, braindump: s.braindump.filter((b) => b.id !== id) })), toggleBrainDone: (id) => setState((s) => ({ ...s, braindump: s.braindump.map((b) => b.id === id ? { ...b, done: !b.done } : b), })), toggleBrainFlag: (id) => setState((s) => ({ ...s, braindump: s.braindump.map((b) => b.id === id ? { ...b, flagged: !b.flagged } : b), })), promoteBrainItem: (id) => setState((s) => { const item = s.braindump.find((b) => b.id === id); if (!item) return s; return { ...s, braindump: s.braindump.filter((b) => b.id !== id), priorities: [...s.priorities, { id: 'p' + Date.now(), title: item.text, tag: item.kind === 'idea' ? 'marketing' : 'ops', urgency: 'soon', transferred: false, rollovers: 0, extended: false, done: false, tasks: [], }], }; }), startBurst: (priorityId, taskId, length) => setState((s) => ({ ...s, burst: { ...s.burst, active: true, priorityId, taskId, length, remaining: length, phase: 'focus', running: true }, lastBurstPriorityId: priorityId || s.lastBurstPriorityId, })), pauseBurst: () => setState((s) => ({ ...s, burst: { ...s.burst, running: !s.burst.running } })), closeBurst: () => setState((s) => ({ ...s, burst: { ...s.burst, active: false, running: false } })), tickBurst: () => setState((s) => { if (!s.burst.active || !s.burst.running) return s; const r = s.burst.remaining - 1; if (r <= 0) { const p = s.priorities.find((x) => x.id === s.burst.priorityId); const t = p?.tasks.find((x) => x.id === s.burst.taskId); return { ...s, burst: { ...s.burst, remaining: 0, running: false, completedToday: s.burst.completedToday + 1 }, wrap: { active: true, priorityId: s.burst.priorityId, taskId: s.burst.taskId, taskLabel: t?.label || p?.title || 'this burst', loops: [], win: '', }, }; } return { ...s, burst: { ...s.burst, remaining: r } }; }, { quiet: true }), setBurstLength: (sec) => setState((s) => ({ ...s, burst: { ...s.burst, length: sec, remaining: sec } })), // ---- next-week forward queue ---- addNextWeekItem: (text, block = 'other') => setState((s) => ({ ...s, nextWeek: [{ id: 'nw' + Date.now(), text, block, at: new Date().toISOString().slice(0, 10) }, ...(s.nextWeek || [])], })), deleteNextWeekItem: (id) => setState((s) => ({ ...s, nextWeek: (s.nextWeek || []).filter((i) => i.id !== id) })), promoteNextWeekItem: (id) => setState((s) => { const item = (s.nextWeek || []).find((i) => i.id === id); if (!item) return s; return { ...s, nextWeek: (s.nextWeek || []).filter((i) => i.id !== id), priorities: [...s.priorities, { id: 'p' + Date.now(), title: item.text, tag: 'ops', urgency: 'soon', transferred: false, rollovers: 0, extended: false, done: false, tasks: [], }], }; }), updateNextWeekBlock: (id, block) => setState((s) => ({ ...s, nextWeek: (s.nextWeek || []).map((i) => i.id === id ? { ...i, block } : i), })), completeAdmin: (id) => setState((s) => ({ ...s, admin: s.admin.filter((a) => a.id !== id) })), addWin: (text) => setState((s) => ({ ...s, wins: [{ id: 'w' + Date.now(), text, when: 'just now' }, ...s.wins] })), // Reset clears the DB row and reverts to seed data. reset: () => { _setState(SEED()); const sb = window._sbClient; if (sb) { (async () => { try { const { data: { user } } = await sb.auth.getUser(); if (user) await sb.from('command_center_state').delete().eq('user_id', user.id); } catch (e) {} })(); } else { try { localStorage.removeItem('mc-command-center-v1'); } catch (e) {} } }, // Sign out of Supabase (shows the sign-in screen). signOut: () => { window._sbClient?.auth.signOut(); }, }), [state, syncState, setState]); return {children}; } const useStore = () => React.useContext(StoreCtx); Object.assign(window, { StoreProvider, useStore });