/* LifeOS frontend — vanilla JS, raw fetch to Gradio Server SSE API. No deps. */ "use strict"; const $ = (sel) => document.querySelector(sel); const $$ = (sel) => Array.from(document.querySelectorAll(sel)); /* ---------------- status indicator ---------------- */ let busyCount = 0; let modelState = "loading"; // loading | ready | error (from /status) function renderStatus() { const dot = $("#status-dot"); const text = $("#status-text"); if (!dot || !text) return; const thinking = busyCount > 0; dot.classList.toggle("thinking", thinking); dot.classList.toggle("error", modelState === "error" && !thinking); if (thinking) text.textContent = "model: thinking"; else if (modelState === "loading") text.textContent = "model: loading…"; else if (modelState === "error") text.textContent = "model: unavailable"; else text.textContent = "model: ready"; } function setThinking(on) { busyCount += on ? 1 : -1; if (busyCount < 0) busyCount = 0; renderStatus(); } /* Poll /status until the model is ready (first run also downloads it). */ async function pollModelStatus() { try { const res = await fetch("/status"); if (res.ok) { const s = await res.json(); modelState = s.state === "ready" ? "ready" : s.state === "error" ? "error" : "loading"; renderStatus(); if (modelState === "ready" || modelState === "error") return; } } catch { /* server not up yet — keep polling */ } setTimeout(pollModelStatus, 2000); } /* ---------------- toasts ---------------- */ function toast(msg, ok = false) { const el = document.createElement("div"); el.className = "toast" + (ok ? " ok" : ""); el.textContent = msg; $("#toast-stack").appendChild(el); setTimeout(() => el.remove(), 4200); } /* ---------------- button busy state ---------------- */ function setBusy(btn, busy) { btn.disabled = busy; if (busy) { const sp = document.createElement("span"); sp.className = "spinner"; btn.prepend(sp); } else { const sp = btn.querySelector(".spinner"); if (sp) sp.remove(); } } /* ---------------- Gradio SSE helper ---------------- POST /gradio_api/call/ {data:[...]} -> {event_id} GET /gradio_api/call// -> SSE stream onUpdate(cumulativeText) called on each "generating"/"complete" event. Resolves with the final output[0]. Throws Error with .status on HTTP fail. */ async function callGradio(name, args, onUpdate) { const post = await fetch(`/gradio_api/call/${name}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: args }), }); if (!post.ok) { const err = new Error(`${name}: HTTP ${post.status}`); err.status = post.status; throw err; } const { event_id } = await post.json(); const res = await fetch(`/gradio_api/call/${name}/${event_id}`); if (!res.ok || !res.body) throw new Error(`${name}: stream HTTP ${res.status}`); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = "", eventType = "", final = null; for (;;) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); let nl; while ((nl = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, nl).replace(/\r$/, ""); buf = buf.slice(nl + 1); if (line.startsWith("event:")) { eventType = line.slice(6).trim(); } else if (line.startsWith("data:")) { const payload = line.slice(5).trim(); if (!payload || payload === "null") continue; if (eventType === "error") throw new Error(`${name}: ${payload}`); let arr; try { arr = JSON.parse(payload); } catch { continue; } if (!Array.isArray(arr)) continue; if (eventType === "generating" || eventType === "complete") { final = arr[0]; if (onUpdate) onUpdate(arr[0]); } if (eventType === "complete") return final; } } } if (final === null) throw new Error(`${name}: stream ended without output`); return final; } /* Friendly handling of endpoints the backend may not ship yet. */ function isMissing(e) { return e && (e.status === 404 || e.status === 422 || /HTTP 404/.test(e.message || "")); } function featureUnavailable(el, label) { if (el) el.innerHTML = `
${escapeHtml(label)} isn't available on this backend build yet — try again after the next update.
`; else toast(`${label}: not available on this backend yet`); } /* ---------------- tiny markdown renderer (XSS-safe) ---------------- */ function escapeHtml(s) { return String(s).replace(/&/g, "&").replace(//g, ">") .replace(/"/g, """).replace(/'/g, "'"); } function mdInline(s) { return s .replace(/`([^`]+)`/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2"); } function renderMarkdown(raw) { const lines = escapeHtml(raw).split("\n"); let html = "", i = 0, list = null, para = []; const flushPara = () => { if (para.length) { html += `

${mdInline(para.join(" "))}

`; para = []; } }; const closeList = () => { if (list) { html += ``; list = null; } }; while (i < lines.length) { const line = lines[i]; const t = line.trim(); // fenced code block if (t.startsWith("```")) { flushPara(); closeList(); const code = []; i++; while (i < lines.length && !lines[i].trim().startsWith("```")) { code.push(lines[i]); i++; } html += `
${code.join("\n")}
`; i++; // skip closing fence continue; } // table: header row + separator row if (t.startsWith("|") && i + 1 < lines.length && /^\|?[\s:|-]+\|?$/.test(lines[i + 1].trim()) && lines[i + 1].includes("-")) { flushPara(); closeList(); const cells = (r) => r.trim().replace(/^\||\|$/g, "").split("|").map((c) => mdInline(c.trim())); html += "" + cells(t).map((c) => ``).join("") + ""; i += 2; while (i < lines.length && lines[i].trim().startsWith("|")) { html += "" + cells(lines[i]).map((c) => ``).join("") + ""; i++; } html += "
${c}
${c}
"; continue; } let m; if ((m = t.match(/^>\s?(.*)/))) { flushPara(); closeList(); const quote = [m[1]]; while (i + 1 < lines.length && (m = lines[i + 1].trim().match(/^>\s?(.*)/))) { quote.push(m[1]); i++; } html += `
${mdInline(quote.join(" "))}
`; } else if ((m = t.match(/^(#{1,3})\s+(.*)/))) { flushPara(); closeList(); html += `${mdInline(m[2])}`; } else if ((m = t.match(/^\*\*([^*]+?)\*\*:?\s*$/))) { // A line that's only a bold span (e.g. "**Identified**") is a section // title — the model's usual way of heading sections. Render as a heading. flushPara(); closeList(); html += `

${mdInline(m[1])}

`; } else if ((m = t.match(/^[-*]\s+(.*)/))) { flushPara(); if (list !== "ul") { closeList(); html += "
    "; list = "ul"; } html += `
  • ${mdInline(m[1])}
  • `; } else if ((m = t.match(/^\d+[.)]\s+(.*)/))) { flushPara(); if (list !== "ol") { closeList(); html += "
      "; list = "ol"; } html += `
    1. ${mdInline(m[1])}
    2. `; } else if (/^([-_*])\s*\1\s*\1[\s\-_*]*$/.test(t)) { flushPara(); closeList(); html += "
      "; } else if (t === "") { flushPara(); closeList(); } else { closeList(); para.push(t); } i++; } flushPara(); closeList(); return html; } /* Strip + markdown syntax for speech. */ function stripForSpeech(raw) { let text = raw.replace(/[\s\S]*?(<\/think>|$)/g, ""); return text .replace(/```[\s\S]*?```/g, "") .replace(/`([^`]+)`/g, "$1") .replace(/\*\*?([^*]+)\*\*?/g, "$1") .replace(/^#{1,6}\s+/gm, "") .replace(/^\s*[-*]\s+/gm, "") .replace(/\|/g, " ") .replace(/\s+/g, " ") .trim(); } /* Handle ... before MD: fold into a collapsed details block. */ function renderResponse(raw) { let html = ""; let text = raw; const open = text.indexOf(""); if (open >= 0) { const close = text.indexOf(""); const inner = close >= 0 ? text.slice(open + 7, close) : text.slice(open + 7); text = close >= 0 ? text.slice(0, open) + text.slice(close + 8) : text.slice(0, open); html += `
      reasoning

      ${escapeHtml(inner.trim()).replace(/\n/g, "
      ")}

      `; } return html + renderMarkdown(text.trim()); } /* Stream a generator endpoint into a result element. */ async function streamInto(name, args, el, btn, label) { setBusy(btn, true); setThinking(true); el.classList.add("streaming"); el.innerHTML = "

      thinking...

      "; try { await callGradio(name, args, (text) => { if (typeof text === "string") el.innerHTML = renderResponse(text); }); } catch (e) { if (isMissing(e)) featureUnavailable(el, label || name); else { toast(`error: ${e.message}`); el.innerHTML = "

      something went wrong — is the backend running?

      "; } } finally { el.classList.remove("streaming"); setBusy(btn, false); setThinking(false); } } /* ---------------- sidebar navigation ---------------- */ function navTo(name) { $$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.nav === name)); $$(".panel").forEach((p) => p.classList.toggle("active", p.id === `panel-${name}`)); if (name === "memory") { renderMemRows(); loadNotes(); } } document.querySelector(".sidebar").addEventListener("click", (e) => { const item = e.target.closest(".nav-item"); if (item) navTo(item.dataset.nav); }); $("#money-to-goals").addEventListener("click", () => navTo("goals")); /* ================= KITCHEN ================= */ let deals = []; function renderDeals() { const wrap = $("#deal-chips"); if (!deals.length) { wrap.innerHTML = "

      no deals yet — upload a flyer or paste its text

      "; } else { wrap.innerHTML = deals.map((d) => `${escapeHtml(d.item)} ${escapeHtml(d.price_text)}`).join(""); } $("#recipe-btn").disabled = !deals.length; } async function uploadFile(url, file) { const fd = new FormData(); fd.append("file", file); const res = await fetch(url, { method: "POST", body: fd }); if (!res.ok) { const err = new Error(`upload failed: HTTP ${res.status}`); err.status = res.status; throw err; } return res.json(); } function wireDropzone(dropEl, inputEl, handler) { dropEl.addEventListener("click", () => inputEl.click()); inputEl.addEventListener("change", () => { if (inputEl.files[0]) handler(inputEl.files[0]); inputEl.value = ""; }); dropEl.addEventListener("dragover", (e) => { e.preventDefault(); dropEl.classList.add("dragover"); }); dropEl.addEventListener("dragleave", () => dropEl.classList.remove("dragover")); dropEl.addEventListener("drop", (e) => { e.preventDefault(); dropEl.classList.remove("dragover"); if (e.dataTransfer.files[0]) handler(e.dataTransfer.files[0]); }); } wireDropzone($("#flyer-drop"), $("#flyer-file"), async (file) => { setThinking(true); try { const out = await uploadFile("/upload/flyer", file); deals = out.deals || []; renderDeals(); toast(`extracted ${deals.length} deals`, true); } catch (e) { toast(`flyer upload: ${e.message}`); } finally { setThinking(false); } }); $("#flyer-text-btn").addEventListener("click", async () => { const text = $("#flyer-text").value.trim(); if (!text) { toast("paste some flyer text first"); return; } const btn = $("#flyer-text-btn"); setBusy(btn, true); setThinking(true); try { const res = await fetch("/upload/flyer_text", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const out = await res.json(); deals = out.deals || []; renderDeals(); toast(`extracted ${deals.length} deals`, true); } catch (e) { toast(`text extract: ${e.message}`); } finally { setBusy(btn, false); setThinking(false); } }); $("#recipe-btn").addEventListener("click", () => { streamInto("food_recommend", [JSON.stringify(deals)], $("#food-result"), $("#recipe-btn"), "recipe picks"); }); /* ---- meal photo ---- */ let mealOcrText = ""; wireDropzone($("#meal-drop"), $("#meal-file"), async (file) => { setThinking(true); $("#meal-ocr-wrap").hidden = true; $("#meal-result").innerHTML = "

      🔍 identifying food items on your machine — hang tight, this runs fully offline…

      "; try { const out = await uploadFile("/upload/meal_photo", file); if (out.error) { $("#meal-ocr-wrap").hidden = true; $("#meal-result").innerHTML = `
      ${escapeHtml(out.error)}
      `; return; } mealOcrText = out.text || ""; if (!mealOcrText.trim()) { $("#meal-result").innerHTML = "
      Couldn't make out any food items in that photo — try a clearer, well-lit shot.
      "; $("#meal-ocr-wrap").hidden = true; return; } const fromVision = out.source !== "ocr"; $("#meal-ocr-title").textContent = fromVision ? "identified items" : "text read from photo"; // The vision model returns a markdown bullet list; render it formatted. $("#meal-ocr").innerHTML = fromVision ? renderMarkdown(mealOcrText) : escapeHtml(mealOcrText.slice(0, 600) + (mealOcrText.length > 600 ? " ..." : "")); $("#meal-ocr-wrap").hidden = false; $("#meal-result").innerHTML = "

      pick a label and hit Analyze

      "; toast(fromVision ? "food items identified" : "photo text read", true); } catch (e) { if (isMissing(e)) featureUnavailable($("#meal-result"), "meal photo analysis"); else toast(`meal photo: ${e.message}`); } finally { setThinking(false); } }); $("#meal-analyze-btn").addEventListener("click", () => { if (!mealOcrText.trim()) { toast("upload a meal photo first"); return; } streamInto("meal_analyze", [mealOcrText, $("#meal-label").value], $("#meal-result"), $("#meal-analyze-btn"), "meal analysis"); }); /* ================= HEALTH ================= */ const TYPE_ABBR = { run: "RUN", "push (chest/triceps)": "PSH", "pull (back/biceps)": "PLL", legs: "LEG", cycling: "CYC", swim: "SWM", yoga: "YOG", other: "OTH", }; const isoDate = (d) => { const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; }; const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; function renderWeekStrip(mem) { const strip = $("#week-strip"); const days = []; const today = new Date(); for (let i = 6; i >= 0; i--) { const d = new Date(today); d.setDate(today.getDate() - i); days.push(d); } const byDate = {}; (mem.workouts || []).forEach((w) => { byDate[w.date] = w; }); strip.innerHTML = days.map((d, idx) => { const w = byDate[isoDate(d)]; const isToday = idx === 6; const abbr = w ? (TYPE_ABBR[w.type] || w.type.slice(0, 3).toUpperCase()) : "·"; return `
      ${DAY_NAMES[d.getDay()]}${abbr}${w ? `${w.duration_min}m` : ""}
      `; }).join(""); } const WORKOUT_LOG_RECENT = 7; let showAllWorkouts = false; let allWorkouts = []; function renderWorkoutLog(mem) { const box = $("#workout-log"); if (!box) return; allWorkouts = (mem.workouts || []).slice().sort((a, b) => (a.date < b.date ? 1 : -1)); paintWorkoutLog(); } function paintWorkoutLog() { const box = $("#workout-log"); const btn = $("#workout-history-btn"); if (!allWorkouts.length) { box.innerHTML = "

      no workouts logged yet

      "; if (btn) btn.hidden = true; return; } const shown = showAllWorkouts ? allWorkouts : allWorkouts.slice(0, WORKOUT_LOG_RECENT); box.classList.toggle("scroll", showAllWorkouts); const header = `
      date workout duration
      `; box.innerHTML = header + shown.map((w) => `
      ${escapeHtml(w.date)} ${escapeHtml(w.type)} ${w.duration_min} min
      `).join(""); if (btn) { if (allWorkouts.length > WORKOUT_LOG_RECENT) { btn.hidden = false; btn.textContent = showAllWorkouts ? "show recent only" : `view full history (${allWorkouts.length})`; } else { btn.hidden = true; } } } $("#workout-history-btn").addEventListener("click", () => { showAllWorkouts = !showAllWorkouts; paintWorkoutLog(); }); /* ---- schedule editor ---- */ $("#sched-days").addEventListener("click", (e) => { const chip = e.target.closest(".day-chip"); if (chip) chip.classList.toggle("on"); }); function selectedDays() { return $$("#sched-days .day-chip.on").map((c) => c.dataset.day); } function renderSchedule(mem) { const ws = (mem && mem.workout_schedule) || {}; const days = ws.days || []; $$("#sched-days .day-chip").forEach((c) => c.classList.toggle("on", days.includes(c.dataset.day))); if (ws.time) $("#sched-time").value = ws.time; $("#sched-hint").textContent = days.length ? `scheduled: ${days.join(", ")} at ${ws.time || "07:00"}` : "pick training days and a time"; } $("#sched-save-btn").addEventListener("click", async () => { const btn = $("#sched-save-btn"); const days = selectedDays(); if (!days.length) { toast("pick at least one day"); return; } setBusy(btn, true); setThinking(true); try { await callGradio("set_schedule", [JSON.stringify(days), $("#sched-time").value || "07:00"]); toast("schedule saved", true); refreshMemoryViews(); } catch (e) { if (isMissing(e)) featureUnavailable(null, "workout schedule"); else toast(`schedule: ${e.message}`); } finally { setBusy(btn, false); setThinking(false); } }); /* ---- browser reminder ---- */ function nextWorkoutSlot() { const days = selectedDays(); const time = $("#sched-time").value || "07:00"; if (!days.length) return null; const [hh, mm] = time.split(":").map(Number); const dayIdx = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }; const now = new Date(); for (let i = 0; i < 8; i++) { const d = new Date(now); d.setDate(now.getDate() + i); d.setHours(hh, mm, 0, 0); const key = Object.keys(dayIdx).find((k) => dayIdx[k] === d.getDay()); if (days.includes(key) && d > now) return d; } return null; } let reminderTimer = null; $("#remind-btn").addEventListener("click", async () => { if (!("Notification" in window)) { toast("notifications not supported in this browser"); return; } const slot = nextWorkoutSlot(); if (!slot) { toast("save a schedule first — no upcoming slot found"); return; } let perm = Notification.permission; if (perm === "default") perm = await Notification.requestPermission(); if (perm !== "granted") { toast("notification permission denied"); return; } if (reminderTimer) clearTimeout(reminderTimer); const ms = slot.getTime() - Date.now(); reminderTimer = setTimeout(() => { new Notification("LifeOS — time to train", { body: `Scheduled workout at ${$("#sched-time").value}. Go get it.` }); }, ms); toast(`reminder set for ${DAY_NAMES[slot.getDay()]} ${slot.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} (while this page stays open)`, true); }); /* ---- calendar ---- */ function next7(mem) { const events = (mem && mem.calendar) || []; const out = []; const today = new Date(); for (let i = 0; i < 7; i++) { const d = new Date(today); d.setDate(today.getDate() + i); const key = isoDate(d); out.push({ date: d, key, events: events.filter((ev) => ev.date === key) }); } return out; } function renderCalendar(mem) { const days = next7(mem); $("#cal-strip").innerHTML = days.map((d, i) => `
      ${DAY_NAMES[d.date.getDay()]}${d.events.length || "·"}
      `).join(""); const all = days.flatMap((d) => d.events.map((ev) => ({ ...ev, _day: DAY_NAMES[d.date.getDay()] }))); const list = $("#event-list"); if (!all.length) { list.innerHTML = "

      no events in the next 7 days

      "; } else { list.innerHTML = all.map((ev) => `
      ${ev._day} ${escapeHtml(ev.start || "")}–${escapeHtml(ev.end || "")} ${escapeHtml(ev.title || "(untitled)")} ${escapeHtml(ev.kind || "other")}
      `).join(""); } } $("#event-form").addEventListener("submit", async (e) => { e.preventDefault(); const btn = $("#ev-add-btn"); let kind = $("#ev-kind").value; if (kind === "other") { const desc = $("#ev-other").value.trim(); if (!desc) { toast("describe what \"other\" is"); return; } kind = desc; } const ev = { title: $("#ev-title").value.trim(), date: $("#ev-date").value, start: $("#ev-start").value, end: $("#ev-end").value, kind, }; if (!ev.title || !ev.date) { toast("event needs a title and a date"); return; } setBusy(btn, true); setThinking(true); try { await callGradio("add_event", [JSON.stringify(ev)]); toast(`added "${ev.title}"`, true); $("#ev-title").value = ""; $("#ev-kind").value = "appointment"; $("#ev-other-wrap").hidden = true; $("#ev-other").value = ""; refreshMemoryViews(); } catch (err) { if (isMissing(err)) featureUnavailable(null, "calendar events"); else toast(`add event: ${err.message}`); } finally { setBusy(btn, false); setThinking(false); } }); $("#event-list").addEventListener("click", async (e) => { const btn = e.target.closest(".ev-del"); if (!btn) return; setBusy(btn, true); setThinking(true); try { await callGradio("delete_event", [btn.dataset.id]); toast("event deleted", true); refreshMemoryViews(); } catch (err) { setBusy(btn, false); if (isMissing(err)) featureUnavailable(null, "calendar events"); else toast(`delete event: ${err.message}`); } finally { setThinking(false); } }); /* ---- "other" describe toggles ---- */ function wireOtherDescribe(selectId, wrapId, inputId) { const sel = $(selectId), wrap = $(wrapId), input = $(inputId); if (!sel || !wrap) return; const sync = () => { const on = sel.value === "other"; wrap.hidden = !on; if (on) input.focus(); else input.value = ""; }; sel.addEventListener("change", sync); } wireOtherDescribe("#workout-type", "#workout-other-wrap", "#workout-other"); wireOtherDescribe("#ev-kind", "#ev-other-wrap", "#ev-other"); /* ---- calendar day view (modal) ---- */ const CAL_DAY_START = 6, CAL_DAY_END = 23; // visible hour band const KIND_COLORS = { class: "#6366f1", work: "#0ea5e9", social: "#ec4899", workout: "#22c55e", appointment: "#f59e0b", other: "#94a3b8", }; const toMin = (t) => { const [h, m] = String(t || "0:0").split(":").map(Number); return (h || 0) * 60 + (m || 0); }; function renderCalGrid(mem) { const grid = $("#cal-grid"); if (!grid) return; const days = next7(mem); const hours = []; for (let h = CAL_DAY_START; h <= CAL_DAY_END; h++) hours.push(h); const pxPerMin = 0.9, top0 = CAL_DAY_START * 60; const header = `
      ` + days.map((d) => `
      ${DAY_NAMES[d.date.getDay()]}${d.date.getDate()}
      `).join(""); const gutter = `
      ` + hours.map((h) => `
      ${String(h).padStart(2, "0")}:00
      `).join("") + `
      `; const cols = days.map((d) => { const evs = d.events.map((ev) => { const s = Math.max(toMin(ev.start), top0), e = Math.min(toMin(ev.end || ev.start), CAL_DAY_END * 60 + 60); const top = (s - top0) * pxPerMin, h = Math.max((e - s) * pxPerMin, 18); const c = KIND_COLORS[ev.kind] || KIND_COLORS.other; return `
      ${escapeHtml(ev.title || "(untitled)")} ${escapeHtml(ev.start || "")}–${escapeHtml(ev.end || "")} · ${escapeHtml(ev.kind || "")}
      `; }).join(""); return `
      ${hours.map((h) => `
      `).join("")}${evs}
      `; }).join(""); grid.innerHTML = `
      ${header}
      ${gutter}
      ${cols}
      `; } $("#cal-view-btn").addEventListener("click", () => { if (lastMem) renderCalGrid(lastMem); $("#cal-modal").hidden = false; }); const closeCalModal = () => { $("#cal-modal").hidden = true; }; $("#cal-modal-close").addEventListener("click", closeCalModal); $("#cal-modal").addEventListener("click", (e) => { if (e.target.id === "cal-modal") closeCalModal(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !$("#cal-modal").hidden) closeCalModal(); }); /* ---- workout log + coach ---- */ $("#workout-form").addEventListener("submit", async (e) => { e.preventDefault(); const btn = $("#workout-btn"); let type = $("#workout-type").value; if (type === "other") { const desc = $("#workout-other").value.trim(); if (!desc) { toast("describe what \"other\" is"); return; } type = desc; } const dur = Number($("#workout-duration").value); if (!dur || dur < 1) { toast("enter a valid duration"); return; } setBusy(btn, true); setThinking(true); try { const out = await callGradio("log_workout", [type, dur]); const mem = typeof out === "string" ? JSON.parse(out) : out; applyMemory(mem); $("#workout-type").value = "run"; $("#workout-other-wrap").hidden = true; $("#workout-other").value = ""; toast(`logged ${type} · ${dur} min`, true); } catch (err) { toast(`log workout: ${err.message}`); } finally { setBusy(btn, false); setThinking(false); } }); $("#health-btn").addEventListener("click", () => { $("#free-slots").hidden = true; streamInto("health_recommend", [], $("#health-result"), $("#health-btn"), "coach"); }); $("#plan-week-btn").addEventListener("click", async () => { const btn = $("#plan-week-btn"); const slotsEl = $("#free-slots"); setBusy(btn, true); setThinking(true); try { const out = await callGradio("health_plan", []); let plan = null; try { plan = typeof out === "string" ? JSON.parse(out) : out; } catch { /* not JSON */ } const slots = plan && (plan.recommendation_slots || plan.free_slots || plan.slots || (Array.isArray(plan) ? plan : null)); if (slots && slots.length) { slotsEl.innerHTML = "
      free slots this week
      " + slots.map((s) => `${escapeHtml(typeof s === "string" ? s : `${s.day || s.date || ""} ${s.start || s.time || ""}${s.end ? "–" + s.end : ""}`.trim())}`).join("") + "
      "; } else { slotsEl.innerHTML = "
      no free slots returned — week looks packed
      "; } slotsEl.hidden = false; } catch (e) { setBusy(btn, false); setThinking(false); if (isMissing(e)) { featureUnavailable($("#health-result"), "weekly training plan"); return; } toast(`plan: ${e.message}`); return; } setBusy(btn, false); setThinking(false); streamInto("health_recommend", [], $("#health-result"), $("#plan-week-btn"), "coach"); }); /* ================= MONEY ================= */ let subscriptions = []; function renderSubs() { const wrap = $("#subs-table-wrap"); if (!subscriptions.length) { wrap.innerHTML = "

      no subscriptions detected yet — upload a transactions CSV

      "; } else { const rows = subscriptions.map((s) => `${escapeHtml(s.name)}$${Number(s.cost).toFixed(2)}${escapeHtml(s.last_charged || "—")}${s.cadence_days != null ? s.cadence_days + "d" : "—"}`).join(""); wrap.innerHTML = `${rows}
      name $ / mo last charged cadence

      name = merchant · $/mo = monthly cost · last charged = most recent bill · cadence = days between charges

      `; } $("#money-btn").disabled = !subscriptions.length; } wireDropzone($("#csv-drop"), $("#csv-file"), async (file) => { setThinking(true); try { const out = await uploadFile("/upload/transactions", file); subscriptions = out.subscriptions || []; renderSubs(); toast(`detected ${subscriptions.length} subscriptions`, true); } catch (e) { toast(`csv upload: ${e.message}`); } finally { setThinking(false); } }); $("#money-btn").addEventListener("click", () => { streamInto("money_review", [JSON.stringify(subscriptions)], $("#money-result"), $("#money-btn"), "subscription audit"); }); /* ---- monthly payments editor ---- */ function payRowEl(p = {}) { const row = document.createElement("div"); row.className = "pay-row"; row.innerHTML = ` `; return row; } function renderPayments(list) { const wrap = $("#pay-rows"); wrap.innerHTML = ""; (list && list.length ? list : [{}]).forEach((p) => wrap.appendChild(payRowEl(p))); } $("#pay-add-btn").addEventListener("click", () => $("#pay-rows").appendChild(payRowEl())); $("#pay-rows").addEventListener("click", (e) => { const del = e.target.closest(".pay-del"); if (del) del.closest(".pay-row").remove(); }); $("#pay-save-btn").addEventListener("click", async () => { const btn = $("#pay-save-btn"); const payments = $$("#pay-rows .pay-row").map((r) => ({ name: r.querySelector(".pay-name").value.trim(), amount: Number(r.querySelector(".pay-amount").value) || 0, due_day: Number(r.querySelector(".pay-due").value) || 1, })).filter((p) => p.name); setBusy(btn, true); setThinking(true); try { await callGradio("set_payments", [JSON.stringify(payments)]); toast(`saved ${payments.length} monthly payments`, true); refreshMemoryViews(); // Let the model explain how these payments affect the user's goals. const impact = $("#pay-impact"); if (impact) { impact.hidden = false; impact.innerHTML = "

      checking how this affects your goals…

      "; try { await callGradio("payment_impact", [], (text) => { if (typeof text === "string") impact.innerHTML = renderResponse(text); }); } catch { impact.hidden = true; } } } catch (e) { if (isMissing(e)) featureUnavailable(null, "monthly payments"); else toast(`payments: ${e.message}`); } finally { setBusy(btn, false); setThinking(false); } }); /* ================= GOALS ================= */ let goalHistory = []; let activeGoal = null; // goal object passed to the coach let goalBusy = false; let currentGoals = []; function goalPct(g) { return g.target_amount ? Math.min(100, Math.round((Number(g.saved) || 0) / Number(g.target_amount) * 100)) : 0; } function renderGoals(mem) { currentGoals = (mem && mem.goals) || []; const grid = $("#goals-grid"); if (!currentGoals.length) { grid.innerHTML = "

      no goals yet — add one below

      "; } else { grid.innerHTML = currentGoals.map((g) => `
      ${escapeHtml(g.title || "goal")}
      $${Number(g.saved || 0).toFixed(0)} / $${Number(g.target_amount || 0).toFixed(0)} (${goalPct(g)}%) deadline: ${escapeHtml(g.deadline || "—")}
      `).join(""); } // keep coach target in sync if (activeGoal) activeGoal = currentGoals.find((g) => g.id === activeGoal.id) || null; if (!activeGoal) activeGoal = currentGoals[0] || null; renderGoalSummary(); } function renderGoalSummary() { const el = $("#goal-summary"); if (!activeGoal) { el.innerHTML = "

      pick a goal with \"Coach me\" — or just start typing

      "; return; } const g = activeGoal; el.innerHTML = `
      ${escapeHtml(g.title || "goal")}
      $${Number(g.saved || 0).toFixed(0)} / $${Number(g.target_amount || 0).toFixed(0)} (${goalPct(g)}%) deadline: ${escapeHtml(g.deadline || "—")}
      `; } function fillGoalForm(g) { $("#g-id").value = g && g.id ? g.id : ""; $("#g-title").value = g ? g.title || "" : ""; $("#g-target").value = g && g.target_amount != null ? g.target_amount : ""; $("#g-saved").value = g && g.saved != null ? g.saved : ""; $("#g-deadline").value = g && g.deadline ? String(g.deadline).slice(0, 7) : ""; $("#goal-form-title").textContent = g ? "// edit goal" : "// new goal"; $("#g-cancel-btn").hidden = !g; } $("#g-cancel-btn").addEventListener("click", () => fillGoalForm(null)); $("#goals-grid").addEventListener("click", async (e) => { const card = e.target.closest(".goal-card"); if (!card) return; const goal = currentGoals.find((g) => String(g.id) === card.dataset.id); if (e.target.closest(".goal-edit-btn")) { fillGoalForm(goal); $("#g-title").focus(); } else if (e.target.closest(".goal-coach-btn")) { activeGoal = goal || null; goalHistory = []; $("#goal-list").innerHTML = "

      talk it through — the coach asks questions, you find the plan

      "; renderGoalSummary(); $("#goal-input").focus(); } else if (e.target.closest(".goal-del-btn")) { const btn = e.target.closest(".goal-del-btn"); setBusy(btn, true); setThinking(true); try { const out = await callGradio("delete_goal", [String(goal && goal.id || "")]); const mem = typeof out === "string" ? JSON.parse(out) : out; applyMemory(mem); toast("goal deleted", true); } catch (err) { setBusy(btn, false); if (isMissing(err)) featureUnavailable(null, "goal delete"); else toast(`delete goal: ${err.message}`); } finally { setThinking(false); } } }); $("#goal-edit-form").addEventListener("submit", async (e) => { e.preventDefault(); const btn = $("#g-save-btn"); const goal = { title: $("#g-title").value.trim(), target_amount: Number($("#g-target").value) || 0, saved: Number($("#g-saved").value) || 0, deadline: $("#g-deadline").value, }; if ($("#g-id").value) goal.id = $("#g-id").value; if (!goal.title) { toast("goal needs a title"); return; } setBusy(btn, true); setThinking(true); try { const out = await callGradio("upsert_goal", [JSON.stringify(goal)]); const mem = typeof out === "string" ? JSON.parse(out) : out; applyMemory(mem); toast(`saved goal "${goal.title}"`, true); fillGoalForm(null); } catch (err) { if (isMissing(err)) featureUnavailable(null, "goal save"); else toast(`save goal: ${err.message}`); } finally { setBusy(btn, false); setThinking(false); } }); /* ---- goal coach (Socratic) ---- */ function addGoalBubble(role) { const hint = $("#goal-list .empty-hint"); if (hint) hint.remove(); return addMsg($("#goal-list"), role); } $("#goal-form").addEventListener("submit", async (e) => { e.preventDefault(); const input = $("#goal-input"); const message = input.value.trim(); if (!message || goalBusy) return; input.value = ""; goalBusy = true; const btn = $("#goal-send"); setBusy(btn, true); setThinking(true); const userEl = addGoalBubble("user"); userEl.textContent = message; const asstEl = addGoalBubble("assistant"); asstEl.classList.add("streaming"); asstEl.innerHTML = "

      thinking...

      "; $("#goal-list").scrollTop = $("#goal-list").scrollHeight; try { const finalText = await callGradio("goal_chat", [message, JSON.stringify(goalHistory), JSON.stringify(activeGoal || {})], (text) => { if (typeof text === "string") { asstEl.innerHTML = renderResponse(text); $("#goal-list").scrollTop = $("#goal-list").scrollHeight; } }); goalHistory.push({ role: "user", content: message }); goalHistory.push({ role: "assistant", content: finalText }); } catch (err) { if (isMissing(err)) featureUnavailable(asstEl, "goal coach"); else { toast(`goal coach: ${err.message}`); asstEl.innerHTML = "

      no response — is the backend running?

      "; } } finally { asstEl.classList.remove("streaming"); setBusy(btn, false); setThinking(false); goalBusy = false; } }); /* ================= CHAT ================= */ let history = []; // [{role, content}] let chatBusy = false; let chatArity = 4; // preferred arg count for chat(); degrades on 404/422 /* ---- toggles ---- */ function setToggle(btn, on) { btn.setAttribute("aria-pressed", String(on)); const st = btn.querySelector(".toggle-state"); if (st) st.textContent = on ? "on" : "off"; } function wireToggle(btn, onChange) { btn.addEventListener("click", () => { const on = btn.getAttribute("aria-pressed") !== "true"; setToggle(btn, on); if (onChange) onChange(on); }); } wireToggle($("#tts-toggle"), (on) => { if (!on && "speechSynthesis" in window) speechSynthesis.cancel(); }); wireToggle($("#web-toggle"), (on) => { if (on) toast("web search ON — requests will use the internet"); }); const ttsOn = () => $("#tts-toggle").getAttribute("aria-pressed") === "true"; const webOn = () => $("#web-toggle").getAttribute("aria-pressed") === "true"; function speak(text) { if (!("speechSynthesis" in window)) return; speechSynthesis.cancel(); const clean = stripForSpeech(text); if (!clean) return; const u = new SpeechSynthesisUtterance(clean.slice(0, 1500)); u.rate = 1.05; speechSynthesis.speak(u); } if (!("speechSynthesis" in window)) $("#tts-toggle").hidden = true; /* ---- domain refs (@Kitchen / @Health / @Money) ---- */ $("#ref-row").addEventListener("click", (e) => { const chip = e.target.closest(".ref-chip"); if (!chip) return; chip.setAttribute("aria-pressed", chip.getAttribute("aria-pressed") !== "true" ? "true" : "false"); }); function selectedRefs() { return $$(".ref-chip[aria-pressed='true']").map((c) => c.dataset.ref); } /* ---- mic (speech-to-text) ---- */ const SR = window.SpeechRecognition || window.webkitSpeechRecognition; if (SR) { const micBtn = $("#mic-btn"); micBtn.hidden = false; let rec = null; micBtn.addEventListener("click", () => { if (rec) { rec.stop(); return; } rec = new SR(); rec.lang = "en-US"; rec.interimResults = true; micBtn.classList.add("listening"); let finalTranscript = ""; rec.onresult = (e) => { let interim = ""; for (const r of e.results) { if (r.isFinal) finalTranscript += r[0].transcript; else interim += r[0].transcript; } $("#chat-input").value = (finalTranscript + interim).trim(); }; rec.onerror = (e) => { if (e.error !== "aborted") toast(`mic: ${e.error}`); }; rec.onend = () => { micBtn.classList.remove("listening"); rec = null; const msg = $("#chat-input").value.trim(); if (msg) { $("#chat-input").value = ""; sendChat(msg); } }; try { rec.start(); } catch { micBtn.classList.remove("listening"); rec = null; } }); } /* message DOM: assistant rows get an avatar; returns the bubble element */ function addMsg(listEl, role) { const row = document.createElement("div"); row.className = `msg ${role}`; if (role === "assistant") { const av = document.createElement("div"); av.className = "avatar"; av.innerHTML = ''; row.appendChild(av); } const el = document.createElement("div"); el.className = `bubble ${role} md`; row.appendChild(el); listEl.appendChild(row); return el; } function addBubble(role) { const greet = $("#chat-greeting"); if (greet) greet.remove(); return addMsg($("#chat-list"), role); } function scrollChat() { const list = $("#chat-list"); list.scrollTop = list.scrollHeight; } function restoreGreeting() { $("#chat-list").innerHTML = `
      Hello
      your local-first copilot for meals, training and money — ask anything
      `; applyGreeting(); } let firstName = ""; function applyGreeting() { const el = $("#greet-hello"); if (el) el.textContent = firstName ? `Hello, ${firstName}` : "Hello"; } async function sendChat(message) { if (chatBusy || !message.trim()) return; chatBusy = true; const btn = $("#chat-send"); setBusy(btn, true); setThinking(true); const refs = selectedRefs(); const userEl = addBubble("user"); userEl.textContent = message; if (refs.length) { const tags = document.createElement("div"); tags.className = "msg-refs"; tags.innerHTML = refs.map((r) => `@${escapeHtml(r)}`).join(""); userEl.prepend(tags); } const asstEl = addBubble("assistant"); asstEl.classList.add("streaming"); asstEl.innerHTML = "

      thinking...

      "; scrollChat(); const onUpdate = (text) => { if (typeof text === "string") { asstEl.innerHTML = renderResponse(text); scrollChat(); } }; let finalText = ""; try { const histJson = JSON.stringify(history); const argSets = { 4: [message, histJson, webOn() ? "1" : "", JSON.stringify(refs)], 3: [message, histJson, webOn() ? "1" : ""], 2: [message, histJson], }; for (;;) { try { finalText = await callGradio("chat", argSets[chatArity], onUpdate); break; } catch (e) { if (isMissing(e) && chatArity > 2) { if (chatArity === 4 && refs.length) toast("context refs not supported by this backend build yet — sent without them"); chatArity--; } else throw e; } } history.push({ role: "user", content: message }); history.push({ role: "assistant", content: finalText }); if (ttsOn()) speak(finalText); } catch (e) { toast(`chat: ${e.message}`); asstEl.innerHTML = "

      no response — is the backend running?

      "; } finally { asstEl.classList.remove("streaming"); setBusy(btn, false); setThinking(false); chatBusy = false; } } $("#chat-form").addEventListener("submit", (e) => { e.preventDefault(); const input = $("#chat-input"); const msg = input.value; input.value = ""; sendChat(msg); }); $("#example-chips").addEventListener("click", (e) => { const chip = e.target.closest(".chip-btn"); if (chip) sendChat(chip.textContent.trim()); }); /* ================= PROFILE ================= */ function renderProfile(mem) { const p = (mem && mem.user_profile) || {}; const f = (mem && mem.finances) || {}; firstName = p.first_name || (p.name ? String(p.name).split(" ")[0] : ""); applyGreeting(); if (document.activeElement && document.activeElement.closest && document.activeElement.closest("#profile-form")) return; // don't clobber while editing let first = p.first_name || "", last = p.last_name || ""; if (!first && !last && p.name) { const parts = String(p.name).split(" "); first = parts.shift() || ""; last = parts.join(" "); } $("#pf-first").value = first; $("#pf-last").value = last; $("#pf-city").value = p.city || ""; $("#pf-address").value = p.address || ""; $("#pf-postal").value = p.postal_code || ""; $("#pf-country").value = p.country || ""; $("#pf-income").value = f.income_monthly != null ? f.income_monthly : ""; $("#pf-budget").value = p.budget_weekly != null ? p.budget_weekly : ""; $("#pf-diet").value = Array.isArray(p.dietary_prefs) ? p.dietary_prefs.join(", ") : (p.dietary_prefs || ""); $("#pf-fitness").value = p.fitness_goal || ""; const shown = [first || p.name, p.city].filter(Boolean).join(" · "); $("#pf-saved-state").textContent = shown ? `saved: ${shown}` : ""; updateLocalDealsBtn(); } function updateLocalDealsBtn() { const btn = $("#local-deals-btn"); const hasCity = !!$("#pf-city").value.trim(); btn.disabled = !hasCity; btn.title = hasCity ? "look up grocery deals near you (online)" : "set your city first"; } $("#pf-city").addEventListener("input", updateLocalDealsBtn); $("#profile-form").addEventListener("submit", async (e) => { e.preventDefault(); const btn = $("#pf-save-btn"); const first = $("#pf-first").value.trim(); const last = $("#pf-last").value.trim(); const profile = { first_name: first, last_name: last, name: [first, last].filter(Boolean).join(" "), address: $("#pf-address").value.trim(), city: $("#pf-city").value.trim(), postal_code: $("#pf-postal").value.trim(), country: $("#pf-country").value.trim(), income_monthly: Number($("#pf-income").value) || 0, budget_weekly: Number($("#pf-budget").value) || 0, dietary_prefs: $("#pf-diet").value.split(",").map((s) => s.trim()).filter(Boolean), fitness_goal: $("#pf-fitness").value.trim(), }; setBusy(btn, true); setThinking(true); try { await callGradio("set_profile", [JSON.stringify(profile)]); toast("profile saved", true); $("#pf-saved-state").textContent = `saved: ${[profile.name, profile.city].filter(Boolean).join(" · ")}`; firstName = first; applyGreeting(); refreshMemoryViews(); } catch (err) { if (isMissing(err)) featureUnavailable(null, "profile"); else toast(`profile: ${err.message}`); } finally { setBusy(btn, false); setThinking(false); } }); $("#local-deals-btn").addEventListener("click", () => { const city = $("#pf-city").value.trim(); if (!city) { toast("set your city first"); return; } streamInto("local_deals", [city], $("#local-deals-result"), $("#local-deals-btn"), "local flyer deals"); }); /* ================= MEMORY SECTION ================= */ let lastMem = null; /* field definitions per editable short-term section */ const MEM_FIELDS = { meals: [ { key: "date", label: "date", type: "date" }, { key: "dish", label: "dish", type: "text", grow: true }, { key: "ingredients", label: "ingredients (comma-separated)", type: "list", grow: true }, ], workouts: [ { key: "date", label: "date", type: "date" }, { key: "type", label: "type", type: "text", grow: true }, { key: "duration_min", label: "minutes", type: "number" }, ], calendar: [ { key: "id", label: "id", type: "hidden" }, { key: "title", label: "title", type: "text", grow: true }, { key: "date", label: "date", type: "date" }, { key: "start", label: "start", type: "time" }, { key: "end", label: "end", type: "time" }, { key: "kind", label: "kind", type: "text" }, ], subscriptions: [ { key: "name", label: "name", type: "text", grow: true }, { key: "cost", label: "cost ($/mo)", type: "number" }, { key: "last_used", label: "last used", type: "date" }, ], monthly_payments: [ { key: "name", label: "name", type: "text", grow: true }, { key: "amount", label: "amount ($)", type: "number" }, { key: "due_day", label: "due day", type: "number" }, ], }; function memSectionList(mem, section) { if (!mem) return []; if (section === "subscriptions" || section === "monthly_payments") { return (mem.finances && mem.finances[section]) || []; } return mem[section] || []; } function memRowEl(section, item = {}) { const row = document.createElement("div"); row.className = "mem-row"; MEM_FIELDS[section].forEach((f) => { const val = item[f.key]; const str = f.type === "list" && Array.isArray(val) ? val.join(", ") : (val != null ? String(val) : ""); const input = document.createElement("input"); input.type = f.type === "list" ? "text" : (f.type === "hidden" ? "hidden" : f.type); input.dataset.key = f.key; input.placeholder = f.label; input.title = f.label; input.value = str; if (f.grow) input.classList.add("grow"); row.appendChild(input); }); const del = document.createElement("button"); del.type = "button"; del.className = "btn btn-ghost mem-del"; del.title = "remove row"; del.innerHTML = ''; row.appendChild(del); return row; } function renderMemRows() { const section = $("#mem-section").value; const list = memSectionList(lastMem, section); const wrap = $("#mem-rows"); wrap.innerHTML = ""; list.forEach((item) => wrap.appendChild(memRowEl(section, item))); $("#mem-short-hint").textContent = list.length ? `${list.length} ${section.replace("_", " ")} rows — edit, add or delete, then Save section` : `no ${section.replace("_", " ")} rows yet — add one`; } $("#mem-section").addEventListener("change", renderMemRows); $("#mem-add-row").addEventListener("click", () => { $("#mem-rows").appendChild(memRowEl($("#mem-section").value)); }); $("#mem-rows").addEventListener("click", (e) => { const del = e.target.closest(".mem-del"); if (del) del.closest(".mem-row").remove(); }); function collectMemRows(section) { return $$("#mem-rows .mem-row").map((row) => { const item = {}; MEM_FIELDS[section].forEach((f) => { const v = row.querySelector(`input[data-key="${f.key}"]`).value.trim(); if (f.type === "hidden" && !v) return; // let backend assign ids if (f.type === "number") item[f.key] = Number(v) || 0; else if (f.type === "list") item[f.key] = v.split(",").map((s) => s.trim()).filter(Boolean); else item[f.key] = v; }); return item; }).filter((item) => Object.values(item).some((v) => Array.isArray(v) ? v.length : v !== "" && v !== 0)); } $("#mem-save-btn").addEventListener("click", async () => { const btn = $("#mem-save-btn"); const section = $("#mem-section").value; const items = collectMemRows(section); setBusy(btn, true); setThinking(true); try { const out = await callGradio("edit_memory", [section, JSON.stringify(items)]); const mem = typeof out === "string" ? JSON.parse(out) : out; if (mem && mem.error) { toast(`save: ${mem.error}`); return; } applyMemory(mem); renderMemRows(); toast(`saved ${items.length} ${section.replace("_", " ")} rows`, true); } catch (e) { if (isMissing(e)) featureUnavailable($("#mem-rows"), "manual memory editing"); else toast(`memory save: ${e.message}`); } finally { setBusy(btn, false); setThinking(false); } }); /* ---- segments ---- */ $("#mem-segs").addEventListener("click", (e) => { const seg = e.target.closest(".seg"); if (!seg) return; $$("#mem-segs .seg").forEach((s) => s.classList.toggle("active", s === seg)); $("#mem-short").hidden = seg.dataset.seg !== "short"; $("#mem-long").hidden = seg.dataset.seg !== "long"; if (seg.dataset.seg === "long") loadNotes(); }); /* ---- long-term notes ---- */ let notesLoading = false; async function loadNotes() { if (notesLoading) return; notesLoading = true; const list = $("#notes-list"); try { const out = await callGradio("list_notes", []); const notes = typeof out === "string" ? JSON.parse(out) : out; if (!Array.isArray(notes) || !notes.length) { list.innerHTML = "

      no long-term notes yet — say \"remember ...\" in chat

      "; return; } list.innerHTML = notes.map((n) => `
      ${escapeHtml(String(n.kind || "fact"))}
      ${escapeHtml(String(n.text || ""))}
      `).join(""); } catch (e) { if (isMissing(e)) featureUnavailable(list, "long-term notes"); else list.innerHTML = "

      couldn't load notes — is the backend running?

      "; } finally { notesLoading = false; } } $("#notes-list").addEventListener("click", async (e) => { const row = e.target.closest(".note-row"); if (!row) return; const id = row.dataset.id; if (e.target.closest(".note-edit-btn")) { row.querySelector(".note-text").hidden = true; row.querySelector(".note-edit").hidden = false; row.querySelector(".note-edit-btn").hidden = true; row.querySelector(".note-save-btn").hidden = false; row.querySelector(".note-edit").focus(); } else if (e.target.closest(".note-save-btn")) { const btn = e.target.closest(".note-save-btn"); const text = row.querySelector(".note-edit").value.trim(); if (!text) { toast("note can't be empty"); return; } setBusy(btn, true); setThinking(true); try { await callGradio("update_note", [id, text]); toast("note updated", true); await loadNotes(); } catch (err) { setBusy(btn, false); if (isMissing(err)) featureUnavailable(null, "note editing"); else toast(`update note: ${err.message}`); } finally { setThinking(false); } } else if (e.target.closest(".note-del-btn")) { const btn = e.target.closest(".note-del-btn"); setBusy(btn, true); setThinking(true); try { await callGradio("delete_note", [id]); toast("note deleted", true); await loadNotes(); } catch (err) { setBusy(btn, false); if (isMissing(err)) featureUnavailable(null, "note deletion"); else toast(`delete note: ${err.message}`); } finally { setThinking(false); } } }); /* ================= MEMORY (shared state) ================= */ function applyMemory(mem) { if (!mem) return; lastMem = mem; renderWeekStrip(mem); renderWorkoutLog(mem); renderSchedule(mem); renderCalendar(mem); if (!$("#cal-modal").hidden) renderCalGrid(mem); renderGoals(mem); renderProfile(mem); renderPayments((mem.finances && mem.finances.monthly_payments) || []); } async function refreshMemoryViews() { try { const out = await callGradio("get_memory", []); const mem = typeof out === "string" ? JSON.parse(out) : out; applyMemory(mem); return mem; } catch (e) { $("#week-strip").innerHTML = "

      couldn't load memory — is the backend running?

      "; return null; } } /* ================= SETTINGS ================= */ const LS_TTS = "lifeos.tts_default"; const LS_WEB = "lifeos.web_default"; function initSettings() { const ttsDef = localStorage.getItem(LS_TTS) === "1"; const webDef = localStorage.getItem(LS_WEB) === "1"; setToggle($("#set-tts"), ttsDef); setToggle($("#set-web"), webDef); setToggle($("#tts-toggle"), ttsDef); setToggle($("#web-toggle"), webDef); } wireToggle($("#set-tts"), (on) => { localStorage.setItem(LS_TTS, on ? "1" : "0"); setToggle($("#tts-toggle"), on); }); wireToggle($("#set-web"), (on) => { localStorage.setItem(LS_WEB, on ? "1" : "0"); setToggle($("#web-toggle"), on); }); /* ================= THEME TOGGLE ================= */ const LS_THEME = "lifeos.theme"; function setTheme(theme) { if (theme === "light") { document.body.classList.add("light-mode"); $("#theme-icon-sun").style.display = "none"; $("#theme-icon-moon").style.display = "inline-block"; $("#theme-text").textContent = "dark"; localStorage.setItem(LS_THEME, "light"); } else { document.body.classList.remove("light-mode"); $("#theme-icon-sun").style.display = "inline-block"; $("#theme-icon-moon").style.display = "none"; $("#theme-text").textContent = "light"; localStorage.setItem(LS_THEME, "dark"); } } function initTheme() { const saved = localStorage.getItem(LS_THEME) || "light"; setTheme(saved); } $("#theme-toggle").addEventListener("click", () => { const isLight = document.body.classList.contains("light-mode"); setTheme(isLight ? "dark" : "light"); }); $$(".reset-btn").forEach((btn) => btn.addEventListener("click", async () => { // Wipe all stored data back to a blank slate. (In demo builds, the demo // persona can be reloaded server-side via LIFEOS_DEMO + reset_demo.) if (!confirm("Erase all your LifeOS data and start fresh? This can't be undone.")) return; setBusy(btn, true); setThinking(true); try { const out = await callGradio("reset_account", []); const mem = typeof out === "string" ? JSON.parse(out) : out; if (mem.error) { toast(mem.error); return; } applyMemory(mem); deals = []; renderDeals(); subscriptions = []; renderSubs(); history = []; goalHistory = []; activeGoal = null; restoreGreeting(); $("#goal-list").innerHTML = "

      talk it through — the coach asks questions, you find the plan

      "; renderMemRows(); loadNotes(); toast("data cleared", true); } catch (e) { toast(`reset: ${e.message}`); } finally { setBusy(btn, false); setThinking(false); } })); /* ================= init ================= */ $("#ev-date").value = isoDate(new Date()); renderPayments([]); initSettings(); initTheme(); refreshMemoryViews(); renderStatus(); pollModelStatus();