Skip to content

fix(caldav): resolve iCloud cross-host calendar sharding (3.3.0-rc2) - #39

Merged
Pascal-ZeGerman merged 38 commits into
mainfrom
fix/caldav-icloud-cross-host-sharding
Aug 1, 2026
Merged

Pascal-ZeGerman merged 38 commits into
mainfrom
fix/caldav-icloud-cross-host-sharding

Conversation

@Pascal-ZeGerman

Copy link
Copy Markdown
Owner

Summary

Fix: iCloud CalDAV cross-host calendar sharding (3.3.0-rc2)

Every CalDAV write from the asp_parking integration to iCloud has been failing
since at least 2026-07-25 (19/19 attempts in the prior 7 days, 0 successes),
with:

ValueError: https://caldav.icloud.com/ can't be joined with
https://p117-caldav.icloud.com:443/278773852/calendars/.../

Root cause: iCloud shards each account's actual calendar data onto a
per-account host (e.g. p117-caldav.icloud.com) that differs from the
generic login entry point (caldav.icloud.com) the CalDAV client connects
with. caldav_sync._get_calendar() reconnected with the generic host and
called principal.calendar(cal_url=...), whose caldav 2.1.0 implementation
does a purely local URL.join() (caldav/lib/url.py) that raises
ValueError whenever the client's host and the target URL's host differ —
which is always true for iCloud accounts.

Changes

custom_components/asp_parking/caldav_sync.py

_get_calendar() still tries the cheap local principal.calendar(cal_url=...)
join first (no extra network round-trip — correct for same-host servers like
Radicale/Nextcloud/Baikal). On the specific cross-host ValueError, it now
falls back to principal.calendars() — a real network request that follows
iCloud's redirect to the correct sharded host — and matches the target
calendar by URL path, which stays stable even if iCloud rebalances the
shard in the future. Raises a clear CalDAVWriteError if the fallback also
finds no match, instead of the previous opaque ValueError.

tests/test_caldav_sync.py

Added 3 tests reproducing the exact production failure:

  • cross-host ValueError → fallback finds the matching calendar by path
  • cross-host ValueError → no path match → raises CalDAVWriteError
  • same-host regression guard: the fast local-join path never pays the extra
    principal.calendars() network round-trip

custom_components/asp_parking/manifest.json

Version bump 3.3.0-rc1 → 3.3.0-rc2.

Verification

  • New unit tests pass (3/3), reproducing the exact ValueError from
    production logs before the fix, passing after.
  • Full CalDAV test suite: 124/124 passed, no regressions.
  • Full project test suite: 988 passed (6 pre-existing, unrelated
    test_sign_retrieval.py failures confirmed via git stash to predate
    this change — network-sandboxed test environment issue).
  • Live verification against production iCloud account: ran
    write_or_update_event → _get_calendar → delete_event end-to-end
    inside the running Home_Assistant container with the real stored
    credentials and the real caldav_url/caldav_calendar pair that was
    previously failing. Write and cleanup both succeeded
    (WRITE_RESULT=OK uid_match=True, CLEANUP_RESULT=OK).
  • Deployed to the live container (docker cp + restart); confirmed
    asp_parking loads cleanly post-restart with zero CalDAV write failed warnings.

Key Decisions

  • Kept the cheap local principal.calendar(cal_url=...) join as the fast
    path rather than switching entirely to principal.calendars() matching,
    to avoid an extra network round-trip for the common self-hosted CalDAV
    case and to avoid touching the large existing test surface that mocks
    principal.calendar directly.
  • Matched by URL path (not full URL) in the fallback so the fix also
    survives a future iCloud shard rebalance that changes the hostname again.

Pascal-ZeGerman and others added 30 commits July 27, 2026 16:33
.planning/ is already gitignored; ROADMAP.md was a historical tracked
exception. Untracking it so it stops showing as locally-modified —
consistent with commit_docs: false, same as every other planning doc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same reasoning as ROADMAP.md: .planning/ has been gitignored for a
while, but 40 files predating that rule stayed tracked (MILESTONES.md,
RETROSPECTIVE.md, v3.0 milestone docs, and SUMMARY.md files from
phases 13-39). Untracking brings them in line with commit_docs: false
and the rest of .planning/ — files remain on disk, only removed from
the git index.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Create docs/demo/index.html: semantic single-page shell (hero, map,
  readout, HA sensor card, calendar) with all 26 DOM contract ids for app.js
- Pin Leaflet 1.9.4 CSS/JS via unpkg CDN with verified SRI + crossorigin (T-41-03)
- Wire styles.css and deferred app.js (app.js created in 41-04)
- Add docs/.nojekyll so GitHub Pages serves files without Jekyll (Pitfall 6)
- No inline handlers or business logic; behavior lives in 41-04
- reprojection bounds (EPSG:2263 -> WGS84, [lon,lat] NYC bbox)
- weekly-pattern-not-absolute-datetime guard
- mock-sensor attribute keys subset of real HA sensor keys
- dataset completeness + status across schedule_found/resolution_failed/no_match
- Implement UI-SPEC palette (--bg/--surface/--border/--fg/--muted/--accent/
  --warning/--positive) and 8-point spacing scale as CSS custom properties
- Four type roles (Display/Heading/Body/Label-mono), two weights, system-ui + mono
- Explicit #map min-height 320/360/480px (mobile/tablet/desktop) so Leaflet never
  collapses to 0px (Pitfall 7)
- Responsive: single-column default, two-column ~60/40 sticky panel at >=1024px
- State chips (positive/warning/neutral) and accent reserved to CTA, active profile,
  next-move calendar cell, and links only
- Calendar highlight fade-in + scale + ring-pulse, all disabled under
  prefers-reduced-motion (static highlight only)
- >=44px touch targets on profile/mode/copy buttons
- reproject_wkt_to_wgs84: EPSG:2263 -> WGS84 [lon,lat] GeoJSON order
- build_sensor_shapes: mock next_move + resolved_street, subset of real HA keys
- build_point_entry: explicit dict build, weekly pattern (no absolute datetime)
- dump_point: per-point fail-soft over 5 resolver exceptions
- main: DEMO_POINTS -> demo.json + demo-segments.geojson; never serializes token
…ndar)

