Groundskeeper β Design Spec
Date: 2026-06-05
Repo: https://github.com/louisalexander/groundskeeper
Status: Approved design β pending implementation plan
Supersedes: the single-file Claude artifact yard-map.html
1. Purpose & Goals#
Take the existing single-file "Yard Map" Claude artifact (irrigation heads + plant inventory on a survey-accurate SVG of a residential lot) to a hosted, multi-device, higher-accuracy web app.
Prioritized goals (from brainstorming):
- Home Assistant export (the core purpose). The whole field-survey β map pipeline exists to
produce a Home Assistant yard dashboard. Phase 1 emits a
picture-elementscard + a rendered background image with accurately positioned, tappable Rachio zones and live sensor overlays; afloorplancustom-card output is a planned Phase 2. See Β§6. - Accessible anywhere β stable URL, any device, data persists outside the Claude artifact.
- Sharper map accuracy β imagery-aligned to the certified survey + better GPS math (so the exported dashboard is positionally correct).
- Polish & robustness β fix latent bugs, undo, offline support, clean mobile UX, exports.
Explicitly out of scope:
- App-side live HA control β the app generates artifacts (image + YAML) the user imports; it does not hold HA tokens or call HA live. (Live behavior β toggling zones, live sensor values β happens in the imported card, which is HA-native.)
- Multi-user / shared editing (single owner, multi-device only).
- Automation-blueprint export and floorplan output β designed-for but deferred to later phases.
Owner model: single owner, multiple devices (survey on phone β view on laptop), auto-sync. No shared/multi-user editing.
2. Architecture#
Stack
- Leaflet β map engine (georeferenced imagery, pan/zoom/touch, markers). ~40KB.
- Bundled public-domain orthophoto β the default and offline basemap (see Β§3.3). Sourced from VGIN (Virginia GIS) / the county GIS portal / USDA NAIP, committed as a static app asset. Optional live tile layers (Esri/Google/Mapbox) can be toggled on when online for freshness.
- Vanilla JS in ES modules β no framework.
- Supabase JS client β sync + passwordless auth (free tier).
- Vite β dev server + static production build.
- Cloudflare Pages β static hosting, custom domain, automatic HTTPS (required for GPS).
Decision: light build + modules (vs. staying zero-build single-file). Vite outputs a plain static site; the payoff is isolation, testability, and files small enough to reason about. The valuable survey-geometry math ports over nearly verbatim as a pure, unit-tested module.
Project structure
groundskeeper/
βββ index.html # thin shell
βββ src/
β βββ main.js # bootstrap, view wiring
β βββ geometry.js # survey math (bv/mv/corners/house) β pure + unit-tested
β βββ georef.js # survey-feet β WGS84 lat/lon transform (accuracy core)
β βββ map.js # Leaflet map, imagery layer, outline/house polygons, markers
β βββ gps.js # averaging, outlier rejection, confidence
β βββ survey.js # Survey-view workflow (place/record heads/plants/sensors)
β βββ store.js # Supabase + IndexedDB cache, offline queue
β βββ plants.js # PLANT_TYPES + care data (imported from data/plant-care.json)
β βββ ha.js # HA export: render background image + picture-elements YAML
β βββ ui/ # panels, sidebar, pickers, HA-entity settings
βββ public/
β βββ basemap/ # bundled georeferenced orthophoto (.jpg/.png) + bounds.json
βββ data/ # survey-geometry.json, plant-care.json (source of truth)
βββ test/ # geometry + georef + gps unit tests (Vitest)
βββ docs/ # survey plat, specs
βββ CLAUDE.md / README.md
3. Georeferencing & Map Rendering (accuracy core)#
3.1 The transform#
The survey gives exact relative geometry in feet (origin = NW corner A, +x east, +y south)
with true-north bearings. A single 2-D similarity transform maps survey-feet β WGS84 lat/lon,
defined by three stored numbers: lat0, lon0 (real-world position of origin A) and ΞΈ (small
rotation correcting survey-grid north vs. imagery/true north; β0, nudge-able).
// local (E ft east, S ft south) β lat/lon
northFt = -S
e' = EΒ·cosΞΈ β northFtΒ·sinΞΈ
n' = EΒ·sinΞΈ + northFtΒ·cosΞΈ
lat = lat0 + n'/364000
lon = lon0 + e'/(364000Β·cos(lat0)) // inverse used for GPS β local
(364000 ft/degree latitude is a local-flat-earth approximation, valid over a single lot.)
3.2 Calibration β align-by-imagery (primary)#
The owner drags/rotates the survey outline over the basemap to match rooflines / driveway,
once. Because every map pixel has a known lat/lon, reading the outline's final position
yields lat0, lon0, ΞΈ. This achieves sub-foot precision (relative error cancels; see Β§3.5),
takes ~30 seconds, is stored in calibration, and syncs to all devices.
The Power-Box GPS anchor is demoted to an optional fallback / sanity check β no longer a required ritual.
3.3 Basemap β bundled public-domain orthophoto#
Because this is a fixed single lot, a live global slippy-tile service is the wrong tool: it's
finicky to cache and its terms generally forbid offline tile storage. Instead the basemap is
one high-resolution, georeferenced orthophoto of the lot, committed as a static app asset
(public/basemap/) and drawn via L.imageOverlay at its known lat/lon corner bounds.
- Source: public-domain imagery β VGIN (Virginia GIS), the county GIS portal, or USDA NAIP. VA local orthoimagery is often 6-inch to 1-foot resolution (frequently crisper than consumer basemaps) and legal to store and ship.
- Offline by construction: it's part of the app shell, so it works with no signal β no tile pre-fetching or tile-pyramid caching needed (see Β§4.4).
- Accuracy bonus: higher-res orthorectified county imagery can improve the align-by-imagery calibration vs. consumer tiles.
- Optional live layer: when online, the user may toggle a live tile layer (Esri/Google/ Mapbox) for current imagery. Online-only, off by default; not relied upon.
3.4 Rendering on Leaflet#
- Base: the bundled orthophoto
imageOverlay(+ optional live tile layer when online). - Lot outline + house footprint: georeferenced polygons. The front cul-de-sac arcs (R=40'/50', reverse-S) are tessellated into short lat/lon segments using the existing arc math so the curve stays survey-accurate.
- Heads: colored circle markers + a true-to-scale spray-radius ring (real feet). Plants:
emoji/hex
divIconmarkers. Sensors: a distinct marker bykind(soil/weather). All draggable (drag-to-correct). - North-up. The stylized "blueprint" outline is drawn on top of imagery to preserve the survey aesthetic.
3.5 Expected accuracy (honest budget)#
Accuracy is relative: imagery's ~1β3 m absolute error is a near-uniform shift across the ~40 m lot and cancels when outline + items share the same basemap. Internal imagery accuracy is ~0.15β0.3 m/pixel.
| Feature | Visible from above? | Realistic accuracy | Method |
|---|---|---|---|
| Hedges, shrubs, trees, beds | β Yes | ~1 ft (sometimes inches) | Click the plant in the imagery |
| Sprinkler heads | β No (flush pop-ups) | ~1β3 ft | GPS/measure, then drag-correct |
Heads are invisible to imagery, so they land at GPS accuracy (3β8 ft suburban, multipath) unless
drag-corrected against the accurate basemap or measured from now-accurate landmarks (1β2 ft).
Survey-grade sub-foot for heads would require RTK hardware ($300β1000) β out of scope.
4. Data Model, Sync, Offline & Auth#
4.1 Canonical coordinates#
lat/lon is canonical (imagery-aligned, GPS-native). Feet/px are derived for display via the
inverse transform β the "X ft E Β· Y ft S" readout is preserved.
calibration: { lat0, lon0, theta, method, updatedAt }
zone: { id, name, color, haEntity, updatedAt, deletedAt } // haEntity: Rachio switch (Β§6.2)
head: { id, lat, lon, zoneId, type, radiusFt, label, notes,
gps:{acc}|null, placedBy:'imagery|gps|manual|drag', confidenceFt, updatedAt, deletedAt }
plant: { id, lat, lon, typeId, label, notes, updatedAt, deletedAt }
sensor: { id, lat, lon, kind:'soil|weather|other', label, haEntity, notes,
placedBy, confidenceFt, gps:{acc}|null, updatedAt, deletedAt } // Β§6.3
Every entity carries deletedAt (soft-delete / tombstone) β see Β§4.2.
4.2 Supabase β per-entity tables (not a JSON blob)#
Tables: heads, plants, sensors, zones, settings (calibration + global HA entity map +
misc). Each row carries user_id and updated_at.
Rationale: a single JSON-document model would clobber a laptop edit when the phone syncs
(whole-doc last-write-wins). Per-entity rows mean edits to different items on different
devices both survive. Same-row conflicts are last-write-wins by updated_at β fine for a single
owner.
Soft-delete / tombstones (required for correct offline sync): deletes set deleted_at
rather than removing the row, and sync like any other edit. Without this, deleting an item on an
offline phone would let a stale device that still has the row "win" on reconnect and resurrect
it. The UI filters out deleted_at != null; a background job may hard-purge old tombstones later.
Row-Level Security: every row tied to auth.uid(); only the owner can read/write.
4.3 Auth#
Supabase magic-link email (passwordless). New device = click an emailed link. No passwords.
4.4 Offline-first (PWA)#
Full offline is a hard requirement β the property has spotty cell signal and wifi does not reach the backyard. The whole survey workflow must work with zero connectivity.
This is made tractable by the bundled-orthophoto decision (Β§3.3): there is no tile pyramid to cache β the basemap is a single static asset in the app shell.
- Full dataset cached in IndexedDB; app renders from cache instantly, works with no signal.
- Optimistic writes: update cache β render β queue to Supabase β flush on reconnect.
- Load: render cache β fetch remote β merge per-row by
updated_at(respectingdeleted_at) β update cache. - Service worker caches a small, fixed set: app shell + Leaflet + the one orthophoto image. No on-demand tile caching, which removes the finicky/ToS-laden part of offline maps.
- Manifest for "Add to Home Screen."
- iOS caveat & mitigation: iOS may evict PWA storage after ~7 days unused and lacks background sync. Neutralized here because (a) the basemap is a build asset, cheaply re-fetched when next online if evicted, and (b) the dataset is tiny and re-syncs on reconnect. Queued writes flush on next app open with signal (no reliance on iOS background sync).
- Sync status reuses the existing status-bar indicator (β Saved / β offline-queued).
4.5 Migration#
One-time importer: existing data (current x,y SVG pixels, or exported JSON) β feet
(subtract OFF_X/OFF_Y, Γ· F) β lat/lon via the new calibration. Low-stakes (little field data
yet) but provided.
5. GPS Math, Polish & Bugfixes#
5.1 Better GPS math (gps.js, unit-tested)#
- Outlier rejection: drop readings with reported accuracy worse than ~15 m, then drop points beyond 2Ο from the running median.
- Accuracy-weighted average: combine survivors weighted by 1/accΒ² (inverse-variance).
- Confidence per point: spread in feet β stored as
confidenceFt, drawn as a faint confidence ring + "Β±X ft" readout. - Averaging caps by count and time, with live convergence feedback (existing meter UI retained).
5.2 Polish & robustness#
- Fix
addZone()prompt()bug β inline DOM input (therenameZone()pattern). - Undo/redo: small action-history stack (place / move / delete / edit) + button + Ctrl/Cmd-Z.
- Drag-correct wiring: dragging updates lat/lon, sets
placedBy:'drag', bumpsupdatedAt, syncs. - Mobile: Leaflet native touch (removes custom touch code); larger tap targets in survey flow.
- Export: keep JSON export; add one-click PNG/print of the map. The HA export is a primary feature with its own pipeline β see Β§6.
- Error states: denied GPS permission, expired login (keep working offline), queued-write status.
6. Home Assistant Export & Integration#
HA export is a primary deliverable, not a side feature. Phase 1 targets a picture-elements
card; a floorplan custom-card output is a planned Phase 2 (the same georeference + entity map
feeds both). The app produces files the user imports into HA β it does not call HA live.
6.1 Rendered background image#
Export generates a self-contained background PNG: the bundled orthophoto with the survey
outline + house drawn on top, at fixed, known pixel dimensions and known lat/lon bounds. Because
the image bounds are known, every item's on-image position is an exact linear map from its
lat/lon β percentage, so card elements line up perfectly with the imagery.
β saved to /config/www/ (e.g. yard-basemap.png).
6.2 Entity mapping (settings UI)#
A Settings β "HA Entities" panel maps app concepts to your real HA entity IDs, replacing
the old hardcoded switch.rachio_zone_N:
- each zone β a Rachio switch entity (e.g.
switch.rachio_zone_1), stored aszone.haEntity - each placed sensor β its HA entity (
sensor.soil_moisture_*,sensor.ecowitt_ws90_*), stored assensor.haEntity - any non-placed/global entities (e.g. a WS90 rain-rate label) β kept in
settingsSynced across devices; consumed by the export.
6.3 New entity type: sensor#
A third placeable item type alongside heads/plants (schema in Β§4.1), positioned with the same
imagery / GPS / drag workflow and rendered with a distinct marker by kind (soil / weather).
This lets soil-moisture and weather sensors export at their true physical positions.
6.4 Generated picture-elements card#
Card YAML positions each element by % over the background image:
- Heads β
state-badgebound to the zone's mapped Rachio switch; tap toggles the zone and the badge colors by running state. - Sensors β
state-label(or badge) showing the live value (rain rate, soil %, temperature) at its true position. - Plants β lightweight label/marker (info only).
β saved as
yard-card.yaml, pasted into a dashboard. No HACS/add-ons required for Phase 1.
6.5 Deferred (designed-for, later phases)#
- Floorplan custom-card output (SVG + config) β Phase 2.
- Automation blueprints (rain skip, soil-moisture gate, wind hold, beetle-season reminders) referencing the mapped entities.
7. Testing#
- Vitest unit tests on the pure modules:
geometry.jsβ computed corners match certified survey values.georef.jsβ localβlat/lon round-trips; a known reference point lands correctly.gps.jsβ outlier rejection + weighted averaging on synthetic readings.ha.jsβ lat/lon β image-% positioning matches the rendered background bounds; YAML emits the mapped entity IDs for zones/sensors.
- Manual field checklist for GPS / imagery-calibration / sync paths, plus an import-into-HA smoke test (paste card YAML + drop the PNG, confirm elements align).
- No heavy E2E framework yet (YAGNI); add Playwright later only if it earns its keep.
8. Deployment#
- Repo:
github.com/louisalexander/groundskeeper. - Cloudflare Pages connected to the repo β auto-deploy on push; build =
vite build, outputdist/. - Custom domain (e.g.
groundskeeper.app) optional; Cloudflare provides HTTPS (GPS requires it). - Supabase project (free tier) holds data + auth; keys via Vite env vars (anon key is public-safe with RLS enforcing per-user access).
9. Open questions / deferred#
- Sourcing the orthophoto (implementation task): obtain the best available public-domain
georeferenced image of the lot from VGIN / the county GIS portal / USDA NAIP, record its lat/lon
corner bounds, and commit it to
public/basemap/. - Optional live tile layer + any API key (Mapbox/Google) β deferred; bundled orthophoto is the default and is sufficient to start.
- Per-plant photos (deferred β not requested).
- RTK / survey-grade head placement (out of scope unless hardware is acquired).
- Playwright E2E (deferred).