// Root app — orchestrates screens and flows. const { useState: useS, useEffect: useE } = React; function App() { // Server-backed user state (null while loading, {} on network error) const [user, setUser] = useS(null); // onboarded starts as null (unknown). Shown as "loading" until /api/user resolves. // Only flip to true after the server confirms — never just on local optimism. const [onboarded, setOnboarded] = useS(null); const [voiceChosen, setVoiceChosen] = useS(() => localStorage.getItem("fs.voice") || ""); const [installed, setInstalled] = useS(() => localStorage.getItem("fs.installed") === "1"); const [notifDecided, setNotifDecided] = useS(() => localStorage.getItem("fs.notif") || ""); const [notifOn, setNotifOn] = useS(() => localStorage.getItem("fs.notif") === "on"); // UI state const [tab, setTab] = useS(() => localStorage.getItem("fs.tab") || "today"); const [showSettings, setShowSettings] = useS(false); const [showVoice, setShowVoice] = useS(false); const [splashGone, setSplashGone] = useS(false); const [onboardError, setOnboardError] = useS(""); useE(() => { const t = setTimeout(() => setSplashGone(true), 1400); return () => clearTimeout(t); }, []); useE(() => { localStorage.setItem("fs.tab", tab); }, [tab]); // Version check + ghost-SW kill switch. Two layers: // 1. Build version drift: if server's BUILD_VERSION differs from our cached // one, call reg.update() to encourage the SW to fetch the new assets. // 2. SW version cross-check: ask the running SW directly what its VERSION // constant is. If it reports an old value (e.g. "fs-v9" while server // wants "fs-v10"), we're stuck. Try reg.update() once; if still stuck // on next load (second consecutive mismatch), unregister all SWs and // reload — nuclear option to recover users with a zombie SW. useE(() => { api.getVersion().then((info) => { const { version, sw_version } = info || {}; if (version) { const prev = localStorage.getItem("fs.version") || ""; if (prev && prev !== version) { navigator.serviceWorker?.getRegistration?.().then(r => r?.update?.()); } localStorage.setItem("fs.version", version); } if (!sw_version || !("serviceWorker" in navigator)) return; const controller = navigator.serviceWorker.controller; if (!controller) return; // MessageChannel is a two-way pipe dedicated to this one question. // Direct postMessage wouldn't give us a reply path; channels do. const chan = new MessageChannel(); const timer = setTimeout(() => { try { chan.port1.close(); } catch {} }, 3000); chan.port1.onmessage = (ev) => { clearTimeout(timer); const running = ev.data && ev.data.version; if (!running) return; if (running === sw_version) { localStorage.setItem("fs.sw_stuck", "0"); return; } const stuck = (parseInt(localStorage.getItem("fs.sw_stuck") || "0", 10) || 0) + 1; localStorage.setItem("fs.sw_stuck", String(stuck)); if (stuck >= 2) { navigator.serviceWorker.getRegistrations().then(regs => Promise.all(regs.map(r => r.unregister().catch(() => false))) ).then(() => { localStorage.setItem("fs.sw_stuck", "0"); location.reload(); }).catch(() => {}); } else { navigator.serviceWorker.getRegistration?.().then(r => r?.update?.()); } }; try { controller.postMessage({ type: "version" }, [chan.port2]); } catch {} }).catch(() => {}); }, []); // Load user on mount. Server is the source of truth for onboarded state. // If no token → LoginScreen. If token but /api/user 401s → clear token, LoginScreen. useE(() => { if (!api.hasToken()) { setUser({}); setOnboarded(false); return; } api.getUser().then(u => { setUser(u); setOnboarded(!!u.onboarded); if (u.voice_id) { localStorage.setItem("fs.voice", "mine"); setVoiceChosen("mine"); } }).catch(e => { console.warn("[app] getUser failed:", e); if (e?.status === 401) { // Stale/invalid token — fall back to LoginScreen cleanly. api.clearToken(); } setUser({}); setOnboarded(false); }); }, []); // Flow handlers — only mark onboarded locally after server confirms. const completeOnboarding = async (answers = {}) => { const profile = mapOnboardingAnswers(answers); setOnboardError(""); try { await api.onboard(profile); } catch (e) { console.warn("onboard failed:", e); setOnboardError(e.message || "Couldn't save. Check your connection and try again."); return; // do NOT advance — user retries } // Fresh onboarding implies fresh setup — drop any sticky flags from a prior // install so voice/install/notif prompts all get a clean shot to appear. ["fs.voice", "fs.installed", "fs.notif"].forEach(k => localStorage.removeItem(k)); setVoiceChosen(""); setInstalled(false); setNotifDecided(""); setNotifOn(false); try { const u = await api.getUser(); setUser(u); } catch {} setOnboarded(true); }; const chooseVoice = (v) => { localStorage.setItem("fs.voice", v); setVoiceChosen(v); setShowVoice(false); }; const didInstall = () => { localStorage.setItem("fs.installed", "1"); setInstalled(true); }; const laterInstall = () => { localStorage.setItem("fs.installed", "1"); setInstalled(true); }; const enableNotif = async () => { try { await enablePushNotifications(); } catch (e) { console.warn("push enable failed:", e); } localStorage.setItem("fs.notif", "on"); setNotifDecided("on"); setNotifOn(true); }; const laterNotif = () => { localStorage.setItem("fs.notif", "later"); setNotifDecided("later"); }; const booting = onboarded === null; const showLogin = !api.hasToken(); const showOnboarding = !showLogin && onboarded === false; const showVoiceFlow = !showLogin && onboarded === true && (!voiceChosen || showVoice); const showInstall = !showLogin && onboarded === true && voiceChosen && !installed && !showVoice && isIOSSafari() && !isStandalone(); const showNotif = !showLogin && onboarded === true && voiceChosen && (installed || !isIOSSafari()) && !notifDecided && !showVoice; return ( <>