cookAIware / web /src /ui /app.ts
Juan Jimenez Carrero
feat: redo for the meal plan (button + voice), completing undo/redo
88ed190
Raw
History Blame Contribute Delete
73.7 kB
import type { ReachyMiniInstance } from "@pollen-robotics/reachy-mini-sdk";
import type { RobotGestures } from "../robot/gestures";
import type { InventoryItem, MealPlan, PlanMatrix, PlannedMeal } from "../domain/types";
import { effectiveMatrix } from "../domain/familyProfile";
import { generatePlanLLM } from "../voice/planLLM";
import type { AppData, AppSettings } from "../storage/store";
import {
loadData,
saveData,
loadSettings,
saveSettings,
exportToFile,
importFromFile,
summarizeData,
REALTIME_VOICES,
pickSyncableSettings,
applySyncableSettings,
} from "../storage/store";
import { uploadData, downloadData, downloadSettings } from "../storage/hfDataset";
import { VoiceController, type VoiceStatus } from "../voice/controller";
import { captureFrame, getRobotVideoTrack } from "../voice/camera";
import * as inv from "../domain/inventory";
import * as planner from "../domain/mealPlanner";
import * as shopping from "../domain/shoppingList";
import { formatQuantity } from "../domain/units";
import { mealLabel, t, type Lang, type Strings } from "../i18n";
type Tab = "inventory" | "plan" | "shopping" | "settings";
const EXPIRY_SOON_DAYS = 2;
/** Rounded line icons (stroke = currentColor) matching the landing's
* hand-drawn SVG style — replaces the platform emojis that clashed with the
* cartoon avatars. */
const svg = (paths: string) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
const ICONS = {
inventory: svg(
'<path d="M4.5 10h15l-1.4 8.2a2 2 0 0 1-2 1.8H7.9a2 2 0 0 1-2-1.8L4.5 10z"/><path d="M8.5 10c0-4 1.5-6.5 3.5-6.5S15.5 6 15.5 10"/>',
),
plan: svg(
'<rect x="3.5" y="5" width="17" height="15.5" rx="3.5"/><path d="M3.5 9.8h17M8.3 3v3.6M15.7 3v3.6M8 14h3M8 17h5"/>',
),
shopping: svg(
'<path d="M4 5h2.2l2.1 10.4a1.6 1.6 0 0 0 1.6 1.3h7.3a1.6 1.6 0 0 0 1.6-1.3L20 8.5H7"/><circle cx="10.2" cy="20" r="1.3"/><circle cx="17" cy="20" r="1.3"/>',
),
settings: svg(
'<path d="M4.5 8.2h9M17.5 8.2h2M4.5 15.8h2M10.5 15.8h9"/><circle cx="15.5" cy="8.2" r="2.1"/><circle cx="8.5" cy="15.8" r="2.1"/>',
),
mic: svg(
'<rect x="9.2" y="3.5" width="5.6" height="10" rx="2.8"/><path d="M6 11.5a6 6 0 0 0 12 0M12 17.5V21M9.5 21h5"/>',
),
stop: svg('<rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/>'),
speaker: svg(
'<path d="M4.5 9.5v5h3l4 3.5v-12l-4 3.5h-3z"/><path d="M15 9a4.5 4.5 0 0 1 0 6M17.5 6.8a8 8 0 0 1 0 10.4"/>',
),
spinner: svg('<path d="M12 3.5a8.5 8.5 0 1 0 8.5 8.5" class="spin-path"/>'),
edit: svg('<path d="M14.5 5.5l4 4M4 20l4.5-1 9-9-4-4-9 9L4 20z"/>'),
trash: svg('<path d="M5 7h14M9.5 7V5.2A1.2 1.2 0 0 1 10.7 4h2.6a1.2 1.2 0 0 1 1.2 1.2V7M7 7l1 12.2a1.5 1.5 0 0 0 1.5 1.4h5a1.5 1.5 0 0 0 1.5-1.4L17.5 7"/>'),
use: svg('<circle cx="12" cy="12" r="8.5"/><path d="M8 12h8"/>'),
drag: svg('<circle cx="9" cy="6" r="1.3"/><circle cx="15" cy="6" r="1.3"/><circle cx="9" cy="12" r="1.3"/><circle cx="15" cy="12" r="1.3"/><circle cx="9" cy="18" r="1.3"/><circle cx="15" cy="18" r="1.3"/>'),
clock: svg('<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>'),
pin: svg('<path d="M12 21s6-5.3 6-10a6 6 0 1 0-12 0c0 4.7 6 10 6 10z"/><circle cx="12" cy="11" r="2.2"/>'),
lock: svg('<rect x="6" y="10.5" width="12" height="9" rx="2.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>'),
unlock: svg('<rect x="6" y="10.5" width="12" height="9" rx="2.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 6.8-1.2"/>'),
refresh: svg('<path d="M4.5 12a7.5 7.5 0 0 1 13-5.2M19.5 12a7.5 7.5 0 0 1-13 5.2"/><path d="M17.5 3.5v3.6h-3.6M6.5 20.5v-3.6h3.6"/>'),
undo: svg('<path d="M9 7L4.5 11.5 9 16"/><path d="M4.5 11.5H14a5.5 5.5 0 0 1 0 11H8"/>'),
redo: svg('<path d="M15 7l4.5 4.5L15 16"/><path d="M19.5 11.5H10a5.5 5.5 0 0 0 0 11h6"/>'),
};
export class App {
private root: HTMLElement;
private gestures: RobotGestures;
private data: AppData;
private settings: AppSettings;
private tab: Tab = "inventory";
private warnedExpiry = false;
private transcriptMinimized = false;
private locationFilter: string | null = null;
private syncTimer: ReturnType<typeof setTimeout> | null = null;
private planHistory: MealPlan[] = [];
private planFuture: MealPlan[] = [];
private voice: VoiceController;
private voiceStatus: VoiceStatus = "idle";
private transcript: { role: "user" | "assistant"; text?: string; image?: string }[] = [];
constructor(root: HTMLElement, gestures: RobotGestures, getReachy: () => ReachyMiniInstance | null = () => null) {
this.root = root;
this.gestures = gestures;
this.data = loadData();
this.settings = loadSettings();
this.voice = new VoiceController({
getReachy,
gestures,
getApiKey: () => this.settings.openaiKey,
getLang: () => this.settings.language,
getName: () => this.settings.robotName,
getVoice: () => this.settings.voice,
getActivationMode: () => this.settings.activationMode,
getAllowInterrupt: () => this.settings.allowInterrupt,
toolCtx: {
getData: () => this.data,
commit: (d) => {
this.data = d;
this.commit();
},
// Voice "design the menu" routes through the SAME headless thinking
// generator the button uses — so spoken plans match the button quality.
designPlan: (guidance) => this.designAndCommitPlan(guidance),
// Voice "change just today's dinner" edits one slot, not the whole week.
designMeal: (day, meal, request) => this.designMeal(day, meal, request),
setMealLock: (day, meal, locked) => this.setMealLock(day, meal, locked),
undoPlan: () => (this.undoPlan() ? { ok: true } : { ok: false, error: "nothing to undo" }),
redoPlan: () => (this.redoPlan() ? { ok: true } : { ok: false, error: "nothing to redo" }),
react: (kind) => {
// While a voice session is live the daemon animates the head from the
// speaker audio; don't add our own gestures on top (two masters →
// motor jitter). Touch-triggered gestures still run normally.
if (this.voice.active) return;
if (kind === "add") void this.gestures.antennaWiggle();
else if (kind === "plan") void this.gestures.happyDance();
else if (kind === "shop" || kind === "look") void this.gestures.sweepLook();
},
takePicture: async (_question: string) => {
// Capture only — the frame is fed straight into the Realtime session
// as an input_image item (native gpt-realtime vision), so no extra
// API scope is needed beyond the Realtime one the key already has.
const reachy = getReachy();
const track = getRobotVideoTrack(reachy);
if (!track) return { error: "camera unavailable" };
let dataUrl: string;
try {
dataUrl = await captureFrame(track);
} catch (err) {
console.warn("[voice] frame capture failed", err);
return { error: "camera unavailable" };
}
// Show the photo in the conversation panel right away.
this.transcript.push({ role: "assistant", image: dataUrl });
this.updateTranscript();
return { dataUrl };
},
},
onStatus: (s) => {
this.voiceStatus = s;
this.updateVoiceButton();
this.updateTranscript();
},
onError: (m) => this.toast(m),
onTranscript: (role, text) => {
this.transcript.push({ role, text });
this.updateTranscript();
},
});
}
private get s(): Strings {
return t(this.settings.language);
}
start(): void {
this.render();
void this.gestures.greet();
}
private commit(): void {
saveData(this.data);
this.scheduleAutoSync();
this.render();
}
private toast(message: string): void {
document.querySelector(".toast")?.remove();
const el = document.createElement("div");
el.className = "toast";
el.textContent = message;
document.body.appendChild(el);
setTimeout(() => el.remove(), 3500);
}
// ── Reusable modal + confirm ──────────────────────────────────────────────
private showModal(opts: {
title: string;
body: HTMLElement;
primaryLabel: string;
onPrimary: () => void | Promise<void>;
}): void {
const s = this.s;
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
const card = document.createElement("div");
card.className = "modal-card";
card.innerHTML = `<div class="modal-title">${escapeHtml(opts.title)}</div>`;
const bodyWrap = document.createElement("div");
bodyWrap.className = "modal-body";
bodyWrap.appendChild(opts.body);
const actions = document.createElement("div");
actions.className = "modal-actions";
const cancel = document.createElement("button");
cancel.className = "btn";
cancel.textContent = s.cancel;
const primary = document.createElement("button");
primary.className = "btn primary";
primary.textContent = opts.primaryLabel;
actions.append(cancel, primary);
card.append(bodyWrap, actions);
backdrop.appendChild(card);
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
cancel.onclick = close;
backdrop.onclick = (e) => {
if (e.target === backdrop) close();
};
primary.onclick = async () => {
await opts.onPrimary();
close();
};
// Focus the first input for quick keyboard entry.
bodyWrap.querySelector<HTMLElement>("input, select")?.focus();
}
/** Two-way choice dialog; resolves "a", "b", or null when dismissed. */
private choose(title: string, message: string, labelA: string, labelB: string): Promise<"a" | "b" | null> {
return new Promise((resolve) => {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal-card">
<div class="modal-title">${escapeHtml(title)}</div>
<div class="modal-body confirm-msg">${escapeHtml(message)}</div>
<div class="modal-actions">
<button class="btn" data-a>${escapeHtml(labelA)}</button>
<button class="btn primary" data-b>${escapeHtml(labelB)}</button>
</div>
</div>`;
document.body.appendChild(backdrop);
const done = (v: "a" | "b" | null) => {
backdrop.remove();
resolve(v);
};
backdrop.querySelector<HTMLButtonElement>("[data-a]")!.onclick = () => done("a");
backdrop.querySelector<HTMLButtonElement>("[data-b]")!.onclick = () => done("b");
backdrop.onclick = (e) => {
if (e.target === backdrop) done(null);
};
});
}
private confirm(message: string): Promise<boolean> {
const s = this.s;
return new Promise((resolve) => {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal-card">
<div class="modal-body confirm-msg">${escapeHtml(message)}</div>
<div class="modal-actions">
<button class="btn" data-no>${s.cancel}</button>
<button class="btn primary" data-yes>${s.confirm_yes}</button>
</div>
</div>`;
document.body.appendChild(backdrop);
const done = (v: boolean) => {
backdrop.remove();
resolve(v);
};
backdrop.querySelector<HTMLButtonElement>("[data-no]")!.onclick = () => done(false);
backdrop.querySelector<HTMLButtonElement>("[data-yes]")!.onclick = () => done(true);
backdrop.onclick = (e) => {
if (e.target === backdrop) done(false);
};
});
}
// ── Auto-sync to HF (debounced) ───────────────────────────────────────────
private scheduleAutoSync(): void {
if (!this.settings.hfDatasetSync || !this.settings.hfToken) return;
if (this.syncTimer) clearTimeout(this.syncTimer);
this.syncTimer = setTimeout(() => void this.pushToCloud(true), 4000);
}
private async pushToCloud(silent: boolean): Promise<void> {
const token = this.settings.hfToken;
if (!token) return;
try {
const identity = await uploadData(token, this.data, pickSyncableSettings(this.settings));
this.settings.lastSyncedAt = new Date().toISOString();
saveSettings(this.settings);
if (!silent) this.toast(this.s.hf_sync_ok(identity.repoId));
// Refresh the "last synced" label if Settings is open.
if (this.tab === "settings") this.updateSyncLabel();
} catch (err) {
console.error("[cookaiware] auto-sync failed", err);
if (!silent) {
const msg = err instanceof Error ? err.message : String(err);
this.toast(`${this.s.hf_sync_err}${msg.slice(0, 140)}`);
}
}
}
private updateSyncLabel(): void {
const el = this.root.querySelector<HTMLElement>("#hf-last-synced");
if (!el) return;
el.textContent = this.settings.lastSyncedAt
? this.s.hf_last_synced(relativeTime(this.settings.lastSyncedAt, this.settings.language))
: this.s.hf_never_synced;
}
private render(): void {
const s = this.s;
// Full re-render is simple, but don't let it feel clunky: keep the scroll
// position and the focused field's identity across renders.
const prevMain = this.root.querySelector<HTMLElement>(".app-main");
const prevScroll = prevMain?.scrollTop ?? 0;
this.root.innerHTML = "";
const main = document.createElement("main");
main.className = "app-main";
switch (this.tab) {
case "inventory":
this.renderInventory(main);
break;
case "plan":
this.renderPlan(main);
break;
case "shopping":
this.renderShopping(main);
break;
case "settings":
this.renderSettings(main);
break;
}
this.root.appendChild(main);
main.scrollTop = prevScroll;
const tabs = document.createElement("nav");
tabs.className = "app-tabs";
const defs: [Tab, string, string][] = [
["inventory", ICONS.inventory, s.tab_inventory],
["plan", ICONS.plan, s.tab_plan],
["shopping", ICONS.shopping, s.tab_shopping],
["settings", ICONS.settings, s.tab_settings],
];
for (const [id, icon, label] of defs) {
const btn = document.createElement("button");
btn.className = this.tab === id ? "active" : "";
btn.innerHTML = `<span class="tab-icon">${icon}</span><span>${label}</span>`;
btn.onclick = () => {
this.tab = id;
this.render();
};
tabs.appendChild(btn);
}
this.root.appendChild(tabs);
// Floating "Talk to Reachy" button, present on every tab.
const fab = document.createElement("button");
fab.className = "voice-fab";
fab.id = "voice-fab";
fab.onclick = () => void this.voice.toggle();
this.root.appendChild(fab);
// Live conversation transcript (shown while voice is active or when there
// is history) — the old app's conversation view, and a quick way to SEE
// whether Reachy heard you.
const panel = document.createElement("div");
panel.className = "voice-transcript";
panel.id = "voice-transcript";
this.root.appendChild(panel);
this.updateVoiceButton();
this.updateTranscript();
}
private updateTranscript(): void {
const panel = this.root.querySelector<HTMLDivElement>("#voice-transcript");
if (!panel) return;
const s = this.s;
const voiceActive = this.voiceStatus !== "idle" && this.voiceStatus !== "error";
if (!voiceActive && this.transcript.length === 0) {
panel.style.display = "none";
return;
}
panel.style.display = "block";
panel.classList.toggle("minimized", this.transcriptMinimized);
const rows =
this.transcript.length === 0
? `<div class="vt-empty">${s.voice_transcript_empty}</div>`
: this.transcript
.map((m) => {
const inner = m.image
? `<img class="photo" src="${m.image}" alt="Reachy's photo" />`
: escapeHtml(m.text ?? "");
return `<div class="vt-msg ${m.role}"><img class="avatar" src="${
m.role === "user" ? "/user_avatar.png" : "/reachymini_avatar.png"
}" alt="${m.role}" /><div class="bubble${m.image ? " photo-bubble" : ""}">${inner}</div></div>`;
})
.join("");
const toggleIcon = this.transcriptMinimized
? svg('<path d="M7 14l5-5 5 5"/>') // chevron up = expand
: svg('<path d="M7 10l5 5 5-5"/>'); // chevron down = minimize
panel.innerHTML = `
<div class="vt-head" id="vt-head">
<span>${s.voice_transcript_title}${this.transcriptMinimized && this.transcript.length ? ` · ${this.transcript.length}` : ""}</span>
<div class="vt-head-actions">
<button class="vt-clear" id="vt-clear">${s.voice_transcript_clear}</button>
<button class="vt-min icon-btn" id="vt-min" aria-label="minimize">${toggleIcon}</button>
</div>
</div>
<div class="vt-body" id="vt-body">${rows}</div>`;
const toggle = () => {
this.transcriptMinimized = !this.transcriptMinimized;
this.updateTranscript();
};
panel.querySelector<HTMLButtonElement>("#vt-min")!.onclick = toggle;
// Tapping the header (not the buttons) also toggles when minimized.
panel.querySelector<HTMLElement>("#vt-head")!.onclick = (e) => {
if ((e.target as HTMLElement).closest("button")) return;
if (this.transcriptMinimized) toggle();
};
panel.querySelector<HTMLButtonElement>("#vt-clear")!.onclick = (e) => {
e.stopPropagation();
this.transcript = [];
this.updateTranscript();
};
const body = panel.querySelector<HTMLDivElement>("#vt-body");
if (body) body.scrollTop = body.scrollHeight;
}
private updateVoiceButton(): void {
const fab = this.root.querySelector<HTMLButtonElement>("#voice-fab");
if (!fab) return;
const s = this.s;
const active = this.voiceStatus !== "idle" && this.voiceStatus !== "error";
fab.classList.toggle("active", active);
fab.classList.toggle("speaking", this.voiceStatus === "speaking");
let icon: string;
let label: string;
switch (this.voiceStatus) {
case "connecting":
icon = ICONS.spinner;
label = s.voice_connecting;
break;
case "listening":
icon = ICONS.stop;
label = s.voice_stop;
break;
case "speaking":
icon = ICONS.speaker;
label = s.voice_speaking;
break;
default:
icon = ICONS.mic;
label = s.voice_start;
}
fab.innerHTML = `${icon}<span>${label}</span>`;
}
// ---------- Inventory ----------
private renderInventory(main: HTMLElement): void {
const s = this.s;
// First-run welcome: shown until the family size is set, so meal plans
// are sized correctly from the start.
if (this.data.family_profile.adults === null) {
const welcome = document.createElement("form");
welcome.className = "card welcome-card";
welcome.innerHTML = `
<div class="item-title">${s.welcome_title}</div>
<p class="item-sub">${s.welcome_body}</p>
<div class="form-grid">
<div>
<label>${s.adults}</label>
<input name="adults" type="number" inputmode="numeric" min="0" step="1" value="2" />
</div>
<div>
<label>${s.children}</label>
<input name="children" type="number" inputmode="numeric" min="0" step="1" value="0" />
</div>
</div>
<button class="btn primary block" type="submit">${s.welcome_save}</button>
<p class="hint" style="margin-top:0.6rem;">🎙️ ${s.welcome_voice_hint}</p>`;
welcome.onsubmit = (event) => {
event.preventDefault();
const fd = new FormData(welcome);
this.data.family_profile.adults = Math.max(0, Math.trunc(Number(fd.get("adults") || 0)));
this.data.family_profile.children = Math.max(0, Math.trunc(Number(fd.get("children") || 0)));
this.commit();
};
main.appendChild(welcome);
}
main.insertAdjacentHTML("beforeend", `<h1>${s.inventory_title}</h1>`);
// Sort control.
const sortBar = document.createElement("div");
sortBar.className = "sort-bar";
sortBar.innerHTML = `<span class="sort-label">${s.sort_label}</span>`;
const seg = document.createElement("div");
seg.className = "segmented";
for (const [mode, label] of [
["expiration", s.sort_expiration],
["name", s.sort_name],
["location", s.sort_location],
["custom", s.sort_custom],
] as const) {
const b = document.createElement("button");
b.className = this.settings.inventorySort === mode ? "active" : "";
b.textContent = label;
b.onclick = () => {
this.settings.inventorySort = mode;
saveSettings(this.settings);
this.render();
};
seg.appendChild(b);
}
sortBar.appendChild(seg);
main.appendChild(sortBar);
// Active location filter banner.
if (this.locationFilter) {
const banner = document.createElement("button");
banner.className = "filter-banner";
banner.innerHTML = `${ICONS.pin}<span>${s.filter_location(this.locationFilter)}</span>`;
banner.onclick = () => {
this.locationFilter = null;
this.render();
};
main.appendChild(banner);
}
const items = this.orderedInventory();
if (items.length === 0) {
main.insertAdjacentHTML("beforeend", `<div class="empty">${s.inventory_empty}</div>`);
}
if (this.settings.inventorySort === "custom" && items.length > 1 && !this.locationFilter) {
main.insertAdjacentHTML("beforeend", `<p class="hint drag-hint">${ICONS.drag} ${s.drag_hint}</p>`);
}
const list = document.createElement("div");
list.className = "inv-list";
const draggable = this.settings.inventorySort === "custom" && !this.locationFilter;
let hasExpiring = false;
items.forEach((item) => {
const days = inv.daysToExpiration(item);
if (days !== null && days <= EXPIRY_SOON_DAYS) hasExpiring = true;
const card = this.renderInventoryCard(item, draggable);
list.appendChild(card);
});
main.appendChild(list);
if (hasExpiring && !this.warnedExpiry) {
this.warnedExpiry = true;
void this.gestures.concernedTilt();
}
// Add-item form.
main.insertAdjacentHTML("beforeend", `<h2>${s.add_item}</h2>`);
const form = this.itemForm(null);
form.onsubmit = (event) => {
event.preventDefault();
const fd = new FormData(form);
const { inventory, errors } = inv.addItems(this.data.inventory, [
{
name: String(fd.get("name") ?? ""),
quantity: String(fd.get("quantity") ?? ""),
unit: String(fd.get("unit") ?? "pcs"),
expiration_date: String(fd.get("expiration") ?? "") || null,
storage_location: inv.canonicalizeLocation(this.data.inventory, String(fd.get("location") ?? "") || null),
},
]);
if (errors.length === 0) {
this.data.inventory = inventory;
void this.gestures.antennaWiggle();
this.commit();
} else {
this.toast(errors.join(", "));
}
};
main.appendChild(form);
}
private orderedInventory(): InventoryItem[] {
let items = this.data.inventory.filter((i) => i.quantity > 0);
const mode = this.settings.inventorySort;
if (mode === "name") items = [...items].sort((a, b) => a.display_name.localeCompare(b.display_name));
else if (mode === "expiration") items = inv.sortInventory(items);
else if (mode === "location")
items = [...items].sort((a, b) => {
// Items without a location go last; within a location, sort by name.
const la = a.storage_location ?? "￿";
const lb = b.storage_location ?? "￿";
const cmp = la.localeCompare(lb);
return cmp !== 0 ? cmp : a.display_name.localeCompare(b.display_name);
});
// "custom" keeps the underlying array order.
if (this.locationFilter) items = items.filter((i) => (i.storage_location ?? "") === this.locationFilter);
return items;
}
private existingLocations(): string[] {
return [...new Set(this.data.inventory.map((i) => i.storage_location).filter((l): l is string => !!l))].sort();
}
private renderInventoryCard(item: InventoryItem, draggable: boolean): HTMLElement {
const s = this.s;
const card = document.createElement("div");
card.className = "card inv-card";
const days = inv.daysToExpiration(item);
let chip = "";
if (days !== null && days < 0) chip = `<span class="chip danger">${s.expired}</span>`;
else if (days !== null && days <= EXPIRY_SOON_DAYS) chip = `<span class="chip warn">${s.expires_in_days(days)}</span>`;
const expText = item.expiration_date
? `${ICONS.clock}<span>${s.expires_on(formatDate(item.expiration_date, this.settings.language))}</span>`
: `${ICONS.clock}<span>${s.no_expiration}</span>`;
card.innerHTML = `
${draggable ? `<span class="drag-handle" aria-label="reorder">${ICONS.drag}</span>` : ""}
<div class="grow">
<div class="item-title">${escapeHtml(item.display_name)}${chip}</div>
<div class="item-meta">
<span class="qty">${escapeHtml(inv.displayQuantity(item))}</span>
<span class="exp ${days !== null && days < 0 ? "over" : ""}">${expText}</span>
${item.storage_location ? `<button class="loc-chip">${ICONS.pin}${escapeHtml(item.storage_location)}</button>` : ""}
</div>
</div>
<div class="inv-actions">
<button class="icon-btn use" aria-label="${s.cook_some}">${ICONS.use}</button>
<button class="icon-btn edit" aria-label="${s.edit_item}">${ICONS.edit}</button>
<button class="icon-btn danger del" aria-label="${s.delete_item}">${ICONS.trash}</button>
</div>`;
card.querySelector<HTMLButtonElement>(".use")!.onclick = () => this.openUse(item);
card.querySelector<HTMLButtonElement>(".edit")!.onclick = () => this.openEdit(item);
card.querySelector<HTMLButtonElement>(".del")!.onclick = async () => {
if (await this.confirm(s.delete_confirm(item.display_name))) {
this.data.inventory = this.data.inventory.filter((e) => e !== item);
this.commit();
}
};
card.querySelector<HTMLButtonElement>(".loc-chip")?.addEventListener("click", () => {
this.locationFilter = item.storage_location;
this.render();
});
if (draggable) this.wireDrag(card, item);
return card;
}
/** Pointer-based drag reorder (works on touch, unlike HTML5 DnD). The dragged
* card carries its InventoryItem reference so the final order is rebuilt from
* the DOM directly. */
private wireDrag(card: HTMLElement, item: InventoryItem): void {
(card as HTMLElement & { _item?: InventoryItem })._item = item;
const handle = card.querySelector<HTMLElement>(".drag-handle");
if (!handle) return;
handle.style.touchAction = "none";
handle.addEventListener("pointerdown", (down: PointerEvent) => {
down.preventDefault();
const list = card.parentElement!;
card.classList.add("dragging");
const move = (e: PointerEvent) => {
const after = [...list.querySelectorAll<HTMLElement>(".inv-card:not(.dragging)")].find((sib) => {
const box = sib.getBoundingClientRect();
return e.clientY < box.top + box.height / 2;
});
if (after) list.insertBefore(card, after);
else list.appendChild(card);
};
const up = () => {
card.classList.remove("dragging");
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
const newVisible = [...list.querySelectorAll<HTMLElement & { _item?: InventoryItem }>(".inv-card")]
.map((el) => el._item)
.filter((it): it is InventoryItem => !!it);
const hidden = this.data.inventory.filter((i) => i.quantity <= 0);
this.data.inventory = [...newVisible, ...hidden];
this.commit();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
}
/** Shared add/edit form. `existing` null → add mode. */
private itemForm(existing: InventoryItem | null): HTMLFormElement {
const s = this.s;
const form = document.createElement("form");
form.className = existing ? "" : "card";
const locOptions = this.existingLocations()
.map((l) => `<option value="${escapeHtml(l)}"></option>`)
.join("");
const units: [string, string][] = [
["g", "g"],
["kg", "kg"],
["ml", "ml"],
["l", "l"],
["pcs", "pcs"],
];
// Show the item's stored unit; for grams/ml the stored unit is the base.
const curUnit = existing ? existing.unit : "g";
form.innerHTML = `
<label>${s.item_name}</label>
<input name="name" required autocomplete="off" value="${existing ? escapeHtml(existing.display_name) : ""}" />
<div class="form-grid">
<div>
<label>${s.item_quantity}</label>
<input name="quantity" type="number" inputmode="decimal" min="0" step="any" required value="${existing ? existing.quantity : ""}" />
</div>
<div>
<label>${s.item_unit}</label>
<select name="unit">${units.map(([v, l]) => `<option value="${v}"${v === curUnit ? " selected" : ""}>${l}</option>`).join("")}</select>
</div>
</div>
<label>${s.item_expiration}</label>
<input name="expiration" type="date" value="${existing?.expiration_date ?? ""}" />
<label>${s.item_location}</label>
<input name="location" list="loc-list" placeholder="${s.location_placeholder}" autocomplete="off" value="${existing ? escapeHtml(existing.storage_location ?? "") : ""}" />
<datalist id="loc-list">${locOptions}</datalist>
<div class="loc-suggestions"></div>
${existing ? "" : `<button class="btn primary block" type="submit">${s.add_item}</button>`}`;
// Existing locations as tappable tags — one tap reuses the exact casing,
// so "Nevera"/"nevera" never fork into two labels.
const locInput = form.querySelector<HTMLInputElement>('input[name="location"]')!;
const sug = form.querySelector<HTMLDivElement>(".loc-suggestions")!;
for (const loc of this.existingLocations()) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "loc-chip";
chip.innerHTML = `${ICONS.pin}${escapeHtml(loc)}`;
chip.onclick = () => {
locInput.value = loc;
};
sug.appendChild(chip);
}
// In the edit modal there is no submit button; stop Enter from reloading.
if (existing) form.addEventListener("submit", (e) => e.preventDefault());
return form;
}
private openUse(item: InventoryItem): void {
const s = this.s;
const body = document.createElement("div");
body.innerHTML = `
<label>${s.use_amount} (${item.unit})</label>
<input name="amount" type="number" inputmode="decimal" min="0" step="any" placeholder="0" />
<button class="btn block" type="button" data-all>${s.use_all}</button>`;
const input = body.querySelector<HTMLInputElement>("input")!;
body.querySelector<HTMLButtonElement>("[data-all]")!.onclick = () => {
input.value = String(item.quantity);
};
this.showModal({
title: s.use_title(item.display_name),
body,
primaryLabel: s.cook_some,
onPrimary: () => {
const raw = input.value.trim();
if (!raw) return;
const { inventory, errors } = inv.cookItems(this.data.inventory, [
{ name: item.name, quantity: raw, unit: item.unit },
]);
if (errors.length > 0) this.toast(errors.join(", "));
this.data.inventory = inventory;
this.commit();
},
});
}
private openEdit(item: InventoryItem): void {
const s = this.s;
const form = this.itemForm(item);
this.showModal({
title: s.edit_item,
body: form,
primaryLabel: s.save,
onPrimary: () => {
const fd = new FormData(form);
const normalized = inv.normalizeItemInput({
name: String(fd.get("name") ?? ""),
quantity: String(fd.get("quantity") ?? ""),
unit: String(fd.get("unit") ?? "pcs"),
expiration_date: String(fd.get("expiration") ?? "") || null,
storage_location: inv.canonicalizeLocation(this.data.inventory, String(fd.get("location") ?? "") || null),
});
if (!normalized) {
this.toast(this.s.item_quantity);
return;
}
// Replace this item in place (preserve position).
const idx = this.data.inventory.indexOf(item);
if (idx >= 0) {
this.data.inventory = this.data.inventory.map((e, i) => (i === idx ? normalized : e));
this.commit();
}
},
});
}
// ---------- Meal plan ----------
private renderPlan(main: HTMLElement): void {
const s = this.s;
main.insertAdjacentHTML("beforeend", `<h1>${s.plan_title}</h1>`);
// Planning is meaningless without the family size — hard requirement.
if (this.data.family_profile.adults === null) {
const gate = document.createElement("div");
gate.className = "card";
gate.innerHTML = `<p class="item-sub">${s.plan_need_family}</p>`;
const cta = document.createElement("button");
cta.className = "btn primary block";
cta.textContent = s.plan_need_family_cta;
cta.onclick = () => {
this.tab = "settings";
this.render();
};
gate.appendChild(cta);
main.appendChild(gate);
return;
}
// "Who eats what" matrix editor.
main.insertAdjacentHTML("beforeend", `<h2>${s.plan_who_title}</h2>`);
main.appendChild(this.renderMatrixEditor());
main.insertAdjacentHTML("beforeend", `<p class="hint" style="margin:0.4rem 0 0.8rem;">${s.plan_who_hint}</p>`);
// Free-text wishes fed to the planner ("Friday is pizza night", …).
// Saved without re-rendering so typing never loses focus.
const notesLabel = document.createElement("label");
notesLabel.textContent = s.plan_notes_label;
const notes = document.createElement("textarea");
notes.className = "plan-notes";
notes.rows = 2;
notes.placeholder = s.plan_notes_placeholder;
notes.value = this.data.family_profile.plan_notes ?? "";
notes.oninput = () => {
this.data.family_profile.plan_notes = notes.value.trim() || null;
saveData(this.data);
this.scheduleAutoSync();
};
main.append(notesLabel, notes);
const generate = document.createElement("button");
generate.className = "btn primary block";
generate.textContent = s.generate_plan;
generate.onclick = () => void this.generatePlanSmart(generate);
main.appendChild(generate);
const plan = this.data.meal_plan;
if (!plan) {
main.insertAdjacentHTML("beforeend", `<div class="empty">${s.plan_empty}</div>`);
return;
}
// Week header with lock-all / unlock-all bulk controls.
const weekHead = document.createElement("div");
weekHead.className = "week-head";
weekHead.innerHTML = `<h2>${s.plan_week_of} ${escapeHtml(plan.week_start)}</h2>`;
const setAllLocks = (locked: boolean) => {
for (const day of plan.days) for (const meal of day.meals) meal.locked = locked;
this.commit();
};
const lockAll = document.createElement("button");
lockAll.className = "btn small";
lockAll.innerHTML = `${ICONS.lock}<span>${s.lock_all}</span>`;
lockAll.onclick = () => setAllLocks(true);
const unlockAll = document.createElement("button");
unlockAll.className = "btn small";
unlockAll.innerHTML = `${ICONS.unlock}<span>${s.unlock_all}</span>`;
unlockAll.onclick = () => setAllLocks(false);
const headBtns = document.createElement("div");
headBtns.className = "week-head-btns";
headBtns.append(lockAll, unlockAll);
// Undo / Redo — restore the previous or re-apply an undone plan (covers
// voice regenerations too).
if (this.planHistory.length > 0) {
const undo = document.createElement("button");
undo.className = "btn small";
undo.innerHTML = `${ICONS.undo}<span>${s.plan_undo}</span>`;
undo.onclick = () => this.undoPlan();
headBtns.appendChild(undo);
}
if (this.planFuture.length > 0) {
const redo = document.createElement("button");
redo.className = "btn small";
redo.innerHTML = `${ICONS.redo}<span>${s.plan_redo}</span>`;
redo.onclick = () => this.redoPlan();
headBtns.appendChild(redo);
}
weekHead.appendChild(headBtns);
main.appendChild(weekHead);
for (const day of plan.days) {
if (day.meals.length === 0) continue;
const card = document.createElement("div");
card.className = "card day-card";
const dayLabel = s.day_names[day.day] ?? day.day;
card.innerHTML = `<div class="day-name">${escapeHtml(dayLabel)} <span class="item-sub">${escapeHtml(day.date)}</span></div>`;
for (const meal of day.meals) {
const ingredients = meal.ingredients
.map((ing) => `${escapeHtml(ing.display_name)} (${escapeHtml(ing.display_quantity)})`)
.join(", ");
const mealName = meal.name === "No available ingredients" ? s.no_ingredients : meal.name;
const row = document.createElement("div");
row.className = `meal${meal.locked ? " locked" : ""}`;
row.dataset.slot = `${day.day}-${meal.meal}`;
row.innerHTML = `
<div class="meal-main">
<div class="meal-kind">${escapeHtml(mealLabel(s, meal.meal))} · ${s.servings(meal.servings)}</div>
<div class="meal-name">${escapeHtml(mealName)}</div>
<div class="meal-ing">${ingredients}</div>
</div>
<div class="meal-actions">
<button class="icon-btn m-lock${meal.locked ? " on" : ""}" aria-label="${meal.locked ? s.meal_keep : s.meal_unlock}">${meal.locked ? ICONS.lock : ICONS.unlock}</button>
<button class="icon-btn m-regen" aria-label="${s.meal_regen}" ${meal.locked ? "disabled" : ""}>${ICONS.refresh}</button>
</div>`;
row.querySelector<HTMLButtonElement>(".m-lock")!.onclick = () => {
meal.locked = !meal.locked;
this.commit();
};
row.querySelector<HTMLButtonElement>(".m-regen")!.onclick = (e) =>
void this.regenerateMeal(day.day, meal.meal, e.currentTarget as HTMLButtonElement);
card.appendChild(row);
}
main.appendChild(card);
}
}
/** 7×3 grid: rows = days, columns = breakfast/lunch/dinner; each cell shows
* "adults+children" (or –). Tap a cell to edit it, a day name to edit the
* whole row, a meal name for the whole column, or ✎ for everything. */
private renderMatrixEditor(): HTMLElement {
const s = this.s;
const matrix = effectiveMatrix(this.data.family_profile);
const kinds = ["breakfast", "lunch", "dinner"] as const;
const wrap = document.createElement("div");
wrap.className = "card matrix-card";
const table = document.createElement("div");
table.className = "matrix";
// Header row: ✎ (all) + one button per meal column.
const allBtn = document.createElement("button");
allBtn.className = "m-head m-btn";
allBtn.innerHTML = ICONS.edit;
allBtn.setAttribute("aria-label", s.plan_edit_all);
allBtn.onclick = () => this.openMatrixBulk([...planner.DAY_NAMES], [...kinds], s.plan_edit_all, matrix);
table.appendChild(allBtn);
for (const kind of kinds) {
const head = document.createElement("button");
head.className = "m-head m-btn";
head.textContent = mealLabel(s, kind);
head.onclick = () =>
this.openMatrixBulk([...planner.DAY_NAMES], [kind], s.plan_edit_meal(mealLabel(s, kind)), matrix);
table.appendChild(head);
}
for (const day of planner.DAY_NAMES) {
const dayBtn = document.createElement("button");
dayBtn.className = "m-day m-btn";
dayBtn.textContent = (s.day_names[day] ?? day).slice(0, 3);
dayBtn.onclick = () => this.openMatrixBulk([day], [...kinds], s.plan_edit_day(s.day_names[day] ?? day), matrix);
table.appendChild(dayBtn);
for (const kind of kinds) {
const cell = document.createElement("button");
const people = matrix[day]?.[kind];
cell.className = `m-cell${people ? " on" : ""}`;
cell.textContent = people ? `${people.adults}+${people.children}` : "–";
cell.onclick = () =>
this.openMatrixBulk([day], [kind], s.plan_people_title(s.day_names[day] ?? day, mealLabel(s, kind)), matrix);
table.appendChild(cell);
}
}
wrap.appendChild(table);
return wrap;
}
/** Edit one cell, a row, a column, or the whole matrix with one dialog. */
private openMatrixBulk(
days: string[],
kinds: ("breakfast" | "lunch" | "dinner")[],
title: string,
matrix: PlanMatrix,
): void {
const s = this.s;
// Prefill: if every targeted planned cell agrees, use that; else family size.
const targeted = days.flatMap((d) => kinds.map((k) => matrix[d]?.[k]).filter(Boolean));
const first = targeted[0] ?? null;
const uniform =
first !== null && targeted.every((p) => p!.adults === first.adults && p!.children === first.children);
const defA = uniform ? first!.adults : (this.data.family_profile.adults ?? 2);
const defC = uniform ? first!.children : (this.data.family_profile.children ?? 0);
const body = document.createElement("div");
body.innerHTML = `
<div class="form-grid">
<div>
<label>${s.adults}</label>
<input name="a" type="number" inputmode="numeric" min="0" step="1" value="${defA}" />
</div>
<div>
<label>${s.children}</label>
<input name="c" type="number" inputmode="numeric" min="0" step="1" value="${defC}" />
</div>
</div>
<button class="btn block" type="button" data-none>${s.plan_no_meal}</button>`;
const inputA = body.querySelector<HTMLInputElement>('input[name="a"]')!;
const inputC = body.querySelector<HTMLInputElement>('input[name="c"]')!;
const apply = (people: { adults: number; children: number } | null) => {
const next: PlanMatrix = {};
for (const d of planner.DAY_NAMES) next[d] = { ...(matrix[d] ?? {}) };
for (const d of days) {
for (const k of kinds) {
if (people && people.adults + people.children > 0) next[d][k] = { ...people };
else delete next[d][k];
}
}
this.data.family_profile.plan_matrix = next;
this.commit();
};
body.querySelector<HTMLButtonElement>("[data-none]")!.onclick = () => {
apply(null);
document.querySelector(".modal-backdrop")?.remove();
};
this.showModal({
title,
body,
primaryLabel: s.save,
onPrimary: () => {
apply({
adults: Math.max(0, Math.trunc(Number(inputA.value) || 0)),
children: Math.max(0, Math.trunc(Number(inputC.value) || 0)),
});
},
});
}
/** Meals marked "keep" in the current plan, keyed by `day|meal`. */
private lockedMeals(): Map<string, PlannedMeal> {
const locked = new Map<string, PlannedMeal>();
for (const day of this.data.meal_plan?.days ?? []) {
for (const meal of day.meals) if (meal.locked) locked.set(`${day.day}|${meal.meal}`, meal);
}
return locked;
}
/** Merge freshly generated meals with the locked ones (locked always win). */
private mergeWithLocked(generated: MealPlan): MealPlan {
const locked = this.lockedMeals();
if (locked.size === 0) return generated;
const kindOrder = ["breakfast", "lunch", "dinner"];
return {
...generated,
days: generated.days.map((day) => {
const meals = day.meals.filter((m) => !locked.has(`${day.day}|${m.meal}`));
for (const [key, meal] of locked) {
if (key.startsWith(`${day.day}|`)) meals.push(meal);
}
meals.sort((a, b) => kindOrder.indexOf(a.meal) - kindOrder.indexOf(b.meal));
return { ...day, meals };
}),
};
}
/** The matrix minus locked slots (they must not be re-planned), plus a list
* of the kept meals so the LLM avoids repeating them. */
private unlockedMatrix(): { matrix: PlanMatrix; fixed: string[] } {
const full = effectiveMatrix(this.data.family_profile);
const locked = this.lockedMeals();
if (locked.size === 0) return { matrix: full, fixed: [] };
const matrix: PlanMatrix = {};
for (const [day, cell] of Object.entries(full)) {
matrix[day] = { ...cell };
for (const kind of ["breakfast", "lunch", "dinner"] as const) {
if (locked.has(`${day}|${kind}`)) delete matrix[day][kind];
}
}
const fixed = [...locked.entries()].map(([key, meal]) => `${key.replace("|", " ")}: ${meal.name}`);
return { matrix, fixed };
}
/** Snapshot the current plan onto the undo stack before an edit. A genuine
* new edit invalidates any redo future (standard undo/redo semantics). */
private snapshotPlan(): void {
if (this.data.meal_plan) {
this.planHistory.push(structuredClone(this.data.meal_plan));
if (this.planHistory.length > 10) this.planHistory.shift();
}
this.planFuture = [];
}
/** Restore the previous plan; remembers the current one so redo can return.
* Returns false if there's nothing to undo. */
private undoPlan(): boolean {
const prev = this.planHistory.pop();
if (!prev) return false;
if (this.data.meal_plan) this.planFuture.push(structuredClone(this.data.meal_plan));
this.data.meal_plan = prev;
this.commit();
return true;
}
/** Re-apply a plan that was just undone. Returns false if there's nothing to
* redo (empty after any fresh edit). */
private redoPlan(): boolean {
const next = this.planFuture.pop();
if (!next) return false;
if (this.data.meal_plan) this.planHistory.push(structuredClone(this.data.meal_plan));
this.data.meal_plan = next;
this.commit();
return true;
}
/** Lock ("keep") or unlock a single planned meal — the voice equivalent of
* the padlock button. */
private setMealLock(dayInput: string, kind: string, locked: boolean): { ok: boolean; error?: string; day?: string } {
const day = this.resolveDay(dayInput);
if (!day) return { ok: false, error: `unknown day "${dayInput}"` };
const meal = this.data.meal_plan?.days.find((d) => d.day === day)?.meals.find((m) => m.meal === kind);
if (!meal) return { ok: false, error: `no ${kind} planned for ${day}` };
meal.locked = locked;
this.commit();
return { ok: true, day };
}
/** Spin the regenerate icon on every unlocked meal currently on screen, so
* the user sees which meals are being replaced while a new plan is thought
* up (locked meals keep still — they survive). */
private markReplacingMeals(): void {
this.root
.querySelectorAll<HTMLElement>(".meal:not(.locked) .m-regen")
.forEach((b) => b.classList.add("spinning"));
}
/** Shared "thinking" generation used by BOTH the button and voice: the
* headless gpt-realtime designer (prioritizing expiring pantry, allowed to
* add to-buy items); algorithmic rotation only as a fallback. Locked meals
* are preserved; `guidance` is a one-off request merged with saved wishes. */
private async designAndCommitPlan(guidance?: string): Promise<{ ok: boolean; meals?: number; error?: string }> {
this.snapshotPlan();
this.markReplacingMeals();
const profile = this.data.family_profile;
const { matrix, fixed } = this.unlockedMatrix();
const notes =
[this.data.family_profile.plan_notes, guidance].filter((n): n is string => !!n && !!n.trim()).join(". ") || null;
let generated: MealPlan;
this.voice.startThinking();
try {
const key = this.settings.openaiKey;
if (!key) throw new Error("no OpenAI key");
generated = await generatePlanLLM(key, this.settings.language, this.data.inventory, matrix, profile.schedule, fixed, notes);
} catch (err) {
console.warn("[plan] LLM generation failed, using algorithmic fallback:", err);
this.toast(this.s.plan_llm_fallback);
generated = planner.generatePlanFromMatrix(this.data.inventory, matrix, profile.schedule);
} finally {
this.voice.stopThinking();
}
this.data.meal_plan = this.mergeWithLocked(generated);
this.commit();
return { ok: true, meals: this.data.meal_plan.days.reduce((n, d) => n + d.meals.length, 0) };
}
/** Map a spoken day ("today", "tomorrow", "friday", "hoy", "mañana") to a
* canonical lowercase English weekday, or null if unrecognized. */
private resolveDay(input: string): string | null {
const raw = input.trim().toLowerCase();
const idxToday = (new Date().getDay() + 6) % 7; // Monday = 0
if (raw === "today" || raw === "hoy") return planner.DAY_NAMES[idxToday];
if (raw === "tomorrow" || raw === "mañana" || raw === "manana")
return planner.DAY_NAMES[(idxToday + 1) % 7];
return planner.DAY_NAMES.find((d) => d === raw) ?? null;
}
/** Plan or replace ONE meal slot (e.g. today's dinner), leaving the rest of
* the week untouched. Used by voice for precise edits — `request` carries the
* user's concrete ask (e.g. "use the spinach that just expired"). Creates a
* plan if none exists yet. */
private async designMeal(
dayInput: string,
kind: string,
request?: string,
): Promise<{ ok: boolean; day?: string; meal?: string; name?: string; error?: string }> {
const day = this.resolveDay(dayInput);
if (!day) return { ok: false, error: `unknown day "${dayInput}"` };
if (!["breakfast", "lunch", "dinner"].includes(kind))
return { ok: false, error: "meal must be breakfast, lunch or dinner" };
const kindT = kind as "breakfast" | "lunch" | "dinner";
// Respect locks: a kept meal must be unlocked (by the user) before voice or
// the app may replace it.
const lockedExisting = this.data.meal_plan?.days
.find((d) => d.day === day)
?.meals.find((m) => m.meal === kind && m.locked);
if (lockedExisting) {
return {
ok: false,
error: `locked: ${day} ${kind} is kept ("${lockedExisting.name}"). Ask the user to unlock it first, then try again.`,
};
}
// Servings from the matrix cell, or the family size if that slot isn't
// normally planned (the user can ask for a meal in any slot).
const matrix = effectiveMatrix(this.data.family_profile);
const people = matrix[day]?.[kindT] ?? {
adults: this.data.family_profile.adults ?? 2,
children: this.data.family_profile.children ?? 0,
};
this.snapshotPlan();
this.root.querySelector<HTMLElement>(`.meal[data-slot="${day}-${kind}"] .m-regen`)?.classList.add("spinning");
const slot: PlanMatrix = { [day]: { [kindT]: people } };
const existing = this.data.meal_plan;
// Avoid repeating the week's OTHER meals.
const fixed = (existing?.days ?? []).flatMap((d) =>
d.meals.filter((m) => !(d.day === day && m.meal === kind)).map((m) => `${d.day} ${m.meal}: ${m.name}`),
);
const notes =
[
this.data.family_profile.plan_notes,
request
? `For ${day} ${kind} specifically, the user asks: "${request}". Honor this exactly and include any ingredient they name even if it is expiring or expired.`
: null,
]
.filter((n): n is string => !!n && !!n.trim())
.join(". ") || null;
let generated: MealPlan;
this.voice.startThinking();
try {
const key = this.settings.openaiKey;
if (!key) throw new Error("no OpenAI key");
generated = await generatePlanLLM(
key,
this.settings.language,
this.data.inventory,
slot,
this.data.family_profile.schedule,
fixed,
notes,
);
} catch (err) {
console.warn("[plan] single-meal LLM design failed, using fallback:", err);
generated = planner.generatePlanFromMatrix(this.data.inventory, slot, this.data.family_profile.schedule);
} finally {
this.voice.stopThinking();
}
const newMeal = generated.days.find((d) => d.day === day)?.meals.find((m) => m.meal === kind);
if (!newMeal) return { ok: false, error: "could not design that meal" };
if (existing) {
const kindOrder = ["breakfast", "lunch", "dinner"];
this.data.meal_plan = {
...existing,
days: existing.days.map((d) => {
if (d.day !== day) return d;
const meals = d.meals.filter((m) => m.meal !== kind);
meals.push(newMeal);
meals.sort((a, b) => kindOrder.indexOf(a.meal) - kindOrder.indexOf(b.meal));
return { ...d, meals };
}),
};
} else {
// No plan yet — the generated skeleton IS the plan (just this one meal).
this.data.meal_plan = generated;
}
this.commit();
return { ok: true, day, meal: kind, name: newMeal.name };
}
private async generatePlanSmart(button: HTMLButtonElement): Promise<void> {
const s = this.s;
void this.gestures.sweepLook();
const original = button.textContent;
button.disabled = true;
button.textContent = s.plan_generating;
try {
await this.designAndCommitPlan();
} finally {
button.disabled = false;
button.textContent = original;
}
await this.gestures.happyDance();
}
/** Regenerate a single meal slot, leaving everything else untouched. */
private async regenerateMeal(day: string, kind: string, button: HTMLButtonElement): Promise<void> {
const s = this.s;
const plan = this.data.meal_plan;
if (!plan) return;
const full = effectiveMatrix(this.data.family_profile);
const people = full[day]?.[kind as "breakfast" | "lunch" | "dinner"];
if (!people) return;
this.snapshotPlan();
const slot: PlanMatrix = { [day]: { [kind]: people } };
const fixed = plan.days
.flatMap((d) => d.meals.map((m) => ({ d: d.day, m })))
.filter(({ d, m }) => !(d === day && m.meal === kind))
.map(({ d, m }) => `${d} ${m.meal}: ${m.name}`);
button.disabled = true;
button.classList.add("spinning");
let newMeal: PlannedMeal | undefined;
try {
const key = this.settings.openaiKey;
if (!key) throw new Error("no OpenAI key");
const mini = await generatePlanLLM(key, this.settings.language, this.data.inventory, slot, this.data.family_profile.schedule, fixed, this.data.family_profile.plan_notes ?? null);
newMeal = mini.days.find((d) => d.day === day)?.meals.find((m) => m.meal === kind);
} catch (err) {
console.warn("[plan] single-meal LLM regen failed, using fallback:", err);
const mini = planner.generatePlanFromMatrix(this.data.inventory, slot, this.data.family_profile.schedule);
newMeal = mini.days.find((d) => d.day === day)?.meals.find((m) => m.meal === kind);
} finally {
button.disabled = false;
button.classList.remove("spinning");
}
if (!newMeal) return this.toast(s.plan_llm_fallback);
this.data.meal_plan = {
...plan,
days: plan.days.map((d) =>
d.day === day ? { ...d, meals: d.meals.map((m) => (m.meal === kind ? newMeal! : m)) } : d,
),
};
this.commit();
}
// ---------- Shopping list ----------
private renderShopping(main: HTMLElement): void {
const s = this.s;
main.insertAdjacentHTML("beforeend", `<h1>${s.shopping_title}</h1>`);
const generate = document.createElement("button");
generate.className = "btn primary block";
generate.textContent = s.generate_shopping;
generate.onclick = () => {
void this.gestures.sweepLook();
this.data.shopping_list = shopping.generateFromPlan(this.data.meal_plan, this.data.inventory);
this.commit();
};
main.appendChild(generate);
const items = shopping.sortList(this.data.shopping_list);
if (items.length === 0) {
main.insertAdjacentHTML("beforeend", `<div class="empty">${s.shopping_empty}</div>`);
return;
}
for (const item of items) {
const card = document.createElement("div");
card.className = "card row";
const detail =
item.required_quantity !== undefined && item.available_quantity !== undefined
? s.shopping_need(
formatQuantity(item.required_quantity, item.unit),
formatQuantity(item.available_quantity, item.unit),
)
: "";
card.innerHTML = `
<div class="grow">
<div class="item-title">${escapeHtml(planner.displayName(item.name))}</div>
<div class="item-sub">${escapeHtml(formatQuantity(item.quantity, item.unit))}${detail ? " · " + escapeHtml(detail) : ""}</div>
</div>`;
// Tap the item to see which planned meals need it, and when.
card.querySelector<HTMLElement>(".grow")!.onclick = () => this.openShoppingUsage(item.name, item.unit);
const boughtBtn = document.createElement("button");
boughtBtn.className = "btn small";
boughtBtn.textContent = s.bought;
boughtBtn.onclick = () => {
// Bought: remove from the list and add to the inventory.
this.data.shopping_list = shopping.removeItems(this.data.shopping_list, [
{ name: item.name, unit: item.unit },
]);
const { inventory } = inv.addItems(this.data.inventory, [
{ name: item.name, quantity: item.quantity, unit: item.unit },
]);
this.data.inventory = inventory;
void this.gestures.antennaWiggle();
this.commit();
};
const trashBtn = document.createElement("button");
trashBtn.className = "icon-btn danger";
trashBtn.innerHTML = ICONS.trash;
trashBtn.setAttribute("aria-label", s.delete_item);
trashBtn.onclick = () => {
// Remove from the list WITHOUT adding to the inventory.
this.data.shopping_list = shopping.removeItems(this.data.shopping_list, [
{ name: item.name, unit: item.unit },
]);
this.commit();
};
card.append(boughtBtn, trashBtn);
main.appendChild(card);
}
const clear = document.createElement("button");
clear.className = "btn block";
clear.textContent = s.clear_list;
clear.onclick = async () => {
if (await this.confirm(s.confirm_clear)) {
this.data.shopping_list = [];
this.commit();
}
};
main.appendChild(clear);
}
/** Which planned meals need this shopping item, and on which day/date. */
private openShoppingUsage(name: string, unit: string): void {
const s = this.s;
const body = document.createElement("div");
const uses: string[] = [];
for (const day of this.data.meal_plan?.days ?? []) {
for (const meal of day.meals) {
for (const ing of meal.ingredients) {
if (ing.name === name && ing.unit === unit) {
const dayLabel = s.day_names[day.day] ?? day.day;
uses.push(
`<div class="usage-row"><div class="usage-when">${escapeHtml(dayLabel)} · ${escapeHtml(
formatDate(day.date, this.settings.language),
)} · ${escapeHtml(mealLabel(s, meal.meal))}</div><div class="usage-meal">${escapeHtml(meal.name)} <span class="item-sub">(${escapeHtml(ing.display_quantity)})</span></div></div>`,
);
}
}
}
}
body.innerHTML = uses.length
? `<div class="usage-title">${s.shopping_used_in}:</div>${uses.join("")}`
: `<p class="hint">${s.shopping_no_usage}</p>`;
this.showModal({
title: planner.displayName(name),
body,
primaryLabel: s.confirm_yes,
onPrimary: () => {
/* informational */
},
});
}
// ---------- Settings ----------
private renderSettings(main: HTMLElement): void {
const s = this.s;
main.insertAdjacentHTML("beforeend", `<h1>${s.settings_title}</h1>`);
// Language
main.insertAdjacentHTML("beforeend", `<h2>${s.language_label}</h2>`);
const langCard = document.createElement("div");
langCard.className = "card lang-toggle";
for (const lang of ["es", "en"] as Lang[]) {
const btn = document.createElement("button");
btn.className = `btn ${this.settings.language === lang ? "active" : ""}`;
btn.textContent = lang === "es" ? "Español" : "English";
btn.onclick = () => {
this.settings.language = lang;
saveSettings(this.settings);
this.render();
};
langCard.appendChild(btn);
}
main.appendChild(langCard);
// Family
main.insertAdjacentHTML("beforeend", `<h2>${s.family_title}</h2>`);
const family = document.createElement("form");
family.className = "card";
family.innerHTML = `
<div class="form-grid">
<div>
<label>${s.adults}</label>
<input name="adults" type="number" min="0" step="1" value="${this.data.family_profile.adults ?? ""}" />
</div>
<div>
<label>${s.children}</label>
<input name="children" type="number" min="0" step="1" value="${this.data.family_profile.children ?? ""}" />
</div>
</div>
<button class="btn primary block" type="submit">${s.save}</button>`;
family.onsubmit = (event) => {
event.preventDefault();
const fd = new FormData(family);
const adults = String(fd.get("adults") ?? "");
const children = String(fd.get("children") ?? "");
this.data.family_profile.adults = adults === "" ? null : Math.max(0, Math.trunc(Number(adults)));
this.data.family_profile.children = children === "" ? null : Math.max(0, Math.trunc(Number(children)));
this.commit();
this.tab = "settings";
};
main.appendChild(family);
// Data export / import
main.insertAdjacentHTML("beforeend", `<h2>${s.data_title}</h2>`);
const dataCard = document.createElement("div");
dataCard.className = "card";
const exportBtn = document.createElement("button");
exportBtn.className = "btn block";
exportBtn.textContent = s.export_data;
exportBtn.onclick = () => exportToFile(this.data);
const importBtn = document.createElement("button");
importBtn.className = "btn block";
importBtn.textContent = s.import_data;
importBtn.onclick = async () => {
try {
this.data = await importFromFile();
this.commit();
this.toast(s.import_ok);
} catch {
this.toast(s.import_err);
}
};
dataCard.append(exportBtn, importBtn);
main.appendChild(dataCard);
// HF sync
main.insertAdjacentHTML("beforeend", `<h2>${s.hf_sync_title}</h2>`);
const hfCard = document.createElement("div");
hfCard.className = "card";
hfCard.insertAdjacentHTML("beforeend", `<p class="hint">${s.hf_sync_hint}</p>`);
const tokenLabel = document.createElement("label");
tokenLabel.textContent = s.hf_token_label;
const tokenInput = document.createElement("input");
tokenInput.type = "password";
tokenInput.placeholder = "hf_…";
tokenInput.value = this.settings.hfToken ?? "";
tokenInput.oninput = () => {
this.settings.hfToken = tokenInput.value.trim() || null;
saveSettings(this.settings);
};
// Save / Load side by side.
const syncRow = document.createElement("div");
syncRow.className = "sync-row";
const saveCloud = document.createElement("button");
saveCloud.className = "btn primary";
saveCloud.textContent = s.hf_save_to_cloud;
saveCloud.onclick = async () => {
const token = this.settings.hfToken;
if (!token) return this.toast(s.hf_sync_err);
const sum = summarizeData(this.data);
const summary = s.data_summary(sum.inventory, sum.hasPlan, sum.shopping);
if (!(await this.confirm(s.hf_save_confirm(summary)))) return;
await this.pushToCloud(false);
void this.gestures.happyDance();
};
const loadCloud = document.createElement("button");
loadCloud.className = "btn";
loadCloud.textContent = s.hf_load_from_cloud;
loadCloud.onclick = async () => {
const token = this.settings.hfToken;
if (!token) return this.toast(s.hf_sync_err);
try {
const remote = await downloadData(token);
if (!remote) return this.toast(s.hf_sync_err);
const sum = summarizeData(remote);
const summary = s.data_summary(sum.inventory, sum.hasPlan, sum.shopping);
if (!(await this.confirm(s.hf_load_confirm(summary)))) return;
this.data = remote;
// Bring over synced preferences too (robot name, voice, activation…).
const rs = await downloadSettings(token);
if (rs) {
this.settings = applySyncableSettings(this.settings, rs);
saveSettings(this.settings);
}
this.commit();
this.toast(s.import_ok);
} catch (err) {
console.error("[cookaiware] HF download failed", err);
const msg = err instanceof Error ? err.message : String(err);
this.toast(`${s.hf_sync_err}${msg.slice(0, 140)}`);
}
};
syncRow.append(saveCloud, loadCloud);
// Auto-sync toggle.
const autoRow = document.createElement("label");
autoRow.className = "switch-row";
autoRow.innerHTML = `<span><strong>${s.hf_autosync}</strong><br><span class="hint">${s.hf_autosync_hint}</span></span>`;
const autoInput = document.createElement("input");
autoInput.type = "checkbox";
autoInput.className = "switch";
autoInput.checked = this.settings.hfDatasetSync;
autoInput.onchange = async () => {
if (!autoInput.checked) {
this.settings.hfDatasetSync = false;
saveSettings(this.settings);
return;
}
const token = this.settings.hfToken;
if (!token) {
autoInput.checked = false;
return this.toast(s.hf_sync_err);
}
// Before the first auto-save, reconcile with whatever is already in the
// cloud so enabling the toggle can never silently overwrite it.
try {
const remote = await downloadData(token);
if (remote) {
const rc = summarizeData(remote);
const lc = summarizeData(this.data);
const differs = JSON.stringify(remote) !== JSON.stringify(this.data);
if (differs && (rc.inventory > 0 || rc.hasPlan || rc.shopping > 0)) {
const pick = await this.choose(
s.hf_choose_title,
s.hf_choose_body(
s.data_summary(rc.inventory, rc.hasPlan, rc.shopping),
s.data_summary(lc.inventory, lc.hasPlan, lc.shopping),
),
s.hf_keep_cloud,
s.hf_keep_device,
);
if (pick === null) {
autoInput.checked = false;
return;
}
if (pick === "a") {
this.data = remote;
saveData(this.data);
const rs = await downloadSettings(token);
if (rs) {
this.settings = applySyncableSettings(this.settings, rs);
saveSettings(this.settings);
}
}
}
}
} catch (err) {
console.warn("[cookaiware] auto-sync pre-check failed", err);
}
this.settings.hfDatasetSync = true;
saveSettings(this.settings);
await this.pushToCloud(false);
this.render();
};
autoRow.appendChild(autoInput);
const lastSynced = document.createElement("p");
lastSynced.className = "hint sync-status";
lastSynced.id = "hf-last-synced";
lastSynced.textContent = this.settings.lastSyncedAt
? s.hf_last_synced(relativeTime(this.settings.lastSyncedAt, this.settings.language))
: s.hf_never_synced;
hfCard.append(tokenLabel, tokenInput, syncRow, autoRow, lastSynced);
main.appendChild(hfCard);
// Voice
main.insertAdjacentHTML("beforeend", `<h2>${s.voice_title}</h2>`);
const voiceCard = document.createElement("div");
voiceCard.className = "card";
const keyLabel = document.createElement("label");
keyLabel.textContent = s.voice_key_label;
const keyInput = document.createElement("input");
keyInput.type = "password";
keyInput.placeholder = "sk-…";
keyInput.autocomplete = "off";
keyInput.value = this.settings.openaiKey ?? "";
// Save on every input (incl. paste) so it persists even if the user taps
// "Talk to Reachy" before the field loses focus.
keyInput.oninput = () => {
this.settings.openaiKey = keyInput.value.trim() || null;
saveSettings(this.settings);
};
voiceCard.append(keyLabel, keyInput);
voiceCard.insertAdjacentHTML("beforeend", `<p class="hint">${s.voice_key_hint}</p>`);
const nameLabel = document.createElement("label");
nameLabel.textContent = s.voice_name_label;
const nameInput = document.createElement("input");
nameInput.type = "text";
nameInput.autocomplete = "off";
nameInput.value = this.settings.robotName;
nameInput.oninput = () => {
this.settings.robotName = nameInput.value.trim() || "Reachy";
saveSettings(this.settings);
};
voiceCard.append(nameLabel, nameInput);
voiceCard.insertAdjacentHTML("beforeend", `<p class="hint">${s.voice_name_hint}</p>`);
// Robot voice picker (OpenAI gpt-realtime voices).
const voiceLabel = document.createElement("label");
voiceLabel.textContent = s.voice_voice_label;
const voiceSelect = document.createElement("select");
for (const v of REALTIME_VOICES) {
const opt = document.createElement("option");
opt.value = v;
opt.textContent = v.charAt(0).toUpperCase() + v.slice(1);
opt.selected = this.settings.voice === v;
voiceSelect.appendChild(opt);
}
voiceSelect.onchange = () => {
this.settings.voice = voiceSelect.value;
saveSettings(this.settings);
};
voiceCard.append(voiceLabel, voiceSelect);
voiceCard.insertAdjacentHTML("beforeend", `<p class="hint">${s.voice_voice_hint}</p>`);
// Proactivity: respond to anything vs only when addressed by name.
const actLabel = document.createElement("label");
actLabel.textContent = s.voice_activation_label;
const actSeg = document.createElement("div");
actSeg.className = "segmented";
for (const [mode, label] of [
["always", s.voice_activation_always],
["name", s.voice_activation_name],
] as const) {
const b = document.createElement("button");
b.className = this.settings.activationMode === mode ? "active" : "";
b.textContent = label;
b.onclick = () => {
this.settings.activationMode = mode;
saveSettings(this.settings);
this.render();
};
actSeg.appendChild(b);
}
voiceCard.append(actLabel, actSeg);
voiceCard.insertAdjacentHTML("beforeend", `<p class="hint">${s.voice_activation_hint}</p>`);
// Barge-in toggle.
const intRow = document.createElement("label");
intRow.className = "switch-row";
intRow.innerHTML = `<span><strong>${s.voice_interrupt_label}</strong><br><span class="hint">${s.voice_interrupt_hint}</span></span>`;
const intInput = document.createElement("input");
intInput.type = "checkbox";
intInput.className = "switch";
intInput.checked = this.settings.allowInterrupt;
intInput.onchange = () => {
this.settings.allowInterrupt = intInput.checked;
saveSettings(this.settings);
};
intRow.appendChild(intInput);
voiceCard.appendChild(intRow);
main.appendChild(voiceCard);
main.insertAdjacentHTML(
"beforeend",
`<p class="hint about-line">CookAIware · ${__BUILD__} · <a href="https://huggingface.co/spaces/jimenezcarrero/cookAIware" target="_blank" rel="noopener">Hugging Face</a></p>`,
);
}
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
/** "just now" / "5 min ago" / a local date for older timestamps. */
function relativeTime(iso: string, lang: Lang): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return iso;
const secs = Math.round((Date.now() - then) / 1000);
const es = lang === "es";
if (secs < 60) return es ? "hace un momento" : "just now";
const mins = Math.round(secs / 60);
if (mins < 60) return es ? `hace ${mins} min` : `${mins} min ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return es ? `hace ${hrs} h` : `${hrs} h ago`;
return new Date(iso).toLocaleDateString(es ? "es-ES" : "en-US");
}
/** A locale-formatted expiration date. */
function formatDate(iso: string, lang: Lang): string {
const d = new Date(iso + "T00:00:00");
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString(lang === "es" ? "es-ES" : "en-US", { day: "numeric", month: "short" });
}