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) => ``; const ICONS = { inventory: svg( '', ), plan: svg( '', ), shopping: svg( '', ), settings: svg( '', ), mic: svg( '', ), stop: svg(''), speaker: svg( '', ), spinner: svg(''), edit: svg(''), trash: svg(''), use: svg(''), drag: svg(''), clock: svg(''), pin: svg(''), lock: svg(''), unlock: svg(''), refresh: svg(''), undo: svg(''), redo: svg(''), }; 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 | 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 { const s = this.s; const backdrop = document.createElement("div"); backdrop.className = "modal-backdrop"; const card = document.createElement("div"); card.className = "modal-card"; card.innerHTML = ``; 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("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 = ` `; document.body.appendChild(backdrop); const done = (v: "a" | "b" | null) => { backdrop.remove(); resolve(v); }; backdrop.querySelector("[data-a]")!.onclick = () => done("a"); backdrop.querySelector("[data-b]")!.onclick = () => done("b"); backdrop.onclick = (e) => { if (e.target === backdrop) done(null); }; }); } private confirm(message: string): Promise { const s = this.s; return new Promise((resolve) => { const backdrop = document.createElement("div"); backdrop.className = "modal-backdrop"; backdrop.innerHTML = ` `; document.body.appendChild(backdrop); const done = (v: boolean) => { backdrop.remove(); resolve(v); }; backdrop.querySelector("[data-no]")!.onclick = () => done(false); backdrop.querySelector("[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 { 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("#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(".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 = `${icon}${label}`; 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("#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 ? `
${s.voice_transcript_empty}
` : this.transcript .map((m) => { const inner = m.image ? `Reachy's photo` : escapeHtml(m.text ?? ""); return `
${m.role}
${inner}
`; }) .join(""); const toggleIcon = this.transcriptMinimized ? svg('') // chevron up = expand : svg(''); // chevron down = minimize panel.innerHTML = `
${s.voice_transcript_title}${this.transcriptMinimized && this.transcript.length ? ` · ${this.transcript.length}` : ""}
${rows}
`; const toggle = () => { this.transcriptMinimized = !this.transcriptMinimized; this.updateTranscript(); }; panel.querySelector("#vt-min")!.onclick = toggle; // Tapping the header (not the buttons) also toggles when minimized. panel.querySelector("#vt-head")!.onclick = (e) => { if ((e.target as HTMLElement).closest("button")) return; if (this.transcriptMinimized) toggle(); }; panel.querySelector("#vt-clear")!.onclick = (e) => { e.stopPropagation(); this.transcript = []; this.updateTranscript(); }; const body = panel.querySelector("#vt-body"); if (body) body.scrollTop = body.scrollHeight; } private updateVoiceButton(): void { const fab = this.root.querySelector("#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}${label}`; } // ---------- 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 = `
${s.welcome_title}

${s.welcome_body}

🎙️ ${s.welcome_voice_hint}

`; 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", `

${s.inventory_title}

`); // Sort control. const sortBar = document.createElement("div"); sortBar.className = "sort-bar"; sortBar.innerHTML = `${s.sort_label}`; 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}${s.filter_location(this.locationFilter)}`; banner.onclick = () => { this.locationFilter = null; this.render(); }; main.appendChild(banner); } const items = this.orderedInventory(); if (items.length === 0) { main.insertAdjacentHTML("beforeend", `
${s.inventory_empty}
`); } if (this.settings.inventorySort === "custom" && items.length > 1 && !this.locationFilter) { main.insertAdjacentHTML("beforeend", `

${ICONS.drag} ${s.drag_hint}

`); } 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", `

${s.add_item}

`); 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 = `${s.expired}`; else if (days !== null && days <= EXPIRY_SOON_DAYS) chip = `${s.expires_in_days(days)}`; const expText = item.expiration_date ? `${ICONS.clock}${s.expires_on(formatDate(item.expiration_date, this.settings.language))}` : `${ICONS.clock}${s.no_expiration}`; card.innerHTML = ` ${draggable ? `${ICONS.drag}` : ""}
${escapeHtml(item.display_name)}${chip}
${escapeHtml(inv.displayQuantity(item))} ${expText} ${item.storage_location ? `` : ""}
`; card.querySelector(".use")!.onclick = () => this.openUse(item); card.querySelector(".edit")!.onclick = () => this.openEdit(item); card.querySelector(".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(".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(".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(".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(".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) => ``) .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 = `
${locOptions}
${existing ? "" : ``}`; // Existing locations as tappable tags — one tap reuses the exact casing, // so "Nevera"/"nevera" never fork into two labels. const locInput = form.querySelector('input[name="location"]')!; const sug = form.querySelector(".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 = ` `; const input = body.querySelector("input")!; body.querySelector("[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", `

${s.plan_title}

`); // 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 = `

${s.plan_need_family}

`; 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", `

${s.plan_who_title}

`); main.appendChild(this.renderMatrixEditor()); main.insertAdjacentHTML("beforeend", `

${s.plan_who_hint}

`); // 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", `
${s.plan_empty}
`); return; } // Week header with lock-all / unlock-all bulk controls. const weekHead = document.createElement("div"); weekHead.className = "week-head"; weekHead.innerHTML = `

${s.plan_week_of} ${escapeHtml(plan.week_start)}

`; 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}${s.lock_all}`; lockAll.onclick = () => setAllLocks(true); const unlockAll = document.createElement("button"); unlockAll.className = "btn small"; unlockAll.innerHTML = `${ICONS.unlock}${s.unlock_all}`; 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}${s.plan_undo}`; 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}${s.plan_redo}`; 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 = `
${escapeHtml(dayLabel)} ${escapeHtml(day.date)}
`; 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 = `
${escapeHtml(mealLabel(s, meal.meal))} · ${s.servings(meal.servings)}
${escapeHtml(mealName)}
${ingredients}
`; row.querySelector(".m-lock")!.onclick = () => { meal.locked = !meal.locked; this.commit(); }; row.querySelector(".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 = `
`; const inputA = body.querySelector('input[name="a"]')!; const inputC = body.querySelector('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("[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 { const locked = new Map(); 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(".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(`.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 { 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 { 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", `

${s.shopping_title}

`); 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", `
${s.shopping_empty}
`); 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 = `
${escapeHtml(planner.displayName(item.name))}
${escapeHtml(formatQuantity(item.quantity, item.unit))}${detail ? " · " + escapeHtml(detail) : ""}
`; // Tap the item to see which planned meals need it, and when. card.querySelector(".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( `
${escapeHtml(dayLabel)} · ${escapeHtml( formatDate(day.date, this.settings.language), )} · ${escapeHtml(mealLabel(s, meal.meal))}
${escapeHtml(meal.name)} (${escapeHtml(ing.display_quantity)})
`, ); } } } } body.innerHTML = uses.length ? `
${s.shopping_used_in}:
${uses.join("")}` : `

${s.shopping_no_usage}

`; 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", `

${s.settings_title}

`); // Language main.insertAdjacentHTML("beforeend", `

${s.language_label}

`); 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", `

${s.family_title}

`); const family = document.createElement("form"); family.className = "card"; family.innerHTML = `
`; 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", `

${s.data_title}

`); 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", `

${s.hf_sync_title}

`); const hfCard = document.createElement("div"); hfCard.className = "card"; hfCard.insertAdjacentHTML("beforeend", `

${s.hf_sync_hint}

`); 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 = `${s.hf_autosync}
${s.hf_autosync_hint}
`; 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", `

${s.voice_title}

`); 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", `

${s.voice_key_hint}

`); 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", `

${s.voice_name_hint}

`); // 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", `

${s.voice_voice_hint}

`); // 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", `

${s.voice_activation_hint}

`); // Barge-in toggle. const intRow = document.createElement("label"); intRow.className = "switch-row"; intRow.innerHTML = `${s.voice_interrupt_label}
${s.voice_interrupt_hint}
`; 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", `

CookAIware · ${__BUILD__} · Hugging Face

`, ); } } function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } /** "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" }); }