// app/screen-shopping.jsx — categorised shopping list, generated from the // recipes you've added. Tick items you already have; order from Woolworths. const AISLE_RULES = [ { aisle: 'Fresh produce', keys: ['onion', 'garlic', 'ginger', 'chilli', 'chili', 'lemon', 'lime', 'broccoli', 'cauliflower', 'pumpkin', 'eggplant', 'spring onion', 'coriander', 'parsley', 'mint', 'tomato', 'mushroom', 'pomegranate', 'lemongrass', 'galangal', 'kaffir', 'celery', 'potato', 'carrot'] }, { aisle: 'Meat & seafood', keys: ['chicken', 'lamb', 'beef', 'pork', 'anchov', 'prawn', 'fish', 'bacon', 'sausage'] }, { aisle: 'Dairy & eggs', keys: ['butter', 'yoghurt', 'buttermilk', 'milk', 'cream', 'egg', 'parmesan', 'cheese'] }, { aisle: 'Pantry & dry', keys: ['flour', 'rice', 'bean', 'freekeh', 'noodle', 'oil', 'soy', 'vinegar', 'wine', 'miso', 'mirin', 'sake', 'sugar', 'salt', 'spice', 'peppercorn', 'cumin', 'paprika', 'stock', 'tahini', 'breadcrumb', 'honey', 'bicarb', 'baking', 'yeast', 'semolina', 'sesame', 'tin', 'oats', 'mustard'] }, ]; function aisleFor(line) { const l = line.toLowerCase(); for (const r of AISLE_RULES) if (r.keys.some((k) => l.includes(k))) return r.aisle; return 'Other'; } const AISLE_ORDER = ['Fresh produce', 'Meat & seafood', 'Pantry & dry', 'Dairy & eggs', 'Other']; // ── Woolworths order CTA — structured: logo tile · label+sub · price ── function WoolworthsButton({ total, pending, full }) { const [hover, setHover] = React.useState(false); return ( ); } // ── "Shop up to" date helpers (continuous dated plan) ── const SHOP_DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const SHOP_MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const shopPad = (n) => String(n).padStart(2, '0'); const shopIso = (d) => `${d.getFullYear()}-${shopPad(d.getMonth() + 1)}-${shopPad(d.getDate())}`; const shopParse = (k) => { const [y, m, d] = (k || '').split('-').map(Number); return new Date(y, (m || 1) - 1, d || 1); }; const shopFmt = (k) => { const d = shopParse(k); return `${SHOP_DOW[d.getDay()]} ${d.getDate()} ${SHOP_MON[d.getMonth()]}`; }; const SHOP_CAL = 'M8 2v4M16 2v4M3 9h18M5 4h14a2 2 0 012 2v13a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z'; const ShopCal = ({ size = 14 }) => ( ); function buildList(shoppingRecipes, getRecipe) { const byAisle = {}; shoppingRecipes.forEach((id) => { const r = getRecipe(id); if (!r) return; const short = r.name.split(' with ')[0]; r.ingredients.forEach((line) => { if (!line || line.endsWith(':')) return; const aisle = aisleFor(line); (byAisle[aisle] = byAisle[aisle] || []).push({ key: id + '::' + line, name: line, for: short, recipeId: id }); }); }); return AISLE_ORDER.filter((a) => byAisle[a]).map((a) => ({ aisle: a, items: byAisle[a] })); } function ShoppingScreen() { const { layout, state, getRecipe, toggleShopItem, removeRecipeFromShopping, openRecipe, setTab, WEEK_DAYS } = useApp(); const isMobile = layout === 'mobile'; const [filter, setFilter] = React.useState('everything'); const [pendingOnly, setPendingOnly] = React.useState(false); const [fromOpen, setFromOpen] = React.useState(false); // The Groceries list reflects the plan LIVE, scoped to a "Shop up to" cutoff // date: every upcoming planned meal from today through the chosen date (not // skipped) contributes its ingredients, unioned with anything added manually // via "Add to shopping" (manual adds aren't date-bound). De-duped by recipe // id so a meal planned on several days is only counted once. const todayKey = (WEEK_DAYS[0] && WEEK_DAYS[0].key) || '0000-00-00'; const endOfWeekKey = (WEEK_DAYS[6] && WEEK_DAYS[6].key) || todayKey; // upcoming planned meals (today onward), regardless of cutoff const plannedFuture = React.useMemo(() => Object.entries(state.plan || {}) .filter(([k, e]) => e && !e.skip && e.recipeId && k >= todayKey) .map(([k, e]) => ({ key: k, id: e.recipeId })), [state.plan, todayKey]); const minKey = todayKey; const maxKey = plannedFuture.reduce((m, p) => (p.key > m ? p.key : m), todayKey); // default cutoff = end of the current week, clamped to the last planned meal const [cutoff, setCutoff] = React.useState(endOfWeekKey); const cutoffKey = cutoff > maxKey ? maxKey : (cutoff < minKey ? minKey : cutoff); const stepCutoff = (delta) => { const d = shopParse(cutoffKey); d.setDate(d.getDate() + delta); const k = shopIso(d); if (k < minKey || k > maxKey) return; setCutoff(k); }; const manualIds = state.shoppingRecipes || []; const plannedWindowIds = plannedFuture.filter((p) => p.key <= cutoffKey).map((p) => p.id); const sourceIds = React.useMemo(() => [...new Set([...plannedWindowIds, ...manualIds])], [plannedWindowIds.join(','), manualIds.join(',')]); const hasAnySource = plannedFuture.length > 0 || manualIds.length > 0; const aisles = React.useMemo(() => buildList(sourceIds, getRecipe), [sourceIds, getRecipe]); const checked = new Set(state.shoppingChecked); const allItems = aisles.flatMap((a) => a.items); const pending = allItems.filter((it) => !checked.has(it.key)).length; // Consumption cost per ingredient line (the value used), keyed by "::", // sourced from each recipe's cost breakdown. The grocery total is the sum of // those fractions — labelled "ingredient value" (PRD v1 = consumption, not // pack-rounded purchase cost). const costByKey = React.useMemo(() => { const m = {}; sourceIds.forEach((id) => { const r = getRecipe(id); (r && r.costBreakdown ? r.costBreakdown : []).forEach((b) => { if (b.status === 'section' || b.lineCost == null) return; m[id + '::' + b.line] = b.lineCost; }); }); return m; }, [sourceIds.join(','), state.recipes]); const estTotal = allItems.reduce((s, it) => s + (costByKey[it.key] || 0), 0); // "Shop up to" stepper control — used in the header (desktop right / mobile full) const stepBtnStyle = (disabled) => ({ width: 36, height: 36, borderRadius: 999, padding: 0, cursor: disabled ? 'default' : 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', background: disabled ? 'transparent' : 'rgba(255,255,255,0.04)', border: '1px solid var(--hairline)', color: disabled ? 'var(--ink-4)' : 'var(--ink-2)', opacity: disabled ? 0.45 : 1, flexShrink: 0 }); const ShopUpTo = ({ full }) => (
Shop up to {shopFmt(cutoffKey)}
); const FILTERS = ['everything', ...AISLE_ORDER.filter((a) => aisles.find((x) => x.aisle === a)).map((a) => a.split(' ')[0].toLowerCase())]; const visAisles = aisles.filter((a) => filter === 'everything' || a.aisle.split(' ')[0].toLowerCase() === filter); if (!hasAnySource) { return (

Nothing on the list yet.

Open any recipe and tap Add to shopping — or plan a few dinners and they'll show up here automatically.

setTab('recipes')}>Browse recipes →
); } return ( {/* Header */}
Shopping list · from {sourceIds.length} {sourceIds.length === 1 ? 'recipe' : 'recipes'}

{allItems.length} items {' '}across {aisles.length} aisles.

{!isMobile && ( )}
{!isMobile && (

Items you already have are crossed off — tick the rest as you shop.

)} {isMobile && (
)}
{/* ── Controls ── */} {!isMobile ? ( <> {/* Filter bar */}
{FILTERS.map((c) => ( ))}
) : (
{/* progress + All/To-buy segmented */}
{pending} still to buy
{[['all', 'All'], ['buy', 'To buy']].map(([v, label]) => { const on = (v === 'buy') === pendingOnly; return ( ); })}
{/* aisle chips — single scrolling row */}
{FILTERS.map((c) => ( ))}
)} {/* Aisles */}
{allItems.length === 0 && (
No dinners planned through {shopFmt(cutoffKey)}.
{cutoffKey < maxKey ? 'Nudge “Shop up to” later to pull more meals in.' : 'Plan a few dinners and they\u2019ll show up here.'}
)} {visAisles.map((a) => { const items = pendingOnly ? a.items.filter((it) => !checked.has(it.key)) : a.items; if (!items.length) return null; return (
{a.aisle} {items.length}
    {items.map((it) => { const has = checked.has(it.key); return (
  • ); })}
); })}
); } Object.assign(window, { ShoppingScreen });