// app/screen-plan.jsx — AI-driven weekly dinner planner. // Pick a day → AI pick + cyclable alternatives. Cook this / show another / // don't plan this day. Inputs rail shows what the AI is weighing. const RAIL_PATHS = { leaf: 'M11 20A7 7 0 019.8 6.1C15.5 5 17 4.5 19 2c1 2 2 4.2 2 8 0 5.5-4.8 10-10 10zM2 21c0-3 1.85-5.4 5.08-6', cloud: 'M16 13v6M8 13v5M12 16v5M20 16.6A5 5 0 0018 7h-1.26A8 8 0 104 15.25', calendar: 'M8 2v4M16 2v4M3 9h18M5 4h14a2 2 0 012 2v13a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z', box: 'M3 7l9-4 9 4v10l-9 4-9-4V7zM3 7l9 4 9-4M12 11v10', rotate: 'M3 12a9 9 0 109-9 9 9 0 00-6.36 2.64L3 8M3 3v5h5', heart: 'M20.8 4.6a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 10-7.78 7.78L12 21.2l8.84-8.83a5.5 5.5 0 000-7.78z', }; const RailIcon = ({ name, size = 16 }) => ( ); function PlanThumb({ recipe, src, style }) { if (src) return ; return
; } function dayWeight(label) { const w = { Calendar: 'high', Pantry: 'high' }; return w[label] || (label === 'Favourites' ? 'low' : 'medium'); } // ── dated carousel helpers ── const PLAN_DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const PLAN_MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const planPad = (n) => String(n).padStart(2, '0'); const planIso = (d) => `${d.getFullYear()}-${planPad(d.getMonth() + 1)}-${planPad(d.getDate())}`; const planDayMeta = (d) => ({ key: planIso(d), day: PLAN_DOW[d.getDay()], date: String(d.getDate()), month: PLAN_MON[d.getMonth()], year: d.getFullYear() }); const planParseKey = (k) => { const [y, m, dd] = (k || '').split('-').map(Number); return new Date(y, (m || 1) - 1, dd || 1); }; const CAROUSEL_PAST = 14; // days shown before today const CAROUSEL_FUTURE = 120; // days shown after today const planNavArrow = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 36, height: 36, borderRadius: 999, cursor: 'pointer', background: 'rgba(255,255,255,0.04)', border: '1px solid var(--hairline)', color: 'var(--ink-2)', flexShrink: 0 }; const planTodayBtn = { display: 'inline-flex', alignItems: 'center', gap: 7, height: 36, padding: '0 14px', borderRadius: 999, cursor: 'pointer', background: 'rgba(240,168,80,0.10)', border: '1px solid rgba(240,168,80,0.40)', color: 'var(--olive)', fontFamily: 'Bricolage Grotesque', fontSize: 12.5, fontWeight: 600, letterSpacing: '0.02em', whiteSpace: 'nowrap', flexShrink: 0, boxSizing: 'border-box' }; const AUTO_RANGES = [3, 5, 7, 14]; function AutoScheduleDialog({ isMobile, selObj, range, setRange, override, setOverride, preview, onClose, onConfirm }) { const startLabel = selObj ? `${selObj.day} ${selObj.date} ${selObj.month}` : 'the selected day'; const chip = (on) => ({ flex: 1, padding: '11px 0', borderRadius: 11, cursor: 'pointer', textAlign: 'center', fontFamily: 'Bricolage Grotesque', fontSize: 14, fontWeight: 600, letterSpacing: '0.01em', background: on ? 'rgba(240,168,80,0.12)' : 'rgba(255,255,255,0.03)', border: on ? '1px solid rgba(240,168,80,0.45)' : '1px solid var(--hairline)', color: on ? 'var(--olive)' : 'var(--ink-2)' }); return (
e.stopPropagation()} style={{ width: '100%', maxWidth: isMobile ? '100%' : 440, background: 'rgba(20,20,24,0.98)', border: '1px solid var(--hairline)', borderRadius: isMobile ? '22px 22px 0 0' : 20, boxShadow: '0 40px 100px rgba(0,0,0,0.7)', padding: isMobile ? '24px 20px 28px' : 28, maxHeight: '100%', overflowY: 'auto', boxSizing: 'border-box' }}>
Auto schedule

Fill the plan from {startLabel}.

