[object Object]

← back to AbramsOS

savings: life-optimizer module — cheaper/better substitutes from what you buy (local gemma3:12b, $0)

db2fd4874d8e87cd1de9be49386b73a6f1ce5953 · 2026-07-13 00:19:09 -0700 · Steve

- 0010_savings.sql: savings_suggestion + merchant_coupon tables
- lib/savings-advisor.js: grounded strategy suggestions (no hallucinated SKUs/prices)
- routes/savings.js + views/savings.ejs: dashboard w/ created date+time chips, sort+density, save/dismiss
- seeded reorder items from real Amazon email history; 4 suggestions generated
- switched local model qwen3:14b->gemma3:12b (qwen3 thinking-mode returns empty under format:json)

Files touched

Diff

commit db2fd4874d8e87cd1de9be49386b73a6f1ce5953
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 13 00:19:09 2026 -0700

    savings: life-optimizer module — cheaper/better substitutes from what you buy (local gemma3:12b, $0)
    
    - 0010_savings.sql: savings_suggestion + merchant_coupon tables
    - lib/savings-advisor.js: grounded strategy suggestions (no hallucinated SKUs/prices)
    - routes/savings.js + views/savings.ejs: dashboard w/ created date+time chips, sort+density, save/dismiss
    - seeded reorder items from real Amazon email history; 4 suggestions generated
    - switched local model qwen3:14b->gemma3:12b (qwen3 thinking-mode returns empty under format:json)
---
 db/migrations/0010_savings.sql |  56 ++++++++++++++++
 lib/ids.js                     |   2 +
 lib/savings-advisor.js         | 141 +++++++++++++++++++++++++++++++++++++++++
 routes/savings.js              |  82 ++++++++++++++++++++++++
 server.js                      |   2 +
 views/partials/header.ejs      |   1 +
 views/savings.ejs              | 139 ++++++++++++++++++++++++++++++++++++++++
 7 files changed, 423 insertions(+)

