[object Object]

← back to AbramsOS

feat(ui): Bills, Reorders, and unified Deadlines pages

43aea4bf6c712ebdfe2f7e1df8ae79dcd5aa0ebb · 2026-07-07 07:32:16 -0700 · Steve

- /bills, /reorders CRUD (audit-logged) + /deadlines over the reminder engine
- add-forms, mark-paid/ordered (rolls due date forward + refreshes deadline), created date+time chip on every admin card
- generalized sort-density.js to any [data-grid]; nav links; liquid-glass styles

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 43aea4bf6c712ebdfe2f7e1df8ae79dcd5aa0ebb
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 7 07:32:16 2026 -0700

    feat(ui): Bills, Reorders, and unified Deadlines pages
    
    - /bills, /reorders CRUD (audit-logged) + /deadlines over the reminder engine
    - add-forms, mark-paid/ordered (rolls due date forward + refreshes deadline), created date+time chip on every admin card
    - generalized sort-density.js to any [data-grid]; nav links; liquid-glass styles
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/css/app.css        |  37 +++++++++++++
 public/js/sort-density.js |  65 ++++++++++++++++++++++-
 routes/bills.js           | 127 +++++++++++++++++++++++++++++++++++++++++++++
 routes/reminders.js       |  18 +++++++
 routes/reorders.js        | 124 ++++++++++++++++++++++++++++++++++++++++++++
 server.js                 |   4 ++
 views/bills.ejs           | 129 ++++++++++++++++++++++++++++++++++++++++++++++
 views/deadlines.ejs       |  44 ++++++++++++++++
 views/partials/header.ejs |   7 ++-
 views/reorders.ejs        | 115 +++++++++++++++++++++++++++++++++++++++++
 10 files changed, 666 insertions(+), 4 deletions(-)

diff --git a/public/css/app.css b/public/css/app.css
index 331a726..892949f 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -329,3 +329,40 @@ form input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0
 @media (prefers-reduced-motion: reduce) {
   * { transition: none !important; }
 }
+
+/* ── Bills / Reorders / Deadlines (0007) ─────────────────────────────── */
+.page-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; flex-wrap: wrap; }
+
+.chip { font-size: 11px; padding: 3px 9px; border-radius: 999px; background: var(--surface-strong); border: 1px solid var(--border); color: var(--text-dim); text-transform: capitalize; }
+.chip.cat { color: var(--accent); }
+.chip.save { color: var(--ok); background: rgba(134,239,172,0.12); }
+
+.badge { display: inline-block; font-size: 11px; padding: 3px 9px; border-radius: 999px; background: var(--surface-strong); color: var(--text-dim); }
+.badge.red { background: rgba(248,113,113,0.16); color: var(--danger); }
+.badge.amber { background: rgba(251,191,36,0.16); color: var(--warn); }
+
+.purchase.overdue, .deadline.overdue { border-color: rgba(248,113,113,0.4); }
+
+.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 999px; border: 1px solid var(--border-strong); background: var(--surface); color: var(--text); font-size: 13px; cursor: pointer; }
+.btn:hover { background: var(--surface-strong); }
+.btn.primary { background: var(--accent); border-color: var(--accent); color: #05121f; font-weight: 600; }
+.btn.small { padding: 4px 10px; font-size: 12px; }
+.btn.danger { color: var(--danger); border-color: rgba(248,113,113,0.4); }
+
+.add-form { padding: 16px; margin: 0 0 18px; display: flex; flex-direction: column; gap: 10px; }
+.add-form .row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
+.add-form label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--text-dim); }
+.add-form label.grow { flex: 1; min-width: 160px; }
+.add-form label.chk { flex-direction: row; align-items: center; gap: 6px; }
+.add-form input, .add-form select { padding: 7px 9px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--surface); color: var(--text); font-size: 13px; }
+
+.card-actions { display: flex; gap: 8px; margin-top: 12px; }
+.when { font-size: 11px; color: var(--text-dim); margin-top: 10px; }
+
+.deadline-list { display: flex; flex-direction: column; gap: 10px; }
+.deadline { display: flex; align-items: center; gap: 16px; padding: 14px 18px; }
+.deadline .dl-when { min-width: 92px; text-align: center; flex-shrink: 0; }
+.deadline .dl-date { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
+.deadline .dl-body { flex: 1; }
+.deadline .dl-body h3 { margin: 0 0 4px; font-size: 15px; }
+.deadline .dl-body p { margin: 0 0 6px; font-size: 13px; }
diff --git a/public/js/sort-density.js b/public/js/sort-density.js
index 7f349f2..37bec59 100644
--- a/public/js/sort-density.js
+++ b/public/js/sort-density.js
@@ -2,6 +2,7 @@
 // Inherits the pattern from /sort-skill (corkwallcovering, dw-collections-viewer).
 // Persists choices in localStorage so they survive reloads.
 
+// ── Legacy: the original purchases grid (kept verbatim for back-compat) ──────
 (function () {
   const grid = document.getElementById('purchaseGrid');
   if (!grid) return;
@@ -28,7 +29,6 @@
     cards.sort(cmp).forEach((c) => grid.appendChild(c));
   }
 
-  // Restore
   try {
     const savedSort = localStorage.getItem(SORT_KEY);
     if (savedSort && sortSelect) { sortSelect.value = savedSort; applySort(savedSort); }
@@ -36,7 +36,6 @@
     if (savedDensity && densityRange) { densityRange.value = savedDensity; applyDensity(Number(savedDensity)); }
   } catch (e) {}
 
-  // Wire
   sortSelect?.addEventListener('change', (e) => {
     applySort(e.target.value);
     try { localStorage.setItem(SORT_KEY, e.target.value); } catch (_) {}
@@ -46,3 +45,65 @@
     try { localStorage.setItem(DENSITY_KEY, e.target.value); } catch (_) {}
   });
 })();