{/* day range */}
How many days
{AUTO_RANGES.map((n) => ( ))}
{/* override toggle */} {/* note */}
Days you've marked “don't plan” are always left untouched.
{/* summary */}
{preview.fill} {preview.fill === 1 ? 'day' : 'days'} will be filled {preview.kept > 0 && · {preview.kept} kept} {preview.skipped > 0 && · {preview.skipped} skipped}
{/* actions */}
Cancel Schedule
); } function SkipDayCard({ selKey, selLabel, skipReason, onReason, onPlan }) { const [reason, setReason] = React.useState(skipReason || ''); React.useEffect(() => { setReason(skipReason || ''); }, [selKey]); return (
Not cooking {selLabel}.
{ setReason(e.target.value); onReason(e.target.value); }} placeholder="Enter a reason (optional)" style={{ width: '100%', textAlign: 'center', padding: '8px 10px', background: 'transparent', border: 'none', borderBottom: '1px solid var(--hairline)', borderRadius: 0, color: 'var(--ink)', fontFamily: 'Spectral', fontSize: 17, outline: 'none', boxSizing: 'border-box' }} />
); } function PlanScreen() { const { layout, state, getRecipe, photoFor, setPlanDay, openRecipe, go, WEEK_DAYS, AI_INPUTS, PLAN_ALTERNATIVES, regeneratePlan, autoSchedule, set, settings, setPlanServings, costForServings, money } = useApp(); const isMobile = layout === 'mobile'; const isTablet = layout === 'tablet'; const defaultServes = (settings && settings.defaultPlanningServings) || 2; const entryServes = (e) => (e && e.plannedServings) || defaultServes; const [selDay, setSelDay] = React.useState(null); const [altIdx, setAltIdx] = React.useState(0); const [toast, setToast] = React.useState(null); const stripRef = React.useRef(null); const [visibleMonth, setVisibleMonth] = React.useState(''); const plan = state.plan; const todayKey = (WEEK_DAYS[0] && WEEK_DAYS[0].key) || null; // The continuous carousel: many dated days centred on today. const days = React.useMemo(() => { if (!todayKey) return []; const anchor = planParseKey(todayKey); const out = []; for (let i = -CAROUSEL_PAST; i <= CAROUSEL_FUTURE; i++) { const d = new Date(anchor); d.setDate(d.getDate() + i); out.push({ ...planDayMeta(d), isToday: i === 0 }); } return out; }, [todayKey]); const selKey = selDay || todayKey; const selObj = days.find((d) => d.key === selKey) || WEEK_DAYS.find((d) => d.key === selKey) || null; const selLabel = selObj ? selObj.day : ''; const entry = (selKey && plan[selKey]) || {}; const alts = PLAN_ALTERNATIVES; const shownId = entry.skip ? null : (entry.recipeId || alts[altIdx % alts.length]); const shown = shownId ? getRecipe(shownId) : null; // Summary spans from today to the furthest planned day — you can plan well // beyond the current week. Cost reflects each meal's planned servings. const upcomingPlanned = Object.entries(plan) .filter(([k, e]) => e && !e.skip && e.recipeId && k >= todayKey) .sort((a, b) => (a[0] < b[0] ? -1 : 1)); const cooked = upcomingPlanned; const total = upcomingPlanned.reduce((s, [, e]) => s + (costForServings(getRecipe(e.recipeId), entryServes(e)) || 0), 0); const lastPlannedKey = upcomingPlanned.length ? upcomingPlanned[upcomingPlanned.length - 1][0] : todayKey; const planRangeLabel = (() => { if (!todayKey) return ''; const s = planParseKey(todayKey), e = planParseKey(lastPlannedKey); if (todayKey === lastPlannedKey) return `from ${s.getDate()} ${PLAN_MON[s.getMonth()]}`; if (s.getMonth() === e.getMonth()) return `${s.getDate()}–${e.getDate()} ${PLAN_MON[e.getMonth()]}`; return `${s.getDate()} ${PLAN_MON[s.getMonth()]} – ${e.getDate()} ${PLAN_MON[e.getMonth()]}`; })(); const planSummary = cooked.length === 0 ? 'Nothing planned yet' : `${planRangeLabel} · ${cooked.length} ${cooked.length === 1 ? 'meal' : 'meals'} · $${total.toFixed(2)}`; // planned-servings for the selected day (local, syncs to the entry on commit) const [planServes, setPlanServesLocal] = React.useState(defaultServes); React.useEffect(() => { setPlanServesLocal(entryServes(plan[selKey] || {})); }, [selKey, entry.recipeId, entry.plannedServings]); const changeServes = (n) => { const v = Math.max(1, n); setPlanServesLocal(v); if (entry.recipeId) setPlanServings(selKey, v); }; const shownTotal = shown ? costForServings(shown, planServes) : null; const cookThis = () => { if (!shown) return; setPlanDay(selKey, { recipeId: shown.id, why: 'you picked this for the night', tag: 'your pick', plannedServings: planServes }); }; const showAnother = () => { setPlanDay(selKey, {}); setAltIdx((i) => i + 1); }; const dontPlan = () => { setPlanDay(selKey, { skip: true }); }; // ── auto-schedule dialog ── const [autoOpen, setAutoOpen] = React.useState(false); const [autoRange, setAutoRange] = React.useState(7); const [autoOverride, setAutoOverride] = React.useState(false); // Live preview of how the run will land on the days from the selected one. const autoPreview = React.useMemo(() => { let fill = 0, kept = 0, skipped = 0; if (selKey) { const anchor = planParseKey(selKey); for (let i = 0; i < autoRange; i++) { const d = new Date(anchor); d.setDate(d.getDate() + i); const e = plan[planIso(d)]; if (e && e.skip) { skipped++; continue; } if (e && e.recipeId && !autoOverride) { kept++; continue; } fill++; } } return { fill, kept, skipped }; }, [selKey, autoRange, autoOverride, plan]); const runAutoSchedule = async () => { const res = await autoSchedule({ start: selKey, days: autoRange, override: autoOverride }); setAutoOpen(false); const n = res && res.filled ? res.filled.length : autoPreview.fill; setToast(n ? `Auto-scheduled ${n} ${n === 1 ? 'day' : 'days'}` : 'Nothing to fill — all days are set'); }; // ── carousel scroll helpers ── const leftPad = isMobile ? 18 : (isTablet ? 32 : 48); const updateVisibleMonth = () => { const c = stripRef.current; if (!c) return; const cLeft = c.getBoundingClientRect().left; const kids = c.children; for (let i = 0; i < kids.length; i++) { const r = kids[i].getBoundingClientRect(); if (r.right > cLeft + leftPad + 4) { const d = days[i]; if (d) setVisibleMonth(`${d.month} ${d.year}`); break; } } }; const scrollStrip = (dir) => { const c = stripRef.current; if (!c) return; c.scrollBy({ left: dir * c.clientWidth * 0.82, behavior: 'smooth' }); }; const goToday = () => { setSelDay(todayKey); const c = stripRef.current; if (!c) return; const el = c.querySelector('[data-today="1"]'); if (el) c.scrollTo({ left: Math.max(0, el.offsetLeft - leftPad), behavior: 'smooth' }); }; // Centre today on first mount (and when layout/data changes) + seed the month // label. Two passes (0ms + 140ms) so it outlasts the carousel's fixed-width // card layout; offsetLeft is measured against the position:relative strip. React.useEffect(() => { if (!todayKey) return; const c = stripRef.current; if (!c) return; const center = () => { const el = c.querySelector('[data-today="1"]'); if (el) c.scrollLeft = Math.max(0, el.offsetLeft - leftPad); updateVisibleMonth(); }; const t1 = setTimeout(center, 0); const t2 = setTimeout(center, 140); return () => { clearTimeout(t1); clearTimeout(t2); }; }, [todayKey, isMobile, isTablet, days.length]); // ── day card (one box in the carousel) ── const DayCard = ({ d }) => { const e = plan[d.key] || {}; const r = e.recipeId ? getRecipe(e.recipeId) : null; const active = selKey === d.key; const out = e.skip; const today = d.isToday; const dayColor = active || today ? 'var(--olive)' : 'var(--ink-3)'; // ── Mobile: clean uniform pill with a status dot ── if (isMobile) { const dotColor = out ? 'transparent' : r ? (e.tag === 'your pick' ? 'var(--olive)' : 'var(--ink-2)') : 'var(--ink-4)'; return ( ); } return ( ); }; // ── pick card ── const PickCard = shown && (
openRecipe(shown.id)} style={{ position: 'relative', cursor: 'pointer', aspectRatio: isMobile ? '4 / 4.2' : '16 / 9' }}>
✦ {entry.recipeId ? 'YOUR PICK' : `AI PICK · ${(altIdx % alts.length) + 1}/${alts.length}`}
{[`★ ${shown.rating || 4}`, money(shownTotal)].map((m, i) => ( {m} ))}
{(shown.meals || []).slice(0, 3).join(' · ')}