diff --git a/db/migrations/0010_savings.sql b/db/migrations/0010_savings.sql
new file mode 100644
index 0000000..e0febbd
--- /dev/null
+++ b/db/migrations/0010_savings.sql
@@ -0,0 +1,56 @@
+-- 0010_savings.sql
+-- The "life optimizer" layer: turn what Steve actually buys into money-saving /
+-- quality-improving suggestions, plus coupon leads at the places he shops.
+--   savings_suggestion — one actionable idea (cheaper/better substitute, reorder timing,
+--                        price drop, or a coupon) generated from purchase + reorder_item data.
+--   merchant_coupon    — a discount/coupon lead tied to a merchant Steve buys from.
+-- Both feed the /savings dashboard. Local-LLM ($0) generated; nothing is auto-purchased.
+-- Idempotent. Safe to re-run.
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS savings_suggestion (
+  id               text PRIMARY KEY,
+  user_id          text NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
+  kind             text NOT NULL DEFAULT 'substitute',  -- substitute|upgrade|coupon|reorder-timing|price-drop|bundle
+  title            text NOT NULL,
+  current_item     text,                                 -- what Steve buys today
+  current_price    numeric(12,2),
+  suggested_item   text,                                 -- the cheaper/better alternative
+  suggested_price  numeric(12,2),
+  est_savings      numeric(12,2),                         -- estimated $ saved
+  savings_basis    text,                                  -- "per order" | "per year" | "one-time"
+  quality_note     text,                                  -- quality tradeoff or upgrade rationale
+  merchant         text,                                  -- where to buy the suggestion
+  source_url       text,                                  -- link to verify (never auto-buy)
+  rationale        text,                                  -- why this suggestion
+  confidence       numeric(4,3) NOT NULL DEFAULT 0.5,     -- 0..1
+  reorder_item_id  text REFERENCES reorder_item(id) ON DELETE SET NULL,
+  status           text NOT NULL DEFAULT 'new',           -- new|saved|dismissed|done
+  metadata_jsonb   jsonb NOT NULL DEFAULT '{}'::jsonb,
+  created_at       timestamptz NOT NULL DEFAULT now(),
+  updated_at       timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS savings_user_status_idx ON savings_suggestion (user_id, status, created_at DESC);
+CREATE INDEX IF NOT EXISTS savings_kind_idx        ON savings_suggestion (user_id, kind);
+
+CREATE TABLE IF NOT EXISTS merchant_coupon (
+  id              text PRIMARY KEY,
+  user_id         text NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
+  merchant        text NOT NULL,                          -- e.g. "Amazon", "Costco", "Nespresso"
+  merchant_domain text,
+  title           text NOT NULL,
+  code            text,                                   -- promo code, if any
+  discount_text   text,                                   -- "15% off", "$10 off $50"
+  description      text,
+  url             text,
+  expires_on      date,
+  source          text,                                   -- where the lead came from
+  status          text NOT NULL DEFAULT 'active',         -- active|expired|dismissed
+  metadata_jsonb  jsonb NOT NULL DEFAULT '{}'::jsonb,
+  created_at      timestamptz NOT NULL DEFAULT now(),
+  updated_at      timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS coupon_user_merchant_idx ON merchant_coupon (user_id, merchant, status);
+
+COMMIT;
diff --git a/lib/ids.js b/lib/ids.js
index d1d8af1..00c751b 100644
--- a/lib/ids.js
+++ b/lib/ids.js
@@ -16,6 +16,8 @@ const PREFIX = {
   medication: 'med',
   medrecall: 'mrcl',
   prescription_fill: 'rxfl',
+  saving: 'sav',
+  coupon: 'coup',
 };
 
 function id(kind) {
diff --git a/lib/savings-advisor.js b/lib/savings-advisor.js
new file mode 100644
index 0000000..b235baf
--- /dev/null
+++ b/lib/savings-advisor.js
@@ -0,0 +1,141 @@
+// savings-advisor.js — the "life optimizer" engine.
+//
+// Turns what Steve actually buys (reorder_item + purchase history) into concrete
+// money-saving / quality-improving suggestions, using the LOCAL Ollama model only
+// (Steve's rule: never the Anthropic API; local = $0). Nothing here buys anything —
+// every suggestion is a lead for Steve to review on the /savings dashboard.
+//
+// Design lesson (2026-07-13): asking an LLM for a SPECIFIC cheaper product + exact
+// price makes it hallucinate (qwen3:14b proposed "L'Oréal face cream" for coffee pods).
+// So we ask for a grounded SAVINGS STRATEGY (generic swap / third-party-compatible /
+// bulk / subscribe-save / seasonal timing) + a rough % range — never an invented SKU
+// or a fabricated dollar price. gemma3:12b stays on-topic; qwen3:14b (a thinking model)
+// returns empty under format:json and is unusable here.
+//
+// Honesty guards:
+//   • est_savings is derived only when we KNOW the typical price (typical_price * pct);
+//     otherwise it stays null and the card shows a "~N% less" range instead of fake $.
+//   • source_url is NEVER model-generated (stays null) so nothing looks authoritative
+//     that isn't. Real links only come from the coupon feed (merchant_coupon).
+//   • confidence is normalized to 0..1 (some models answer 0..100).
+
+const db = require('./db');
+const ollama = require('./ollama');
+const { id } = require('./ids');
+
+const SYSTEM = `You are a frugal, practical household shopping advisor. Given ONE item a
+person buys repeatedly, propose the single best way to spend less on THAT SAME item, or
+a genuine quality upgrade for similar money. Stay strictly on the given item — never
+switch product categories. Never invent a specific brand name or an exact price; speak
+in strategies and rough percentage ranges. Reply as strict JSON only.`;
+
+function prompt(item) {
+  return `Item bought repeatedly:
+- name: ${item.name}
+- category: ${item.category || 'unknown'}
+- unit/size: ${item.unit || 'unknown'}
+- usual merchant: ${item.merchant || 'unknown'}
+- price usually paid: ${item.typical_price != null ? '$' + item.typical_price : 'unknown'}
+
+Return JSON with EXACTLY these keys:
+{
+  "strategy": "generic-swap" | "third-party-compatible" | "bulk-buy" | "subscribe-save" | "seasonal-timing" | "quality-upgrade",
+  "label": "short action label, <= 6 words, about THIS item",
+  "suggestion": "one concrete sentence about this exact item",
+  "est_savings_pct": number,          // rough %, 0-70; 0 if it's a quality upgrade
+  "quality_note": "one sentence: tradeoff or why it's better",
+  "merchant": "generic where to look (e.g. Amazon, store brand, warehouse club)",
+  "confidence": number                 // 0..1 (or 0..100), be conservative
+}`;
+}
+
+function normConf(v) {
+  const n = Number(v);
+  if (!Number.isFinite(n)) return 0.4;
+  const c = n > 1 ? n / 100 : n;
+  return Math.max(0, Math.min(1, c));
+}
+function pct(v) {
+  const n = Number(v);
+  if (!Number.isFinite(n)) return null;
+  return Math.max(0, Math.min(90, n));
+}
+
+async function suggestForItem(userId, item, opts = {}) {
+  let j;
+  try {
+    j = await ollama.generateJson(prompt(item), {
+      system: SYSTEM,
+      timeoutMs: opts.timeoutMs || 60_000,
+      model: opts.model,
+    });
+  } catch (err) {
+    return { ok: false, error: err.message, item: item.name };
+  }
+  const label = j && (j.label || j.suggestion);
+  if (!label) return { ok: false, error: 'empty model output', item: item.name };
+
+  const strategy = String(j.strategy || 'generic-swap');
+  const kind = strategy === 'quality-upgrade' ? 'upgrade' : 'substitute';
+  const savingsPct = pct(j.est_savings_pct);
+  const confidence = normConf(j.confidence);
+  const est_savings =
+    item.typical_price != null && savingsPct != null
+      ? Math.round(Number(item.typical_price) * (savingsPct / 100) * 100) / 100
+      : null;
+
+  // Dedupe: skip if an open suggestion already exists for this item + same strategy.
+  const dupe = await db.query(
+    `SELECT 1 FROM savings_suggestion
+      WHERE user_id = $1 AND reorder_item_id IS NOT DISTINCT FROM $2
+        AND metadata_jsonb->>'strategy' = $3 AND status IN ('new','saved')`,
+    [userId, item.id || null, strategy]
+  );
+  if (dupe.rows.length) return { ok: true, skipped: 'dupe', item: item.name };
+
+  const sid = id('saving');
+  await db.query(
+    `INSERT INTO savings_suggestion
+       (id, user_id, kind, title, current_item, current_price, suggested_item,
+        suggested_price, est_savings, savings_basis, quality_note, merchant,
+        source_url, rationale, confidence, reorder_item_id, metadata_jsonb)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,NULL,$8,$9,$10,$11,NULL,$12,$13,$14,$15)`,
+    [
+      sid, userId, kind,
+      `Save on ${item.name}`.slice(0, 200),
+      item.name, item.typical_price ?? null,
+      String(j.label || strategy).slice(0, 120),
+      est_savings,
+      est_savings != null ? 'per order' : (savingsPct != null ? `~${savingsPct}% less` : 'varies'),
+      (j.quality_note || '').slice(0, 500),
+      (j.merchant || item.merchant || '').slice(0, 120),
+      (j.suggestion || '').slice(0, 1000),
+      confidence, item.id || null,
+      JSON.stringify({ estimate: true, strategy, est_savings_pct: savingsPct, model: opts.model || ollama.DEFAULT_MODEL }),
+    ]
+  );
+  return { ok: true, id: sid, item: item.name, strategy, suggested: j.label };
+}
+
+// Generate suggestions for every active reorder item that doesn't already have a fresh one.
+async function generateSuggestions(userId, opts = {}) {
+  const items = await db.query(
+    `SELECT id, name, merchant, category, unit, typical_price, best_price
+       FROM reorder_item WHERE user_id = $1 AND status = 'active'
+       ORDER BY updated_at DESC LIMIT $2`,
+    [userId, opts.limit || 50]
+  );
+  const results = [];
+  for (const item of items.rows) {
+    results.push(await suggestForItem(userId, item, opts));
+  }
+  return {
+    scanned: items.rows.length,
+    created: results.filter((r) => r.ok && r.id).length,
+    skipped: results.filter((r) => r.skipped).length,
+    errors: results.filter((r) => !r.ok),
+    results,
+  };
+}
+
+module.exports = { generateSuggestions, suggestForItem };
diff --git a/routes/savings.js b/routes/savings.js
new file mode 100644
index 0000000..f83ba60
--- /dev/null
+++ b/routes/savings.js
@@ -0,0 +1,82 @@
+// Savings — the life-optimizer dashboard: cheaper/better substitutes for what Steve
+// buys, plus coupon leads at the merchants he shops. Read + curate; never auto-buys.
+const express = require('express');
+const db = require('../lib/db');
+const advisor = require('../lib/savings-advisor');
+const audit = require('../lib/audit');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+async function listSuggestions(userId) {
+  const r = await db.query(
+    `SELECT id, kind, title, current_item, current_price, suggested_item, suggested_price,
+            est_savings, savings_basis, quality_note, merchant, source_url, rationale,
+            confidence, status, created_at
+       FROM savings_suggestion
+      WHERE user_id = $1 AND status IN ('new','saved')
+      ORDER BY (status='saved') DESC, est_savings DESC NULLS LAST, confidence DESC
+      LIMIT 200`,
+    [userId]
+  );
+  return r.rows;
+}
+
+async function listCoupons(userId) {
+  const r = await db.query(
+    `SELECT id, merchant, merchant_domain, title, code, discount_text, description,
+            url, expires_on, source, created_at
+       FROM merchant_coupon
+      WHERE user_id = $1 AND status = 'active'
+        AND (expires_on IS NULL OR expires_on >= current_date)
+      ORDER BY created_at DESC LIMIT 200`,
+    [userId]
+  );
+  return r.rows;
+}
+
+router.get('/savings', async (_req, res) => {
+  const [suggestions, coupons] = await Promise.all([
+    listSuggestions(DEV_USER_ID),
+    listCoupons(DEV_USER_ID),
+  ]);
+  const totalSavings = suggestions.reduce((s, x) => s + (Number(x.est_savings) || 0), 0);
+  res.render('savings', { suggestions, coupons, totalSavings });
+});
+
+router.get('/api/savings', async (_req, res) => {
+  res.json(await listSuggestions(DEV_USER_ID));
+});
+
+router.get('/api/coupons', async (_req, res) => {
+  res.json(await listCoupons(DEV_USER_ID));
+});
+
+// Generate fresh substitute suggestions from reorder_item data (local LLM, $0).
+router.post('/api/savings/run', async (_req, res) => {
+  try {
+    const out = await advisor.generateSuggestions(DEV_USER_ID, { limit: 50 });
+    await audit.log({
+      actorType: 'system', actorId: 'savings-advisor', objectType: 'savings_suggestion',
+      objectId: null, eventType: 'savings_generated',
+      metadata: { scanned: out.scanned, created: out.created },
+    }).catch(() => {});
+    res.json({ ok: true, ...out, results: undefined });
+  } catch (err) {
+    res.status(500).json({ ok: false, error: err.message });
+  }
+});
+
+router.post('/api/savings/:id/status', async (req, res) => {
+  const status = ['new', 'saved', 'dismissed', 'done'].includes(req.body.status) ? req.body.status : null;
+  if (!status) return res.status(400).json({ error: 'bad status' });
+  const r = await db.query(
+    `UPDATE savings_suggestion SET status = $1, updated_at = now()
+      WHERE id = $2 AND user_id = $3 RETURNING id, status`,
+    [status, req.params.id, DEV_USER_ID]
+  );
+  if (!r.rows.length) return res.status(404).json({ error: 'not found' });
+  res.json(r.rows[0]);
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 7ec6b9e..2075403 100644
--- a/server.js
+++ b/server.js
@@ -24,6 +24,7 @@ const recallsRouter = require('./routes/recalls');
 const uploadRouter = require('./routes/upload');
 const billsRouter = require('./routes/bills');
 const reordersRouter = require('./routes/reorders');
+const savingsRouter = require('./routes/savings');
 const warrantiesRouter = require('./routes/warranties');
 const peopleRouter = require('./routes/people');
 const medicationsRouter = require('./routes/medications');
@@ -87,6 +88,7 @@ app.use(recallsRouter);             // /recalls, /api/recalls
 app.use(uploadRouter);              // /api/upload/csv
 app.use(billsRouter);               // /bills, /api/bills*
 app.use(reordersRouter);            // /reorders, /api/reorders*
+app.use(savingsRouter);             // /savings, /api/savings*, /api/coupons
 app.use(warrantiesRouter);          // /warranties, /api/warranties*
 app.use(peopleRouter);              // /household, /api/people*
 app.use(medicationsRouter);         // /medications, /api/medications*
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index 3a4a795..fd673f8 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -26,6 +26,7 @@
       <a href="/bills">Bills</a>
       <a href="/warranties">Warranties</a>
       <a href="/reorders">Reorders</a>
+      <a href="/savings">Savings</a>
       <a href="/medications">Meds</a>
       <a href="/prescriptions">Rx</a>
       <a href="/household">Household</a>
diff --git a/views/savings.ejs b/views/savings.ejs
new file mode 100644
index 0000000..9f44896
--- /dev/null
+++ b/views/savings.ejs
@@ -0,0 +1,139 @@
+<%- include('partials/header', { title: 'Savings' }) %>
+
+<section class="page-head">
+  <div>
+    <h1>Savings &amp; Smart Buys</h1>
+    <p class="subtle">
+      <%= suggestions.length %> ideas ·
+      <% if (totalSavings) { %>est. USD <%= totalSavings.toFixed(2) %> potential savings<% } else { %>run the advisor to find cheaper &amp; better swaps<% } %>
+      · <span class="subtle">prices are AI estimates — verify before buying</span>
+    </p>
+  </div>
+  <div class="grid-controls">
+    <label>Sort
+      <select data-sort-for="savings">
+        <option value="savings-desc">Biggest savings</option>
+        <option value="confidence-desc">Most confident</option>
+        <option value="created-desc">Newest</option>
+        <option value="merchant-asc">Merchant A→Z</option>
+      </select>
+    </label>
+    <label>Density
+      <input data-density-for="savings" type="range" min="260" max="480" step="10" value="340">
+    </label>
+    <button type="button" class="btn primary" id="runAdvisor">Find savings</button>
+  </div>
+</section>
+
+<% if (!suggestions.length) { %>
+  <section class="empty glass">
+    <p>No suggestions yet. Click <strong>Find savings</strong> — the advisor reads what you
+    buy a lot (your <a href="/reorders">Reorders</a>) and proposes cheaper or better swaps,
+    using a local AI model ($0).</p>
+  </section>
+<% } %>
+
+<section data-grid="savings" class="purchase-grid" style="grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));">
+  <% suggestions.forEach(s => {
+       const conf = Math.round((Number(s.confidence)||0) * 100);
+       const created = new Date(s.created_at);
+       const createdStr = created.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
+  %>
+    <article class="purchase glass<%= s.status==='saved' ? ' saved' : '' %>"
+             data-id="<%= s.id %>"
+             data-merchant="<%= (s.merchant||'').toLowerCase() %>"
+             data-savings="<%= Number(s.est_savings)||0 %>"
+             data-confidence="<%= Number(s.confidence)||0 %>"
+             data-created="<%= created.toISOString() %>">
+      <header>
+        <h3><%= s.suggested_item %></h3>
+        <span class="chip <%= s.kind==='upgrade' ? 'upgrade' : 'save' %>"><%= s.kind %></span>
+      </header>
+
+      <div class="amount">
+        <% if (s.est_savings && Number(s.est_savings) > 0) { %>
+          <span class="save-amt">save ~$<%= Number(s.est_savings).toFixed(2) %></span>
+          <span class="subtle"><%= s.savings_basis || 'per order' %></span>
+        <% } else if (s.savings_basis && /%/.test(s.savings_basis)) { %>
+          <span class="save-amt"><%= s.savings_basis %></span>
+        <% } else { %><span class="subtle">smart buy</span><% } %>
+      </div>
+
+      <dl class="meta">
+        <% if (s.current_item) { %>
+          <dt>Instead of</dt>
+          <dd><%= s.current_item %><% if (s.current_price) { %> <span class="subtle">($<%= Number(s.current_price).toFixed(2) %>)</span><% } %></dd>
+        <% } %>
+        <% if (s.merchant) { %><dt>Where</dt><dd><%= s.merchant %></dd><% } %>
+        <% if (s.quality_note) { %><dt>Quality</dt><dd><%= s.quality_note %></dd><% } %>
+        <dt>Confidence</dt><dd><span class="badge<%= conf>=70?' amber':'' %>"><%= conf %>%</span></dd>
+      </dl>
+
+      <% if (s.rationale) { %><p class="rationale subtle"><%= s.rationale %></p><% } %>
+
+      <footer class="card-foot">
+        <span class="when" title="<%= created.toISOString() %>">🕓 <%= createdStr %></span>
+        <span class="actions">
+          <% if (s.status !== 'saved') { %><button type="button" class="btn tiny" data-act="save" data-id="<%= s.id %>">Save</button><% } %>
+          <button type="button" class="btn tiny ghost" data-act="dismiss" data-id="<%= s.id %>">Dismiss</button>
+        </span>
+      </footer>
+    </article>
+  <% }) %>
+</section>
+
+<% if (coupons.length) { %>
+  <section class="page-head" style="margin-top:2rem"><div><h2>Coupons at places you shop</h2></div></section>
+  <section class="purchase-grid" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));">
+    <% coupons.forEach(c => {
+         const cCreated = new Date(c.created_at);
+         const cStr = cCreated.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
+    %>
+      <article class="purchase glass">
+        <header><h3><%= c.merchant %></h3><% if (c.discount_text) { %><span class="chip save"><%= c.discount_text %></span><% } %></header>
+        <div class="amount"><%= c.title %></div>
+        <dl class="meta">
+          <% if (c.code) { %><dt>Code</dt><dd><code><%= c.code %></code></dd><% } %>
+          <% if (c.expires_on) { %><dt>Expires</dt><dd><%= c.expires_on %></dd><% } %>
+          <% if (c.source) { %><dt>Source</dt><dd class="subtle"><%= c.source %></dd><% } %>
+        </dl>
+        <footer class="card-foot">
+          <span class="when" title="<%= cCreated.toISOString() %>">🕓 <%= cStr %></span>
+          <% if (c.url) { %><a class="btn tiny" href="<%= c.url %>" target="_blank" rel="noopener noreferrer">Open</a><% } %>
+        </footer>
+      </article>
+    <% }) %>
+  </section>
+<% } %>
+
+<script>
+  window.__csrf = '<%= csrfToken %>';
+  async function post(url, body) {
+    const r = await fetch(url, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(Object.assign({ _csrf: window.__csrf }, body || {})),
+    });
+    return r.json();
+  }
+  document.getElementById('runAdvisor')?.addEventListener('click', async (e) => {
+    const btn = e.currentTarget; btn.disabled = true; btn.textContent = 'Thinking…';
+    try {
+      const out = await post('/api/savings/run');
+      btn.textContent = out.ok ? `+${out.created} found` : 'Error';
+      if (out.ok && out.created) setTimeout(() => location.reload(), 700);
+      else btn.disabled = false;
+    } catch (_) { btn.textContent = 'Error'; btn.disabled = false; }
+  });
+  document.querySelectorAll('[data-act]').forEach(b => b.addEventListener('click', async (e) => {
+    const el = e.currentTarget; const id = el.dataset.id;
+    const status = el.dataset.act === 'save' ? 'saved' : 'dismissed';
+    el.disabled = true;
+    await post('/api/savings/' + id + '/status', { status });
+    const card = el.closest('article');
+    if (status === 'dismissed') { card.style.opacity = 0.35; card.remove(); }
+    else { card.classList.add('saved'); el.remove(); }
+  }));
+</script>
+
+<%- include('partials/footer', { gridScript: true }) %>

← 367d888 auto-save: 2026-07-12T23:51:20 (1 files) — backups/  ·  back to AbramsOS  ·  savings: home dashboard tile + honest merchant-savings leads a121be0 →