← back to AbramsOS
assets: net-worth tracker — enter home by address + current value, finance category, details/notes
a78d4aefa41e35fb686bed77e6edae3ce79a395c · 2026-07-13 01:15:52 -0700 · Steve
- 0013_assets.sql: asset table (property/vehicle/account/valuable) w/ address, current_value, category, details, notes, geocode
- routes/assets.js + views/assets.ejs: /assets dashboard (net worth total + by-category stat row, add form, created date+time chips, per-property 'Watch' link to neighborhood)
- nav + wiring. Values user-entered (auto-valuation API is a later gated add). Feeds upcoming neighborhood-watch via property address
Files touched
A db/migrations/0013_assets.sqlM lib/ids.jsA routes/assets.jsM server.jsA views/assets.ejsM views/partials/header.ejs
Diff
commit a78d4aefa41e35fb686bed77e6edae3ce79a395c
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 13 01:15:52 2026 -0700
assets: net-worth tracker — enter home by address + current value, finance category, details/notes
- 0013_assets.sql: asset table (property/vehicle/account/valuable) w/ address, current_value, category, details, notes, geocode
- routes/assets.js + views/assets.ejs: /assets dashboard (net worth total + by-category stat row, add form, created date+time chips, per-property 'Watch' link to neighborhood)
- nav + wiring. Values user-entered (auto-valuation API is a later gated add). Feeds upcoming neighborhood-watch via property address
---
db/migrations/0013_assets.sql | 33 +++++++++++++
lib/ids.js | 1 +
routes/assets.js | 89 +++++++++++++++++++++++++++++++++++
server.js | 2 +
views/assets.ejs | 106 ++++++++++++++++++++++++++++++++++++++++++
views/partials/header.ejs | 1 +
6 files changed, 232 insertions(+)
diff --git a/db/migrations/0013_assets.sql b/db/migrations/0013_assets.sql
new file mode 100644
index 0000000..60361d8
--- /dev/null
+++ b/db/migrations/0013_assets.sql
@@ -0,0 +1,33 @@
+-- 0013_assets.sql
+-- Assets / net-worth tracker: the user enters things they own (home by address, vehicles,
+-- accounts) with a current value, a finance category, and free-form details + notes.
+-- Feeds a simple net-worth total and (later) links to the neighborhood-watch feature via
+-- a property's address. Values are user-entered (or, later, an optional estimate API).
+-- Idempotent. Safe to re-run.
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS asset (
+ id text PRIMARY KEY,
+ user_id text NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
+ person_id text REFERENCES person(id) ON DELETE SET NULL, -- optional owner
+ kind text NOT NULL DEFAULT 'property', -- property|vehicle|account|valuable|other
+ name text NOT NULL, -- e.g. "Primary residence"
+ address text, -- for property (also used by neighborhood watch)
+ latitude numeric(9,6), -- optional geocode
+ longitude numeric(9,6),
+ current_value numeric(14,2),
+ value_source text NOT NULL DEFAULT 'manual', -- manual|estimate
+ category text, -- finance grouping: real_estate|vehicle|cash|investment|other (free text)
+ details text, -- structured-ish details (beds/baths, VIN, acct hint…)
+ notes text, -- free-form
+ valued_at date, -- as-of date for current_value
+ status text NOT NULL DEFAULT 'active', -- active|sold|archived
+ 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 asset_user_idx ON asset (user_id, status);
+CREATE INDEX IF NOT EXISTS asset_category_idx ON asset (user_id, category);
+
+COMMIT;
diff --git a/lib/ids.js b/lib/ids.js
index 1660a5d..750bf2a 100644
--- a/lib/ids.js
+++ b/lib/ids.js
@@ -19,6 +19,7 @@ const PREFIX = {
saving: 'sav',
coupon: 'coup',
reading: 'hr',
+ asset: 'ast',
};
function id(kind) {
diff --git a/routes/assets.js b/routes/assets.js
new file mode 100644
index 0000000..2a272eb
--- /dev/null
+++ b/routes/assets.js
@@ -0,0 +1,89 @@
+// Assets — net-worth tracker: home (by address) + vehicles/accounts with current value,
+// finance category, details + notes. Values are user-entered. Read + curate.
+const express = require('express');
+const db = require('../lib/db');
+const audit = require('../lib/audit');
+const { id } = require('../lib/ids');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+async function listAssets(userId) {
+ return (await db.query(
+ `SELECT id, kind, name, address, current_value, value_source, category, details, notes,
+ valued_at, status, latitude, longitude, created_at
+ FROM asset WHERE user_id = $1 AND status = 'active'
+ ORDER BY current_value DESC NULLS LAST, created_at DESC`,
+ [userId]
+ )).rows;
+}
+
+router.get('/assets', async (_req, res) => {
+ const assets = await listAssets(DEV_USER_ID);
+ const netWorth = assets.reduce((s, a) => s + (Number(a.current_value) || 0), 0);
+ const byCategory = {};
+ for (const a of assets) {
+ const c = a.category || 'uncategorized';
+ byCategory[c] = (byCategory[c] || 0) + (Number(a.current_value) || 0);
+ }
+ res.render('assets', { assets, netWorth, byCategory });
+});
+
+router.get('/api/assets', async (_req, res) => {
+ res.json(await listAssets(DEV_USER_ID));
+});
+
+function clean(b) {
+ const num = (v) => (v === '' || v == null ? null : (Number.isFinite(Number(v)) ? Number(v) : null));
+ return {
+ kind: (b.kind || 'property').slice(0, 40),
+ name: (b.name || '').slice(0, 200),
+ address: b.address ? String(b.address).slice(0, 400) : null,
+ current_value: num(b.current_value),
+ category: b.category ? String(b.category).slice(0, 80) : null,
+ details: b.details ? String(b.details).slice(0, 2000) : null,
+ notes: b.notes ? String(b.notes).slice(0, 2000) : null,
+ valued_at: b.valued_at || null,
+ latitude: num(b.latitude),
+ longitude: num(b.longitude),
+ };
+}
+
+router.post('/api/assets', async (req, res) => {
+ const a = clean(req.body || {});
+ if (!a.name) return res.status(400).json({ error: 'name required' });
+ const aid = id('asset');
+ await db.query(
+ `INSERT INTO asset (id, user_id, kind, name, address, current_value, value_source, category, details, notes, valued_at, latitude, longitude)
+ VALUES ($1,$2,$3,$4,$5,$6,'manual',$7,$8,$9,$10,$11,$12)`,
+ [aid, DEV_USER_ID, a.kind, a.name, a.address, a.current_value, a.category, a.details, a.notes, a.valued_at, a.latitude, a.longitude]
+ );
+ await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'asset', objectId: aid, eventType: 'asset_created', metadata: { kind: a.kind, category: a.category } }).catch(() => {});
+ const r = await db.query(`SELECT * FROM asset WHERE id = $1`, [aid]);
+ res.json({ ok: true, asset: r.rows[0] });
+});
+
+router.post('/api/assets/:id', async (req, res) => {
+ const a = clean(req.body || {});
+ const sets = [], vals = [];
+ let i = 1;
+ for (const [k, v] of Object.entries(a)) {
+ if (v !== undefined) { sets.push(`${k} = $${i++}`); vals.push(v); }
+ }
+ if (!sets.length) return res.status(400).json({ error: 'nothing to update' });
+ sets.push(`updated_at = now()`);
+ vals.push(req.params.id, DEV_USER_ID);
+ const r = await db.query(
+ `UPDATE asset 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: 'asset', objectId: req.params.id, eventType: 'asset_updated', metadata: {} }).catch(() => {});
+ res.json({ ok: true, asset: r.rows[0] });
+});
+
+router.post('/api/assets/:id/delete', async (req, res) => {
+ await db.query(`UPDATE asset SET status = 'archived', updated_at = now() WHERE id = $1 AND user_id = $2`, [req.params.id, DEV_USER_ID]);
+ res.json({ ok: true });
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index ae88465..0093c93 100644
--- a/server.js
+++ b/server.js
@@ -26,6 +26,7 @@ const billsRouter = require('./routes/bills');
const reordersRouter = require('./routes/reorders');
const savingsRouter = require('./routes/savings');
const vitalsRouter = require('./routes/vitals').router;
+const assetsRouter = require('./routes/assets');
const warrantiesRouter = require('./routes/warranties');
const peopleRouter = require('./routes/people');
const medicationsRouter = require('./routes/medications');
@@ -91,6 +92,7 @@ app.use(billsRouter); // /bills, /api/bills*
app.use(reordersRouter); // /reorders, /api/reorders*
app.use(savingsRouter); // /savings, /api/savings*, /api/coupons
app.use(vitalsRouter); // /health, /api/health/readings*
+app.use(assetsRouter); // /assets, /api/assets*
app.use(warrantiesRouter); // /warranties, /api/warranties*
app.use(peopleRouter); // /household, /api/people*
app.use(medicationsRouter); // /medications, /api/medications*
diff --git a/views/assets.ejs b/views/assets.ejs
new file mode 100644
index 0000000..d8ccf28
--- /dev/null
+++ b/views/assets.ejs
@@ -0,0 +1,106 @@
+<%- include('partials/header', { title: 'Assets' }) %>
+
+<% const fmt = (d) => new Date(d).toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
+ const money = (n) => (n == null ? '—' : '$' + Number(n).toLocaleString(undefined, { maximumFractionDigits: 0 }));
+ const kindIcon = { property:'🏠', vehicle:'🚗', account:'🏦', valuable:'💎', other:'📦' }; %>
+
+<section class="page-head">
+ <div>
+ <h1>Assets & Net Worth</h1>
+ <p class="subtle">
+ <% if (netWorth) { %>Net worth (entered): <strong><%= money(netWorth) %></strong> · <% } %>
+ <%= assets.length %> asset<%= assets.length===1?'':'s' %> · values you enter
+ </p>
+ </div>
+ <div class="grid-controls">
+ <button type="button" class="btn primary" id="toggleAdd">+ Add asset</button>
+ </div>
+</section>
+
+<% if (Object.keys(byCategory).length) { %>
+ <section class="stat-row">
+ <% Object.entries(byCategory).forEach(([c, v]) => { %>
+ <div class="stat glass"><div class="num" style="font-size:1.4rem"><%= money(v) %></div><div class="label"><%= c.replace(/_/g,' ') %></div></div>
+ <% }) %>
+ </section>
+<% } %>
+
+<form id="addForm" class="add-form glass" hidden>
+ <div class="row">
+ <label>Type
+ <select name="kind" id="kindSel">
+ <option value="property">🏠 Property</option>
+ <option value="vehicle">🚗 Vehicle</option>
+ <option value="account">🏦 Account</option>
+ <option value="valuable">💎 Valuable</option>
+ <option value="other">📦 Other</option>
+ </select>
+ </label>
+ <label class="grow">Name*<input name="name" required placeholder="Primary residence"></label>
+ <label>Category<input name="category" placeholder="real_estate" list="catList"></label>
+ <datalist id="catList"><option value="real_estate"><option value="vehicle"><option value="cash"><option value="investment"><option value="retirement"><option value="other"></datalist>
+ </div>
+ <div class="row addr-row">
+ <label class="grow">Address<input name="address" placeholder="123 Main St, City, ST 91436"></label>
+ </div>
+ <div class="row">
+ <label>Current value ($)<input name="current_value" type="number" step="0.01" placeholder="0"></label>
+ <label>As of<input name="valued_at" type="date"></label>
+ </div>
+ <div class="row">
+ <label class="grow">Details<input name="details" placeholder="4 bed / 3 bath, 2,400 sqft"></label>
+ </div>
+ <div class="row">
+ <label class="grow">Notes<input name="notes" placeholder="optional"></label>
+ <button type="submit" class="btn primary">Save asset</button>
+ </div>
+</form>
+
+<% if (!assets.length) { %>
+ <section class="empty glass">
+ <p>No assets yet. Click <strong>+ Add asset</strong> to enter your home (by address), a vehicle, or an account with its current value and a finance category. Property assets link to <a href="/neighborhood">Neighborhood Watch</a>.</p>
+ </section>
+<% } %>
+
+<section class="purchase-grid" style="grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));">
+ <% assets.forEach(a => { %>
+ <article class="purchase glass" data-id="<%= a.id %>">
+ <header>
+ <h3><%= kindIcon[a.kind] || '📦' %> <%= a.name %></h3>
+ <% if (a.category) { %><span class="chip" style="background:rgba(128,128,128,.15)"><%= a.category.replace(/_/g,' ') %></span><% } %>
+ </header>
+ <div class="amount"><%= money(a.current_value) %><% if (a.valued_at) { %> <span class="subtle">as of <%= new Date(a.valued_at).toLocaleDateString() %></span><% } %></div>
+ <dl class="meta">
+ <% if (a.address) { %><dt>Address</dt><dd><%= a.address %></dd><% } %>
+ <% if (a.details) { %><dt>Details</dt><dd><%= a.details %></dd><% } %>
+ <% if (a.notes) { %><dt>Notes</dt><dd class="subtle"><%= a.notes %></dd><% } %>
+ </dl>
+ <footer class="card-foot">
+ <span class="when" title="<%= new Date(a.created_at).toISOString() %>">🕓 <%= fmt(a.created_at) %></span>
+ <span class="actions">
+ <% if (a.kind === 'property' && a.address) { %><a class="btn tiny" href="/neighborhood?asset=<%= a.id %>">🛡 Watch</a><% } %>
+ <button type="button" class="btn tiny ghost" data-del="<%= a.id %>">Remove</button>
+ </span>
+ </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(); }
+ const kindSel=document.getElementById('kindSel');
+ function syncAddr(){ document.querySelector('.addr-row').style.display = (kindSel.value==='property')?'':'none'; }
+ kindSel?.addEventListener('change', syncAddr); if(kindSel) syncAddr();
+ document.getElementById('toggleAdd')?.addEventListener('click', ()=>{ const f=document.getElementById('addForm'); f.hidden=!f.hidden; });
+ document.getElementById('addForm')?.addEventListener('submit', async (e)=>{
+ e.preventDefault(); const fd=Object.fromEntries(new FormData(e.target).entries());
+ const out=await post('/api/assets', fd); if(out.ok) location.reload(); else alert(out.error||'error');
+ });
+ document.querySelectorAll('[data-del]').forEach(b=>b.addEventListener('click', async (e)=>{
+ if(!confirm('Remove this asset?')) return;
+ await post('/api/assets/'+e.currentTarget.dataset.del+'/delete'); e.currentTarget.closest('article').remove();
+ }));
+</script>
+
+<%- include('partials/footer', {}) %>
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index c3fe0d5..51d4714 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="/assets">Assets</a>
<a href="/warranties">Warranties</a>
<a href="/reorders">Reorders</a>
<a href="/savings">Savings</a>
← 8598287 health: lapsed-habit BP reading reminders (only nudges prior
·
back to AbramsOS
·
neighborhood: Neighborhood Watch — address+radius, Leaflet m 42d431f →