← back to AbramsOS
feat(warranties): manual Warranties page + Deadlines coverage
61b2dca0311a2b07f811508ce3e7307aec9dff90 · 2026-07-07 07:45:18 -0700 · Steve
- /warranties + /api/warranties CRUD (audit-logged) writing to service_commitment (source='manual')
- reminder-engine scans service_commitment.refund_window_ends_at -> 'coverage_window_closing' deadlines
- nav link, warranty id prefix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M lib/ids.jsM lib/reminder-engine.jsA routes/warranties.jsM server.jsM views/partials/header.ejsA views/warranties.ejs
Diff
commit 61b2dca0311a2b07f811508ce3e7307aec9dff90
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jul 7 07:45:18 2026 -0700
feat(warranties): manual Warranties page + Deadlines coverage
- /warranties + /api/warranties CRUD (audit-logged) writing to service_commitment (source='manual')
- reminder-engine scans service_commitment.refund_window_ends_at -> 'coverage_window_closing' deadlines
- nav link, warranty id prefix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
lib/ids.js | 1 +
lib/reminder-engine.js | 23 ++++++++++
routes/warranties.js | 104 +++++++++++++++++++++++++++++++++++++++++++++
server.js | 2 +
views/partials/header.ejs | 1 +
views/warranties.ejs | 105 ++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 236 insertions(+)
diff --git a/lib/ids.js b/lib/ids.js
index 9360880..721bae0 100644
--- a/lib/ids.js
+++ b/lib/ids.js
@@ -11,6 +11,7 @@ const PREFIX = {
bill: 'bill',
reorder: 'reord',
reminder: 'rem',
+ warranty: 'wty',
};
function id(kind) {
diff --git a/lib/reminder-engine.js b/lib/reminder-engine.js
index e39a29e..d9ef012 100644
--- a/lib/reminder-engine.js
+++ b/lib/reminder-engine.js
@@ -117,6 +117,29 @@ async function generateForUser(userId) {
}
}
+ // 6) Warranty / return / guarantee windows closing — fire within 30 days
+ const commitments = await db.query(
+ `SELECT id, provider_name, commitment_type, refund_window_ends_at
+ FROM service_commitment
+ WHERE user_id = $1 AND refund_window_ends_at IS NOT NULL`,
+ [userId]
+ );
+ for (const c of commitments.rows) {
+ const daysUntil = (new Date(c.refund_window_ends_at).getTime() - Date.now()) / 86400e3;
+ if (daysUntil > 0 && daysUntil <= 30) {
+ const label = c.provider_name || 'coverage';
+ const r = await tryInsert({
+ userId, ownerTable: 'service_commitment', ownerId: c.id,
+ dueAt: new Date(c.refund_window_ends_at),
+ reasonCode: 'coverage_window_closing',
+ title: `${label} ${c.commitment_type || 'warranty'} window closes in ${Math.ceil(daysUntil)}d`,
+ body: `File any ${c.commitment_type || 'warranty'} claim before ${new Date(c.refund_window_ends_at).toDateString()}.`,
+ metadata: { commitment_type: c.commitment_type },
+ });
+ if (r) inserted += 1;
+ }
+ }
+
return inserted;
}
diff --git a/routes/warranties.js b/routes/warranties.js
new file mode 100644
index 0000000..aa49102
--- /dev/null
+++ b/routes/warranties.js
@@ -0,0 +1,104 @@
+// Warranties — manual entry of warranties / guarantees / return windows.
+// Rows land in service_commitment (source='manual'), same table the receipt
+// extractor populates, so they share the Deadlines engine + claims flow.
+const express = require('express');
+const db = require('../lib/db');
+const audit = require('../lib/audit');
+const { id } = require('../lib/ids');
+const { daysUntil } = require('../lib/recurrence');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+const TYPES = ['warranty', 'return', 'guarantee', 'service', 'other'];
+
+function decorate(w) {
+ const d = w.refund_window_ends_at ? daysUntil(w.refund_window_ends_at) : null;
+ return {
+ ...w,
+ days_until: d,
+ is_expired: d != null && d < 0,
+ is_expiring_soon: d != null && d >= 0 && d <= 30,
+ };
+}
+
+async function listWarranties(userId) {
+ const r = await db.query(
+ `SELECT * FROM service_commitment
+ WHERE user_id = $1
+ ORDER BY (refund_window_ends_at IS NULL), refund_window_ends_at ASC, created_at DESC`,
+ [userId]
+ );
+ return r.rows.map(decorate);
+}
+
+router.get('/warranties', async (_req, res) => {
+ const warranties = await listWarranties(DEV_USER_ID);
+ res.render('warranties', { warranties, types: TYPES });
+});
+
+router.get('/api/warranties', async (_req, res) => {
+ res.json(await listWarranties(DEV_USER_ID));
+});
+
+router.post('/api/warranties', async (req, res) => {
+ try {
+ const b = req.body || {};
+ if (!b.provider_name && !b.guarantee_text) return res.status(400).json({ error: 'provider or coverage description required' });
+ const wId = id('warranty');
+ await db.query(
+ `INSERT INTO service_commitment
+ (id, user_id, provider_name, commitment_type, guarantee_text, promised_outcome, window_days, refund_window_ends_at, conditions, source, confidence)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'manual',1.0)`,
+ [
+ wId, DEV_USER_ID, b.provider_name || null,
+ TYPES.includes(b.commitment_type) ? b.commitment_type : 'warranty',
+ b.guarantee_text || b.provider_name || 'Warranty',
+ b.promised_outcome || null,
+ b.window_days ? parseInt(b.window_days, 10) : null,
+ b.refund_window_ends_at || null,
+ b.conditions || null,
+ ]
+ );
+ await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'service_commitment', objectId: wId, eventType: 'warranty_created', metadata: { provider: b.provider_name, type: b.commitment_type } });
+ const r = await db.query(`SELECT * FROM service_commitment WHERE id = $1`, [wId]);
+ res.status(201).json(decorate(r.rows[0]));
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+router.post('/api/warranties/:id', async (req, res) => {
+ try {
+ const b = req.body || {};
+ const fields = ['provider_name', 'commitment_type', 'guarantee_text', 'promised_outcome', 'window_days', 'refund_window_ends_at', 'conditions'];
+ 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' });
+ vals.push(req.params.id, DEV_USER_ID);
+ const r = await db.query(
+ `UPDATE service_commitment 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: 'service_commitment', objectId: req.params.id, eventType: 'warranty_updated' });
+ res.json(decorate(r.rows[0]));
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+router.post('/api/warranties/:id/delete', async (req, res) => {
+ await db.query(`DELETE FROM calendar_reminder WHERE user_id = $1 AND owner_table = 'service_commitment' AND owner_id = $2`, [DEV_USER_ID, req.params.id]);
+ await db.query(`DELETE FROM service_commitment WHERE id = $1 AND user_id = $2`, [req.params.id, DEV_USER_ID]);
+ await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'service_commitment', objectId: req.params.id, eventType: 'warranty_deleted' });
+ res.json({ ok: true });
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 3b41ca6..42277d6 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 warrantiesRouter = require('./routes/warranties');
const app = express();
const PORT = parseInt(process.env.PORT || '9931', 10);
@@ -83,6 +84,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(warrantiesRouter); // /warranties, /api/warranties*
// Step-up-required routes (must re-verify TOTP within 60s)
app.use('/import', requireStepUp, importRouter);
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index c344320..214e8a1 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -24,6 +24,7 @@
<a href="/">Home</a>
<a href="/deadlines">Deadlines</a>
<a href="/bills">Bills</a>
+ <a href="/warranties">Warranties</a>
<a href="/reorders">Reorders</a>
<a href="/purchases">Purchases</a>
<a href="/claims">Claims</a>
diff --git a/views/warranties.ejs b/views/warranties.ejs
new file mode 100644
index 0000000..1e1ed19
--- /dev/null
+++ b/views/warranties.ejs
@@ -0,0 +1,105 @@
+<%- include('partials/header', { title: 'Warranties' }) %>
+
+<section class="page-head">
+ <div>
+ <h1>Warranties & Guarantees</h1>
+ <p class="subtle"><%= warranties.length %> tracked · manual entries live alongside anything the receipt reader finds · expiry windows flow into <a href="/deadlines">Deadlines</a>.</p>
+ </div>
+ <div class="grid-controls">
+ <label>Sort
+ <select data-sort-for="warranty">
+ <option value="due-asc">Expires soonest</option>
+ <option value="due-desc">Expires latest</option>
+ <option value="provider-asc">Provider A→Z</option>
+ <option value="type-asc">Type</option>
+ <option value="created-desc">Newest added</option>
+ </select>
+ </label>
+ <label>Density
+ <input data-density-for="warranty" type="range" min="240" max="460" step="10" value="320">
+ </label>
+ <button type="button" class="btn" id="toggleAdd">+ Add warranty</button>
+ </div>
+</section>
+
+<form id="addForm" class="add-form glass" hidden>
+ <div class="row">
+ <label>Provider / item*<input name="provider_name" required placeholder="LG fridge / AppleCare"></label>
+ <label>Type
+ <select name="commitment_type">
+ <% types.forEach(t => { %><option value="<%= t %>" <%= t==='warranty'?'selected':'' %>><%= t %></option><% }) %>
+ </select>
+ </label>
+ <label>Expires<input name="refund_window_ends_at" type="date"></label>
+ </div>
+ <div class="row">
+ <label class="grow">What it covers<input name="guarantee_text" placeholder="1-yr parts & labor; extended to 3-yr via AppleCare"></label>
+ <label>Window (days)<input name="window_days" type="number" placeholder="365"></label>
+ </div>
+ <div class="row">
+ <label class="grow">Promised outcome<input name="promised_outcome" placeholder="free repair or replacement"></label>
+ <label class="grow">Conditions / notes<input name="conditions" placeholder="keep receipt; excludes accidental damage"></label>
+ </div>
+ <div class="row"><button type="submit" class="btn primary">Save warranty</button></div>
+</form>
+
+<% if (!warranties.length) { %>
+ <section class="empty glass"><p>No warranties yet. Click <strong>+ Add warranty</strong> to log one.</p></section>
+<% } %>
+
+<section data-grid="warranty" class="purchase-grid" style="grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));">
+ <% warranties.forEach(w => { %>
+ <article class="purchase glass<%= w.is_expired ? ' overdue' : '' %>"
+ data-provider="<%= (w.provider_name||'').toLowerCase() %>"
+ data-type="<%= w.commitment_type || '' %>"
+ data-due="<%= w.refund_window_ends_at ? new Date(w.refund_window_ends_at).toISOString().slice(0,10) : '' %>"
+ data-created="<%= new Date(w.created_at).toISOString() %>">
+ <header>
+ <h3><%= w.provider_name || 'Warranty' %></h3>
+ <span class="chip cat"><%= w.commitment_type || 'warranty' %></span>
+ </header>
+ <div class="amount" style="font-size:14px;font-weight:500">
+ <% if (w.refund_window_ends_at) { %>
+ expires <%= new Date(w.refund_window_ends_at).toLocaleDateString() %>
+ <% if (w.is_expired) { %><span class="badge red">expired</span>
+ <% } else if (w.is_expiring_soon) { %><span class="badge amber">in <%= w.days_until %>d</span>
+ <% } else { %><span class="badge">in <%= w.days_until %>d</span><% } %>
+ <% } else if (w.window_days) { %><span class="subtle"><%= w.window_days %>-day window</span>
+ <% } else { %><span class="subtle">no expiry set</span><% } %>
+ </div>
+ <dl class="meta">
+ <% if (w.guarantee_text) { %><dt>Covers</dt><dd><%= w.guarantee_text %></dd><% } %>
+ <% if (w.promised_outcome) { %><dt>Outcome</dt><dd><%= w.promised_outcome %></dd><% } %>
+ <% if (w.conditions) { %><dt>Notes</dt><dd><%= w.conditions %></dd><% } %>
+ <% if (w.source && w.source !== 'manual') { %><dt>Source</dt><dd><%= w.source %><% if (w.confidence != null) { %> (<%= Math.round(Number(w.confidence)*100) %>%)<% } %></dd><% } %>
+ </dl>
+ <div class="when" title="<%= new Date(w.created_at).toISOString() %>">🕓 added <%= new Date(w.created_at).toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }) %></div>
+ <div class="card-actions">
+ <% if (w.commitment_type !== 'return') { %><a class="btn small" href="/claims">File claim →</a><% } %>
+ <button class="btn small danger" data-action="delete" data-id="<%= w.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/warranties', { 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="delete"]').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ if (!confirm('Delete this warranty?')) return;
+ const res = await fetch(`/api/warranties/${btn.dataset.id}/delete`, { method: 'POST' });
+ if (res.ok) location.reload(); else alert('Delete failed');
+ });
+ });
+</script>
+
+<%- include('partials/footer', { gridScript: true }) %>
← 43aea4b feat(ui): Bills, Reorders, and unified Deadlines pages
·
back to AbramsOS
·
feat(digest): opt-in daily deadline digest email via George 8cf03df →