// 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 });