+
+// ── Generic: any element with [data-grid="<key>"] ───────────────────────────
+// Controls matched by <select data-sort-for="<key>"> and
+// <input type="range" data-density-for="<key>">. Cards carry data-<field>
+// attributes; option values are "<field>" (asc) or "<field>-asc"/"<field>-desc".
+// Field type (number / date / string) is auto-detected from the value.
+(function () {
+  document.querySelectorAll('[data-grid]').forEach((grid) => {
+    const key = grid.getAttribute('data-grid');
+    const sortSelect = document.querySelector(`[data-sort-for="${key}"]`);
+    const densityRange = document.querySelector(`[data-density-for="${key}"]`);
+    const SORT_KEY = `abramsos.sort.${key}`;
+    const DENSITY_KEY = `abramsos.density.${key}`;
+
+    function detect(v) {
+      if (v === undefined || v === null || v === '') return { t: 'empty', v: null };
+      if (!isNaN(Number(v))) return { t: 'num', v: Number(v) };
+      const d = Date.parse(v);
+      if (!isNaN(d)) return { t: 'date', v: d };
+      return { t: 'str', v: String(v).toLowerCase() };
+    }
+
+    function applyDensity(px) {
+      grid.style.gridTemplateColumns = `repeat(auto-fill, minmax(${px}px, 1fr))`;
+    }
+
+    function applySort(mode) {
+      if (!mode) return;
+      let field = mode, dir = 'asc';
+      const m = mode.match(/^(.*)-(asc|desc)$/);
+      if (m) { field = m[1]; dir = m[2]; }
+      const sign = dir === 'desc' ? -1 : 1;
+      Array.from(grid.children).sort((a, b) => {
+        const av = detect(a.dataset[field]);
+        const bv = detect(b.dataset[field]);
+        if (av.t === 'empty' && bv.t === 'empty') return 0;
+        if (av.t === 'empty') return 1;   // empties always last
+        if (bv.t === 'empty') return -1;
+        if (av.v < bv.v) return -1 * sign;
+        if (av.v > bv.v) return 1 * sign;
+        return 0;
+      }).forEach((c) => grid.appendChild(c));
+    }
+
+    try {
+      const savedSort = localStorage.getItem(SORT_KEY);
+      if (savedSort && sortSelect) sortSelect.value = savedSort;
+      applySort(sortSelect ? sortSelect.value : null);
+      const savedDensity = localStorage.getItem(DENSITY_KEY);
+      if (savedDensity && densityRange) { densityRange.value = savedDensity; applyDensity(Number(savedDensity)); }
+    } catch (e) {}
+
+    sortSelect?.addEventListener('change', (e) => {
+      applySort(e.target.value);
+      try { localStorage.setItem(SORT_KEY, e.target.value); } catch (_) {}
+    });
+    densityRange?.addEventListener('input', (e) => {
+      applyDensity(Number(e.target.value));
+      try { localStorage.setItem(DENSITY_KEY, e.target.value); } catch (_) {}
+    });
+  });
+})();
diff --git a/routes/bills.js b/routes/bills.js
new file mode 100644
index 0000000..594a9e1
--- /dev/null
+++ b/routes/bills.js
@@ -0,0 +1,127 @@
+// Bills to Pay — recurring & one-off money owed, incl. tax/government (CRA) payments.
+const express = require('express');
+const db = require('../lib/db');
+const audit = require('../lib/audit');
+const { id } = require('../lib/ids');
+const { nextDue, daysUntil, toISODate } = require('../lib/recurrence');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+const CATEGORIES = ['utility', 'rent', 'mortgage', 'insurance', 'subscription', 'loan', 'card', 'phone', 'internet', 'tax', 'government', 'other'];
+const CADENCES = ['once', 'weekly', 'biweekly', 'monthly', 'quarterly', 'semiannual', 'annual'];
+
+// Decorate a row with derived fields the UI needs (days until due, overdue flag).
+function decorate(b) {
+  const d = b.due_date ? daysUntil(b.due_date) : null;
+  return {
+    ...b,
+    days_until: d,
+    is_overdue: b.status === 'active' && d != null && d < 0,
+    is_due_soon: b.status === 'active' && d != null && d >= 0 && d <= 7,
+  };
+}
+
+async function listBills(userId) {
+  const r = await db.query(
+    `SELECT * FROM bill WHERE user_id = $1 ORDER BY (due_date IS NULL), due_date ASC, created_at DESC`,
+    [userId]
+  );
+  return r.rows.map(decorate);
+}
+
+// ── page ──────────────────────────────────────────────
+router.get('/bills', async (_req, res) => {
+  const bills = await listBills(DEV_USER_ID);
+  const monthlyBurden = bills
+    .filter((b) => b.status === 'active' && b.cadence === 'monthly' && b.amount)
+    .reduce((s, b) => s + Number(b.amount), 0);
+  res.render('bills', { bills, categories: CATEGORIES, cadences: CADENCES, monthlyBurden });
+});
+
+// ── api ───────────────────────────────────────────────
+router.get('/api/bills', async (_req, res) => {
+  res.json(await listBills(DEV_USER_ID));
+});
+
+router.post('/api/bills', async (req, res) => {
+  try {
+    const b = req.body || {};
+    if (!b.name) return res.status(400).json({ error: 'name required' });
+    const billId = id('bill');
+    await db.query(
+      `INSERT INTO bill (id, user_id, name, payee, category, amount, currency, cadence, due_date, autopay, account_hint, url, status, notes)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
+      [
+        billId, DEV_USER_ID, b.name, b.payee || null,
+        CATEGORIES.includes(b.category) ? b.category : 'other',
+        b.amount || null, b.currency || 'USD',
+        CADENCES.includes(b.cadence) ? b.cadence : 'monthly',
+        b.due_date || null, b.autopay === true || b.autopay === 'on',
+        b.account_hint || null, b.url || null, 'active', b.notes || null,
+      ]
+    );
+    await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'bill', objectId: billId, eventType: 'bill_created', metadata: { name: b.name, category: b.category } });
+    const r = await db.query(`SELECT * FROM bill WHERE id = $1`, [billId]);
+    res.status(201).json(decorate(r.rows[0]));
+  } catch (err) {
+    res.status(500).json({ error: err.message });
+  }
+});
+
+router.post('/api/bills/:id', async (req, res) => {
+  try {
+    const b = req.body || {};
+    const fields = ['name', 'payee', 'category', 'amount', 'currency', 'cadence', 'due_date', 'autopay', 'account_hint', 'url', 'status', 'notes'];
+    const sets = [];
+    const vals = [];
+    let i = 1;
+    for (const f of fields) {
+      if (b[f] === undefined) continue;
+      sets.push(`${f} = $${i++}`);
+      vals.push(f === 'autopay' ? (b[f] === true || b[f] === 'on') : (b[f] === '' ? null : b[f]));
+    }
+    if (!sets.length) return res.status(400).json({ error: 'no fields to update' });
+    sets.push(`updated_at = now()`);
+    vals.push(req.params.id, DEV_USER_ID);
+    const r = await db.query(
+      `UPDATE bill SET ${sets.join(', ')} WHERE id = $${i++} AND user_id = $${i} RETURNING *`,
+      vals
+    );
+    if (!r.rows.length) return res.status(404).json({ error: 'not found' });
+    await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'bill', objectId: req.params.id, eventType: 'bill_updated' });
+    res.json(decorate(r.rows[0]));
+  } catch (err) {
+    res.status(500).json({ error: err.message });
+  }
+});
+
+// Mark a bill paid → stamp last_paid_at; roll due_date forward for recurring bills, else close it.
+router.post('/api/bills/:id/paid', async (req, res) => {
+  try {
+    const cur = await db.query(`SELECT * FROM bill WHERE id = $1 AND user_id = $2`, [req.params.id, DEV_USER_ID]);
+    if (!cur.rows.length) return res.status(404).json({ error: 'not found' });
+    const bill = cur.rows[0];
+    const today = toISODate(new Date());
+    const rolled = nextDue(bill.due_date || today, bill.cadence);
+    const r = await db.query(
+      `UPDATE bill SET last_paid_at = $1, due_date = $2, status = $3, updated_at = now()
+       WHERE id = $4 AND user_id = $5 RETURNING *`,
+      [today, rolled || bill.due_date, rolled ? 'active' : 'closed', req.params.id, DEV_USER_ID]
+    );
+    // Clear the old deadline so the engine regenerates against the new due_date.
+    await db.query(`DELETE FROM calendar_reminder WHERE user_id = $1 AND owner_table = 'bill' AND owner_id = $2`, [DEV_USER_ID, req.params.id]);
+    await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'bill', objectId: req.params.id, eventType: 'bill_paid', metadata: { paid_on: today, next_due: rolled } });
+    res.json(decorate(r.rows[0]));
+  } catch (err) {
+    res.status(500).json({ error: err.message });
+  }
+});
+
+router.post('/api/bills/:id/delete', async (req, res) => {
+  await db.query(`DELETE FROM bill WHERE id = $1 AND user_id = $2`, [req.params.id, DEV_USER_ID]);
+  await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'bill', objectId: req.params.id, eventType: 'bill_deleted' });
+  res.json({ ok: true });
+});
+
+module.exports = router;
diff --git a/routes/reminders.js b/routes/reminders.js
index b53e5f6..fdd3737 100644
--- a/routes/reminders.js
+++ b/routes/reminders.js
@@ -6,6 +6,24 @@ const engine = require('../lib/reminder-engine');
 const router = express.Router();
 const DEV_USER_ID = 'user_steve';
 
+// Unified Deadlines page — regenerates from all owner tables (purchases, bills,
+// reorders, warranties, recalls) then renders everything due in the next 120 days.
+router.get('/deadlines', async (req, res) => {
+  try {
+    await engine.generateForUser(DEV_USER_ID);
+  } catch (err) {
+    console.error('[deadlines] regenerate failed', err.message);
+  }
+  const days = parseInt(req.query.days || '120', 10);
+  const rows = await engine.upcomingForUser(DEV_USER_ID, { days });
+  const now = Date.now();
+  const deadlines = rows.map((r) => ({
+    ...r,
+    days_until: Math.round((new Date(r.due_at).getTime() - now) / 86400000),
+  }));
+  res.render('deadlines', { deadlines });
+});
+
 // Manual recompute trigger (cheap; idempotent because of the dedupe unique index)
 router.post('/api/reminders/regenerate', async (_req, res) => {
   try {
diff --git a/routes/reorders.js b/routes/reorders.js
new file mode 100644
index 0000000..8ecd89a
--- /dev/null
+++ b/routes/reorders.js
@@ -0,0 +1,124 @@
+// Reorders — "what we order a lot" + best-price ("save money") tracking.
+const express = require('express');
+const db = require('../lib/db');
+const audit = require('../lib/audit');
+const { id } = require('../lib/ids');
+const { nextDueDays, daysUntil, toISODate } = require('../lib/recurrence');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+const CATEGORIES = ['grocery', 'household', 'pet', 'office', 'health', 'other'];
+
+function decorate(r) {
+  const d = r.next_due_date ? daysUntil(r.next_due_date) : null;
+  // savings = how much cheaper the best price is vs. what we typically pay
+  const savings = r.typical_price != null && r.best_price != null
+    ? Number(r.typical_price) - Number(r.best_price)
+    : null;
+  return {
+    ...r,
+    days_until: d,
+    is_due: r.status === 'active' && d != null && d <= 0,
+    savings: savings != null && savings > 0 ? Number(savings.toFixed(2)) : 0,
+  };
+}
+
+async function listItems(userId) {
+  const r = await db.query(
+    `SELECT * FROM reorder_item WHERE user_id = $1 ORDER BY (next_due_date IS NULL), next_due_date ASC, created_at DESC`,
+    [userId]
+  );
+  return r.rows.map(decorate);
+}
+
+router.get('/reorders', async (_req, res) => {
+  const items = await listItems(DEV_USER_ID);
+  const totalSavings = items.reduce((s, x) => s + (x.savings || 0), 0);
+  res.render('reorders', { items, categories: CATEGORIES, totalSavings });
+});
+
+router.get('/api/reorders', async (_req, res) => {
+  res.json(await listItems(DEV_USER_ID));
+});
+
+router.post('/api/reorders', async (req, res) => {
+  try {
+    const b = req.body || {};
+    if (!b.name) return res.status(400).json({ error: 'name required' });
+    const cadence = b.reorder_cadence_days ? parseInt(b.reorder_cadence_days, 10) : null;
+    const nextDue = b.last_ordered_at && cadence ? nextDueDays(b.last_ordered_at, cadence) : (b.next_due_date || null);
+    const itemId = id('reorder');
+    await db.query(
+      `INSERT INTO reorder_item (id, user_id, name, merchant, category, unit, typical_price, best_price, best_price_source, currency, reorder_cadence_days, last_ordered_at, next_due_date, url, status, notes)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`,
+      [
+        itemId, DEV_USER_ID, b.name, b.merchant || null,
+        CATEGORIES.includes(b.category) ? b.category : (b.category || null),
+        b.unit || null, b.typical_price || null, b.best_price || null, b.best_price_source || null,
+        b.currency || 'USD', cadence, b.last_ordered_at || null, nextDue, b.url || null, 'active', b.notes || null,
+      ]
+    );
+    await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'reorder_item', objectId: itemId, eventType: 'reorder_created', metadata: { name: b.name } });
+    const r = await db.query(`SELECT * FROM reorder_item WHERE id = $1`, [itemId]);
+    res.status(201).json(decorate(r.rows[0]));
+  } catch (err) {
+    res.status(500).json({ error: err.message });
+  }
+});
+
+router.post('/api/reorders/:id', async (req, res) => {
+  try {
+    const b = req.body || {};
+    const fields = ['name', 'merchant', 'category', 'unit', 'typical_price', 'best_price', 'best_price_source', 'currency', 'reorder_cadence_days', 'last_ordered_at', 'next_due_date', 'url', 'status', 'notes'];
+    const sets = [];
+    const vals = [];
+    let i = 1;
+    for (const f of fields) {
+      if (b[f] === undefined) continue;
+      sets.push(`${f} = $${i++}`);
+      vals.push(b[f] === '' ? null : b[f]);
+    }
+    if (!sets.length) return res.status(400).json({ error: 'no fields to update' });
+    sets.push(`updated_at = now()`);
+    vals.push(req.params.id, DEV_USER_ID);
+    const r = await db.query(
+      `UPDATE reorder_item SET ${sets.join(', ')} WHERE id = $${i++} AND user_id = $${i} RETURNING *`,
+      vals
+    );
+    if (!r.rows.length) return res.status(404).json({ error: 'not found' });
+    await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'reorder_item', objectId: req.params.id, eventType: 'reorder_updated' });
+    res.json(decorate(r.rows[0]));
+  } catch (err) {
+    res.status(500).json({ error: err.message });
+  }
+});
+
+// Mark ordered → stamp last_ordered_at=today and roll next_due_date forward by cadence.
+router.post('/api/reorders/:id/ordered', async (req, res) => {
+  try {
+    const cur = await db.query(`SELECT * FROM reorder_item WHERE id = $1 AND user_id = $2`, [req.params.id, DEV_USER_ID]);
+    if (!cur.rows.length) return res.status(404).json({ error: 'not found' });
+    const item = cur.rows[0];
+    const today = toISODate(new Date());
+    const nextDue = item.reorder_cadence_days ? nextDueDays(today, item.reorder_cadence_days) : null;
+    const r = await db.query(
+      `UPDATE reorder_item SET last_ordered_at = $1, next_due_date = $2, updated_at = now()
+       WHERE id = $3 AND user_id = $4 RETURNING *`,
+      [today, nextDue, req.params.id, DEV_USER_ID]
+    );
+    await db.query(`DELETE FROM calendar_reminder WHERE user_id = $1 AND owner_table = 'reorder_item' AND owner_id = $2`, [DEV_USER_ID, req.params.id]);
+    await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'reorder_item', objectId: req.params.id, eventType: 'reorder_ordered', metadata: { ordered_on: today, next_due: nextDue } });
+    res.json(decorate(r.rows[0]));
+  } catch (err) {
+    res.status(500).json({ error: err.message });
+  }
+});
+
+router.post('/api/reorders/:id/delete', async (req, res) => {
+  await db.query(`DELETE FROM reorder_item WHERE id = $1 AND user_id = $2`, [req.params.id, DEV_USER_ID]);
+  await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'reorder_item', objectId: req.params.id, eventType: 'reorder_deleted' });
+  res.json({ ok: true });
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 9ccd9c4..3b41ca6 100644
--- a/server.js
+++ b/server.js
@@ -22,6 +22,8 @@ const claimsRouter = require('./routes/claims');
 const auditRouter = require('./routes/audit');
 const recallsRouter = require('./routes/recalls');
 const uploadRouter = require('./routes/upload');
+const billsRouter = require('./routes/bills');
+const reordersRouter = require('./routes/reorders');
 
 const app = express();
 const PORT = parseInt(process.env.PORT || '9931', 10);
@@ -79,6 +81,8 @@ app.use(claimsRouter);              // /api/claims, /api/claims/from-reminder/:i
 app.use(auditRouter);               // /audit, /api/audit/events
 app.use(recallsRouter);             // /recalls, /api/recalls
 app.use(uploadRouter);              // /api/upload/csv
+app.use(billsRouter);               // /bills, /api/bills*
+app.use(reordersRouter);            // /reorders, /api/reorders*
 
 // Step-up-required routes (must re-verify TOTP within 60s)
 app.use('/import', requireStepUp, importRouter);
diff --git a/views/bills.ejs b/views/bills.ejs
new file mode 100644
index 0000000..4f5c21d
--- /dev/null
+++ b/views/bills.ejs
@@ -0,0 +1,129 @@
+<%- include('partials/header', { title: 'Bills' }) %>
+
+<section class="page-head">
+  <div>
+    <h1>Bills to Pay</h1>
+    <p class="subtle">
+      <%= bills.length %> tracked ·
+      <% if (monthlyBurden) { %>~USD <%= monthlyBurden.toFixed(2) %>/mo recurring<% } %>
+      · tax / government payments (e.g. CRA) live here — set Category to <em>tax</em> or <em>government</em>.
+    </p>
+  </div>
+  <div class="grid-controls">
+    <label>Sort
+      <select data-sort-for="bill">
+        <option value="due-asc">Due soonest</option>
+        <option value="due-desc">Due latest</option>
+        <option value="amount-desc">Amount ↓</option>
+        <option value="amount-asc">Amount ↑</option>
+        <option value="name-asc">Name A→Z</option>
+        <option value="category-asc">Category</option>
+        <option value="created-desc">Newest added</option>
+      </select>
+    </label>
+    <label>Density
+      <input data-density-for="bill" type="range" min="240" max="460" step="10" value="320">
+    </label>
+    <button type="button" class="btn" id="toggleAdd">+ Add bill</button>
+  </div>
+</section>
+
+<form id="addForm" class="add-form glass" hidden>
+  <div class="row">
+    <label>Name*<input name="name" required placeholder="Electric bill"></label>
+    <label>Payee<input name="payee" placeholder="LADWP / CRA / Landlord"></label>
+    <label>Category
+      <select name="category">
+        <% categories.forEach(c => { %><option value="<%= c %>"><%= c %></option><% }) %>
+      </select>
+    </label>
+  </div>
+  <div class="row">
+    <label>Amount<input name="amount" type="number" step="0.01" placeholder="0.00"></label>
+    <label>Currency<input name="currency" value="USD" size="4"></label>
+    <label>Cadence
+      <select name="cadence">
+        <% cadences.forEach(c => { %><option value="<%= c %>" <%= c==='monthly'?'selected':'' %>><%= c %></option><% }) %>
+      </select>
+    </label>
+    <label>Next due<input name="due_date" type="date"></label>
+    <label class="chk">Autopay<input name="autopay" type="checkbox"></label>
+  </div>
+  <div class="row">
+    <label>Account hint<input name="account_hint" placeholder="Visa ••1234 (never full number)"></label>
+    <label>Pay link<input name="url" type="url" placeholder="https://…"></label>
+    <label class="grow">Notes<input name="notes" placeholder="optional"></label>
+  </div>
+  <div class="row"><button type="submit" class="btn primary">Save bill</button></div>
+</form>
+
+<% if (!bills.length) { %>
+  <section class="empty glass"><p>No bills yet. Click <strong>+ Add bill</strong> to track your first one.</p></section>
+<% } %>
+
+<section data-grid="bill" class="purchase-grid" style="grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));">
+  <% bills.forEach(b => { %>
+    <article class="purchase glass<%= b.is_overdue ? ' overdue' : '' %>"
+             data-name="<%= (b.name||'').toLowerCase() %>"
+             data-amount="<%= b.amount || 0 %>"
+             data-category="<%= b.category %>"
+             data-due="<%= b.due_date || '' %>"
+             data-created="<%= new Date(b.created_at).toISOString() %>">
+      <header>
+        <h3><%= b.name %></h3>
+        <span class="chip cat"><%= b.category %></span>
+      </header>
+      <div class="amount">
+        <% if (b.amount) { %><%= b.currency %> <%= Number(b.amount).toFixed(2) %><% } else { %><span class="subtle">amount unset</span><% } %>
+        <span class="subtle">/ <%= b.cadence %></span>
+      </div>
+      <dl class="meta">
+        <% if (b.payee) { %><dt>Payee</dt><dd><%= b.payee %></dd><% } %>
+        <dt>Due</dt>
+        <dd>
+          <% if (b.due_date) { %>
+            <%= b.due_date %>
+            <% if (b.is_overdue) { %><span class="badge red"><%= Math.abs(b.days_until) %>d overdue</span>
+            <% } else if (b.is_due_soon) { %><span class="badge amber">in <%= b.days_until %>d</span>
+            <% } else { %><span class="badge">in <%= b.days_until %>d</span><% } %>
+          <% } else { %><span class="subtle">no date</span><% } %>
+        </dd>
+        <% if (b.autopay) { %><dt>Autopay</dt><dd>✓ on</dd><% } %>
+        <% if (b.account_hint) { %><dt>Acct</dt><dd><%= b.account_hint %></dd><% } %>
+        <% if (b.last_paid_at) { %><dt>Last paid</dt><dd><%= b.last_paid_at %></dd><% } %>
+        <% if (b.url) { %><dt>Link</dt><dd><a href="<%= b.url %>" target="_blank" rel="noopener noreferrer">pay →</a></dd><% } %>
+      </dl>
+      <div class="when" title="<%= new Date(b.created_at).toISOString() %>">🕓 added <%= new Date(b.created_at).toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }) %></div>
+      <div class="card-actions">
+        <button class="btn small" data-action="paid" data-id="<%= b.id %>">Mark paid</button>
+        <button class="btn small danger" data-action="delete" data-id="<%= b.id %>">Delete</button>
+      </div>
+    </article>
+  <% }) %>
+</section>
+
+<script>
+  const $ = (s, r = document) => r.querySelector(s);
+  $('#toggleAdd')?.addEventListener('click', () => { const f = $('#addForm'); f.hidden = !f.hidden; });
+
+  $('#addForm')?.addEventListener('submit', async (e) => {
+    e.preventDefault();
+    const fd = new FormData(e.target);
+    const body = Object.fromEntries(fd.entries());
+    body.autopay = fd.get('autopay') === 'on';
+    const res = await fetch('/api/bills', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+    if (res.ok) location.reload(); else alert('Save failed: ' + (await res.text()));
+  });
+
+  document.querySelectorAll('[data-action]').forEach((btn) => {
+    btn.addEventListener('click', async () => {
+      const id = btn.dataset.id, action = btn.dataset.action;
+      if (action === 'delete' && !confirm('Delete this bill?')) return;
+      const url = action === 'paid' ? `/api/bills/${id}/paid` : `/api/bills/${id}/delete`;
+      const res = await fetch(url, { method: 'POST' });
+      if (res.ok) location.reload(); else alert('Action failed');
+    });
+  });
+</script>
+
+<%- include('partials/footer', { gridScript: true }) %>
diff --git a/views/deadlines.ejs b/views/deadlines.ejs
new file mode 100644
index 0000000..69bc91f
--- /dev/null
+++ b/views/deadlines.ejs
@@ -0,0 +1,44 @@
+<%- include('partials/header', { title: 'Deadlines' }) %>
+
+<section class="page-head">
+  <div>
+    <h1>Deadlines</h1>
+    <p class="subtle">Everything time-sensitive across AbramsOS — bill due dates, reorder points, warranty &amp; return windows, recall actions. Regenerated on each visit.</p>
+  </div>
+</section>
+
+<% if (!deadlines.length) { %>
+  <section class="empty glass"><p>Nothing due in the window. Add <a href="/bills">bills</a> or <a href="/reorders">reorders</a> with due dates and they'll appear here.</p></section>
+<% } else { %>
+  <section class="deadline-list">
+    <% deadlines.forEach(d => {
+         const over = d.days_until < 0;
+         const soon = d.days_until >= 0 && d.days_until <= 7; %>
+      <article class="deadline glass<%= over ? ' overdue' : '' %>" data-id="<%= d.id %>">
+        <div class="dl-when">
+          <% if (over) { %><span class="badge red"><%= Math.abs(d.days_until) %>d overdue</span>
+          <% } else if (soon) { %><span class="badge amber">in <%= d.days_until %>d</span>
+          <% } else { %><span class="badge">in <%= d.days_until %>d</span><% } %>
+          <div class="dl-date"><%= new Date(d.due_at).toLocaleDateString(undefined, { weekday:'short', month:'short', day:'numeric' }) %></div>
+        </div>
+        <div class="dl-body">
+          <h3><%= d.title %></h3>
+          <% if (d.body) { %><p class="subtle"><%= d.body %></p><% } %>
+          <span class="chip"><%= d.reason_code.replace(/_/g, ' ') %></span>
+        </div>
+        <button class="btn small" data-dismiss="<%= d.id %>">Dismiss</button>
+      </article>
+    <% }) %>
+  </section>
+<% } %>
+
+<script>
+  document.querySelectorAll('[data-dismiss]').forEach((btn) => {
+    btn.addEventListener('click', async () => {
+      const res = await fetch(`/api/reminders/${btn.dataset.dismiss}/dismiss`, { method: 'POST' });
+      if (res.ok) btn.closest('.deadline').remove(); else alert('Dismiss failed');
+    });
+  });
+</script>
+
+<%- include('partials/footer', {}) %>
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index 16e7658..c344320 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -22,11 +22,14 @@
     </div>
     <nav>
       <a href="/">Home</a>
-      <a href="/import">Import</a>
-      <a href="/connectors">Connectors</a>
+      <a href="/deadlines">Deadlines</a>
+      <a href="/bills">Bills</a>
+      <a href="/reorders">Reorders</a>
       <a href="/purchases">Purchases</a>
       <a href="/claims">Claims</a>
       <a href="/recalls">Recalls</a>
+      <a href="/import">Import</a>
+      <a href="/connectors">Connectors</a>
       <a href="/audit">Audit</a>
     </nav>
     <% if (typeof userId !== 'undefined' && userId) { %>
diff --git a/views/reorders.ejs b/views/reorders.ejs
new file mode 100644
index 0000000..2a9ccfb
--- /dev/null
+++ b/views/reorders.ejs
@@ -0,0 +1,115 @@
+<%- include('partials/header', { title: 'Reorders' }) %>
+
+<section class="page-head">
+  <div>
+    <h1>Reorders &amp; Savings</h1>
+    <p class="subtle">
+      <%= items.length %> items you buy a lot ·
+      <% if (totalSavings) { %>USD <%= totalSavings.toFixed(2) %> saved by buying at your best-known price<% } else { %>log a typical &amp; best price to see savings<% } %>
+    </p>
+  </div>
+  <div class="grid-controls">
+    <label>Sort
+      <select data-sort-for="reorder">
+        <option value="due-asc">Reorder soonest</option>
+        <option value="savings-desc">Biggest savings</option>
+        <option value="name-asc">Name A→Z</option>
+        <option value="merchant-asc">Merchant A→Z</option>
+        <option value="created-desc">Newest added</option>
+      </select>
+    </label>
+    <label>Density
+      <input data-density-for="reorder" type="range" min="240" max="460" step="10" value="320">
+    </label>
+    <button type="button" class="btn" id="toggleAdd">+ Add item</button>
+  </div>
+</section>
+
+<form id="addForm" class="add-form glass" hidden>
+  <div class="row">
+    <label>Name*<input name="name" required placeholder="Paper towels"></label>
+    <label>Merchant<input name="merchant" placeholder="Costco / Amazon"></label>
+    <label>Category<input name="category" placeholder="household"></label>
+    <label>Unit<input name="unit" placeholder="12-pack"></label>
+  </div>
+  <div class="row">
+    <label>Typical price<input name="typical_price" type="number" step="0.01" placeholder="0.00"></label>
+    <label>Best price<input name="best_price" type="number" step="0.01" placeholder="0.00"></label>
+    <label>Best price source<input name="best_price_source" placeholder="Costco 6/26"></label>
+  </div>
+  <div class="row">
+    <label>Reorder every (days)<input name="reorder_cadence_days" type="number" placeholder="30"></label>
+    <label>Last ordered<input name="last_ordered_at" type="date"></label>
+    <label>Link<input name="url" type="url" placeholder="https://…"></label>
+    <label class="grow">Notes<input name="notes" placeholder="optional"></label>
+  </div>
+  <div class="row"><button type="submit" class="btn primary">Save item</button></div>
+</form>
+
+<% if (!items.length) { %>
+  <section class="empty glass"><p>Nothing tracked yet. Click <strong>+ Add item</strong> to log something you reorder.</p></section>
+<% } %>
+
+<section data-grid="reorder" class="purchase-grid" style="grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));">
+  <% items.forEach(it => { %>
+    <article class="purchase glass<%= it.is_due ? ' overdue' : '' %>"
+             data-name="<%= (it.name||'').toLowerCase() %>"
+             data-merchant="<%= (it.merchant||'').toLowerCase() %>"
+             data-savings="<%= it.savings || 0 %>"
+             data-due="<%= it.next_due_date || '' %>"
+             data-created="<%= new Date(it.created_at).toISOString() %>">
+      <header>
+        <h3><%= it.name %></h3>
+        <% if (it.savings) { %><span class="chip save">save <%= it.currency %> <%= it.savings.toFixed(2) %></span><% } %>
+      </header>
+      <div class="amount">
+        <% if (it.best_price) { %><%= it.currency %> <%= Number(it.best_price).toFixed(2) %> <span class="subtle">best</span>
+        <% } else if (it.typical_price) { %><%= it.currency %> <%= Number(it.typical_price).toFixed(2) %> <span class="subtle">typical</span>
+        <% } else { %><span class="subtle">no price yet</span><% } %>
+        <% if (it.unit) { %><span class="subtle">/ <%= it.unit %></span><% } %>
+      </div>
+      <dl class="meta">
+        <% if (it.merchant) { %><dt>Buy from</dt><dd><%= it.merchant %></dd><% } %>
+        <% if (it.typical_price && it.best_price) { %><dt>Typical</dt><dd><%= it.currency %> <%= Number(it.typical_price).toFixed(2) %></dd><% } %>
+        <% if (it.best_price_source) { %><dt>Best at</dt><dd><%= it.best_price_source %></dd><% } %>
+        <dt>Reorder</dt>
+        <dd>
+          <% if (it.next_due_date) { %>
+            <%= it.next_due_date %>
+            <% if (it.is_due) { %><span class="badge amber">due now</span><% } else { %><span class="badge">in <%= it.days_until %>d</span><% } %>
+          <% } else if (it.reorder_cadence_days) { %>every <%= it.reorder_cadence_days %>d<% } else { %><span class="subtle">as needed</span><% } %>
+        </dd>
+        <% if (it.url) { %><dt>Link</dt><dd><a href="<%= it.url %>" target="_blank" rel="noopener noreferrer">buy →</a></dd><% } %>
+      </dl>
+      <div class="when" title="<%= new Date(it.created_at).toISOString() %>">🕓 added <%= new Date(it.created_at).toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }) %></div>
+      <div class="card-actions">
+        <button class="btn small" data-action="ordered" data-id="<%= it.id %>">Ordered today</button>
+        <button class="btn small danger" data-action="delete" data-id="<%= it.id %>">Delete</button>
+      </div>
+    </article>
+  <% }) %>
+</section>
+
+<script>
+  const $ = (s, r = document) => r.querySelector(s);
+  $('#toggleAdd')?.addEventListener('click', () => { const f = $('#addForm'); f.hidden = !f.hidden; });
+
+  $('#addForm')?.addEventListener('submit', async (e) => {
+    e.preventDefault();
+    const body = Object.fromEntries(new FormData(e.target).entries());
+    const res = await fetch('/api/reorders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+    if (res.ok) location.reload(); else alert('Save failed: ' + (await res.text()));
+  });
+
+  document.querySelectorAll('[data-action]').forEach((btn) => {
+    btn.addEventListener('click', async () => {
+      const id = btn.dataset.id, action = btn.dataset.action;
+      if (action === 'delete' && !confirm('Delete this item?')) return;
+      const url = action === 'ordered' ? `/api/reorders/${id}/ordered` : `/api/reorders/${id}/delete`;
+      const res = await fetch(url, { method: 'POST' });
+      if (res.ok) location.reload(); else alert('Action failed');
+    });
+  });
+</script>
+
+<%- include('partials/footer', { gridScript: true }) %>

← 5693d23 feat(schema): bill + reorder_item tables, recurrence lib, re  ·  back to AbramsOS  ·  feat(warranties): manual Warranties page + Deadlines coverag 61b2dca →