← back to AbramsOS
assets: RentCast home-value estimate wiring (one-key-from-working) + all 37 Amazon totals collected
ba2e47d441e01b67b8f4d37fb85c958f5150c983 · 2026-07-13 09:49:42 -0700 · Steve
- lib/home-value.js: RentCast AVM lookup by address; graceful no-key/no-address (never fabricates a value); Zillow API retired so RentCast is the free option
- routes/assets.js: POST /api/assets/:id/estimate → fills current_value + value_source='estimate' + range; valuationConfigured flag
- views/assets.ejs: 'Estimate' button on property cards (prompts for key if absent)
- .env: RENTCAST_API_KEY= (gated)
- DATA: opened remaining 17 older Amazon orders via George → all 37 now have real totals; complete all-time spend $8,966.66 (2022-2026)
Files touched
A lib/home-value.jsM routes/assets.jsM views/assets.ejs
Diff
commit ba2e47d441e01b67b8f4d37fb85c958f5150c983
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 13 09:49:42 2026 -0700
assets: RentCast home-value estimate wiring (one-key-from-working) + all 37 Amazon totals collected
- lib/home-value.js: RentCast AVM lookup by address; graceful no-key/no-address (never fabricates a value); Zillow API retired so RentCast is the free option
- routes/assets.js: POST /api/assets/:id/estimate → fills current_value + value_source='estimate' + range; valuationConfigured flag
- views/assets.ejs: 'Estimate' button on property cards (prompts for key if absent)
- .env: RENTCAST_API_KEY= (gated)
- DATA: opened remaining 17 older Amazon orders via George → all 37 now have real totals; complete all-time spend $8,966.66 (2022-2026)
---
lib/home-value.js | 37 +++++++++++++++++++++++++++++++++++++
routes/assets.js | 20 +++++++++++++++++++-
views/assets.ejs | 12 +++++++++++-
3 files changed, 67 insertions(+), 2 deletions(-)
diff --git a/lib/home-value.js b/lib/home-value.js
new file mode 100644
index 0000000..21f8ac6
--- /dev/null
+++ b/lib/home-value.js
@@ -0,0 +1,37 @@
+// home-value.js — optional automatic property valuation via RentCast AVM.
+//
+// One-key-from-working: set RENTCAST_API_KEY (free tier = 50 lookups/mo at
+// rentcast.io) and the Assets "Estimate" button fills current_value from RentCast's
+// automated valuation model. No key → graceful "not configured" (never fabricates a value).
+// Zillow's Zestimate API is retired; RentCast is the current no-cost option.
+
+function isConfigured() {
+ return Boolean(process.env.RENTCAST_API_KEY);
+}
+
+async function estimateValue(address) {
+ const key = process.env.RENTCAST_API_KEY;
+ if (!key) return { ok: false, reason: 'no_key' };
+ if (!address) return { ok: false, reason: 'no_address' };
+ const url = `https://api.rentcast.io/v1/avm/value?address=${encodeURIComponent(address)}`;
+ try {
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), 12000);
+ const r = await fetch(url, { headers: { 'X-Api-Key': key, accept: 'application/json' }, signal: ctrl.signal });
+ clearTimeout(t);
+ if (!r.ok) return { ok: false, reason: `http_${r.status}` };
+ const j = await r.json();
+ const price = Number(j.price ?? j.value);
+ if (!Number.isFinite(price)) return { ok: false, reason: 'no_price' };
+ return {
+ ok: true,
+ price: Math.round(price),
+ low: Number.isFinite(Number(j.priceRangeLow)) ? Math.round(Number(j.priceRangeLow)) : null,
+ high: Number.isFinite(Number(j.priceRangeHigh)) ? Math.round(Number(j.priceRangeHigh)) : null,
+ };
+ } catch (e) {
+ return { ok: false, reason: e.message };
+ }
+}
+
+module.exports = { isConfigured, estimateValue };
diff --git a/routes/assets.js b/routes/assets.js
index 2a272eb..b02d6b3 100644
--- a/routes/assets.js
+++ b/routes/assets.js
@@ -4,6 +4,7 @@ const express = require('express');
const db = require('../lib/db');
const audit = require('../lib/audit');
const { id } = require('../lib/ids');
+const homeValue = require('../lib/home-value');
const router = express.Router();
const DEV_USER_ID = 'user_steve';
@@ -26,7 +27,24 @@ router.get('/assets', async (_req, res) => {
const c = a.category || 'uncategorized';
byCategory[c] = (byCategory[c] || 0) + (Number(a.current_value) || 0);
}
- res.render('assets', { assets, netWorth, byCategory });
+ res.render('assets', { assets, netWorth, byCategory, valuationConfigured: homeValue.isConfigured() });
+});
+
+// Estimate a property's value from its address via RentCast (if a key is set).
+router.post('/api/assets/:id/estimate', async (req, res) => {
+ const a = await db.query(`SELECT id, address, kind FROM asset WHERE id = $1 AND user_id = $2`, [req.params.id, DEV_USER_ID]);
+ if (!a.rows.length) return res.status(404).json({ ok: false, error: 'not found' });
+ if (!a.rows[0].address) return res.status(400).json({ ok: false, error: 'asset has no address' });
+ const est = await homeValue.estimateValue(a.rows[0].address);
+ if (!est.ok) return res.status(est.reason === 'no_key' ? 501 : 502).json({ ok: false, reason: est.reason });
+ await db.query(
+ `UPDATE asset SET current_value = $1, value_source = 'estimate', valued_at = current_date,
+ metadata_jsonb = metadata_jsonb || $2::jsonb, updated_at = now()
+ WHERE id = $3 AND user_id = $4`,
+ [est.price, JSON.stringify({ estimate: { source: 'rentcast', low: est.low, high: est.high, at: new Date().toISOString() } }), req.params.id, DEV_USER_ID]
+ );
+ await audit.log({ actorType: 'system', actorId: DEV_USER_ID, objectType: 'asset', objectId: req.params.id, eventType: 'asset_estimated', metadata: { source: 'rentcast', price: est.price } }).catch(() => {});
+ res.json({ ok: true, ...est });
});
router.get('/api/assets', async (_req, res) => {
diff --git a/views/assets.ejs b/views/assets.ejs
index d8ccf28..3622c38 100644
--- a/views/assets.ejs
+++ b/views/assets.ejs
@@ -78,7 +78,10 @@
<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><% } %>
+ <% if (a.kind === 'property' && a.address) { %>
+ <button type="button" class="btn tiny" data-estimate="<%= a.id %>" title="<%= valuationConfigured ? 'Estimate value via RentCast' : 'Add RENTCAST_API_KEY to enable' %>">$ Estimate</button>
+ <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>
@@ -101,6 +104,13 @@
if(!confirm('Remove this asset?')) return;
await post('/api/assets/'+e.currentTarget.dataset.del+'/delete'); e.currentTarget.closest('article').remove();
}));
+ document.querySelectorAll('[data-estimate]').forEach(b=>b.addEventListener('click', async (e)=>{
+ const btn=e.currentTarget; btn.disabled=true; const t=btn.textContent; btn.textContent='…';
+ const out=await post('/api/assets/'+btn.dataset.estimate+'/estimate');
+ if(out.ok){ btn.textContent='$'+out.price.toLocaleString(); setTimeout(()=>location.reload(),900); }
+ else if(out.reason==='no_key'){ alert('Add RENTCAST_API_KEY (free at rentcast.io) via the secrets skill to enable estimates.'); btn.textContent=t; btn.disabled=false; }
+ else { alert('Estimate unavailable: '+(out.reason||out.error||'?')); btn.textContent=t; btn.disabled=false; }
+ }));
</script>
<%- include('partials/footer', {}) %>
← 957c215 docs: savings track done; overnight run complete, loop stopp
·
back to AbramsOS
·
withings: device-vitals auto-sync connector (OAuth2 → weight 5671885 →