[object Object]

← 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

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 &amp; 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 →