- Leaflet map wiring: OSM tiles, keyboard-focusable accent pins, segment
  overlay, invalidateSize (Pitfall 7)
- Loads committed demo.json/demo-segments.geojson via response.json() only;
  reveals #error-state on fetch/parse failure
- selectPoint resolves a block; renderReadout + renderHaCard render ALL
  dataset text via textContent/DOM node creation (T-41-05, no innerHTML/eval)
- computeNextMove mirrors find_next_window: 8-day lookahead, America/New_York
  wall clock, JS Sun=0 -> Python Mon=0 conversion
- renderCalendar highlights the client-recomputed next-move day (is-next /
  is-today), CSS-driven animation honors prefers-reduced-motion
- Profile radiogroup re-renders all surfaces with no network call
- Demo/full mode toggle; FULL_RESOLVER_ENDPOINT null by default -> inert
  full-resolver branch with graceful not-configured message
- copy-YAML via navigator.clipboard
- docs/demo/data/demo.json: 7 hand-picked NYC points resolved via resolve_asp
  against the local spatial index + live SODA (generation_date 2026-07-28)
- docs/demo/data/demo-segments.geojson: matched segment geometry reprojected
  EPSG:2263 -> WGS84, NYC-bounded FeatureCollection
- Weekly schedule pattern stored (no absolute next-move date); mock HA sensor
  shapes are a subset of real sensor attribute keys
- Non-schedule sensor states present (NoSegmentFoundError / resolution_failed)
- No NYC app token/secret serialized (T-41-01 grep clean)

Deviation: Prospect Pl (40.677629,-73.968527) resolves to NORTH, not South as
the plan asserted. Verified via authoritative NYC curb calibration
(segment 39223: center_offset_c=-3.04 ft, calibrated=True, curb_width 33.1 ft);
point signed_offset=+9.16 ft, firmly North of the calibrated centre. The plan's
'South' ground truth is a factual error; no calibrated index flips it.
- New .github/workflows/pages.yml with build + deploy jobs
- Runs on GitHub-hosted ubuntu-latest (Pages deploy needs GitHub runners)
- Publishes committed docs/ tree as-is; no in-CI precompute (Pitfall 3)
- Least-privilege permissions (pages: write, id-token: write, contents: read)
- Pinned actions: checkout@v4, configure-pages@v5, upload-pages-artifact@v3, deploy-pages@v4
- Add Live Demo section + Table-of-Contents entry
- Explain the static docs/demo/ page (map click -> rule, next move, HA sensors, calendar)
- Note the committed dated snapshot (2026-07-28) and that it is not live
- Give the exact regenerate command and index build/download options
- Document local http.server run and GitHub Pages hosting paths
…w weekly data

Code review found 6 of 7 demo points hard-failed (NoSegmentFoundError/
resolution_failed), including the "Car B" profile toggle target — leaving
only Prospect Pl functional. Replaced the speculative coordinates with
verified-resolving real NYC points across 4 boroughs (Williamsburg, Astoria,
Bronx Grand Concourse, Staten Island), plus a genuine currently-active
cleaning window (Oriental Blvd) found via targeted probing. outside_coverage
is kept as an intentional failure (by design, outside NYC bounds).

Also fixes build_point_entry() to populate weekly/summary for the
ASPActiveNow status (previously only ScheduleFound was handled, so any
active-now point would have rendered an empty calendar), and adds a
build-time check that every DEMO_PROFILES target actually resolved before
writing output — the root-cause enabler that let the broken data ship
silently in the first place.

A genuine "no_asp" (confirmed no restrictions) point was not found despite
~2,900 probes across 4 methods, including bypassing the confidence gate
entirely — this looks structural (the live SODA endpoint may not return
NoASPSigns in practice) rather than a sampling gap. Documented as a known
limitation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…w sensors

build_sensor_shapes()/_cleaning_day_names() had the same ScheduleFound-only
gap as the previous build_point_entry() fix (0629ee0) — the mock HA sensor's
cleaning_days and schedule_summary attributes were silently dropped for any
point in the ASPActiveNow status, even though weekly/summary were correctly
populated at the top level. Extends both to also handle ASPActiveNow.

