// 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 ( <>
{booting ? (
) : showLogin ? ( ) : showSettings ? ( setShowSettings(false)} onRerecord={() => { setShowSettings(false); setShowVoice(true); }} hasCloned={voiceChosen === "mine"} setHasCloned={(v) => setVoiceChosen(v ? "mine" : "charlie")} notifOn={notifOn} setNotifOn={(v) => { setNotifOn(v); localStorage.setItem("fs.notif", v ? "on" : "later"); setNotifDecided(v ? "on" : "later"); }} user={user} /> ) : onboarded ? ( <> {tab === "today" && setTab("reflect")} onSettings={() => setShowSettings(true)}/>} {tab === "reflect" && setTab("today")}/>} {tab === "journey" && setShowSettings(true)}/>} ) : null} {!showLogin && showOnboarding && } {!showLogin && !showOnboarding && showVoiceFlow && (
chooseVoice("mine")} onSkip={() => chooseVoice("charlie")}/>
)} {!showLogin && !showOnboarding && !showVoiceFlow && showInstall && ( )} {!showLogin && !showOnboarding && !showVoiceFlow && !showInstall && showNotif && (
)}
f
); } function isIOSSafari() { const ua = navigator.userAgent; const iOS = /iPad|iPhone|iPod/.test(ua) && !window.MSStream; const webkit = /WebKit/.test(ua) && !/CriOS|FxiOS|OPiOS|mercury/i.test(ua); return iOS && webkit; } function isStandalone() { return window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true; } // Onboarding answers come back as {name, age, situation, struggles, goals, // lang_pref, delivery_hour}. These map 1:1 to the columns Claude reads each // night (engine/context_builder.py:34-45). Keep the user's own words verbatim. function mapOnboardingAnswers(ans) { const ageBracketToInt = { "Under 25": 22, "25 – 34": 30, "35 – 44": 39, "45 – 54": 49, "55 +": 58, }; const listenToHour = { "Before 7 AM": 6, "7 – 9 AM": 8, "Commute": 8, "Evening": 20, "No fixed time": 8, }; return { name: (ans.name || "Friend").trim(), age: ageBracketToInt[ans.age], situation: (ans.situation || "").trim(), struggles: (ans.struggles || "").trim(), goals: (ans.goals || "").trim(), lang_pref: (ans.lang_pref || "Hinglish").toLowerCase(), delivery_hour: listenToHour[ans.delivery_hour] ?? 8, }; } async function enablePushNotifications() { if (!("serviceWorker" in navigator) || !("PushManager" in window)) { throw new Error("Push not supported"); } const perm = await Notification.requestPermission(); if (perm !== "granted") throw new Error("Permission denied"); const reg = await navigator.serviceWorker.ready; const { public_key } = await api.getVapidKey(); if (!public_key) throw new Error("No VAPID key on server"); const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: api.urlBase64ToUint8Array(public_key), }); await api.subscribePush(sub); } const root = ReactDOM.createRoot(document.getElementById("root")); root.render();