← 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
M public/css/app.cssM public/js/sort-density.jsA routes/bills.jsM routes/reminders.jsA routes/reorders.jsM server.jsA views/bills.ejsA views/deadlines.ejsM views/partials/header.ejsA views/reorders.ejs
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 & 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 & 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 & 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 →