← back to AbramsOS
neighborhood: Neighborhood Watch — address+radius, Leaflet map, Ring cameras + Neighbors, Nextdoor/Citizen/crime maps/registries
42d431f5f2823584703e13fb54c56daefd0bc880 · 2026-07-13 01:18:32 -0700 · Steve
- lib/neighborhood.js: free OSM Nominatim geocoder (no key) + categorized real deep-link builder (Your Ring first: cameras live-view + Neighbors feed), geo-aware SpotCrime/OSM links
- routes/neighborhood.js: /neighborhood resolves address from ?asset/?address/first property; /api/neighborhood/geocode
- views/neighborhood.ejs: address+radius controls, Leaflet map w/ radius circle, property picker, link cards
- nav + wiring; links to Assets property addresses
Files touched
A lib/neighborhood.jsA routes/neighborhood.jsM server.jsA views/neighborhood.ejsM views/partials/header.ejs
Diff
commit 42d431f5f2823584703e13fb54c56daefd0bc880
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 13 01:18:32 2026 -0700
neighborhood: Neighborhood Watch — address+radius, Leaflet map, Ring cameras + Neighbors, Nextdoor/Citizen/crime maps/registries
- lib/neighborhood.js: free OSM Nominatim geocoder (no key) + categorized real deep-link builder (Your Ring first: cameras live-view + Neighbors feed), geo-aware SpotCrime/OSM links
- routes/neighborhood.js: /neighborhood resolves address from ?asset/?address/first property; /api/neighborhood/geocode
- views/neighborhood.ejs: address+radius controls, Leaflet map w/ radius circle, property picker, link cards
- nav + wiring; links to Assets property addresses
---
lib/neighborhood.js | 68 +++++++++++++++++++++++++++++++++++++++++
routes/neighborhood.js | 47 +++++++++++++++++++++++++++++
server.js | 2 ++
views/neighborhood.ejs | 77 +++++++++++++++++++++++++++++++++++++++++++++++
views/partials/header.ejs | 1 +
5 files changed, 195 insertions(+)
diff --git a/lib/neighborhood.js b/lib/neighborhood.js
new file mode 100644
index 0000000..ec901f8
--- /dev/null
+++ b/lib/neighborhood.js
@@ -0,0 +1,68 @@
+// neighborhood.js — Neighborhood Watch link builder + free geocoder.
+//
+// Given an address (+ optional radius in miles), geocode it via OpenStreetMap Nominatim
+// (free, no API key) and build real deep-links into the community-safety apps: Ring
+// (cameras + Neighbors), Nextdoor, Citizen, crime maps, and the sex-offender registries.
+// No data is fabricated — every link is a real public URL; location-aware ones carry the
+// geocoded lat/lon when we have it, otherwise the raw address query.
+
+async function geocode(address) {
+ if (!address) return null;
+ const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1&addressdetails=1`;
+ try {
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), 8000);
+ const r = await fetch(url, { headers: { 'User-Agent': 'AbramsOS/1.0 (personal household admin)' }, signal: ctrl.signal });
+ clearTimeout(t);
+ if (!r.ok) return null;
+ const j = await r.json();
+ if (!Array.isArray(j) || !j.length) return null;
+ const hit = j[0];
+ return {
+ lat: Number(hit.lat), lon: Number(hit.lon),
+ displayName: hit.display_name,
+ city: (hit.address && (hit.address.city || hit.address.town || hit.address.village || hit.address.county)) || null,
+ state: (hit.address && hit.address.state) || null,
+ };
+ } catch (_) { return null; }
+}
+
+// Build the categorized link set. loc may be null (links degrade to address-query form).
+function links({ address, loc, radiusMi = 2 }) {
+ const lat = loc && loc.lat, lon = loc && loc.lon;
+ const hasGeo = Number.isFinite(lat) && Number.isFinite(lon);
+ const q = encodeURIComponent(address || '');
+ const city = (loc && (loc.city || loc.state)) ? encodeURIComponent(`${loc.city || ''} ${loc.state || ''}`.trim()) : q;
+
+ const cat = (title, items) => ({ title, items: items.filter(Boolean) });
+ const L = (name, url, note) => ({ name, url, note: note || null });
+
+ return [
+ cat('Your Ring', [
+ L('Ring cameras (live view)', 'https://account.ring.com/account/dashboard', 'Your own Ring doorbell/cameras — sign in to view live & history'),
+ L('Ring Neighbors feed', 'https://neighbors.ring.com/', 'Local safety posts & shared camera clips near you'),
+ ]),
+ cat('Community apps', [
+ L('Nextdoor', 'https://nextdoor.com/news_feed/', 'Your registered neighborhood feed'),
+ L('Citizen', 'https://citizen.com/', 'Real-time local safety alerts (app-based)'),
+ L('Amber Alerts', 'https://www.missingkids.org/gethelpnow/amber', 'Active AMBER alerts'),
+ ]),
+ cat(`Crime near here (~${radiusMi} mi)`, [
+ hasGeo
+ ? L('SpotCrime map', `https://spotcrime.com/map?lat=${lat}&lon=${lon}&zoom=15`, 'Recent reported crime on a map')
+ : L('SpotCrime', `https://spotcrime.com/`, 'Recent reported crime'),
+ L('CrimeMapping', address ? `https://www.crimemapping.com/map/location/${q}` : 'https://www.crimemapping.com/', 'Police-sourced incident map'),
+ L('FBI Crime Data Explorer', 'https://cde.ucr.cjis.gov/', 'Official agency crime statistics'),
+ ]),
+ cat('Registries & local', [
+ L('Sex-offender registry (national)', hasGeo ? `https://www.nsopw.gov/en/Search/Verification` : 'https://www.nsopw.gov/', 'US DOJ NSOPW — search by address'),
+ L("CA Megan's Law", 'https://www.meganslaw.ca.gov/', 'California registered-offender map'),
+ L('Local police (non-emergency)', `https://www.google.com/search?q=${city}+police+non-emergency+number`, 'Find your local department'),
+ ]),
+ cat('Map', [
+ hasGeo ? L('Open in OpenStreetMap', `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=15/${lat}/${lon}`, null) : null,
+ ]),
+ ].filter((c) => c.items.length);
+}
+
+module.exports = { geocode, links };
diff --git a/routes/neighborhood.js b/routes/neighborhood.js
new file mode 100644
index 0000000..6d59b8a
--- /dev/null
+++ b/routes/neighborhood.js
@@ -0,0 +1,47 @@
+// Neighborhood Watch — enter/pick an address + radius, get a geocoded map and real
+// deep-links into Ring (cameras + Neighbors), Nextdoor, Citizen, crime maps, registries.
+const express = require('express');
+const db = require('../lib/db');
+const nb = require('../lib/neighborhood');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+async function propertyAssets(userId) {
+ return (await db.query(
+ `SELECT id, name, address, latitude, longitude FROM asset
+ WHERE user_id = $1 AND status = 'active' AND kind = 'property' AND address IS NOT NULL
+ ORDER BY current_value DESC NULLS LAST`,
+ [userId]
+ )).rows;
+}
+
+router.get('/neighborhood', async (req, res) => {
+ const props = await propertyAssets(DEV_USER_ID);
+ const radiusMi = Math.max(0.5, Math.min(25, Number(req.query.radius) || 2));
+
+ // Resolve the target address: explicit ?address, else ?asset lookup, else first property.
+ let address = req.query.address || null;
+ let assetId = req.query.asset || null;
+ if (!address && assetId) {
+ const a = props.find((p) => p.id === assetId);
+ if (a) address = a.address;
+ }
+ if (!address && props.length) { address = props[0].address; assetId = props[0].id; }
+
+ const loc = address ? await nb.geocode(address) : null;
+ const linkGroups = address ? nb.links({ address, loc, radiusMi }) : [];
+
+ res.render('neighborhood', { props, address, assetId, radiusMi, loc, linkGroups });
+});
+
+// Client-side re-geocode when the user edits the address / radius without a full reload.
+router.get('/api/neighborhood/geocode', async (req, res) => {
+ const address = req.query.address;
+ if (!address) return res.status(400).json({ error: 'address required' });
+ const radiusMi = Math.max(0.5, Math.min(25, Number(req.query.radius) || 2));
+ const loc = await nb.geocode(address);
+ res.json({ loc, linkGroups: nb.links({ address, loc, radiusMi }) });
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 0093c93..f665805 100644
--- a/server.js
+++ b/server.js
@@ -27,6 +27,7 @@ const reordersRouter = require('./routes/reorders');
const savingsRouter = require('./routes/savings');
const vitalsRouter = require('./routes/vitals').router;
const assetsRouter = require('./routes/assets');
+const neighborhoodRouter = require('./routes/neighborhood');
const warrantiesRouter = require('./routes/warranties');
const peopleRouter = require('./routes/people');
const medicationsRouter = require('./routes/medications');
@@ -93,6 +94,7 @@ 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(neighborhoodRouter); // /neighborhood, /api/neighborhood/geocode
app.use(warrantiesRouter); // /warranties, /api/warranties*
app.use(peopleRouter); // /household, /api/people*
app.use(medicationsRouter); // /medications, /api/medications*
diff --git a/views/neighborhood.ejs b/views/neighborhood.ejs
new file mode 100644
index 0000000..5672b74
--- /dev/null
+++ b/views/neighborhood.ejs
@@ -0,0 +1,77 @@
+<%- include('partials/header', { title: 'Neighborhood Watch' }) %>
+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
+
+<section class="page-head">
+ <div>
+ <h1>🛡 Neighborhood Watch</h1>
+ <p class="subtle">Safety around a location — your Ring cameras, Nextdoor, Citizen, crime maps & registries within a chosen radius.</p>
+ </div>
+</section>
+
+<section class="glass" style="padding:1rem 1.25rem;margin-bottom:1rem">
+ <form id="nbForm" style="display:flex;gap:.6rem;flex-wrap:wrap;align-items:end">
+ <% if (props.length) { %>
+ <label>Saved property
+ <select id="assetPick">
+ <% props.forEach(p => { %><option value="<%= p.id %>" <%= assetId===p.id?'selected':'' %>><%= p.name %></option><% }) %>
+ <option value="">— custom —</option>
+ </select>
+ </label>
+ <% } %>
+ <label class="grow" style="min-width:240px">Address
+ <input id="addr" name="address" value="<%= address || '' %>" placeholder="123 Main St, City, ST" style="width:100%">
+ </label>
+ <label>Radius: <span id="radLbl"><%= radiusMi %></span> mi
+ <input id="radius" type="range" min="0.5" max="25" step="0.5" value="<%= radiusMi %>">
+ </label>
+ <button type="submit" class="btn primary">Update</button>
+ </form>
+ <% if (address && !loc) { %><p class="subtle" style="margin:.5rem 0 0">Couldn't pinpoint that address on the map — the links below still work by address.</p><% } %>
+</section>
+
+<% if (!address) { %>
+ <section class="empty glass"><p>Enter an address above, or add a property in <a href="/assets">Assets</a> to watch your home's neighborhood.</p></section>
+<% } %>
+
+<% if (loc) { %>
+ <div id="map" style="height:340px;border-radius:12px;margin-bottom:1rem"></div>
+<% } %>
+
+<section id="linkArea">
+ <% linkGroups.forEach(g => { %>
+ <section class="page-head" style="margin:.5rem 0 .25rem"><div><h2 style="font-size:1.1rem;margin:0"><%= g.title %></h2></div></section>
+ <section class="purchase-grid" style="grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))">
+ <% g.items.forEach(it => { %>
+ <a class="purchase glass" href="<%= it.url %>" target="_blank" rel="noopener noreferrer" style="text-decoration:none;display:block">
+ <header><h3 style="margin:0"><%= it.name %> ↗</h3></header>
+ <% if (it.note) { %><p class="subtle" style="margin:.35rem 0 0"><%= it.note %></p><% } %>
+ </a>
+ <% }) %>
+ </section>
+ <% }) %>
+</section>
+
+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
+<script>
+ const LOC = <%- JSON.stringify(loc || null) %>;
+ const RADIUS = <%= radiusMi %>;
+ if (LOC && window.L) {
+ const map = L.map('map').setView([LOC.lat, LOC.lon], 14);
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap' }).addTo(map);
+ L.marker([LOC.lat, LOC.lon]).addTo(map);
+ L.circle([LOC.lat, LOC.lon], { radius: RADIUS * 1609.34, color: '#539bf5', fillColor: '#539bf5', fillOpacity: 0.12 }).addTo(map);
+ setTimeout(() => map.invalidateSize(), 200);
+ }
+ // radius label + submit-as-querystring (server geocodes + rebuilds links)
+ const rad = document.getElementById('radius'), radLbl = document.getElementById('radLbl');
+ rad?.addEventListener('input', () => radLbl.textContent = rad.value);
+ const pick = document.getElementById('assetPick');
+ pick?.addEventListener('change', () => { if (pick.value) { location.href = '/neighborhood?asset=' + pick.value + '&radius=' + rad.value; } });
+ document.getElementById('nbForm')?.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const a = document.getElementById('addr').value.trim();
+ location.href = '/neighborhood?address=' + encodeURIComponent(a) + '&radius=' + rad.value;
+ });
+</script>
+
+<%- include('partials/footer', {}) %>
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index 51d4714..9fe26d3 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -25,6 +25,7 @@
<a href="/deadlines">Deadlines</a>
<a href="/bills">Bills</a>
<a href="/assets">Assets</a>
+ <a href="/neighborhood">Watch</a>
<a href="/warranties">Warranties</a>
<a href="/reorders">Reorders</a>
<a href="/savings">Savings</a>
← a78d4ae assets: net-worth tracker — enter home by address + current
·
back to AbramsOS
·
auto-save: 2026-07-13T01:21:52 (4 files) — lib/ids.js db/mig a4f7803 →