[object Object]

← 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

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