lifeos / static /app.js
awaisaziz's picture
Add column legends/headers to workout, subscriptions and payments tables
d656880
Raw
History Blame Contribute Delete
60.3 kB
/* 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/<name> {data:[...]} -> {event_id}
GET /gradio_api/call/<name>/<event_id> -> 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 = `<div class="feature-banner">${escapeHtml(label)} isn't available on this backend build yet — try again after the next update.</div>`;
else toast(`${label}: not available on this backend yet`);
}
/* ---------------- tiny markdown renderer (XSS-safe) ---------------- */
function escapeHtml(s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function mdInline(s) {
return s
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
}
function renderMarkdown(raw) {
const lines = escapeHtml(raw).split("\n");
let html = "", i = 0, list = null, para = [];
const flushPara = () => { if (para.length) { html += `<p>${mdInline(para.join(" "))}</p>`; para = []; } };
const closeList = () => { if (list) { html += `</${list}>`; 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 += `<pre class="codeblock"><code>${code.join("\n")}</code></pre>`;
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 += "<table><thead><tr>" + cells(t).map((c) => `<th>${c}</th>`).join("") + "</tr></thead><tbody>";
i += 2;
while (i < lines.length && lines[i].trim().startsWith("|")) {
html += "<tr>" + cells(lines[i]).map((c) => `<td>${c}</td>`).join("") + "</tr>";
i++;
}
html += "</tbody></table>";
continue;
}
let m;
if ((m = t.match(/^&gt;\s?(.*)/))) {
flushPara(); closeList();
const quote = [m[1]];
while (i + 1 < lines.length && (m = lines[i + 1].trim().match(/^&gt;\s?(.*)/))) { quote.push(m[1]); i++; }
html += `<blockquote>${mdInline(quote.join(" "))}</blockquote>`;
} else if ((m = t.match(/^(#{1,3})\s+(.*)/))) {
flushPara(); closeList();
html += `<h${m[1].length}>${mdInline(m[2])}</h${m[1].length}>`;
} 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 += `<h4 class="md-section">${mdInline(m[1])}</h4>`;
} else if ((m = t.match(/^[-*]\s+(.*)/))) {
flushPara();
if (list !== "ul") { closeList(); html += "<ul>"; list = "ul"; }
html += `<li>${mdInline(m[1])}</li>`;
} else if ((m = t.match(/^\d+[.)]\s+(.*)/))) {
flushPara();
if (list !== "ol") { closeList(); html += "<ol>"; list = "ol"; }
html += `<li>${mdInline(m[1])}</li>`;
} else if (/^([-_*])\s*\1\s*\1[\s\-_*]*$/.test(t)) {
flushPara(); closeList();
html += "<hr>";
} else if (t === "") {
flushPara(); closeList();
} else {
closeList();
para.push(t);
}
i++;
}
flushPara(); closeList();
return html;
}
/* Strip <think> + markdown syntax for speech. */
function stripForSpeech(raw) {
let text = raw.replace(/<think>[\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 <think>...</think> before MD: fold into a collapsed details block. */
function renderResponse(raw) {
let html = "";
let text = raw;
const open = text.indexOf("<think>");
if (open >= 0) {
const close = text.indexOf("</think>");
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 += `<details><summary>reasoning</summary><p>${escapeHtml(inner.trim()).replace(/\n/g, "<br>")}</p></details>`;
}
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 = "<p class='empty-hint'>thinking...</p>";
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 = "<p class='empty-hint'>something went wrong — is the backend running?</p>";
}
} 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 = "<p class='empty-hint'>no deals yet — upload a flyer or paste its text</p>";
} else {
wrap.innerHTML = deals.map((d) =>
`<span class="deal-chip">${escapeHtml(d.item)} <b>${escapeHtml(d.price_text)}</b></span>`).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 = "<p class='empty-hint'>🔍 identifying food items on your machine — hang tight, this runs fully offline…</p>";
try {
const out = await uploadFile("/upload/meal_photo", file);
if (out.error) {
$("#meal-ocr-wrap").hidden = true;
$("#meal-result").innerHTML = `<div class="feature-banner">${escapeHtml(out.error)}</div>`;
return;
}
mealOcrText = out.text || "";
if (!mealOcrText.trim()) {
$("#meal-result").innerHTML = "<div class='feature-banner'>Couldn't make out any food items in that photo — try a clearer, well-lit shot.</div>";
$("#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 = "<p class='empty-hint'>pick a label and hit Analyze</p>";
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 `<div class="day-box ${w ? "filled" : ""} ${isToday ? "today" : ""}" title="${w ? escapeHtml(w.type) + " " + w.duration_min + "min" : "rest"}">
<span>${DAY_NAMES[d.getDay()]}</span><span class="abbr">${abbr}</span>${w ? `<span>${w.duration_min}m</span>` : ""}
</div>`;
}).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 = "<p class='empty-hint'>no workouts logged yet</p>";
if (btn) btn.hidden = true;
return;
}
const shown = showAllWorkouts ? allWorkouts : allWorkouts.slice(0, WORKOUT_LOG_RECENT);
box.classList.toggle("scroll", showAllWorkouts);
const header =
`<div class="workout-row head">
<span class="wl-date" title="the day you logged this workout">date</span>
<span class="wl-type" title="what kind of training it was">workout</span>
<span class="wl-dur" title="how long you trained, in minutes">duration</span>
</div>`;
box.innerHTML = header + shown.map((w) =>
`<div class="workout-row">
<span class="wl-date">${escapeHtml(w.date)}</span>
<span class="wl-type">${escapeHtml(w.type)}</span>
<span class="wl-dur">${w.duration_min} min</span>
</div>`).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) =>
`<div class="cal-day ${i === 0 ? "today" : ""} ${d.events.length ? "has-events" : ""}">
${DAY_NAMES[d.date.getDay()]}<span class="cal-count">${d.events.length || "·"}</span>
</div>`).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 = "<p class='empty-hint'>no events in the next 7 days</p>";
} else {
list.innerHTML = all.map((ev) =>
`<div class="event-row">
<span class="ev-when">${ev._day} ${escapeHtml(ev.start || "")}${escapeHtml(ev.end || "")}</span>
<span class="ev-title">${escapeHtml(ev.title || "(untitled)")}</span>
<span class="ev-kind">${escapeHtml(ev.kind || "other")}</span>
<button class="btn btn-ghost ev-del" data-id="${escapeHtml(String(ev.id))}" title="delete event"><svg class="icon"><use href="#i-trash"/></svg></button>
</div>`).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 = `<div class="calg-corner"></div>` +
days.map((d) => `<div class="calg-dayhead ${d.key === isoDate(new Date()) ? "today" : ""}">
<span>${DAY_NAMES[d.date.getDay()]}</span><b>${d.date.getDate()}</b></div>`).join("");
const gutter = `<div class="calg-gutter">` +
hours.map((h) => `<div class="calg-hour" style="height:${60 * pxPerMin}px">${String(h).padStart(2, "0")}:00</div>`).join("") +
`</div>`;
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 `<div class="calg-event" style="top:${top}px;height:${h}px;border-left-color:${c};background:${c}22">
<b>${escapeHtml(ev.title || "(untitled)")}</b>
<span>${escapeHtml(ev.start || "")}${escapeHtml(ev.end || "")} · ${escapeHtml(ev.kind || "")}</span>
</div>`;
}).join("");
return `<div class="calg-col" style="height:${(CAL_DAY_END - CAL_DAY_START + 1) * 60 * pxPerMin}px">
${hours.map((h) => `<div class="calg-slot" style="height:${60 * pxPerMin}px"></div>`).join("")}${evs}</div>`;
}).join("");
grid.innerHTML = `<div class="calg-head">${header}</div><div class="calg-body">${gutter}<div class="calg-cols">${cols}</div></div>`;
}
$("#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 = "<div class='legend muted'>free slots this week</div><div class='slot-chips'>" +
slots.map((s) => `<span class="slot-chip">${escapeHtml(typeof s === "string" ? s : `${s.day || s.date || ""} ${s.start || s.time || ""}${s.end ? "–" + s.end : ""}`.trim())}</span>`).join("") +
"</div>";
} else {
slotsEl.innerHTML = "<div class='legend muted'>no free slots returned — week looks packed</div>";
}
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 = "<p class='empty-hint'>no subscriptions detected yet — upload a transactions CSV</p>";
} else {
const rows = subscriptions.map((s) =>
`<tr><td>${escapeHtml(s.name)}</td><td class="num">$${Number(s.cost).toFixed(2)}</td><td>${escapeHtml(s.last_charged || "—")}</td><td class="num">${s.cadence_days != null ? s.cadence_days + "d" : "—"}</td></tr>`).join("");
wrap.innerHTML = `<table class="subs-table">
<thead><tr>
<th title="the merchant this recurring charge is from">name</th>
<th title="how much it costs you each month">$ / mo</th>
<th title="the date this subscription was most recently billed">last charged</th>
<th title="how many days between charges — e.g. 30d means billed monthly">cadence</th>
</tr></thead>
<tbody>${rows}</tbody></table>
<p class="table-legend muted">name = merchant · $/mo = monthly cost · last charged = most recent bill · cadence = days between charges</p>`;
}
$("#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 = `
<input type="text" class="pay-name" placeholder="rent" value="${escapeHtml(p.name || "")}">
<input type="number" class="pay-amount" placeholder="$" min="0" step="0.01" value="${p.amount != null ? escapeHtml(String(p.amount)) : ""}">
<input type="number" class="pay-due" placeholder="due day" min="1" max="31" value="${p.due_day != null ? escapeHtml(String(p.due_day)) : ""}">
<button type="button" class="btn btn-ghost pay-del" title="remove row"><svg class="icon"><use href="#i-trash"/></svg></button>`;
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 = "<p class='empty-hint'>checking how this affects your goals…</p>";
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 = "<p class='empty-hint'>no goals yet — add one below</p>";
} else {
grid.innerHTML = currentGoals.map((g) => `
<div class="goal-card" data-id="${escapeHtml(String(g.id || ""))}">
<div class="goal-title">${escapeHtml(g.title || "goal")}</div>
<div class="goal-bar-wrap"><div class="goal-bar" style="width:${goalPct(g)}%"></div></div>
<div class="goal-meta">
<span>$${Number(g.saved || 0).toFixed(0)} / $${Number(g.target_amount || 0).toFixed(0)} (${goalPct(g)}%)</span>
<span>deadline: ${escapeHtml(g.deadline || "—")}</span>
</div>
<div class="goal-actions">
<button class="btn btn-primary goal-coach-btn"><svg class="icon"><use href="#i-chat"/></svg> Coach me</button>
<button class="btn goal-edit-btn"><svg class="icon"><use href="#i-edit"/></svg> Edit</button>
<button class="btn btn-ghost goal-del-btn" title="delete goal"><svg class="icon"><use href="#i-trash"/></svg></button>
</div>
</div>`).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 = "<p class='empty-hint'>pick a goal with \"Coach me\" — or just start typing</p>";
return;
}
const g = activeGoal;
el.innerHTML = `
<div class="goal-title">${escapeHtml(g.title || "goal")}</div>
<div class="goal-bar-wrap"><div class="goal-bar" style="width:${goalPct(g)}%"></div></div>
<div class="goal-meta">
<span>$${Number(g.saved || 0).toFixed(0)} / $${Number(g.target_amount || 0).toFixed(0)} (${goalPct(g)}%)</span>
<span>deadline: ${escapeHtml(g.deadline || "—")}</span>
</div>`;
}
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 = "<p class='empty-hint'>talk it through — the coach asks questions, you find the plan</p>";
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 = "<p class='empty-hint'>thinking...</p>";
$("#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 = "<p class='empty-hint'>no response — is the backend running?</p>"; }
} 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 = '<svg class="icon"><use href="#i-bolt"/></svg>';
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 = `
<div class="chat-greeting" id="chat-greeting">
<div class="greet-hello" id="greet-hello">Hello</div>
<div class="greet-sub">your local-first copilot for meals, training and money — ask anything</div>
</div>`;
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) => `<span class="msg-ref">@${escapeHtml(r)}</span>`).join("");
userEl.prepend(tags);
}
const asstEl = addBubble("assistant");
asstEl.classList.add("streaming");
asstEl.innerHTML = "<p class='empty-hint'>thinking...</p>";
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 = "<p class='empty-hint'>no response — is the backend running?</p>";
} 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 = '<svg class="icon"><use href="#i-trash"/></svg>';
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 = "<p class='empty-hint'>no long-term notes yet — say \"remember ...\" in chat</p>";
return;
}
list.innerHTML = notes.map((n) => `
<div class="note-row" data-id="${escapeHtml(String(n.id))}">
<span class="note-kind kind-${escapeHtml(String(n.kind || "fact"))}">${escapeHtml(String(n.kind || "fact"))}</span>
<div class="note-body">
<div class="note-text">${escapeHtml(String(n.text || ""))}</div>
<textarea class="note-edit" rows="2" hidden>${escapeHtml(String(n.text || ""))}</textarea>
</div>
<div class="note-actions">
<button class="btn btn-ghost note-edit-btn" title="edit note"><svg class="icon"><use href="#i-edit"/></svg></button>
<button class="btn btn-primary note-save-btn" hidden title="save note"><svg class="icon"><use href="#i-check"/></svg></button>
<button class="btn btn-ghost note-del-btn" title="delete note"><svg class="icon"><use href="#i-trash"/></svg></button>
</div>
</div>`).join("");
} catch (e) {
if (isMissing(e)) featureUnavailable(list, "long-term notes");
else list.innerHTML = "<p class='empty-hint'>couldn't load notes — is the backend running?</p>";
} 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 = "<p class='empty-hint'>couldn't load memory — is the backend running?</p>";
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 = "<p class='empty-hint'>talk it through — the coach asks questions, you find the plan</p>";
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();