{shown.name}

Why this for {selLabel}

{entry.why || shown.description || 'A good fit for the night ahead.'}

); const DecisionBtns = (
{!isMobile && }
); const InputsRail = ( ); const Header = (
{planSummary}

Your upcoming meal plan.

{!isMobile && (
setAutoOpen(true)} style={{ height: 36, padding: '0 18px', fontSize: 13 }}> Auto schedule
)}
); return (
{Header} {/* day carousel — continuous, scrollable across many days */}
{visibleMonth} {isMobile && ( )}
{days.map((d) => )}
{/* body */}
{selObj ? `${selObj.day} · ${selObj.date} ${selObj.month}` : ''}
{entry.skip ? ( setPlanDay(selKey, { skip: true, skipReason: t })} onPlan={() => { setPlanDay(selKey, {}); setAltIdx(0); }} /> ) : ( <> {PickCard} {shown && (
Cooking for {shownTotal != null && ( {money(shownTotal)} )}
)} {DecisionBtns} )}
{InputsRail}
{isMobile && (
setAutoOpen(true)} style={{ width: '100%', minHeight: 54 }}> Auto schedule
)} {autoOpen && ( setAutoOpen(false)} onConfirm={runAutoSchedule} /> )} {toast && setToast(null)} />}
); } Object.assign(window, { PlanScreen });