Spaces:
Running
Running
| 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("&", "&") | |
| .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" }); | |
| } | |