// app/store.jsx — UI runtime. Talks to the backend ONLY through window.LCAdapter. // // Contains NO recipe data, NO seed content, NO domain rules — those live in the // adapter (data) and domain.js (presentation). This file owns React state, // navigation, and the device/cook-timer UI concerns that are prototype-side only. // // The useApp() surface below is the stable contract every screen consumes; it // does not change when the data source flips from mock to live. // // Depends on: window.LCAdapter (adapter.js), window.LCDomain (domain.js) const { createContext, useContext, useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } = React; const AppCtx = createContext(null); const useApp = () => useContext(AppCtx); // device → responsive layout mode + on-screen pixel size (prototype framing only) const DEVICE_LAYOUT = { laptop: 'desktop', ipad: 'tablet', iphone: 'mobile' }; // In the SHIPPED app (live mode) there is no device picker — layout follows the // real viewport. These breakpoints mirror the three prototype sizes. const IS_LIVE = (typeof window !== 'undefined') && window.LC_ADAPTER_MODE === 'live'; function layoutForWidth(w) { if (w >= 1080) return 'desktop'; if (w >= 700) return 'tablet'; return 'mobile'; } function useViewportLayout(active) { const get = () => layoutForWidth(typeof window !== 'undefined' ? window.innerWidth : 1440); const [vp, setVp] = useState(get); useEffect(() => { if (!active) return; const onResize = () => setVp(get()); onResize(); window.addEventListener('resize', onResize); window.addEventListener('orientationchange', onResize); return () => { window.removeEventListener('resize', onResize); window.removeEventListener('orientationchange', onResize); }; }, [active]); return vp; } const DEVICE_SCREEN = { laptop: { w: 1440, h: 900 }, ipad: { w: 1180, h: 820 }, iphone: { w: 390, h: 844 }, }; // ── Session = UI runtime state (prototype-only; never sent to the backend) ── const SESSION_KEY = 'luiscooks.session.v2'; const SESSION_DEFAULT = { device: null, loggedIn: false, screen: 'login', params: {}, history: [], navTab: 'recipes', search: '', filter: 'all', sort: 'recent', timers: [], }; const SESSION_KEYS = Object.keys(SESSION_DEFAULT); // ── Data = per-user state, sourced from the adapter (mock or live) ── const DATA_DEFAULT = { recipes: [], favourites: [], notesByRecipe: {}, checkedByRecipe: {}, cookStepByRecipe: {}, plan: {}, shoppingRecipes: [], shoppingChecked: [], customRecipes: [], settings: { unitSystem: 'metric', defaultPlanningServings: 2 }, ingredientCosts: [], loginError: null, // transient; not persisted (lives outside SESSION_KEYS) }; function loadSession() { try { const raw = localStorage.getItem(SESSION_KEY); const saved = raw ? JSON.parse(raw) : {}; const out = { ...SESSION_DEFAULT }; SESSION_KEYS.forEach((k) => { if (saved[k] !== undefined) out[k] = saved[k]; }); return out; } catch (e) { return { ...SESSION_DEFAULT }; } } function saveSession(state) { try { const out = {}; SESSION_KEYS.forEach((k) => { out[k] = state[k]; }); localStorage.setItem(SESSION_KEY, JSON.stringify(out)); } catch (e) {} } const upsertById = (list, item) => { const exists = (list || []).some((x) => x.id === item.id); return exists ? list.map((x) => (x.id === item.id ? item : x)) : [item, ...(list || [])]; }; function AppProvider({ children }) { const adapter = window.LCAdapter; const [state, setState] = useState(() => ({ ...DATA_DEFAULT, ...loadSession() })); const [ref, setRef] = useState({ WEEK_DAYS: [], AI_INPUTS: [], PLAN_ALTERNATIVES: [], SCAN_ITEMS: [], SHARED_KITCHENS: [], }); const [dataLoaded, setDataLoaded] = useState(false); // ── bootstrap the signed-in snapshot once on mount ── // A failed boot must NEVER trap the app on the splash. The live API // now returns an anonymous-safe empty shell (200) before login, so the common // pre-login case no longer 401s — but we still defend here: on any failure we // set dataLoaded so the app renders, and on a 401 we drop a stale/expired token // and fall back to the login screen instead of a broken signed-in state. const bootstrap = useCallback(async () => { let snap; try { snap = await adapter.bootstrap(); } catch (e) { if (/\b401\b/.test(String((e && e.message) || ''))) { try { window.localStorage.removeItem('luiscooks.token'); } catch (_) {} setState((s) => ({ ...s, loggedIn: false, screen: 'login', params: {}, history: [] })); } setDataLoaded(true); // render the app (login screen) rather than hang on splash return; } setState((s) => ({ ...s, recipes: snap.recipes || [], favourites: snap.favourites || [], notesByRecipe: snap.notes || {}, checkedByRecipe: snap.checkedByRecipe || {}, cookStepByRecipe: snap.cookStepByRecipe || {}, plan: snap.plan || {}, shoppingRecipes: snap.shoppingRecipes || [], shoppingChecked: snap.shoppingChecked || [], customRecipes: snap.customRecipes || [], settings: snap.settings || { unitSystem: 'metric', defaultPlanningServings: 2 }, ingredientCosts: snap.ingredientCosts || [], })); setRef({ WEEK_DAYS: snap.weekDays || [], AI_INPUTS: snap.planInputs || [], PLAN_ALTERNATIVES: snap.planAlternatives || [], SCAN_ITEMS: snap.scanItems || [], SHARED_KITCHENS: snap.sharedKitchens || [], }); setDataLoaded(true); }, [adapter]); useEffect(() => { bootstrap(); }, [bootstrap]); // persist ONLY the session subset useEffect(() => { saveSession(state); }, [state.device, state.loggedIn, state.screen, state.params, state.history, state.navTab, state.search, state.filter, state.sort, state.timers]); const set = useCallback((patch) => { setState((s) => ({ ...s, ...(typeof patch === 'function' ? patch(s) : patch) })); }, []); // ── recipes ── const allRecipes = state.recipes; const getRecipe = useCallback((id) => (state.recipes || []).find((r) => r.id === id) || null, [state.recipes]); const photoFor = useCallback((id) => { const r = (state.recipes || []).find((x) => x.id === id); return r ? (r.photoUrl || null) : null; }, [state.recipes]); // ── navigation (pure UI) ── // Scroll handling: forward nav (go/openRecipe/setTab) starts the new screen at // the top; `back` restores the scroll position of the screen you're returning // to, so casually browsing the library and dipping into a recipe doesn't lose // your place. The outgoing scrollTop is stashed on the history entry and // re-applied on the way back. const historyRef = useRef(state.history); historyRef.current = state.history; const getScroll = () => { const sc = document.querySelector('[data-app-scroll]'); return sc ? sc.scrollTop : 0; }; const scrollTop = () => requestAnimationFrame(() => { const sc = document.querySelector('[data-app-scroll]'); if (sc) sc.scrollTop = 0; }); // `back` stashes the scroll position to restore here; a useLayoutEffect applies // it synchronously BEFORE the browser paints the destination screen, so there's // no flash of the top-of-list before it jumps to where you were. const pendingScroll = useRef(null); const go = useCallback((screen, params = {}) => { const scroll = getScroll(); setState((s) => ({ ...s, history: [...s.history, { screen: s.screen, params: s.params, navTab: s.navTab, scroll }], screen, params })); scrollTop(); }, []); const back = useCallback(() => { const hist = historyRef.current || []; if (!hist.length) return; const prev = hist[hist.length - 1]; pendingScroll.current = prev.scroll || 0; setState((s) => ({ ...s, screen: prev.screen, params: prev.params, navTab: prev.navTab ?? s.navTab, history: s.history.slice(0, -1) })); }, []); const TAB_SCREEN = { recipes: 'library', cook: 'scan', plan: 'plan', groceries: 'shopping' }; const setTab = useCallback((tab) => { setState((s) => ({ ...s, navTab: tab, screen: TAB_SCREEN[tab] || 'library', params: {}, history: [] })); scrollTop(); }, []); const openRecipe = useCallback((id, opts = {}) => { const scroll = getScroll(); setState((s) => ({ ...s, history: [...s.history, { screen: s.screen, params: s.params, navTab: s.navTab, scroll }], screen: 'recipe', params: { recipeId: id, ...opts } })); scrollTop(); }, []); // Apply a pending restore before paint. Runs synchronously after the new // screen's DOM is committed; setting scrollTop here (vs a timeout/rAF) means // the very first painted frame is already at the right position — no flicker. // (Idempotent under StrictMode's double-invoke: it just sets the same value.) useLayoutEffect(() => { if (pendingScroll.current == null) return; const y = pendingScroll.current; pendingScroll.current = null; const sc = document.querySelector('[data-app-scroll]'); if (sc) sc.scrollTop = y; }, [state.screen, state.params]); // ── device (prototype framing) ── const pickDevice = useCallback((device) => setState((s) => ({ ...s, device })), []); const exitDevice = useCallback(() => setState((s) => ({ ...s, device: null })), []); // ── auth ── // Bearer auth is enforced live: a wrong token makes adapter.login REJECT (the // 401 throws), so we must only mark loggedIn when the call RESOLVES. Gating on // "didn't throw / ok !== false" is the right signal in both modes — live throws // on a bad token; the mock always resolves, so the offline demo keeps working. const login = useCallback(async (token) => { let r = null; try { r = await adapter.login(token); } catch (e) { r = null; } if (!r || r.ok === false) { setState((s) => ({ ...s, loginError: "That token didn't work \u2014 check it and try again." })); return; } await bootstrap(); setState((s) => ({ ...s, loggedIn: true, loginError: null, screen: 'library', navTab: 'recipes', params: {}, history: [] })); }, [adapter, bootstrap]); const logout = useCallback(async () => { try { await adapter.logout(); } catch (e) {} setState((s) => ({ ...s, loggedIn: false, screen: 'login', params: {}, history: [] })); }, [adapter]); // ── favourites ── const isFav = useCallback((id) => (state.favourites || []).includes(id), [state.favourites]); const toggleFav = useCallback((id) => { setState((s) => { const has = s.favourites.includes(id); const next = has ? s.favourites.filter((x) => x !== id) : [...s.favourites, id]; adapter.setFavourite(id, !has); return { ...s, favourites: next }; }); }, [adapter]); // ── ratings ── // Rating is a single field on the recipe now (backend consolidated it onto // Recipe.rating; bootstrap.ratings is deprecated/empty — see // docs/design-handoff/ratings-single-field.md). setRating keeps the local // recipe fresh so the editor/planner reflect a just-set rating without a refresh. const ratingFor = useCallback((r) => (r && r.rating) || 0, []); const setRating = useCallback((id, v) => { setState((s) => ({ ...s, recipes: s.recipes.map((r) => (r.id === id ? { ...r, rating: v } : r)), customRecipes: s.customRecipes.map((r) => (r.id === id ? { ...r, rating: v } : r)) })); adapter.setRating(id, v); }, [adapter]); // ── notes ── const noteFor = useCallback((r) => state.notesByRecipe[r.id] != null ? state.notesByRecipe[r.id] : r.notes, [state.notesByRecipe]); const setNote = useCallback((id, v) => { setState((s) => ({ ...s, notesByRecipe: { ...s.notesByRecipe, [id]: v } })); adapter.setNote(id, v); }, [adapter]); // ── cook-mode ingredient check ── const checkedFor = useCallback((id) => new Set(state.checkedByRecipe[id] || []), [state.checkedByRecipe]); const toggleChecked = useCallback((id, line) => { setState((s) => { const cur = new Set(s.checkedByRecipe[id] || []); cur.has(line) ? cur.delete(line) : cur.add(line); const lines = [...cur]; adapter.setChecked(id, lines); return { ...s, checkedByRecipe: { ...s.checkedByRecipe, [id]: lines } }; }); }, [adapter]); const cookStep = useCallback((id) => state.cookStepByRecipe[id] || 0, [state.cookStepByRecipe]); const setCookStep = useCallback((id, idx) => { setState((s) => ({ ...s, cookStepByRecipe: { ...s.cookStepByRecipe, [id]: idx } })); adapter.setCookStep(id, idx); }, [adapter]); // ── timers (count-up · ephemeral UI, prototype-side only) ── const addTimer = useCallback((label) => { setState((s) => ({ ...s, timers: [...s.timers, { id: 't' + Date.now(), label: label || 'Timer', base: 0, startedAt: Date.now(), running: true, }] })); }, []); const toggleTimer = useCallback((id) => { setState((s) => ({ ...s, timers: s.timers.map((t) => { if (t.id !== id) return t; if (t.running) return { ...t, running: false, base: t.base + (Date.now() - t.startedAt) / 1000, startedAt: null }; return { ...t, running: true, startedAt: Date.now() }; }) })); }, []); const removeTimer = useCallback((id) => { setState((s) => ({ ...s, timers: s.timers.filter((t) => t.id !== id) })); }, []); // ── shopping ── const addRecipeToShopping = useCallback((id) => { setState((s) => s.shoppingRecipes.includes(id) ? s : { ...s, shoppingRecipes: [...s.shoppingRecipes, id] }); adapter.addShoppingRecipe(id); }, [adapter]); const removeRecipeFromShopping = useCallback((id) => { setState((s) => ({ ...s, shoppingRecipes: s.shoppingRecipes.filter((x) => x !== id) })); adapter.removeShoppingRecipe(id); }, [adapter]); const toggleShopItem = useCallback((key) => { setState((s) => { const has = s.shoppingChecked.includes(key); const next = has ? s.shoppingChecked.filter((x) => x !== key) : [...s.shoppingChecked, key]; adapter.setShoppingChecked(next); return { ...s, shoppingChecked: next }; }); }, [adapter]); // ── plan ── const setPlanDay = useCallback((day, entry) => { setState((s) => ({ ...s, plan: { ...s.plan, [day]: entry } })); adapter.setPlanDay(day, entry); }, [adapter]); const regeneratePlan = useCallback(async () => { const plan = await adapter.regeneratePlan(); if (plan) setState((s) => ({ ...s, plan })); }, [adapter]); const autoSchedule = useCallback(async (opts) => { const res = await adapter.autoSchedule(opts); if (res && res.plan) setState((s) => ({ ...s, plan: res.plan })); return res; }, [adapter]); // ── recipe CRUD ── const saveRecipe = useCallback((recipe) => { // optimistic insert so the recipe screen finds it immediately setState((s) => ({ ...s, recipes: upsertById(s.recipes, recipe), customRecipes: upsertById(s.customRecipes, recipe) })); Promise.resolve(adapter.saveRecipe(recipe)).then((saved) => { if (saved) setState((s) => ({ ...s, recipes: upsertById(s.recipes, saved) })); }).catch(() => {}); }, [adapter]); // ── photo enhancement (AI) ── // enhancePhoto kicks off the job and polls getRecipe() until the backend // reports done/failed; revertPhoto discards the AI photo. All state lives on // the recipe object (enhancementStatus / photoUrl / originalPhotoUrl), so the // hero re-renders automatically as status changes. const pollers = useRef({}); const applyRecipe = useCallback((saved) => { if (saved) setState((s) => ({ ...s, recipes: upsertById(s.recipes, saved) })); }, []); const stopPoll = useCallback((id) => { if (pollers.current[id]) { clearInterval(pollers.current[id]); delete pollers.current[id]; } }, []); const pollEnhance = useCallback((id) => { if (pollers.current[id]) return; pollers.current[id] = setInterval(async () => { try { const r = await adapter.getRecipe(id); if (r) { applyRecipe(r); if (r.enhancementStatus !== 'enhancing') stopPoll(id); } } catch (e) {} }, 1200); }, [adapter, applyRecipe, stopPoll]); const enhancePhoto = useCallback(async (id, opts = {}) => { setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id ? { ...r, enhancementStatus: 'enhancing', enhancementError: null } : r) })); try { const r = await adapter.enhancePhoto(id, opts); if (r) applyRecipe(r); pollEnhance(id); } catch (e) { setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id ? { ...r, enhancementStatus: 'failed', enhancementError: 'Could not start — try again.' } : r) })); } }, [adapter, applyRecipe, pollEnhance]); const revertPhoto = useCallback(async (id) => { stopPoll(id); try { const r = await adapter.revertPhoto(id); if (r) applyRecipe(r); } catch (e) {} }, [adapter, applyRecipe, stopPoll]); useEffect(() => () => { Object.values(pollers.current).forEach(clearInterval); }, []); // ── recipe costing (AI parse + price DB) ── // calculateCost kicks off the job and polls getRecipe() until the backend // reports costed/needs_attention; mirrors the enhance flow. All state lives on // the recipe (costStatus / costPerServing / fullCost / costBreakdown), so the // cost UI re-renders as status changes. const costPollers = useRef({}); const stopCostPoll = useCallback((id) => { if (costPollers.current[id]) { clearInterval(costPollers.current[id]); delete costPollers.current[id]; } }, []); const pollCost = useCallback((id) => { if (costPollers.current[id]) return; costPollers.current[id] = setInterval(async () => { try { const r = await adapter.getRecipe(id); if (r) { applyRecipe(r); if (r.costStatus !== 'costing') stopCostPoll(id); } } catch (e) {} }, 1000); }, [adapter, applyRecipe, stopCostPoll]); const calculateCost = useCallback(async (id, opts = {}) => { setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id ? { ...r, costStatus: 'costing' } : r) })); try { const r = await adapter.calculateCost(id, opts); if (r) applyRecipe(r); pollCost(id); } catch (e) { setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id ? { ...r, costStatus: r.costStatus === 'costing' ? 'none' : r.costStatus } : r) })); } }, [adapter, applyRecipe, pollCost]); useEffect(() => () => { Object.values(costPollers.current).forEach(clearInterval); }, []); // ── recipe effort estimate (on-demand AI) ── // estimateEffort kicks off the job and polls getRecipe() until effortStatus // leaves 'estimating'; mirrors the cost/enhance flow. State lives on the recipe // (effortStatus / effortMinutes / effort / effortEstimated). const effortPollers = useRef({}); const stopEffortPoll = useCallback((id) => { if (effortPollers.current[id]) { clearInterval(effortPollers.current[id]); delete effortPollers.current[id]; } }, []); const pollEffort = useCallback((id) => { if (effortPollers.current[id]) return; effortPollers.current[id] = setInterval(async () => { try { const r = await adapter.getRecipe(id); if (r) { applyRecipe(r); if (r.effortStatus !== 'estimating') stopEffortPoll(id); } } catch (e) {} }, 1000); }, [adapter, applyRecipe, stopEffortPoll]); const estimateEffort = useCallback(async (id) => { setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id ? { ...r, effortStatus: 'estimating' } : r) })); try { const r = await adapter.estimateEffort(id); if (r) applyRecipe(r); pollEffort(id); } catch (e) { setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id ? { ...r, effortStatus: r.effortMinutes != null ? 'done' : 'none' } : r) })); } }, [adapter, applyRecipe, pollEffort]); useEffect(() => () => { Object.values(effortPollers.current).forEach(clearInterval); }, []); // ── settings ── const settings = state.settings || { unitSystem: 'metric', defaultPlanningServings: 2 }; const setSettings = useCallback((patch) => { setState((s) => ({ ...s, settings: { ...s.settings, ...patch } })); adapter.setSettings(patch); }, [adapter]); // ── ingredient-price DB (Settings ▸ ingredient costs) ── const saveIngredientCost = useCallback(async (item) => { const saved = await adapter.saveIngredientCost(item); if (saved) setState((s) => { const exists = (s.ingredientCosts || []).some((x) => x.key === saved.key); return { ...s, ingredientCosts: exists ? s.ingredientCosts.map((x) => (x.key === saved.key ? saved : x)) : [saved, ...(s.ingredientCosts || [])] }; }); return saved; }, [adapter]); const deleteIngredientCost = useCallback((key) => { setState((s) => ({ ...s, ingredientCosts: (s.ingredientCosts || []).filter((x) => x.key !== key) })); adapter.deleteIngredientCost(key); }, [adapter]); // ── plan: per-meal planned servings (Phase 2 UI) ── const setPlanServings = useCallback((day, n) => { setState((s) => { const e = s.plan[day]; if (!e || e.skip || !e.recipeId) return s; const entry = { ...e, plannedServings: Math.max(1, n) }; adapter.setPlanDay(day, entry); return { ...s, plan: { ...s.plan, [day]: entry } }; }); }, [adapter]); // ── fridge scan helpers (AI endpoints) ── const scanFridge = useCallback(() => adapter.scanFridge(), [adapter]); const matchRecipes = useCallback((items) => adapter.matchRecipes(items), [adapter]); // ── reset (prototype convenience) ── const resetAll = useCallback(async () => { try { await adapter.resetDemo(); } catch (e) {} try { localStorage.removeItem(SESSION_KEY); } catch (e) {} setState((s) => ({ ...DATA_DEFAULT, ...SESSION_DEFAULT, device: s.device })); await bootstrap(); }, [adapter, bootstrap]); const viewportLayout = useViewportLayout(IS_LIVE); // Live (shipped): follow the real viewport. Prototype: follow the chosen device. const layout = IS_LIVE ? viewportLayout : (state.device ? DEVICE_LAYOUT[state.device] : 'desktop'); const value = { state, set, layout, device: state.device, dataLoaded, isLive: IS_LIVE, DEVICE_SCREEN, DEVICE_LAYOUT, allRecipes, getRecipe, photoFor, go, back, setTab, openRecipe, pickDevice, exitDevice, login, logout, isFav, toggleFav, ratingFor, setRating, noteFor, setNote, checkedFor, toggleChecked, cookStep, setCookStep, addTimer, toggleTimer, removeTimer, addRecipeToShopping, removeRecipeFromShopping, toggleShopItem, setPlanDay, regeneratePlan, autoSchedule, saveRecipe, resetAll, enhancePhoto, revertPhoto, calculateCost, settings, setSettings, ingredientCosts: state.ingredientCosts, saveIngredientCost, deleteIngredientCost, setPlanServings, estimateEffort, scanFridge, matchRecipes, // reference data (from bootstrap) WEEK_DAYS: ref.WEEK_DAYS, AI_INPUTS: ref.AI_INPUTS, PLAN_ALTERNATIVES: ref.PLAN_ALTERNATIVES, SCAN_ITEMS: ref.SCAN_ITEMS, SHARED_KITCHENS: ref.SHARED_KITCHENS, // presentation helpers (ship) parseIngredientGroups: window.LCDomain.parseIngredientGroups, buildShoppingList: window.LCDomain.buildShoppingList, costInfo: window.LCDomain.costInfo, money: window.LCDomain.money, costForServings: window.LCDomain.costForServings, scaleQuantities: window.LCDomain.scaleQuantities, }; return {children}; } // ── live clock hook: re-render every second (for ticking timers) ── function useNow(active) { const [, force] = useState(0); useEffect(() => { if (!active) return; const id = setInterval(() => force((n) => n + 1), 1000); return () => clearInterval(id); }, [active]); return Date.now(); } function timerElapsed(t, now) { const base = t.base || 0; return Math.floor(base + (t.running && t.startedAt ? (now - t.startedAt) / 1000 : 0)); } function fmtTime(secs) { const m = Math.floor(secs / 60); const s = secs % 60; return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } Object.assign(window, { AppProvider, useApp, useNow, timerElapsed, fmtTime, DEVICE_LAYOUT, DEVICE_SCREEN, IS_LIVE, });