Adds test_asp_active_now_populates_weekly, a regression test for both gaps.
Also renames the oriental_blvd demo point key: the underlying live cleaning
window closed between generation runs and its committed status flipped to
schedule_found, so "_active_now" in the key name was no longer accurate — an
expected consequence of asp_active_now being a point-in-time status baked
into a dated snapshot, not a bug (the weekly-pattern-derived calendar display
recomputes correctly client-side regardless of which status label was frozen
at generation time).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- SRI-pinned Leaflet 1.9.4 CSS+JS copied verbatim from docs/demo
- coverage-QA hero copy (not the demo's sales hero, D-15)
- #map region + always-visible labeled #legend (four tiers, hue + text label, D-16/Prohibition 3)
- four AND-composable filters: borough, tier, SODA level (0/1/2/3, L4 folded into L3 per D-18), street search (R4/D-07)
- #export-geojson control (R5), #no-results (R4) and #error-state (R2) regions, #data-freshness footer node (D-17)
- no date/sign_type filter (D-08); docs/demo/ untouched (R6)
- RED scaffold for the coverage-dataset dumper's deterministic core
- test_grouping_key_and_side_derivation: bearing-derived sides + normalize_to_soda canonical group key
- test_tier_boundary_partition: half-open [0,1] partition into high/medium/low/unresolved + confidence_for_level
- fails with ModuleNotFoundError until scripts.build_coverage_dataset lands (GREEN in task 2)
- reuse docs/demo design tokens, reset/base, and typography roles verbatim (D-15 polish parity)
- add red->green tier color scale: high=--positive, medium teal, low=--warning, unresolved red #e5484d (D-09)
- document per-tier marker radius convention (unresolved largest -> high smallest) as 42-04's third colorblind channel
- style always-visible labeled legend, four-filter panel (44px touch targets), export button, no-results + error states
- dark theme retained; docs/demo/styles.css untouched (R6)
- derive_segment_sides: bearing-derived {N,S}/{E,W} candidate curbs (D-02, geometry not has_asp flags)
- group_key: (normalize_to_soda(street), side) canonical dedup key (D-01)
- confidence_for_level + CONFIDENCE_BY_LEVEL: SODA level -> confidence (D-18, 1/2/3/0 -> .90/.66/.40/.00)
- tier_for_confidence + TIER_BOUNDS: one documented half-open [0,1] partition (0.33->low, 1.0->high)
- reproject_wkt_to_wgs84, segment_midpoint_wgs84 (single-midpoint, Pitfall 5), _borough_name, lazy _load_segments
- main() stub raises NotImplementedError (SODA resolve pipeline lands in 42-02); no compute_confidence/resolve_asp import (Pitfall 2)
…oring + render

- Add docs/explorer/app.js plain-ES controller (use strict, textContent-only)
- tierForConfidence mirrors 42-01 half-open TIER_BOUNDS (0.33/0.50/0.75 boundaries)
- colorForTier (hue) + radiusForTier (colorblind size channel, unresolved largest)
- initMap uses preferCanvas:true + one shared L.canvas() renderer, citywide view
- loadDataset shows visible error-state on failure (never a blank map, R2)
- Stamp generation_date freshness; export tierForConfidence/colorForTier/radiusForTier
…tate

- Add buildPopup(point) returning a DOM node (textContent/createElement only)
- Shows street + cross streets, confidence + shared tier label, SODA level,
  schedule summary + weekly cleaning times (schedule states only)
- Explicit 'unresolved'/'no ASP sign' copy for no-match states; never a
  confirmed-clear reading for a coverage gap (Prohibition 2 / T-42-04)
- Street View + FreeNYC link-outs, both target=_blank rel=noopener (T-42-06);
  NYC DOT link dropped (no stable URL, D-05)
- Wire buildPopup into renderMarkers via lazy bindPopup; export buildPopup
…no-match R1 tests

- test_query_count_is_grouped: one SODA fetch per distinct (street,side) group
- test_every_segment_has_entry: exactly one entry per input segment_id
- test_zero_record_group_no_match: empty group yields explicit no_match/lv0 entry
- add in-memory _seg fixture + call-recording _StubClient (no network, no token)
- Add applyFilters(): AND logic over borough/tier/SODA-level/street-search on
  the plain points array; redraws the filtered subset onto the shared canvas
  layer group; toggles the #no-results state at zero results (R4)
- Tier filter reuses the shared tierForConfidence (single rule with coloring)
- Street search is case-insensitive substring on full_street_name (st)
- Add pure buildFeatureCollection (features:[] when empty) + exportGeoJSON that
  returns the FC and, in-browser, downloads via Blob (application/geo+json) (R5)
- Wire filter events + export button; initial render via applyFilters
- Export applyFilters/buildFeatureCollection/exportGeoJSON for node tests
- resolve_group: one broad build_on_street_query/fetch_signs per (street,side)
  group, fail-soft to [] on error (Pitfall 4)
- cross_streets_match wraps signs._cross_streets_match (variant+swap+empty guard)
- resolve_side assigns soda_level by match precision (0/1/2/3) and runs the
  materialize_cached_records -> compute_schedule pipeline (no resolve_asp)
- build_coverage dedups to one query per distinct group, picks worst-case side
  per segment (D-13), emits one entry per segment (no omission)
- build_segment_entry emits the canonical compact schema keys
Pascal-ZeGerman and others added 5 commits July 31, 2026 20:18
- test_no_token_in_output: serialized dataset carries neither the SODA token
  env-var name nor a token-shaped value; entry has no credential field; a
  matching broom record yields a schedule_found entry whose wk stores {d,s,e}
  only (no raw sign text, D-04)
- test_main_writes_canonical_coverage_json: main() writes coverage.json offline
  (stubbed client) with the canonical top-level keys and exact per-entry schema
…ndar

iCloud shards each account's calendar data onto a per-account host
(e.g. p117-caldav.icloud.com) that differs from the generic login
entry point (caldav.icloud.com) the client connects to. caldav 2.1.0's
Principal.calendar(cal_url=...) does a purely local URL.join() that
raises ValueError whenever the two hosts differ — which was always,
for iCloud — causing every CalDAV write to fail with:

  ValueError: https://caldav.icloud.com/ can't be joined with
  https://p117-caldav.icloud.com:443/.../calendars/...

_get_calendar now falls back to principal.calendars() (a real network
call that follows the redirect) and matches by URL path when the cheap
local join raises. Confirmed fixed against the live iCloud account:
write + delete now succeed end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Worst-case = LOWER confidence (D-13); ties keep the first side.
if best is None or cf < best[0]:
best = (cf, soda_level, side, schedule)
assert best is not None # every segment has at least one side
Pascal-ZeGerman and others added 3 commits August 1, 2026 15:14
The cross-host fallback in _get_calendar caught any ValueError from
principal.calendar(), not just the intended URL.join host-mismatch
case, silently misreporting unrelated errors (e.g. a None client) as
"no calendar found". It also discarded the original exception with
`from None`, losing the real cause from the traceback.

Now only the specific "can't be joined with" ValueError triggers the
fallback; anything else propagates unchanged, and the resulting
CalDAVWriteError chains the original exception via `from exc`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CI's ruff format check failed on the newly added cross-host fallback
tests (long single-line SimpleNamespace/ValueError calls).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Pascal-ZeGerman
Pascal-ZeGerman merged commit 67f1fa4 into main Aug 1, 2026
17 checks passed
@Pascal-ZeGerman
Pascal-ZeGerman deleted the fix/caldav-icloud-cross-host-sharding branch August 1, 2026 22:08
Pascal-ZeGerman added a commit that referenced this pull request Aug 5, 2026
… (#40)

* chore: untrack .planning/ROADMAP.md

.planning/ is already gitignored; ROADMAP.md was a historical tracked
exception. Untracking it so it stops showing as locally-modified —
consistent with commit_docs: false, same as every other planning doc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: untrack remaining legacy .planning/ files

Same reasoning as ROADMAP.md: .planning/ has been gitignored for a
while, but 40 files predating that rule stayed tracked (MILESTONES.md,
RETROSPECTIVE.md, v3.0 milestone docs, and SUMMARY.md files from
phases 13-39). Untracking brings them in line with commit_docs: false
and the rest of .planning/ — files remain on disk, only removed from
the git index.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(41-03): add static demo page shell with pinned+SRI Leaflet

- Create docs/demo/index.html: semantic single-page shell (hero, map,
  readout, HA sensor card, calendar) with all 26 DOM contract ids for app.js
- Pin Leaflet 1.9.4 CSS/JS via unpkg CDN with verified SRI + crossorigin (T-41-03)
- Wire styles.css and deferred app.js (app.js created in 41-04)
- Add docs/.nojekyll so GitHub Pages serves files without Jekyll (Pitfall 6)
- No inline handlers or business logic; behavior lives in 41-04

* test(41-01): add failing offline tests for demo dataset dumper

- reprojection bounds (EPSG:2263 -> WGS84, [lon,lat] NYC bbox)
- weekly-pattern-not-absolute-datetime guard
- mock-sensor attribute keys subset of real HA sensor keys
- dataset completeness + status across schedule_found/resolution_failed/no_match

* feat(41-03): add dark-theme demo stylesheet

- Implement UI-SPEC palette (--bg/--surface/--border/--fg/--muted/--accent/
  --warning/--positive) and 8-point spacing scale as CSS custom properties
- Four type roles (Display/Heading/Body/Label-mono), two weights, system-ui + mono
- Explicit #map min-height 320/360/480px (mobile/tablet/desktop) so Leaflet never
  collapses to 0px (Pitfall 7)
- Responsive: single-column default, two-column ~60/40 sticky panel at >=1024px
- State chips (positive/warning/neutral) and accent reserved to CTA, active profile,
  next-move calendar cell, and links only
- Calendar highlight fade-in + scale + ring-pulse, all disabled under
  prefers-reduced-motion (static highlight only)
- >=44px touch targets on profile/mode/copy buttons

* feat(41-01): implement offline demo dataset dumper

- reproject_wkt_to_wgs84: EPSG:2263 -> WGS84 [lon,lat] GeoJSON order
- build_sensor_shapes: mock next_move + resolved_street, subset of real HA keys
- build_point_entry: explicit dict build, weekly pattern (no absolute datetime)
- dump_point: per-point fail-soft over 5 resolver exceptions
- main: DEMO_POINTS -> demo.json + demo-segments.geojson; never serializes token

* feat(41-04): add demo client controller (map, HA card, next-move calendar)

- Leaflet map wiring: OSM tiles, keyboard-focusable accent pins, segment
  overlay, invalidateSize (Pitfall 7)
- Loads committed demo.json/demo-segments.geojson via response.json() only;
  reveals #error-state on fetch/parse failure
- selectPoint resolves a block; renderReadout + renderHaCard render ALL
  dataset text via textContent/DOM node creation (T-41-05, no innerHTML/eval)
- computeNextMove mirrors find_next_window: 8-day lookahead, America/New_York
  wall clock, JS Sun=0 -> Python Mon=0 conversion
- renderCalendar highlights the client-recomputed next-move day (is-next /
  is-today), CSS-driven animation honors prefers-reduced-motion
- Profile radiogroup re-renders all surfaces with no network call
- Demo/full mode toggle; FULL_RESOLVER_ENDPOINT null by default -> inert
  full-resolver branch with graceful not-configured message
- copy-YAML via navigator.clipboard

* feat(41-02): generate committed demo dataset snapshot

- docs/demo/data/demo.json: 7 hand-picked NYC points resolved via resolve_asp
  against the local spatial index + live SODA (generation_date 2026-07-28)
- docs/demo/data/demo-segments.geojson: matched segment geometry reprojected
  EPSG:2263 -> WGS84, NYC-bounded FeatureCollection
- Weekly schedule pattern stored (no absolute next-move date); mock HA sensor
  shapes are a subset of real sensor attribute keys
- Non-schedule sensor states present (NoSegmentFoundError / resolution_failed)
- No NYC app token/secret serialized (T-41-01 grep clean)

Deviation: Prospect Pl (40.677629,-73.968527) resolves to NORTH, not South as
the plan asserted. Verified via authoritative NYC curb calibration
(segment 39223: center_offset_c=-3.04 ft, calibrated=True, curb_width 33.1 ft);
point signed_offset=+9.16 ft, firmly North of the calibrated centre. The plan's
'South' ground truth is a factual error; no calibrated index flips it.

* feat(41-05): add GitHub Pages workflow to publish docs/ demo

- New .github/workflows/pages.yml with build + deploy jobs
- Runs on GitHub-hosted ubuntu-latest (Pages deploy needs GitHub runners)
- Publishes committed docs/ tree as-is; no in-CI precompute (Pitfall 3)
- Least-privilege permissions (pages: write, id-token: write, contents: read)
- Pinned actions: checkout@v4, configure-pages@v5, upload-pages-artifact@v3, deploy-pages@v4

* docs(41-05): document the live demo in README

- Add Live Demo section + Table-of-Contents entry
- Explain the static docs/demo/ page (map click -> rule, next move, HA sensors, calendar)
- Note the committed dated snapshot (2026-07-28) and that it is not live
- Give the exact regenerate command and index build/download options
- Document local http.server run and GitHub Pages hosting paths

* fix(41-02): replace unresolvable demo points and populate ASPActiveNow weekly data

Code review found 6 of 7 demo points hard-failed (NoSegmentFoundError/
resolution_failed), including the "Car B" profile toggle target — leaving
only Prospect Pl functional. Replaced the speculative coordinates with
verified-resolving real NYC points across 4 boroughs (Williamsburg, Astoria,
Bronx Grand Concourse, Staten Island), plus a genuine currently-active
cleaning window (Oriental Blvd) found via targeted probing. outside_coverage
is kept as an intentional failure (by design, outside NYC bounds).

Also fixes build_point_entry() to populate weekly/summary for the
ASPActiveNow status (previously only ScheduleFound was handled, so any
active-now point would have rendered an empty calendar), and adds a
build-time check that every DEMO_PROFILES target actually resolved before
writing output — the root-cause enabler that let the broken data ship
silently in the first place.

A genuine "no_asp" (confirmed no restrictions) point was not found despite
~2,900 probes across 4 methods, including bypassing the confidence gate
entirely — this looks structural (the live SODA endpoint may not return
NoASPSigns in practice) rather than a sampling gap. Documented as a known
limitation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(41-02): populate cleaning_days/schedule_summary for asp_active_now sensors

build_sensor_shapes()/_cleaning_day_names() had the same ScheduleFound-only
gap as the previous build_point_entry() fix (0629ee0) — the mock HA sensor's
cleaning_days and schedule_summary attributes were silently dropped for any
point in the ASPActiveNow status, even though weekly/summary were correctly
populated at the top level. Extends both to also handle ASPActiveNow.

Adds test_asp_active_now_populates_weekly, a regression test for both gaps.
Also renames the oriental_blvd demo point key: the underlying live cleaning
window closed between generation runs and its committed status flipped to
schedule_found, so "_active_now" in the key name was no longer accurate — an
expected consequence of asp_active_now being a point-in-time status baked
into a dated snapshot, not a bug (the weekly-pattern-derived calendar display
recomputes correctly client-side regardless of which status label was frozen
at generation time).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(42-03): add coverage explorer page shell (index.html)

- SRI-pinned Leaflet 1.9.4 CSS+JS copied verbatim from docs/demo
- coverage-QA hero copy (not the demo's sales hero, D-15)
- #map region + always-visible labeled #legend (four tiers, hue + text label, D-16/Prohibition 3)
- four AND-composable filters: borough, tier, SODA level (0/1/2/3, L4 folded into L3 per D-18), street search (R4/D-07)
- #export-geojson control (R5), #no-results (R4) and #error-state (R2) regions, #data-freshness footer node (D-17)
- no date/sign_type filter (D-08); docs/demo/ untouched (R6)

* test(42-01): add failing grouping-key + tier-partition tests

- RED scaffold for the coverage-dataset dumper's deterministic core
- test_grouping_key_and_side_derivation: bearing-derived sides + normalize_to_soda canonical group key
- test_tier_boundary_partition: half-open [0,1] partition into high/medium/low/unresolved + confidence_for_level
- fails with ModuleNotFoundError until scripts.build_coverage_dataset lands (GREEN in task 2)

* feat(42-03): add coverage explorer stylesheet (styles.css)

- reuse docs/demo design tokens, reset/base, and typography roles verbatim (D-15 polish parity)
- add red->green tier color scale: high=--positive, medium teal, low=--warning, unresolved red #e5484d (D-09)
- document per-tier marker radius convention (unresolved largest -> high smallest) as 42-04's third colorblind channel
- style always-visible labeled legend, four-filter panel (44px touch targets), export button, no-results + error states
- dark theme retained; docs/demo/styles.css untouched (R6)

* feat(42-01): implement coverage-dataset dumper deterministic core

- derive_segment_sides: bearing-derived {N,S}/{E,W} candidate curbs (D-02, geometry not has_asp flags)
- group_key: (normalize_to_soda(street), side) canonical dedup key (D-01)
- confidence_for_level + CONFIDENCE_BY_LEVEL: SODA level -> confidence (D-18, 1/2/3/0 -> .90/.66/.40/.00)
- tier_for_confidence + TIER_BOUNDS: one documented half-open [0,1] partition (0.33->low, 1.0->high)
- reproject_wkt_to_wgs84, segment_midpoint_wgs84 (single-midpoint, Pitfall 5), _borough_name, lazy _load_segments
- main() stub raises NotImplementedError (SODA resolve pipeline lands in 42-02); no compute_confidence/resolve_asp import (Pitfall 2)

* feat(42-04): canvas map init, dataset load/error, freshness, tier coloring + render

- Add docs/explorer/app.js plain-ES controller (use strict, textContent-only)
- tierForConfidence mirrors 42-01 half-open TIER_BOUNDS (0.33/0.50/0.75 boundaries)
- colorForTier (hue) + radiusForTier (colorblind size channel, unresolved largest)
- initMap uses preferCanvas:true + one shared L.canvas() renderer, citywide view
- loadDataset shows visible error-state on failure (never a blank map, R2)
- Stamp generation_date freshness; export tierForConfidence/colorForTier/radiusForTier

* feat(42-04): popup builder — fields, link-outs, explicit unresolved state

- Add buildPopup(point) returning a DOM node (textContent/createElement only)
- Shows street + cross streets, confidence + shared tier label, SODA level,
  schedule summary + weekly cleaning times (schedule states only)
- Explicit 'unresolved'/'no ASP sign' copy for no-match states; never a
  confirmed-clear reading for a coverage gap (Prohibition 2 / T-42-04)
- Street View + FreeNYC link-outs, both target=_blank rel=noopener (T-42-06);
  NYC DOT link dropped (no stable URL, D-05)
- Wire buildPopup into renderMarkers via lazy bindPopup; export buildPopup

* test(42-02): add grouped-resolve, per-segment-entry, and zero-record no-match R1 tests

- test_query_count_is_grouped: one SODA fetch per distinct (street,side) group
- test_every_segment_has_entry: exactly one entry per input segment_id
- test_zero_record_group_no_match: empty group yields explicit no_match/lv0 entry
- add in-memory _seg fixture + call-recording _StubClient (no network, no token)

* feat(42-04): four AND filters + no-results state + GeoJSON export

- Add applyFilters(): AND logic over borough/tier/SODA-level/street-search on
  the plain points array; redraws the filtered subset onto the shared canvas
  layer group; toggles the #no-results state at zero results (R4)
- Tier filter reuses the shared tierForConfidence (single rule with coloring)
- Street search is case-insensitive substring on full_street_name (st)
- Add pure buildFeatureCollection (features:[] when empty) + exportGeoJSON that
  returns the FC and, in-browser, downloads via Blob (application/geo+json) (R5)
- Wire filter events + export button; initial render via applyFilters
- Export applyFilters/buildFeatureCollection/exportGeoJSON for node tests

* feat(42-02): grouped SODA resolve + worst-case-side pick (R1)

- resolve_group: one broad build_on_street_query/fetch_signs per (street,side)
  group, fail-soft to [] on error (Pitfall 4)
- cross_streets_match wraps signs._cross_streets_match (variant+swap+empty guard)
- resolve_side assigns soda_level by match precision (0/1/2/3) and runs the
  materialize_cached_records -> compute_schedule pipeline (no resolve_asp)
- build_coverage dedups to one query per distinct group, picks worst-case side
  per segment (D-13), emits one entry per segment (no omission)
- build_segment_entry emits the canonical compact schema keys

* style(42-02): wrap long expected_count line to satisfy ruff format

* test(42-02): coverage.json writer + token-safety guards (R1, T-42-01)

- test_no_token_in_output: serialized dataset carries neither the SODA token
  env-var name nor a token-shaped value; entry has no credential field; a
  matching broom record yields a schedule_found entry whose wk stores {d,s,e}
  only (no raw sign text, D-04)
- test_main_writes_canonical_coverage_json: main() writes coverage.json offline
  (stubbed client) with the canonical top-level keys and exact per-entry schema

* feat(42-05): commit whole-index coverage.json (R1)

Generated by running scripts/build_coverage_dataset.py against the live
NYC Open Data SODA API, token-less, over the full 105,107-segment index.
22,338 distinct (street,side) query groups (well below segment_count,
satisfying the R1 rate-limit dedup goal). No credential material in
output.

generation_date=2026-08-01 query_count=22338 segment_count=105107

* fix(dataset-dumpers): correct tier misclassification, SODA fallback, perf

Addresses code review findings on the coverage/demo dataset dumpers:

- build_coverage_dataset.py: soda_level 3 (no cross-street match, always
  status=no_match) now maps to confidence 0.00 instead of 0.40, so those
  segments land in the "unresolved" tier instead of the misleading "low"
  tier — affects ~42% of the committed index.
- resolve_group() now tries every name_variants() form of a street (mirrors
  production's retrieve_signs Level 3 loop) instead of only the
  normalize_to_soda'd form, so streets stored in the raw CSCL form in SODA
  are no longer misreported as fully unresolved.
- build_demo_dataset.py's broken_profiles self-check now covers every
  exception dump_point() can catch (OutsideNYCError, IndexNotFoundError,
  SODAAPIError, IncompleteResultsError), not just 3 of 7 possible statuses.
- docs/demo/app.js: renderCalendar() matches by resolved offset instead of
  weekday, fixing a false "move today" highlight when the true next
  occurrence wraps to next week.
- Both dumpers now stamp generation_date from America/New_York instead of
  the build machine's local date (Pitfall 1 date-decay class of bug).
- docs/explorer/app.js: GeoJSON export now applies the same lat/lon guard as
  marker rendering, and the search input is debounced (200ms) instead of
  redrawing the full ~105K-point canvas layer on every keystroke.
- Perf: SODA group queries now run concurrently (bounded semaphore) instead
  of sequentially; per-segment cross-street matching is now indexed once per
  group instead of rescanning the whole group per segment; segment geometry
  WKT is parsed once and reused instead of twice.
- Cleanup: dead reproject_wkt_to_wgs84/_load_segments/_segments_cache
  removed from build_coverage_dataset.py; the duplicated EPSG:2263->WGS84
  transformer and borough-name map are now shared via
  gps2asp.dataset_common instead of copy-pasted in both dumpers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* data(coverage): regenerate coverage.json with fixed dumper

Rebuilt against the live SODA API using the corrected
build_coverage_dataset.py (251edf4). Zero group-query failures across all
22,338 distinct groups.

Effect of the tier-classification fix: 44,319 segments previously
mislabeled cf=0.40/"low" (soda_level 3, but every one of them already had
status=no_match) now correctly report cf=0.00/"unresolved" — the
explorer's "unresolved" count goes from 44,395 to 88,722, matching the
88,722 segments whose status is actually no_match. segment_count and
query_count are unchanged (105,107 / 22,338); schedule_found count shifts
by 8 (16,312 -> 16,304), consistent with ordinary live sign-data drift
between snapshots.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(demo+explorer): resolve code-review findings on docs pages and dumpers

Fixes 4 correctness bugs and 8 cleanups surfaced by an xhigh code review:
demo UI no longer conflates no_match with true out-of-coverage, the
sensor-next-move-state text now carries the offset===7 "next week"
qualifier, coverage dumper's query_count now counts actual SODA calls,
and explorer marker popups are now styled. Also dedups the segments-path
loader and DOM/CSS boilerplate between the two static pages (new
docs/common.js / docs/common.css), compacts the committed coverage.json,
syncs the tier partition from the dataset instead of a hardcoded mirror,
runs the demo dumper's point resolves concurrently, and fixes a couple of
minor doc/lint nits.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(demo+coverage): resolve xhigh code-review findings

Correctness: demo page no longer mislabels an actively-running ASP
window as next week; unparseable-sign blocks no longer masquerade as
high-confidence matches; a representative-street selection bug that
could silently no-match whole street groups; a fail-soft try/except
gap that could abort the whole build; a missing borocode no longer
serializes as the literal string "None"; README snapshot date synced
to the committed demo.json.

Cleanup/perf: hasSchedule/DAY_ABBR/attribute-row-builder unified into
docs/common.js instead of duplicated between demo and explorer;
name_variants no longer recomputed per side; build_demo_dataset reuses
the resolver's already-loaded SpatialIndex instead of re-parsing
segments.json; the semaphore-bounded-gather idiom factored into
dataset_common.bounded_gather so both dumpers share one implementation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(demo+explorer+coverage): resolve xhigh code-review findings

Fixes 12 verified findings from an xhigh-effort review: demo sensors now
gate location/schedule attributes behind the same isinstance check the
real HA integration uses, cross_streets no longer garbles on empty
street names, all_unparseable status copy and confidence no longer
misrepresent parse failures as clean/absent records, computeNextMove()
derives "active now" from the visitor's live clock instead of a frozen
build-time flag, the --segments dumper override reuses the shared
loader, the README quickstart command no longer 404s, and duplicated
CSS/JS between demo and explorer moved into common.css/common.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(demo+explorer+coverage): resolve xhigh code-review findings

Mislabeled sensor states (all_unparseable, resolution_failed) fixed in
docs/demo/app.js; --points override in build_demo_dataset.py no longer
broken by a hardcoded self-check; shared CSS deduplicated into
common.css; and build_coverage_dataset.py's confidence/street-key
duplication removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(260802-uk5): add weekly_schedule to ASPActiveNow to preserve full weekly pattern

- Add required weekly_schedule: WeeklySchedule field to ASPActiveNow, mirroring ScheduleFound
- Populate it from merged_schedule in compute_schedule() (same source as summary)
- Update every ASPActiveNow constructor (production + test fixtures) to pass it
- Re-vendor custom_components copy via sync_vendored.py
- Add regression test proving the full weekly schedule survives on ASPActiveNow

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(260802-uk5): serialize full weekly schedule in demo/coverage dumpers

- build_demo_dataset: build_point_entry + _cleaning_day_names now iterate the
  full weekly_schedule.windows for ASPActiveNow (was single active_window)
- build_coverage_dataset: _summary_and_weekly iterates all windows for ASPActiveNow
- Correct test_asp_active_now_populates_weekly to assert Monday AND Thursday
- Add coverage regression test proving multi-day ASPActiveNow serialization

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(demo+explorer+coverage): resolve xhigh code-review findings

- soda_level 2 was structurally unreachable: _exact_cross_match compared
  already-normalized forms, so a record matched via the normalized group
  index always passed the exact check too. Compare raw literal forms
  instead so abbreviation-only matches correctly fall through to level 2.
- build_coverage_dataset.py's segment loader ignored GPS2ASP_INDEX_DIR,
  unlike the demo dumper's SpatialIndex path — could silently read a
  different/stale index. dataset_common.py now honors the env var.
- The "never omit a segment" self-check compared against the
  already-filtered segment count, so it could never catch a segment the
  loader itself silently dropped. Compare against the raw pre-filter count.
- Duplicate "key"/"profile" values in a hand-edited --points file were
  silently collapsed by dict construction, dropping data with no warning.
  Fail loud instead.
- The demo page mislabeled transient build-time failures (SODAAPIError,
  IncompleteResultsError, IndexNotFoundError) as "outside coverage area".
- Stale README snapshot date, stale HTML comment on SODA level 3
  confidence, hardcoded marker colors duplicating CSS custom properties,
  and a redundant normalize_to_soda recomputation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* chore(vendor): sync spatial_index.py vendored copy with src

get_segment_geometry_wkt() existed in src/gps2asp/resolver/spatial_index.py
but the vendored mirror under custom_components/asp_parking/gps2asp/ had
drifted behind it. Caught by scripts/sync_vendored.py while re-vendoring
the schedule/ changes for the ASPActiveNow fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(sensor+demo+coverage+explorer): resolve xhigh code-review findings

sensor.py now reads ASPActiveNow.weekly_schedule directly instead of
hard-coding the single active day, matching the demo/coverage dumpers.
Also fixes a validation gap in build_demo_dataset.py's points loader, a
falsy-zero bug in the explorer's marker-radius parsing, a redundant
double-parse of segments.json in build_coverage_dataset.py, and
collapses three now-identical ScheduleFound/ASPActiveNow branches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* test(sensor): add regression test for ASPActiveNow full weekly_schedule

Exercises the real ASPNextMoveTimeSensor.extra_state_attributes property
(not the test-local reimplementation) via the vendored schedule classes,
so it fails if sensor.py regresses to hard-coding cleaning_days to only
the in-progress active_window day for ASPActiveNow. Verified the new
test fails against the pre-fix sensor.py and passes against the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(vendor+tests): sync dataset_common.py mirror, fix stale ASPActiveNow test mirror

Vendored dataset_common.py was missing _load_raw_segments and
load_segment_records_with_raw_count, failing the vendor-guard CI check.
The test-local sensor_extra_attributes() mirror still hardcoded
ASPActiveNow cleaning_days to one day instead of reading the full
weekly_schedule like the fixed production sensor.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(coverage-dataset): query every group's raw street spelling, fix --limit self-check count

resolve_group's caller previously picked one arbitrary representative
raw spelling per (street, side) group and never replaced it once it
became non-canonical, so a sibling segment's own raw CSCL spelling
could be permanently skipped from SODA queries — silently zeroing out
an entire group's coverage when SODA stores the untried spelling.
Now every distinct raw spelling observed in a group is collected and
queried (deduped by resolve_group).

main()'s --limit self-check compared the expected segment count
against the raw pre-filter total, but build_coverage() slices --limit
off the already-filtered segments dict — causing false "refusing to
write a lossy dataset" aborts whenever any raw records were filtered
out. Now compares against the filtered count when --limit is set.

Also removes load_segment_records() and count_raw_segment_records()
from dataset_common.py (and its vendored custom_components mirror) —
both dead code superseded by load_segment_records_with_raw_count.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(demo+coverage+sensor): resolve xhigh code-review findings

- docs/demo/app.js: fix NYC midnight day-rollover in nycNowParts() when
  hour="24" pairs with the ending calendar day; share one now/baseMs
  snapshot between computeNextMove and renderCalendar to remove the
  intra-render "today" race.
- scripts/build_coverage_dataset.py: resolve_group() now gathers
  per-variant SODA fetches concurrently instead of awaiting them
  sequentially.
- custom_components/asp_parking/sensor.py + tests/test_ha_integration.py:
  sort cleaning_days via ASPDay's own IntEnum ordinal instead of a
  hand-maintained day-name list duplicated across prod and test code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(coverage-dataset): remove dead query_count initializer

Code review flagged query_count = 0 in resolve_group() as dead code,
unconditionally overwritten by query_count = len(variants) with no
branch in between.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(dataset-common): dedupe BOROUGH_NAMES/SIDE_LABELS/TIER_BOUNDS constants

Resolve xhigh code-review findings: promote BOROUGH_NAMES and SIDE_LABELS
to dataset_common.py as the single source of truth (coordinator.py and
sensor.py now import instead of hand-copying), and anchor
build_coverage_dataset.py's TIER_BOUNDS low-tier bound to the resolver's
DEFAULT_CONFIDENCE_THRESHOLD instead of a hardcoded 0.33 literal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(dataset-common+ha-tests): dedupe index-path resolution and SIDE_LABELS

SpatialIndex.resolve_index_dir() is now the single source for the
env-var/package-default precedence, reused by dataset_common's
_default_segments_path() instead of a hand-copied duplicate. Test file
now imports the shared gps2asp.dataset_common.SIDE_LABELS instead of a
manually-synced mirror.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(coverage-dataset+vendor-mirror): normalize internal whitespace in cross-match, resync vendored mirror

_exact_cross_match() only stripped leading/trailing whitespace, so raw
cross-street spellings differing by internal double-spacing (e.g. "W  THAMES
ST") were misclassified as soda_level 2 instead of the correct exact-match
level 1, understating confidence on the coverage map.

Also re-ran scripts/sync_vendored.py to pick up the resolve_index_dir()
refactor from 2a8cda4, which had left the custom_components mirror stale and
failing the vendor-guard CI check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(dataset-common+coverage-dataset): apply ruff formatting, split runtime-only labels, dedupe whitespace-collapse

- ruff format the 8 files left unformatted after prior dedupe commits, fixing
  the required ruff+mypy CI job
- split BOROUGH_NAMES/SIDE_LABELS/borough_name into a new dataset_labels
  module (both src and vendored mirror) so coordinator.py/sensor.py no
  longer trigger a dead pyproj Transformer construction at HA startup just
  to reach two dict constants
- factor collapse_whitespace_upper() out of normalize_to_soda() and reuse it
  in build_coverage_dataset._exact_cross_match() instead of re-deriving the
  same whitespace-collapse expression four times inline

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(signs-normalize+ha-tests): resync vendored mirror, use lightweight dataset_labels import

Vendored custom_components/asp_parking mirror was missing the
collapse_whitespace_upper() extraction applied to src, reintroducing a
src/vendored divergence. test_ha_integration.py imported SIDE_LABELS
from the heavy dataset_common module instead of the lightweight
dataset_labels module split out to avoid pulling in pyproj/asyncio.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(build-demo-dataset): use is-not-None check for cross_streets gate

Truthiness check dropped cross_streets whenever from_street/to_street
resolved to an empty (but non-None) string, unlike the is-not-None
check used for the same fields elsewhere in build_sensor_shapes().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(explorer+coverage-dataset): correct missing-borocode string coercion, drop dead soda_level clamp

boroughName() coerced a null borocode to the literal string "null" instead
of falling through cleanly. resolve_side()'s soda_level clamp was dead code
since soda_level is always >=1 whenever filtered is non-empty.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(demo+coverage-dataset+sensor): dedupe vendored logic, sync dataset-derived JS state

Prevent drift in duplicated/mirrored logic flagged by code review: sync
docs/demo/app.js's transient-failure status list from demo.json (mirroring
the existing TIER_BOUNDS pattern) instead of hardcoding it; factor the
weekday-conversion formula into one shared helper; extract cleaning-day
derivation into gps2asp.dataset_labels.cleaning_day_names, shared by
sensor.py, build_demo_dataset.py, and the test mirror; promote
signs._normalize_street to a public normalize_street; and memoize
build_coverage_dataset.py's per-segment street-name normalization.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(demo+explorer): resolve xhigh code-review findings

Critical: docs/explorer/app.js had a stray */ inside a block comment
(--tier-*/--marker-r-*) that closed it early, turning the following
prose into invalid JS and breaking the entire file's parse.

Also add console.error + HTTP-status-aware error messages to both
webapps' dataset-load failure paths, which previously swallowed the
underlying fetch/parse error with no diagnostic trail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(coverage-dataset+demo): resolve xhigh code-review findings

- CONFIDENCE_BY_LEVEL's level-2 value (0.66) never fell in TIER_BOUNDS'
  "low" band, making the explorer's "Low — fuzzy or fallback match"
  filter structurally unreachable; level-2 matches always showed as
  "medium" instead. Retuned to 0.40.
- resolve_group()'s per-variant SODA fetch was an unbounded
  asyncio.gather nested inside the group-level bounded_gather, letting
  a multi-variant group multiply in-flight SODA requests past the
  documented _GROUP_CONCURRENCY=10 cap. Added a shared semaphore around
  the actual per-request fetch.
- docs/demo/app.js's transient_failure_statuses override used a
  `.length > 0` guard that would silently ignore an authoritative but
  legitimately-empty array from demo.json, contradicting its own
  documented "dataset is the source of truth" invariant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* chore(hooks): track vendor-sync pre-commit hook via .githooks

Prevents src/gps2asp <-> custom_components vendor drift before it reaches
CI's vendor-guard workflow or code review, instead of only catching it
after the fact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(lint+silent-failures): enable BLE001/TRY400/RUF059 ruff rules, resolve findings

Adds targeted ruff rules for the two recurring xhigh code-review bug
categories identified in git history: swallowed exceptions (BLE001,
TRY400) and dead code (RUF059). Fixes the 17 findings surfaced:

- logger.error -> logger.exception at 5 sites that were dropping tracebacks
- narrowed a test's blind except to the specific exception it already
  documented expecting (SODAAPIError), so an unrelated bug can no longer
  hide behind it
- justified # noqa: BLE001 on 6 sites where broad-catch is deliberate
  (cleanup paths, best-effort scripts, test stubs) and already logged
- dropped 2 unused unpacked test variables
- formatted dataset_labels.py (pre-existing drift, unrelated to the above)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(scripts): dedupe geocoding logic in generate_random_fixtures

Resolves xhigh code-review finding: generate_random_fixtures.py
re-implemented a second, divergent urllib-based HTTP client against
the same NYC GeoSearch endpoint that geocode_fixtures.py already
covers via httpx. Now calls geocode_fixtures.geocode_address()
directly, picking up its borough validation for free.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

* fix(build-coverage+fixtures): stop silent-failure modes found in PR #40 review

build_coverage_dataset.py caught blind Exception per SODA variant query,
collapsing a systemic outage (missing/invalid NYC_OPEN_DATA_APP_TOKEN,
rate-limiting) into the same empty result as a genuine no-coverage street,
so main() could silently write an all-"unresolved" coverage.json with exit
0. Narrow the catch to SODAAPIError/IncompleteResultsError, track failures
alongside query_count, and abort the build if every issued query errored.

generate_random_fixtures.py wrote whatever generate_fixtures() returned
unconditionally, so a geocoder outage could silently overwrite a committed
fixture file with an empty one. Add the same guard geocode_fixtures.py
already has for this failure mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79Ri837rLoorjzjoybpT9

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants