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):

  1. 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-elements card + a rendered background image with accurately positioned, tappable Rachio zones and live sensor overlays; a floorplan custom-card output is a planned Phase 2. See Β§6.
  2. Accessible anywhere β€” stable URL, any device, data persists outside the Claude artifact.
  3. Sharper map accuracy β€” imagery-aligned to the certified survey + better GPS math (so the exported dashboard is positionally correct).
  4. Polish & robustness β€” fix latent bugs, undo, offline support, clean mobile UX, exports.

Explicitly out of scope:

Owner model: single owner, multiple devices (survey on phone β†’ view on laptop), auto-sync. No shared/multi-user editing.


2. Architecture#

Stack

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.

3.4 Rendering on Leaflet#

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.

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)#

5.2 Polish & robustness#


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:

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:

6.5 Deferred (designed-for, later phases)#


7. Testing#


8. Deployment#


9. Open questions / deferred#