Development worklog entries from Claude Code sessions
Merged PR #14 (thermostat v1.4.2) and PR #15 (account-name fix) to master; tagged framework
v1.2.5. Started feature/thermostat-reset off master for the reset workflow.
Design (agreed with Barry): report by default, act only when a config flag is set.
no email path exists in the framework and a webhook needs no new infra.
account_config → runtime.config: report_webhook_url, do_resets (default false).do_resets; the reset needs role: editor + write:deviceSettings (added in the next commit), so a resettable deploy carries editor and the
flag gates behavior.
Report-first (this commit, read-only): when reset candidates are found, POST { text, candidates }
to report_webhook_url (Slack renders text; forwarder parses candidates); log-only if unset; a
webhook failure doesn't fail the run. Pure buildReport() unit-tested; thin postReport() fetch
wrapper. Manifest stays viewer. 41 tests pass.
Validated the report against a real Slack webhook (Barry supplied an incoming-webhook URL):
posted a sample built by the real buildReport() — HTTP 200/ok, renders correctly. Then added
asset links per candidate: https://<subdomain>.ubxsystems.com/assets/<uuid> (subdomain =
environment, except production → app); Slack links the thermostat name (<url|name>), and the
structured candidates array carries a plain url. 42 tests pass.
do_resets write path (v1.6.0): when runtime.config.do_resets === true, reset each
candidate by resuming its schedule plan — a single updateDeviceSettings(assetUuid,
{ plan_event }, force: true) downlink. Key finding from the sbp-mono WT201 v2 encoders (which
overrode Barry's earlier "set setpoints + resume" idea): switch_schedule_plan makes the device
re-apply that plan's setpoints itself, and **explicitly writing setpoints is what *creates* an
override** — so the reset is plan_event ONLY. plan_event accepts only WAKE/AWAY/HOME/SLEEP;
OCCUPIED/UNOCCUPIED/ECO candidates are skipped+flagged. Per-candidate outcome (done/failed/
unsupported) rendered in the report; manifest → role: editor + write:deviceSettings. Pure
resetPlanFor + report rendering unit-tested (44 pass). do_resets defaults false → default stays
read-only. Committed + pushed; published v1.6.0 to staging (Barry deployed with do_resets:true +
webhook).
develop): the v2 plan_event encoder onlyaccepted WAKE/AWAY/HOME/SLEEP (so OCCUPIED/UNOCCUPIED/ECO overrides can't be reset — the agent
skips them). The device + driver already support all seven (schedule_plans "last 3 only valid
for tstat2"), so this was a GraphQL-layer oversight. Opened a draft PR widening PLANS to the
full seven + a self-contained encoder unit test. Note: the strawberry-gql backend lives on
develop/feature/macos_support, NOT master (master is ~11k commits behind, old graphql/
server) — made the change via an isolated worktree off origin/develop.
RESETTABLE_PLANS now includes all seven plans, so theagent resets OCCUPIED/UNOCCUPIED/ECO overrides too (only skips a candidate whose plan can't be
identified). Committed + pushed but not published — deploy only after PR #879 is live;
against an older backend those three resets fail at the API (caught + reported, not fatal).
est. ≈X kWh / ~$Y saved. Coarse by design — only the wasteful override direction counts;
est_kWh = 0.015 kWh/°F·h/1000ft² × Δ°F × hours × zone/1000, flat 2000 ft² zone (≈0.03×Δ°F×h),
hours = time until schedule resumes (minutesUntilNextTransition), $0.13/kWh. Sources: DOE ~1%/°F
rule + DOE Medium Office prototype, EIA CBECS 2018, EIA 2024 commercial rate (researched + cited).
Gauge/cumulative, not precise; refined later. New pure minutesUntilNextTransition +
estimateSavings (unit-tested, 50 pass). Validated the rendered line in Slack. These numbers
become the thermostat.override_reset payload for the planned detected_event store.
vs ~$0.91 — same Δ°F, so the gap was entirely the "time until next plan" window (7356's next
plan ~40 min out, 1511's ~5 days out on a sparse schedule). Unbounded window ballooned it. Fix:
cap the avoided window at 24h (cappedWindowHours) and surface the inputs — the report line now
shows (Δ°F off, ~Nh) and a per-candidate debug log carries expected/actual/window/savings. 52
tests pass. Also confirmed do_resets' reset-write path fires on staging (both resumed WAKE).
Published v1.7.1 to staging.
do_resets: false whileiterating — read-only detection + Slack reports + savings, no device writes (so the #879
dependency is moot for report-only). First prod publish of this agent, from the unmerged
feature/thermostat-reset branch (merge before it's a settled prod deploy). Flagged: the build
carries role: editor (unused while do_resets false) — offered a viewer-only prod build for
least privilege while iterating. Pushed back on enabling do_resets in prod: the reset write path
hasn't been confirmed even on staging yet.
(keep status/resolved_at; recordDetectedEvent mutation; table detected_event). A
type-discriminated event store: type + JSONB data + first-class indexed
account/building/asset/source/occurred_at, promoted estimated_kwh_savings/estimated_usd_savings
(default 0) + savings_estimated_at (backfilled later by an estimator), status/resolved_at
for ongoing conditions (short-cycling). Mirrored the ui_preference vertical slice: DDL in
create-ubx-tables.sql + hand-run migrations/add-detected-event.sql (no migration framework);
recordDetectedEvent mutation + detectedEvents query; data-access in hvac2/ubx/detected_event.py;
role-based permission YAML entries (record = >=editor, read = any account access); 7 unit tests
(isolation-safe). Built on an isolated worktree off origin/develop; feature/macos_support untouched.
First producer will be thermostat-schedule-checker's thermostat.override_reset events. Migration
is hand-run per cluster before the mutation is usable there.
## Publish state (as of 2026-07-10)
thermostat-schedule-checker: staging = v1.7.0, **production = v1.7.0 (report-only, do_resets:false)**. Local branch feature/thermostat-reset (v1.7.0) is unmerged. Framework tag
v1.2.5 on master. Open PRs: agentic-framework #14 (thermostat detection, merged), sbp-mono #879
(widen v2 PLANS), sbp-mono #880 (detected_event).
Every online v2 thermostat came back overridden/no_schedule and we never once saw
ok/out_of_sync. Chased two wrong hypotheses first (transition-based history; then
"asset plan_event is unreliable, pivot to propertyHistory") — both wrong. Real root cause,
verified against sbp-mono (ubx_device_property_category table + the buildingAssets
resolver): the backend categorizes plan_event and the live fan_mode as settings
properties, not properties. The agent read them from the properties blob, always got
undefined, and treated every device as "not running the schedule". The setpoint temps
(heating_/cooling_target_temperature) really are in properties, so those were fine.
plan_event + live fan_mode from settings (the agent already fetches settings — thedata was in hand). No query change, no history pivot.
["WAKE"] → "WAKE"). category table, not by "live vs config" — plan_event/fan_mode live in settings.
staging. Diagnosed via a browser-console propertyHistory query (7356 showed SLEEP→WAKE, zero
NOT EXECUTED — it was never overridden) + an sbp-mono resolver deep-dive. Debugging lesson:
confirm which blob a property lives in before trusting a source-derived doc.
parseTimeToMinutes now validates fullH:MM shape + bounds via regex (incomplete/out-of-range times returned NaN and slipped past
findActiveBank's null guard); plan_event case normalized once so NOT EXECUTED matches
case-insensitively like the running plans; overrideDuration error path returns samples: 0 for a
consistent output shape. +2 regression tests (40 pass).
ok (setpoints match plan) and offline confirmed, then Barry manually overrode 7356 and watched the duration track across runs: overriddenForMinutes
5 → 65 over one hour (exact, from the plan_event history transition), correctly flipping from
info (under 30) to a reset candidate WARN at 65 min (activeBank: 3 = the SLEEP bank Phase 2
would restore). Every status path — ok / overridden+duration / reset-candidate / offline /
skipped_not_v2 — is now exercised on real data. Phase 1 detection is COMPLETE. Phase 2 (the actual
reset: write the active bank's scheduled setpoints + resume plan_event) is the remaining
follow-up and needs write scope + go-ahead.
The v1.3.0 staging run flagged 1 reset candidate but left 2 of 3 overrides as
"duration undetermined". Root cause (confirmed with Barry): propertyHistory returns
transitions, so an override that started before the 3h window returns no samples —
and an offline device (e.g. 1a16110c) looks identical (empty history). Fixed both:
online boolean (it's on AssetBase, added to the buildingAssets selection). online = false → status offline, skipped before
override classification (after the v1 skip). Offline devices are never reset candidates
(stale state; a downlink wouldn't reach them).
change in the window** (empty samples), treat it as overridden ≥ window (a lower bound)
→ reset candidate. Extracted the pure overrideDurationFromSamples and unit-tested it.
online on every per-thermostat log line; historySamples + durationIsLowerBound on override logs; summary renders
lower-bound durations as ≥Nm; added an offline summary line.
GraphQL online field validated against the live schema. Next: republish to staging and
confirm 1511 becomes a ≥180m candidate and 1a16110c reports offline.
Agent logs showed accountName: "Unknown" on every entry. Root cause: deploy() set
the Lambda ACCOUNT_NAME env var to account_name or '' using only the caller-passed
name, and the runtime logs ACCOUNT_NAME || 'Unknown' — so callers that don't pass a
name (e.g. the Ops Dashboard) leave it empty. Fixed deploy() to resolve the name via
db_client.get_account_by_uuid(account_uuid) when not provided (the record has it and
deploy already has the UUID). Cosmetic/label only — accountUuid was always correct;
takes effect on re-deploy. Tests: 15 pass (2 new). Draft PR #15 off master, independent
of the thermostat work.
Copilot review: the two new tests asserted account_name via a brittle positional
index (_create_or_update_lambda.call_args.args[8]). Fixed by binding the recorded call
against the real method signature (inspect.signature) and asserting on account_name by
name — robust to signature changes. 15 pass. Pushed to the PR branch.
Reworked the agent to read each thermostat's schedule directly from its device asset
and check it against the device-reported active plan (plan_event), replacing the
unreliable "match a separate schedule config by assignedConfigUuids" approach
(not all thermostats have an assigned config, and it can be out of sync).
plan_event names a plan → map to bank and compare programmed heating/cooling targets + fan mode vs live values (±0.5 °C). NOT EXECUTED is ambiguous (no
schedule vs user override), so read the schedule_time slots (building-local time,
weekly carry-over) to classify: active slot → overridden; none → no_schedule.
getBuildingDeviceConfig query. Active plan comes from plan_event; slots are parsed only to disambiguate NOT EXECUTED.
index.test.js (node:test, 21 cases) on the pure decision logic (incl. findActiveBank wrap-around), and SCHEDULE_FORMAT.md documenting the model.
sbp-mono decoders/drivers. Dropped the unused read:deviceConfig scope. Display
nameless assets as "Unnamed" (codebase convention). Current: v1.2.1 on staging.
1 no_schedule. Confirms the override-vs-no_schedule split and v1-skip against real
data. Still unexercised live: the ok/out_of_sync compare path (no running thermostat
in staging). Assumptions flagged: building timeZone for "current time"; plans carry
over across days.
thermostats overridden >30 min back to their scheduled values. There's no on-device
"overridden since" field (property stamps = last report; physical overrides have no
actor), so the agent reads plan_event history via propertyHistory to find the last
transition into NOT EXECUTED and flags reset candidates (>30 min). Read-only detection;
the reset (write scheduled setpoints/modes + resume plan_event) is Phase 2 (a
separate write-enabled PR needing editor/write scope + idempotency + dry-run). 26 unit
tests; all 3 queries validate live. Draft PR #14.
Reorganized the framework documentation and corrected the deployment model, then
merged it as PR #13.
README.md to a front door (overview, how it works in the product,multi-environment support).
agents/README.md (generic agent-developer guide) and docs/FRAMEWORK_DEVELOPERS.md (framework-internals guide); trimmed
hello-world to a concrete example with a dashboard screenshot.
CLI and deployed from the UBX Operations Dashboard** (previously documented
as deploy-agent.py). Documented the (event, context, runtime) handler
signature and runtime.config.
pip install -e .); removed the superseded implementation-plan-1-0.md and absorbed agents/DEPLOYMENT_GUIDE.md.
deploy-agent.py / resource-name examples across agent-runtime-architecture.md, orchestration/README.md,
orchestration/DEPLOYMENT_FLOW.md, orchestration/SLACK_NOTIFICATIONS.md,
docs/SETUP_MANAGEMENT_API.md, TESTING_GUIDE.md.
test is an e2e-only environment (not a publish target) and disambiguated the example uuid8 from the commit sha.
the source tree.
orchestration/test_deployment_state.py still called deploy()/cleanup() with
the old agent_dir= argument and never mocked self.repository, so all 8
state-machine tests failed. Updated them to the current agent_id +
repository.download_agent API (repository mocked in the fixtures). All 13 tests
pass. Shipped in PR #13.
All three GraphQL queries in the agent were written against an assumed schema and
failed against the live API. Validated each against the live schema
(http://127.0.0.1:8000/graphql) and corrected:
buildings (nonexistent) → accounts { groups { buildings } }, flattened + de-duped by UUID; timezone → timeZone.deviceConfigs(...) → getBuildingDeviceConfig(buildingUuid, configType).buildingAssets; read settings via ... on Device { settings }; used scalar assignedConfigUuids instead of the object list assignedDeviceConfigs.Bumped to v1.0.1 and re-published to staging. Opened as draft PR #14 — schema-valid
but still needs an end-to-end staging run to confirm runtime data handling.
Abandoned the building-monitor agent (a non-functional example built on the same
fictional schema).
SYSTEM_TEMP and SYSTEM_HUMIDITY) appeared twice in the expanded child list. buildCompositeAssetIndex pushed a child once per childLinkuse-composite-assets.ts: collapse duplicate child assetUuids per parent (mirrors ParentAssetLinks' uniqBy); added a unit test for the multi-link casefix/composite-duplicate-child-linkDashboardOverview.vue: added compositesActive (no search + no setup-pending filter); the child-hiding, the expand pill (expandable), and isGrouped all now gate on it. Fixes Copilot's finding that an expanded parent rendered its children both inside the group box and as standalone cards during search/filter (and could show installed children in the group under the uninstalled filter). Expansion state is retained and re-applies when search/filter clears. Simplified childrenOf (dropped the now-unneeded search filtering)AddFridgeButton.vue: corrected the stale ABOUTME — createAsset passes the fridge known-asset uuid as assetModelUuid (backend resolves known_asset_uuid), not category/typefeat/composite-assets (PR #1555)DashboardWidget.vue: the composite expand pill's icon inherited .dashboard-widget__title .v-icon { margin-right: 4px }, offsetting it inside the chip; reset to 0 so it sits centeredfeat/composite-assets (PR #1555)DashboardWidget.vue: expand pill icon x-small→small, chip height 18px→22px so it has roomfeat/composite-assets (PR #1555)DashboardWidget.vue: when expanded, the parent card gets a white background + soft shadow (0 2px 10px rgba(0,0,0,0.12)) to lift it off the tinted group box — chose elevation over more color since the box is already primary-tintedfeat/composite-assets (PR #1555)DashboardOverview.vue: .composite-group__parent flex-centers the parent at 366px (the grid card width at the common 3-column layout). Safe now because the whole box reveals as a unit rather than resizing the parent in place (the earlier standalone-centering attempt did)feat/composite-assets (PR #1555)DashboardOverview.vue: an expanded composite now renders parent + children inside a single bounding box (.composite-group) with a dashed border-top between the parent and the children grid, instead of a separate parent card plus a children band. New isGrouped() helper swaps the layout; the whole group animates via v-expand-transitionfeat/composite-assets (PR #1555)DashboardOverview.vue: wrapped the children band in v-expand-transition so it slides open/closed; first attempt at centering the expanded parent in a full-width row (later replaced by the box approach above)feat/composite-assets (PR #1555)DashboardWidget's title row as a chip inline with the set-up chip, using mdiCardMultipleOutline. Threaded via expandable/expanded props + toggle-expand emit (forwarded through AssetComponent attribute fallthrough for the fridge's custom widget)feat/composite-assets (PR #1555)knownAssetUuid, so the setup wizard showed no sensor-config step. Root cause in sbp-mono create_asset: it sets known_asset_uuid only when assetModelUuid is itself a known-asset uuid (KnownAssetCache.is_known_asset_uuid); category/type are not matched. Fix: pass FRIDGE_KNOWN_ASSET_UUID as assetModelUuid. Dropped the now-unused category/type resolverfeat/composite-assets (PR #1555)DashboardOverview.vue: keep the Refrigeration section (+ Add Fridge button) when a building has no fridges yet; don't hide composite children while searching or filtering to setup-pending (so they stay reachable and the uninstalled banner count matches the grid); filter the expand band by the active search; disable Add Fridge's Create until categories load; restored the fallback/dblclick comments dropped during the card extractionfeat/composite-assets (PR #1555)AddFridgeButton.vue: stopped forwarding the wizard's per-step refresh-asset to a dashboard refetch (the dialog covers the dashboard); refetch once on close insteadfeat/composite-assets (PR #1555)DashboardOverview.vue: only render the child band when there are resolvable children; AddFridgeButton.vue: removed a redundant v-model + @update:model-value double-binding on the setup dialogfeat/composite-assets (PR #1555)AddFridgeButton.vue (new) + DashboardOverview.vue: an Add Fridge button in the section containing the fridge known-asset; prompts for a name, creates the fridge, opens the setup wizard dialog. Added i18n keys (refrigerator.add_fridge / fridge_name / setup_title, buttons.create)feat/composite-assets (PR #1555)AssetSetupWizardDialog.vue (new): hosts the existing InstallWizardBody in a StandardDialog, so name/photos/floor plus the asset type's config step (fridge = sensor links) are reused rather than rebuiltfeat/composite-assets (PR #1555)DashboardAssetCard.vue (new) extracted from the dashboard grid and reused for both parent and child cards; DashboardOverview.vue hides child assets by default and reveals them in a tinted band via the parent's card-stack buttonfeat/composite-assets (PR #1555)utils/asset-category-type.ts (+ unit test): pure helper resolving a category/type name pair to account UUIDs (later removed once Add Fridge switched to assetModelUuid)feat/composite-assets (PR #1555)composables/use-composite-assets.ts (+ unit test): pure buildCompositeAssetIndex deriving child uuids + children-by-parent from the building asset list. Keyed on child links (generic; the fridge is the first instance)feat/composite-assets (PR #1555)docs/superpowers/plans/2026-06-30-composite-assets.md: task-by-task TDD implementation planfeat/composite-assets (PR #1555)docs/superpowers/specs/2026-06-30-composite-assets-design.md: brainstormed design — hide child assets by default, card-stack expand into a grouped band, and an Add Fridge flow that reuses the install wizardfeat/composite-assets (PR #1555)docs/.../2026-06-25-asset-inspector-improvements-design.md (toolbar section + sketch, "revised during implementation" note) and the PR description to match; replied to both comment threads. Commit 3bd804b0f (doc-only, no deploy)asset-inspector-improvements (PR #1550):z-index="11000" left the shared v-bottom-sheet at the Vuetify default (~2400), so on mobile the inspector opened beneath the floorplan unplaced-assets/thumbnails overlay (z-index: 10000, BuildingFloorplansTab.unplacedAssetsColumnProps)AssetInspectorPanel.vue: set the bottom sheet :z-index="11000" (restores the old value). Harmless for other mobile views (no high-z overlay there)asset-inspector-improvements (PR #1550)DashboardOverview.vue + AssetTableReport.vue: their update-asset handlers called deselectAsset() after applying an edit, closing the inspector when the edit dialog saved. Now point selectedAsset at the updated asset so the inspector stays open showing the new values. Delete/Remove still close it; floorplan already stayed open (selection derives from a UUID); alerts/thresholds don't wire update-assetasset-inspector-improvements (PR #1550)AssetInspector.vue: Dashboard button x-small→small and variant="outlined"→"flat" (solid primary)asset-inspector-improvements (PR #1550)AssetInspector.vue: ⋯ menu button mr-1 → mr-3 mr-sm-1 (12px gap before close on xs, 4px on sm+)asset-inspector-improvements (PR #1550)AssetInspectorPanel.vue: onClickOutside now passes { ignore: [".v-overlay-container"] } so selecting a ⋯-menu item (or interacting with a dialog the inspector opens) no longer counts as an outside click and closes the panel. Root cause of "Edit/Remove do nothing" — the panel was closing before the dialog rendered (vueuse 10.9 supports string selectors in ignore)AssetInspector.vue: ⋯ menu + close buttons bumped x-small→small and switched to variant="tonal", small gap between themasset-inspector-improvements (PR #1550)AssetInspector.vue: replaced the Dashboard split button's open-in-new-tab icon with a ... (mdiDotsHorizontal) overflow v-menu, and moved Edit / Remove from floor / Delete from the row below the carousel into it. Menu items: Edit, Remove from floor / Delete (same conditions), Open in new tabpt-6→pt-4. Dialogs + emitted events untouched (menu items trigger the same dialogs):z-index="10001" to render above the panel (.asset-inspector-container is z-index:10000; same workaround the SmartAssetName/PropertyValue tooltips use). Tightened the icon→label gap via --v-list-prepend-gap: 12px (Vuetify default for an icon prepend is 32px)asset-inspector-improvements (PR #1550)git revert of 9a1ac4ca (the "pre-fill dwell time with the minimum when enabling it" change) — back to the original "Set dwell time" button that just enables dwell without pre-filling. Used revert (new commit 774453893) rather than reset since the commit was already pushed/deployedDEFAULT_MIN_DWELL_MINUTES = 10 is untouched. Pushed and re-deployed to stagingalert-offline-min-dwellmainSeriesOptions comment said series stay matched "by id", but the chart updates with oneToOne:false → Highcharts matches by array index. Correct catch (the ids were reverted earlier in the branch)3044e0a3f on fix/temp-graph-csv-exportAlertForm.vue: the "Set dwell time" button now calls enableDwell, which turns dwell on and pre-fills dwell_minutes with the selected property's minimum (minDwellMinutes) when the field is empty, so it no longer opens blank. Dwell stays opt-in; existing values aren't overwrittenminDwellMinutes watcheralert-offline-min-dwellconfig/alert-dwell.ts: DEFAULT_MIN_DWELL_MINUTES 1 → 10 (the global floor used when a property config has no minDwellMinutes override; offline still overrides to 60)tests/config/alert-dwell.spec.ts assertion + ABOUTME comment to match; test greenalert-offline-min-dwell to staging (ubxstage) via cap staging deploy — note: deploy uses globally-installed Capistrano (rvm ruby-2.7.3), no Gemfile/bundle execalert-offline-min-dwelluse-series-options.ts back to main; removed the alert series id). Kept only the always-present alert series + its includeInDataExport:falseLineGraph.vue (alert series always present, excluded from CSV) — the shared series builder is untouched, so Compare / multi-series / colors / legend behave exactly as on main. The constant array still fixes the CSV column loss and the color shift because the array length no longer changes on the alert togglefix/temp-graph-csv-exportoneToOne:false update and the overlay's bars stopped drawingLineGraph.vue mainSeriesOptions now always includes the alert series (its data is already empty when no visible alerts, and the alert axis stays height 0), so the series array length never changes — this is the constant-array approach recorded as the preferred fix in [[alert-graph-color-shift]]fix/temp-graph-csv-exportHighchartsGraph updates with oneToOne:false and series had no id, so Highcharts matched series by array index. Prepending the alert overlay only when alerts are visible shifted every series' index, bleeding a placeholder's includeInDataExport:false onto the temperature series → Highcharts dropped it from the CSV, leaving only the alert overlay[CSV-DEBUG] logging in updateExportData (runtime showed Temperature with includeInDataExport:false though the source never sets it on numeric series)id in use-series-options.ts (data/text by group+series+point index, placeholders by group+index) so updates match by id; gave the alert series a stable id + includeInDataExport:false so it stays out of property CSVstests/utils pass. Branch: fix/temp-graph-csv-export (off main). Pending Barry's browser verificationAssetInspectorPanel.vue: bumped .asset-inspector-sheet height from 75vh to 85vhasset-inspector-improvementsAssetInspectorPanel.vue: bumped .asset-inspector-sheet height from 60vh to 75vhasset-inspector-improvementsDashboardWidget.vue: onCardClick excluded .property-value clicks from selecting on every device, so clicking a property value never opened the inspectorasset-inspector-improvementsAssetInspectorPanel, used by the two alerts views; the floorplan, dashboard, asset-table, and thresholds views each duplicated their own side-panel + fullscreen-dialog wrapperAssetInspectorPanel is now the single wrapper: forwards deletable + update/delete/remove/close, owns the tab state, and takes a desktopBreakpoint prop (floorplan passes "md")BuildingFloorplansTab, DashboardOverview, AssetTableReport, AssetsThresholdsReport to <asset-inspector-panel>; removed their duplicated wrappers, inspectorTab refs, and .asset-inspector-container CSS (net −105 lines):asset="inspectorOpen ? selectedAsset : null" and drops its mobile dialog's attach/scrim=false/z-index workarounds; AssetsThresholdsReport desktop inspector changes from a centered modal to the side panelasset-inspector-improvementsAssetInspector.vue: split button dropped to size="x-small" and given ml-4 (16px) so it aligns with the asset name block below the toolbarasset-inspector-improvementsAssetInspector.vue: switched the split button from filled (variant="flat") to variant="outlined" in our primary colorasset-inspector-improvementsAssetInspector.vue: the Dashboard control is now a v-btn-group split button — labeled segment navigates to the asset's full page in the same tab; attached mdiOpenInNew icon segment opens it in a new tab (target="_blank")buttons.open_in_new_tab aria-label string; updated the design specasset-inspector-improvementsAssetInspector.vue: toolbar now holds only a primary "Dashboard" button (navigates to the asset's full page in the same tab) and the close button; removed the open-in-new-tab iconAssetInspectorPanel.vue: mobile now renders a v-bottom-sheet (~60vh, rounded top, content scrolls) instead of a fullscreen v-dialog; desktop right-side panel unchangedbuttons.dashboard i18n stringasset-inspector-improvementsdocs/superpowers/specs/2026-06-25-asset-inspector-improvements-design.mdasset-inspector-improvementsA coworker's Linux make setup failed with git "detected dubious ownership in /workspace" — a
Docker-on-Linux issue: bind-mounted files keep the host UID while git runs as root in the
container (Mac's Docker Desktop/colima remap ownership, so it doesn't show there). Fix: added
git config --system --add safe.directory '*' to the Dockerfile so git trusts the mounted repos.
Their secondary symptom — git reporting standalone-dev-env's own setup-graphql.sh as a "sbp-mono
local change" — was because they're on an OLD version whose clone guard (! -d sbp-mono) skipped
the clone on the always-present mount dir, so git walked up to /workspace; the .git-based guard
(edfa39e) already fixes that. So the coworker needs to pull latest + make build + make setup.
Verified: image builds, safe.directory resolves to *. Committed f40661e, pushed to PR #25.
Two setup-graphql.sh fixes Barry asked for: (1) the -c clear path did rm -rf ubx-repos, which
hits the same mount-busy issue (sbp-mono is a bind mount) — changed to empty the checkout's
*contents* (find -mindepth 1 -delete) and guard the re-clone on .git (so it re-clones first-time
AND after a clear, since the mount dir always exists); (2) discard Gemfile.lock changes before the
dirty check so bundler/capistrano churn no longer blocks the branch checkout. Exposed the clear
path via make setup CLEAR=1. Verified: CLEAR=1 passes -c (dry-run), and a modified
capistrano/backend/Gemfile.lock is discarded -> repo clean (in-container test, net no-op). Did NOT
run the destructive full -c (would re-clone Barry's checkout). Committed edfa39e, pushed to PR #25.
make setup failed at rm -rf gql-py3 — gql-py3 is the gql-venv volume's mount point, which can't
be unlinked. Switched setup-graphql.sh to python -m venv --clear gql-py3 (empties the volume's
contents, keeps the mount) and moved the venv rebuild to AFTER the source checkout, so a
dirty-repo abort no longer leaves a half-cleared venv. Verified: make setup no longer errors on
the venv; it reaches the checkout and stops cleanly on Barry's dirty sbp-mono (an uncommitted
capistrano/backend/Gemfile.lock) without touching the venv. Rebased onto Barry's readme tweak
(0945f6c "Clarify local machine editing"), pushed 5f20e01 to PR #25. Remaining for Barry: clean
ubx-repos/sbp-mono's uncommitted Gemfile.lock before setup will check out a branch. Latent: the
-c clear flag's rm -rf ubx-repos would hit the same mount-busy issue (not exposed via make
setup, so not fixed).
make setup creates gql_env from defaults, but the DBDSN/AWS creds/cert paths must be filled in
before repave-db and run work. Added that step to the quick start (right after make setup,
pointing to the Secrets and certificates section). Committed b5ed4c5, pushed to PR #25.
Barry provided an SVG export (~/Downloads/Dev Environment Architecture.svg). Replaced
docs/architecture.png with docs/architecture.svg (11KB, viewBox 1488x1010, no <script>) and
pointed the README embed at it; removed the PNG. Crisp at any zoom, tiny file. Committed f577f70,
pushed to PR #25. (GitHub renders sanitized SVG via the image tag — worth a glance on the PR.)
The first embed (docs/architecture.png) was 909x540 and rendered blurry. Replaced it with the
2000x1405 export (same path, so the README reference is unchanged). Committed 695e2af, pushed to
PR #25.
Barry had Claude Design produce a polished static architecture diagram. Committed it as
docs/architecture.png (909x540, 23KB) and replaced the Mermaid block with
. Kept the "Three layers" prose below as the image-independent
description. Tradeoff noted in the commit: a static image won't auto-track topology changes the
way the Mermaid did — refresh docs/architecture.png when the architecture changes. Committed
85ff852, pushed to PR #25. (This ends the long diagram iteration: ASCII -> Mermaid -> stacked
Mermaid -> designed PNG.)
Barry asked how much layout control Mermaid gives + specifically for padding under box titles.
Added an init directive to the diagram: subGraphTitleMargin {top:8, bottom:16} (padding around
subgraph titles — the direct answer) and rankSpacing:55 (more gap between the stacked layers).
Mermaid auto-layouts via dagre (no manual positioning); the available knobs are spacing
(nodeSpacing/rankSpacing/subGraphTitleMargin/padding), direction, node order + invisible links,
and themeVariables. GitHub generally honors flowchart config but I can't render-test locally.
Committed e6ac971, pushed to PR #25.
First Mermaid attempt rendered messy — auto-layout spread the subgraphs side-by-side with
crossing edges. Restructured to a clean vertical stack (Local -> Container -> Remote): moved the
browser inside the Local layer, made every edge flow downward, and trimmed nodes (dropped the
volume/secret boxes, collapsed the cloud to one node) so each layer is a simple band. Named
volumes now live only in the "Three layers" text. Committed 719762a, pushed to PR #25. Still
can't render-test locally (no mmdc) — Barry verifies on GitHub.
The ASCII diagram rendered cluttered (floating bidirectional arrows, ragged ─── rules). Barry
asked to clean it up, then to use Mermaid. Replaced it with a Mermaid flowchart (GitHub renders
it): browser -> webapp/gql containers (with ports), host bind mounts (dotted, into the
containers), named volumes, and gql -> remote cloud (CockroachDB ×2 + the rest). Couldn't
render-test locally (no mmdc installed) — conservative syntax, Barry to eyeball on GitHub.
Committed 99c43a5, pushed to PR #25.
The Commands table is make targets; ./dev-script is a wrapper, so it didn't belong as a row.
Removed the row (left a one-line pointer) and relocated the "Running dev-scripts" section to sit
immediately after Commands, with the -e <env> (Secrets Manager DSN) usage broken out into its
own note. Committed b6d8a66, pushed to PR #25.
Removed the login-credentials line from the quick start (edf74be), then dropped the "GraphQL
authentication" and "State-managed fields" sections (d4d4398) — Secrets and certificates is now
the last section. Added a one-time first-admin-user step to the quick start using
./dev-script create_invitation.py you@example.com --account "Test Account 1" --role admin, which
replaces the old admin/standalone01 login note. Pushed to PR #25.
Barry questioned whether the make dev-script ARGS= pattern was best practice — it isn't (it's a
known make workaround for forwarding a free-form argv, with awkward double-quoting). Replaced it
with a thin ./dev-script wrapper that forwards "$@" to run-dev-script.sh in the gql container —
idiomatic, natural shell quoting:
./dev-script create_invitation.py you@x.com --account "Test Account 1" --role admin
Removed the make dev-script target (make help now points to ./dev-script). The wrapper cd's to
its own dir and honors COMPOSE/NAME env overrides. Verified create_invitation.py --help runs
through it (no side effects). Updated readme. Committed ea57dcd, pushed to PR #25. (make stays for
the structured lifecycle targets where named args fit; the wrapper is the right tool for free-form
command passthrough.)
Barry needs to run dev-scripts in the container (common first-setup one: create_invitation.py to
make an admin user). Added a dev-script make target wrapping the existing run-dev-script.sh
launcher (which activates the venv, sources gql_env, resolves the DSN incl. -e env via Secrets
Manager). Usage: make dev-script ARGS='<script.py> [args]'; quoted multi-word args (e.g.
--account "Test Account 1") survive make expansion (verified via make -n). Verified
create_invitation.py --help runs through the launcher in-container with no side effects (printed
the argparse usage). Documented in readme (commands-table row + new "Running dev-scripts"
section). Committed 65fdb0a, pushed to PR #25.
Barry noticed the diagram inconsistency: the webapp mounted its source at /webapp while the
backend's was /workspace/ubx-repos/sbp-mono. The asymmetry existed because the gql container
mounts the whole dev-env at /workspace (it runs the dev-env scripts) while the webapp only needs
its own source. Made it symmetric anyway: webapp now mounts at /workspace/ubx-repos/sbg-webapp
(working_dir + source mount + node_modules volume), still only its own source (no .:/workspace).
The webapp-modules volume remounted at the new path with no reinstall. Verified webapp serves
HTTP 200 from the new path; updated the diagram. Committed a506861, pushed to PR #25.
make setup ran initialize_database.sh (ubx_pave.sql DROPS + recreates the schema, re-seeds test
data) on every invocation, so switching branches or rebuilding silently reset the dev DB. Split
it: make setup now only does checkout + venv + install (non-destructive); new make repave-db
runs initialize_database.sh on the current branch behind a y/N prompt that echoes the target DB
(credentials redacted via grep/sed on DBDSN). Verified the guard aborts on N/empty input without
running the pave. Did NOT run the destructive confirm path (it would wipe Barry's dev DB), so the
in-container pave itself is still unverified — but it's the same scripts that worked in his
native setup, and csql + the verify-full cert mount are already proven. Name (repave-db) and
confirmation were Barry's choices. Updated Makefile + readme (quick start, commands table,
backend-dev section). Committed bc55fd1, pushed to PR #25.
Rewrote readme.md now that it runs both backend + frontend: intro covering both services, an
ASCII architecture diagram (host bind mounts + named volumes, gql/webapp containers with their
ports, and the remote cloud services), and a "how development works" top-level overview.
Revisited every section for front+back consistency — split into "Developing the backend"
(restart, no hot reload) and "Developing the frontend" (Vite HMR), generalized the
multiple-instances section, and aligned all command/var names (GQL_*/WEBAPP_*, run/run-webapp).
Committed 0df0971, pushed to PR #25.
run-all brought up backend + frontend together in one terminal (mixed logs). Barry prefers
running them in separate terminals for independent logs, so renamed run-all -> run-webapp and
made it start ONLY the webapp service (compose --profile webapp up webapp). Workflow: make run
(backend, terminal 1) + make run-webapp (frontend, terminal 2). Verified: run-webapp brings up
the webapp container alone (no gql) and serves the Vite UI (HTTP 200). Makefile + readme updated.
Committed 1b032f7, pushed to PR #25.
Backend override vars were bare (SRC/PORT/G) while the frontend's were WEBAPP_-prefixed —
asymmetric. Renamed for clarity/symmetry: SRC->GQL_SRC, PORT->GQL_PORT, G->GQL_BRANCH;
WEBAPP_SRC/WEBAPP_PORT/WEBAPP_BRANCH unchanged; NAME stays the shared instance knob. (These are
make-var overrides passed through to docker compose ${...} interpolation.) Updated Makefile,
docker-compose.yml, readme. Verified: make run GQL_PORT=8001 maps host 8001 and serves
introspection HTTP 200. Committed 9f738de, pushed to PR #25.
Renamed the managed-repos dir gql-repos -> ubx-repos (it now holds both sbp-mono and
sbg-webapp, so the gql- prefix was a misnomer) across 13 files + the gitignored dir; rebuilt
the venv at the new /workspace/ubx-repos path. Made the frontend symmetric with the backend:
make setup-webapp W=<branch> (default main) host-clones sbg-webapp --recursive into
ubx-repos/sbg-webapp, checks out W, submodule-updates, then runs container npm install
--legacy-peer-deps into the webapp-modules volume. WEBAPP_SRC defaults to the managed checkout
(override for an external one). The clone runs on the HOST (uses Barry's git/ssh, avoids the
clone-into-volume-mounted-dir problem, and keeps the node_modules named volume). Verified:
backend make run -> introspection 200 at the new path; make setup-webapp W=main clones +
installs; webapp serves the Vite UI (HTTP 200, <title>UBX</title>). Committed 1350d79, pushed
to PR #25.
make setup-webapp + the webapp compose service work. Two fixes: (1) install only
modules/vue-ui (dev:all doesn't use the root deps, and a root npm install would write a Linux
node_modules into the bind-mounted host repo); (2) add --legacy-peer-deps — vue-ui has a peer
conflict (eslint-plugin-vue 9 vs @vue/eslint-config-typescript 7) that npm 9 rejects by default
(Barry's native node_modules predates the stricter npm). Verified on host :8091 (to avoid
clobbering his native npm run dev:all on :8090): 1344 packages installed into the isolated
webapp-modules volume (his 969MB host vue-ui modules untouched), Vite v5.4.21 serves the UI
(HTTP 200, <title>UBX</title>). Committed 855f141, pushed to PR #25. Also cleaned up a stray
gql-1 backend I'd orphaned with a botched make run > /dev/null &.
Confirmed gql_env was NEVER committed (gitignored, and the AWS key id / Snowflake password
never appear in history) — so the earlier "rotate exposed keys" concern was wrong; no key
rotation needed on git-exposure grounds. Made the MQTT cert paths portable: gql_env's
GQL_AWS_MQTT_*_FILENAME now point at /certs/..., and compose mounts the host cert dir
(UBX_CERTS_DIR, default /Users/barry/Projects/ubx/certs) at /certs, replacing the host-absolute
identity mount. UBX_CERTS_DIR is now the only host-specific knob. gql_env edited locally only
(gitignored, not committed); compose + readme committed (06e4d9f), pushed to PR #25. Verified:
make run -> mqtt connected rc=0, host introspection HTTP 200. Skipped optional extras
(gql_env.example template, sourcing secrets from Secrets Manager).
Fixed the noisy logs: hvac2/log.py adds a SysLogHandler(/dev/log) unconditionally and the
slim image has no syslogd, so every log record errored with a traceback. Added
run-graphql-container.sh, which binds a /dev/log datagram sink (drains it; console logs are
unaffected via run-graphql.py -c) before launching the server. Wired docker-compose.yml to
launch via that script and to mount the MQTT cert dir at the absolute path gql_env references
(UBX_CERTS_DIR, default /Users/barry/Projects/ubx/certs) so AWS/MQTT works by default. Net:
make run is now a complete one-liner — backend with DB + S3 + MQTT and clean logs, no flags.
Verified under gql-main without publishing a port (to avoid clobbering Barry's live :8000): 0
SysLogHandler errors, "Application startup complete", mqtt connected rc=0. Earlier Barry
confirmed his frontend (~/Projects/sbg-webapp, npm run dev:all) works against the AWS-enabled
container backend. Committed 7aa956c, pushed to PR #25. The host-absolute cert-path default is
temporary, pending the secrets/config cleanup.
Barry installed colima (Server 29.5.2) — earlier make build "hangs" were just the colima
daemon being wedged, not the build. Ran a non-destructive verification (no DB pave, no branch
switch, sbp-mono left on prod): make build (cached, ~1MB context), in-container venv +
install_all.sh develop (clean), then started the server with GQL_DISABLE_AWS=true. First run
crashed on startup — the CockroachDB DSNs use sslmode=verify-full and libpq wanted
~/.postgresql/root.crt, absent in the container. Fixed by mounting host ~/.postgresql →
/root/.postgresql:ro (commit 4bf1198). After that: "Application startup complete", and a host
POST introspection to localhost:8000 returned HTTP 200 with the real schema; GET / served
GraphiQL. Confirms nested bind mount, per-instance venv volume, ~/.ssh mount, and DB-cert mount
all work. Known remaining: noisy syslog logging errors (no /dev/log socket in the container,
cosmetic/non-fatal) and the MQTT certs for the full AWS path (gql_env's absolute /Users/barry
paths — part of the deferred secrets/config work). Frontend (make run-all) and multi-backend
not yet verified. Pushed to PR #25.
Added a profile-gated webapp compose service for the sibling sbg-webapp repo
(Vite/Vue 3, Node 18.16.0; dev server on 8090). make run stays backend-only;
make run-all brings up backend + frontend; make setup-webapp npm-installs it
(WEBAPP_SRC defaults to ../sbg-webapp). The browser reaches the backend at
localhost:8000 (VUE_APP_GRAPHQL_HTTP in modules/vue-ui/.env), so no container-to-
container wiring. Removed the legacy setup-webapp.sh/start-webapp/update-webapp —
Barry doesn't use them (they're the Node 10 / local-Postgres / sbg-buildroot era);
he runs the frontend with npm run dev:all from ~/Projects/sbg-webapp. Committed
46607d6, pushed to PR #25. Still unbuilt/unverified (no Docker engine on this machine).
?ai= to an instance outside the default range (last 7 days) left the timeline + Details graph blank; a watch in AlertConfigDashboard now moves the date range to cover a selected instance that's out of range (in-range bar clicks unaffected).AlertIncidentsTimeline) rendered bar/axis times as raw UTC, so it was offset from the Details graph + banner; shift bar times, axis min/max, and the green track into the building timezone via adjustTimestampToTimeZone + time.useUTC, matching LineGraph, so timeline/graph/banner line up.fix/alert-dashboard-deeplink-timeframe (PR #1545)assetDisplayName(): Configuration-tab chips (BuildingAlertsIndex), alert-dashboard chips (AlertConfigSummary), the Details/activity graph title (AlertConfigDashboard), the Activity-tab name map (BuildingAlertsRecent), the incidents-timeline row labels + tooltip, and the instance-banner firing-asset name.AlertInstanceBanner to refetch the asset when a different-asset instance is selected — it passed a plain string to useAsset, so it never reacted and showed the stale asset.fix/alerts-config-unnamed-asset (PR #1544)shapeArgs, pixels) so the minPointLength padding on very short alerts stays hoverable/clickable.isUbxDevice comment to match the actual knownAssetUuid condition.JournalItemCard to the ambient account/building) stands.1530-alert-bar-length-zoom (#1540), 1512-hide-delete-for-ubx-assets (#1538), 1445-journal-comment-box-scope (#1543)JournalItemCard scoped its abilities to the journal's objectUuid (the asset uuid on asset journals), but Comment is only granted at account/building scope, so can('create','Comment') was false and CommentExpander hid the box. Scope to the ambient account/building context (useScopedAbility()) instead, matching the note box. Reopened the issue.CommentExpander.canCreate=false.)1445-journal-comment-box-scope (PR #1543)temperature_control_status (heat/cool icon + stage) and fan_control_status (fan icon + status) on the building-dashboard tstat card hero line — one line, bottom-right, lighter property-name gray; stage text drops the Heating/Cooling word (the fire/snowflake icon conveys it)1500-tstat-status-on-hero (PR #1542)zIndex pass-through prop to StandardDialog; ThresholdDialog and the property-card context menu now clear the fixed inspector panel (z-index 10000), so the "..." menu and Edit Thresholds work in the inspector1524-inspector-threshold-menu-zindex (PR #1541)resolvedAt) and use xrange minPointLength for a pixel min-width, instead of padding the bar width in ms which ballooned past the resolved time when zoomed in1530-alert-bar-length-zoom (PR #1540)AssetChip.selectAsset; vue-easy-dnd separates tap from drag so drag-to-place still works.v-dialog__content--active) and fell behind the unplaced overlay; set the dialog z-index directly so the inspector sits on top with the unplaced list still open behind it1523-mobile-unplaced-asset-tap (PR #1539)knownAssetUuid); they can still be removed from a floor@remove-asset in the dashboard inspector (DashboardOverview) to persist floorUuid: null — it was unhandled, so "Remove from floor" was a no-op there (surfaced once UBX devices route to that action)1512-hide-delete-for-ubx-assets (PR #1538)assetDisplayName(); fix showFloor() so the floor breadcrumb survives an unnamed asset1510-journal-unnamed-asset-name (PR #1537)1501-change-order-of-building-nav-menu (PR #1536)Stacked feature/dev-env-containerize on the cleanup branch and committed a container-only
setup (commit d78975a). The gql-server now runs in an Ubuntu 22.04 image (python3.10 +
build deps + cockroach-sql client); sbp-mono source is bind-mounted for live editing and the
venv lives in a per-instance named volume. New files: Dockerfile, docker-compose.yml
(parameterized SRC/PORT so multiple backends run at once), Makefile (setup/run/down/shell/test),
.dockerignore, .devcontainer. Modified: csql now calls the cockroach-sql client; setup-graphql.sh
drops the apt/sudo lines and uses python3.10 -m venv. Removed setup.sh/start.sh/start-mac.sh/
setup-crdb.sh/stop.sh (replaced by image+compose+make). NOT yet built/verified: this machine has
the docker CLI but no engine (no Desktop/colima/OrbStack) and no compose plugin — Barry verifies
once an engine is installed (write-now-verify-later, his call).
Rebased Barry's feature/even_more_cleanup onto master (PR #23 / dev-script-runner already merged
there) as feature/dev-env-cleanup — intended as its own cleanup PR. Removes ~109 files of cruft,
strips all platform/Mac branching, drops the pc-*-ec-support packages entirely, and removes
gql-requirements.txt + requirements.txt (deps come from sbp-mono's install_all.sh). Resolved the
setup.sh/setup-graphql.sh conflicts by taking the cleanup version, since the only master changes
to those files were the Mac-support additions we're intentionally dropping. some_cleanup is a
subset and was ignored. requirements3.txt survived — left as an optional extra to remove later.
expandableKeys derived from props.items instead of tableRows, breaking a watcher → expandedGroups → tableRows → expandableKeys feedback loop that froze the tab on Expand Allfix/expand-all-reactive-loop (PR #1535, merged to main)SERIES_COLORS entries for better contrast against threshold zones (#1502)ALERT_COLOR constant; kept bars red (overriding xrange colorByPoint) without shifting data-line colorsclaude/issue-1502-20260603-1459 (PR #1503)alerts-followups (PR #1534)main (PR #1531, branch fix/activity-hide-pending-alerts), deployed to staging.recentInstances filter was state !== "ACTIVE" (so pending leaked into Recent); now also excludes PENDING.recentGroups now filters out any group whose alertDefinitionUuid|assetUuid key is already in activeGroups.AlertInstanceGroupTable.vue) drops the Recipients header + cell below the sm breakpoint (xs) via useDisplay().smAndUp, leaving Alert Name / Asset / When / count on phones. tableHeaders is now built imperatively so the column is omitted when !smAndUp, and the cell has v-if="smAndUp" (expanded-graph row colspan tracks tableHeaders.length).feat/alert-config-dashboard, deployed to staging.PropertyTimestamp component (v-tooltip: relative time + full timestamp on hover) instead of the browser title attribute, matching how asset properties show timestamps. Rows carry the DateTime + relative Duration instead of pre-formatted strings.muted prop to PropertyTimestamp (default true so property displays stay text-disabled/gray); the Activity table passes :muted="false" so the time-ago renders in normal text.byNewest comparator (dropped the A→Z byName).feat/alert-config-dashboard, deployed to staging.useDisplay().lgAndUp (alertHeaders is a computed; the two columns are spread in only when lgAndUp). With table-layout: fixed, the freed width redistributes to the remaining columns. (First shipped at mdAndUp, then bumped to lgAndUp per Barry.)useElementSize for an exact content-width trigger, but used the useDisplay viewport checkpoint per Barry's preference.feat/alert-config-dashboard, deployed to staging.max-width on the <td> is ignored under table-layout:auto.table-layout: fixed with proportional per-column widths (status 6 / name 15 / assets 19 / property 11 / dwell 8 / active-periods 17 / recipients 18 / actions 6 %). Assets/Recipients now expand to their column width (scaling with the viewport) and truncate with an ellipsis when tight; cells clip overflow. Recipient name spans truncate via flex (flex:1; min-width:0) keeping the delivery icons visible.feat/alert-config-dashboard, deployed to staging.AlertFormDialog from a raw v-dialog to the shared StandardDialog, so it gets the consistent title bar + ✕ close button. Title key flips between "Create Alert"/"Edit Alert" by mode.AlertForm is now the dialog body (v-card-text) rather than its own v-card, avoiding nested cards; the inline heading was removed (StandardDialog renders the title).feat/alert-config-dashboard, deployed to staging.AlertForm now emits saved/cancel instead of router.push-ing back (it's a v-card, so it drops into a dialog). New AlertFormDialog.vue hosts it in a modal (scrollable, fullscreen on mobile), loading the building and (for edit) the alert itself.@saved refetches the list. Dialog keyed per target so each open loads fresh.createBuildingAlert/editBuildingAlert routes and the CreateBuildingAlert/EditBuildingAlert page wrappers. AlertConfigDashboard no longer had an edit link (removed earlier); AlertSummaryCard's edit pencil now emits edit instead of routing.feat/alert-config-dashboard, deployed to staging.getPresetRange("last7days", ...) (in the building timezone) instead of an ad-hoc ~1-week range, so the date-range selector shows the "Last 7 days" preset rather than a custom range.feat/alert-config-dashboard, deployed to staging.escapeHtml util (utils/string.ts, with test) and escaped interpolated values in two Highcharts useHTML spots: the AlertIncidentsTimeline axis labels (body + title) and the LineGraph alert tooltip (point name, status, href) — prevents HTML/attribute injection from asset names/fields.AlertInstanceGroupTable: v-for key now item.groupKey (not array index); expandable rows get tabindex/role + Enter/Space handlers for keyboard a11y; guarded fromAlertSubscriptions(... ?? []).BuildingAlertsIndex: toggle() reassigns a new Set (codebase pattern) so the "+N more" UI re-renders reliably.date.ts: typed the toHuman opts cast to Parameters<Duration["toHuman"]>[0] instead of Intl.NumberFormatOptions.feat/alert-config-dashboard (PR #1471), deployed to staging. 224 util tests pass.groupInstances with a group comparator (byNewest for Active, byName for Recent). Recent section unchanged.feat/alert-config-dashboard, deployed to staging.AssetInspectorPanel.vue (right-side slide-in panel on desktop, fullscreen dialog on mobile, wrapping AssetInspector) and used it on both pages instead of duplicating the markup; removed the inline panel from BuildingAlertsRecent.vue.:to/target, now open the inspector; added useBuildingAssets (properties+settings) to resolve the DecoratedAsset.@click, so set color="primary" explicitly on both.feat/alert-config-dashboard, deployed to staging.BuildingAlertsRecent.vue). Diagnosed a real case where the instance pointed at a non-existent asset_uuid (distinct from the alert's scope asset).white-space: nowrap and the Assets/Recipients max-width weren't applying because the headers used cellClass, which Vuetify 3 ignores — it honors cellProps. Switched those headers to cellProps: { class: ... } so the column CSS takes effect. See [reference_vuetify_datatable_cellprops.md](reference_vuetify_datatable_cellprops.md).feat/alert-config-dashboard, deployed to staging.white-space: nowrap on .col-lifecycle so each "Day time - time" stays on one row (column auto-widens into the space freed by the Assets/Recipients max-widths).:to (same-tab router nav). Added target="_blank" so they open the asset page in a new tab — fixed in BuildingAlertsIndex.vue, AlertConfigSummary.vue, and AlertInstanceBanner.vue. (Vue Router's guardEvent skips JS nav when target=_blank, so the native anchor opens the new tab.)feat/alert-config-dashboard, deployed to staging.AlertConfigDashboard.vue, reached via the Configuration tab's "History" action) now opens straight to the activity/incidents timeline.AlertSummaryCard.vue (props: alert, buildingUuid) — parked for reuse on a future alert landing page, not wired anywhere yet.buildingAlerts); was "Activity" → buildingAlertsRecent.building.alerts.incidents.activity_for); was "Incidents".feat/alert-config-dashboard, deployed to staging.BuildingAlertsIndex.vue). On feat/alert-config-dashboard, deployed to staging.v-switch, Tstat-style), wired to a new status: AlertStatus field on the UpdateAlert mutation (update-alert-mutation.ts); per-row pending state, refetch on success. Backend updateAlert now supports status (ENABLED/DISABLED).alertHeaders is now a computed keyed on smAndUp): mobile shows Name / Asset / Actions only. Top-aligned row cells; max-width: 280px on Assets and Recipients columns.feat/alert-config-dashboard, verified on staging.AlertInstanceGroupTable.vue): column "Alert" → "Alert Name"; removed Status column and the inline pencil-edit button; added a Recipients column (subscribed recipients with email/SMS icons + "+N more"); "Time" → "When". Asset is now a grey underlined link.BuildingAlertsRecent.vue): emits select-asset, page owns the inspector as a fixed right-side panel (slide-in) on desktop / fullscreen dialog on mobile — matching the floorplan/dashboard pattern (first attempt used a centered v-dialog, corrected).properties.contact_sensor.magnet_chan*.options.* keys because the page fetched assets without property values, so getProperty defaulted boolean props to false. Root cause: passed a non-existent properties: true option (silently ignored); corrected to includePropertyCategories: { properties: true, settings: true } (the floorplan's option). Needed a dev-server restart to pick up the composable option change.updateExportData (chart.ts) rebuilds the export rows from only the text series' timestamps; numeric series are placed only where their timestamp matches a row, and anything else is silently skipped. A linked property from another device reports on a different interval, so none of its points matched and the whole column was dropped.tests/utils/chart-export.spec.ts updated — the misaligned numeric point now keeps its own row instead of being dropped. 235 util tests pass, eslint clean.fix/csv-export-union-timestamps, PR #1528.initialLoading was true whenever the navigator series was loading, so every aggregation-change refetch (navigator now follows aggregation) swapped the chart for the spinner.initialLoading to only when there's no data yet. usePropertyTrend's series is only updated in onResult (retains previous data during a refetch), so subsequent refetches keep the chart mounted and update in place (the existing loading overlay still shows briefly).fix/navigator-period.fix/navigator-period.fix/navigator-period.groupQueryPeriod(isNavigator, start, end) in utils/periods.ts so it's unit-testable, and added tests (navigator always 21600 regardless of window; non-navigator derives via dateRangeToPeriod).fix/navigator-period.fix/navigator-period (off main). Debug instrumentation branch debug/unsorted-series-data can be deleted now.tests/composables/use-property-indicator.spec.ts verifying a property-level override beats the asset-level window (2h-old reading: Stale under 75-min asset window, not stale under 24h property window). Replied to the comment in-thread and re-requested Copilot review.timestampToRowIndex, so stale entries from the original rows pointed past the rebuilt array; a numeric point at a timestamp absent from the text export resolved to an out-of-range index → assigned to an undefined row → throw.fix/export-data-stale-rows (separate from alert/battery work).staleDataDuration on PropertyConfig (threads through the config reader), preferred over the asset-level value in usePropertyIndicator, and set battery to 24h (1440 min) in base-asset.fix/battery-stale-windowbuildingAlertActivity) was only reachable from the definitions-table action, which now links to the alert config dashboard. No other references remained, so removed the route, its import, and the component.building.alerts.activity.* / return_to_alerts i18n keys in place (harmless; avoids cross-locale churn).feat/alert-config-dashboardfeat/alert-config-dashboardfromAlertSubscriptions (shared with the dashboard) for recipient data instead of a names-only list.title hint rather than the dashboard's v-tooltip, to keep the dense table light.feat/alert-config-dashboardbuilding.alerts.table.show_more / show_less i18n keys.feat/alert-config-dashboardfeat/alert-config-dashboardcan('update', 'AlertDefinition') (the recent tab is visible to viewers too); @click.stop so it doesn't toggle the row's graph.buildingUuid prop on AlertInstanceGroupTable to build the edit route.feat/alert-config-dashboardv-data-table (BuildingAlertsIndex): reduced cell padding and font-size under 600px.mobile-breakpoint="0" (never collapses), so it's still wide on phones — switching to Vuetify's native stacked mobile layout would be the bigger follow-up.feat/alert-config-dashboardfeat/alert-config-dashboardformatDuration (the existing general.time_ago helper), with the absolute timestamp as a hover title.feat/alert-config-dashboardfeat/alert-config-dashboardloading.style pointer-events: none in LineGraph so the overlay is click-through; its visual style is preserved via Highcharts' default merge. Scoped to LineGraph (the only chart with clickable alert bars).feat/alert-config-dashboard.alert-detail-link, it stops propagation (preventing the hide) and navigates directly. Cleaned up on unmount.feat/alert-config-dashboardfeat/alert-config-dashboardfeat/alert-config-dashboardExecuted the implementation plan via subagent-driven development (3 TDD tasks, each
spec- and quality-reviewed, plus a final holistic review). run-dev-script.sh now takes
-e <dev|test|staging|production>; non-dev environments fetch ubx/database/<env>/dsn
from AWS Secrets Manager via inline boto3 and override DBDSN, aborting with exit 69 on
fetch failure. New test-run-dev-script.sh covers it end to end against real AWS
(12 checks). Commits 82733db, af5619b, 3138675. Fetch-failure test uses a dead
HTTPS_PROXY because gql_env clobbers AWS_ACCESS_KEY_ID.
Committed a pre-existing one-line fix Barry had in the working tree: if !mac_os; then
tried to run a command named !mac_os (always false), so setup-crdb.sh never ran.
Now tests the variable: if [ "$mac_os" != true ].
Brainstormed and committed a design spec (docs/superpowers/specs/2026-06-10-dev-script-environments-design.md)
for adding an optional -e <env> flag to run-dev-script.sh. For test/staging/production it
fetches the DB DSN from AWS Secrets Manager (ubx/database/<env>/dsn, same convention as
agentic-framework's environments.yaml) and overrides DBDSN after sourcing gql_env; no flag or
-e dev keeps today's behavior, and gql_env itself is never modified. Fetch failure aborts
(exit 69) rather than falling through to the personal dev DB.
to without a label can't render an empty focusable button.<input accept="image/*">, giving no way to take a photo. Added an explicit Take Photo / Choose from Library split for non-iOS touch devices; iOS (native chooser) and desktop unchanged.is-mobile-platform.ts (isIos, isCoarsePointer), handling the iPadOS-13+ macOS masquerade. 8 unit tests.DocumentUploadDialog.vue: showCameraButtons = image && isCoarsePointer() && !isIos(...) gates a hidden capture="environment" camera input + two icon buttons (camera / photo-library, aria-label from i18n, 48px touch target). On mobile the redundant v-file-input is hidden and selected files render as removable chips; library button + dropzone reuse the existing multiple-capable input..vue SFCs (no vue-jest), so component layer verified manually on Android rather than via a mount test; logic lives in the unit-tested util. Spec/plan under docs/superpowers/.fix/android-image-upload. Deployed to staging.Reworded the smartbutton_lambda RF test Slack/SMS message to identify hubs by asset name and UBX serial number instead of an EUI fragment, so installers can recognize hubs during field surveys. Format: Hub: <Asset Name> (<UBX SN>) = <level> (<rssi> RSSI), degrading gracefully to the original Hub <eui4>: ... line when the hub is unregistered, fields are blank, or any DB lookup fails.
Built via brainstorm → spec → plan → subagent-driven TDD (16 tests, the repo's first pytest suite). New get_hub_info() does a best-effort per-cluster lookup (hubs have no ubx_device_cluster_map rows), positive-only cache, single connection attempt, abort-on-error fallback. Pure format_hub_line() holds the display rules.
Field test surfaced the key bug: the installer-facing UBX SN is ubx_device.ubx_serial_number (asset console "Serial #"), not ubx_asset.serial_number (an EUI copy) — every hub was falling back. Fixed the query, verified against live clusters (hub …b794 → SN 1G1P). Also fixed a pre-existing operator-precedence bug in the low-battery SMS condition (!= -1 & ... < 10 was always False; alert never fired).
Promoted to production (prod checkpointed for rollback). PR #279 open. Branch feature/smartbutton_message_rewording.
/assets/ location with try_files fallback chain to previous-release-1..previous-release-3 in the Bolt-templated modules/nginx/templates/default.epp.bolt plan run nginx::config --targets=staging — the plan writes /etc/nginx/sites-available/default (symlinked from sites-enabled/default), uploads supporting files, runs nginx -s reload under root.c337782 on feature/nginx-asset-fallback in the server-configurations repo. - Vendor-chunk split (vite.config.ts): build.rollupOptions.output.manualChunks splits stable node_modules into 10 vendor chunks (vendor-vue, vendor-vuetify, vendor-apollo, vendor-charts, vendor-maps, vendor-icons, vendor-auth, vendor-rbac, vendor-utils, vendor-misc). ~2.6 MB of JS/CSS now caches across deploys; the central index chunk dropped from 3.3 MB → 964 KB. A/B verified: a one-line app source change rotates ~86 app-chunk hashes but zero vendor-chunk hashes.
- Capistrano deploy:rotate_previous_release_symlinks (lib/capistrano/tasks/asset_slots.rake): post-deploy:cleanup hook that updates previous-release-1..previous-release-3 symlinks in deploy_to to the 3 most recent prior releases (matches keep_releases = 4). Verified on staging: symlinks point at the expected prior timestamps after deploy.
eca12e6c7, 8e04aed07 on chore/vendor-chunk-split.AgentDeploymentConfig only changes because its top-of-file import {...} from "./index-OLDHASH.js" line has the embedded hash updated when index rotates. Confirmed by diffing pre/post builds — the only differences in unrelated chunks are filename strings.index cascades when index's hash moves; every chunk that statically imports from THOSE cascades; etc. App code rarely escapes the cascade because everything imports shared Vue/Apollo/utility code from index.index), app chunks still rotate per deploy. The fix that prevents the user-facing forced-reload is server-side: nginx fallback to previous releases for stale chunk URLs.mb-10 → mb-9 edit to FridgeConfigTab.vue) on staging produced ~90 new chunk hashes across totally unrelated modules (AssetPhotosTab, AgentDeploymentConfig, AssetHeader, AssetJournalTab, etc.).index-*.js chunk holds the dynamic-import filename manifest. Any leaf chunk hash change causes one string in the manifest to update → index content changes by exactly that string → index hash changes → every chunk that statically imports from index (nearly all of them) has its embedded import {...} from "./index-OLDHASH.js" rewritten → universal cascade.index chunk between builds: only 16 of millions of lines differ, all of them embedded asset filenames in the manifest. Confirmed by diffing an "unrelated" chunk (AgentDeploymentConfig): byte-identical except for the import path strings at the top of the file.manualChunks split) at memory:project_vendor_chunk_split with a runnable verification protocol.Failed to load module script ... MIME type "text/html" and Failed to fetch dynamically imported module: ... FridgeConfigTab-CWZ3PpAF.js on prod with an update pending. Cause: deploy replaced hashed chunk files while the tab was still running the previous bundle; the SPA fallback returned index.html for the missing chunk path.window for vite:preloadError triggers window.location.reload() to pick up the fresh bundle. Reload-loop guard via sessionStorage (30s minimum interval) so a genuinely broken new bundle surfaces instead of looping.4ac2f3ad7 on fix/reload-on-vite-preload-error (PR #1509)./graphql 502s with no auth errors — not the auth-redirect flicker path; just gunicorn workers cycling during the deploy itself.RetryLink from @apollo/client/link/retry to the HTTP branch of the Apollo link split. Config: max 3 attempts, exponential backoff with jitter (300ms → 3000ms cap), retry only on status 5xx. Client-side errors (4xx) and auth errors (401 → bounceToLogin) are not retried.WebSocketLink are unaffected (the WS link already has its own reconnect logic with reconnect: true, reconnectionAttempts: Infinity).6dbfd6439 on fix/retry-graphql-on-5xx (PR #1508).DeviceDashboardTabTemp100.vue and IAQ's DeviceDashboardTab.vue — both pass :date-defaults="dateDefaults" to <device-dashboard-layout> with startDate = endDate.minus({ weeks: 1 }).FridgeDashboardTab.vue now does the same: builds endDate = DateTime.now().setZone(appStore.timeZone) and startDate = endDate.minus({ weeks: 1 }), passes { startDate, endDate } as the dateDefaults prop.f4c1c7fa3 on fix/fridge-dashboard-default-period (PR #1507).AssetTableReport's selection model — fridges couldn't be selected for bulk actions (e.g. Move), and the "selected count" UI ignored them.FridgesReportTable now accepts a selectedAssets prop and emits update:selectedAssets, mirroring the AssetsTable contract. An internal effectiveSelectedRows computed bridges the row-shaped <v-data-table> selection back to the DecoratedAsset[] contract. Added :show-select="can('update', 'Asset')" to match the RBAC gate on AssetsTable.BuildingSensorDataReport passes the selection-model props through to <fridges-report-table> the same way it already did for <properties-assets-table>.2251240e9 on feature/fridges-as-ubx-devices.find over childLinks for every link the snapshot walker iterated.FridgeInspectorStatus.vue: replaced the per-call childLinks.find with a uuid → DecoratedAsset map computed once per childLinks update; the composable then does O(1) lookups in the loop.use-compound-asset-status.ts: inlined the resolution into the childLinks loop instead of going through a resolver function. We already have link in hand, so the two-stage check (injected map first, embedded childAsset fallback) now reads directly from the link. The AssetResolver type became unused and was removed.9316f1532 on feature/fridges-as-ubx-devices.DashboardWidget.vue:174-182 has a global CSS rule targeting __hero > div > div > div:first-child that styles the message at 10px / rgba(0, 0, 0, 0.38). That selector only matches inside the dashboard widget's hero slot, so other surfaces hit the v-alert's default text style..fridge-no-sensors-alert__status inside FridgeNoSensorsAlert.vue so the message reads identically on all three surfaces. The CTA link is unchanged (still blue text-info).36fcf25a2 on feature/fridges-as-ubx-devices.→ only for state-transition displays ("before → after", "from → to" in journals, date ranges, copy-thresholds dialog). Per brand guidelines (calm, clinical, never flashy), the arrow leaned toward marketing-flashy. Dropped it. The router-link's color treatment already signals interactivity.bb87463cc on feature/fridges-as-ubx-devices.FridgeDashboardWidget, FridgeInspectorStatus, and FridgeDashboardTab with identical structure and scoped CSS. Pulled it into a new FridgeNoSensorsAlert.vue and replaced the three usages with the shared component.asset (required) and density (default "compact"; the asset-dashboard tab passes "default" to match its slightly less compact alert).c370995a3 on feature/fridges-as-ubx-devices.FridgeDashboardTab.vue (the in-page asset dashboard view). Tonal alert reads "No sensors linked" / "Add sensors →"; the action is a router-link to the assetConfig route. User is already inside the asset detail page so this is a same-page tab navigation.89f01acb8 on feature/fridges-as-ubx-devices.FridgeInspectorStatus.vue. Tonal alert now reads "No sensors linked" / "Add sensors →", with the second line a router-link to the assetConfig route for the fridge. @click.stop keeps the link from racing any parent click handlers.b60fe4e14 on feature/fridges-as-ubx-devices.router-link to the assetConfig route for the fridge. @click.stop keeps the dashboard card's overall click handler from racing the link.refrigerator.dashboard.no_sensors i18n string from "Please configure sensors for this refrigerator." to "No sensors linked" — FridgeInspectorStatus and FridgeDashboardTab consumers benefit from the same shorter wording.refrigerator.dashboard.add_sensors → "Add sensors" for the action line.FridgeDashboardWidget; the inspector and asset-page tab consumers show the status line only. They're already inside the asset where the user can navigate to Config on their own.8b91b17e8 on feature/fridges-as-ubx-devices.#properties slot. With nothing in #hero, the card looked collapsed and unbalanced.#hero slot when !snapshot.hasAnySensors; the #properties slot is left empty in that state. When the fridge has sensors the existing first-reading-as-hero behavior is unchanged.91006eb36 on feature/fridges-as-ubx-devices./asset/:assetUuid page) even though it worked correctly on the dashboard card, inspector, floorplan popup, and report.useAsset, which selected only { assetUuid, knownAssetUuid, name } on each childLink's childAsset. That left the decorated child sensor with empty .properties. getProperty(sensor, "offline") returned undefined, the composable's ?? true defaulted it to "offline", and every linked sensor was reported as offline.offline is defined in base-asset.ts under properties:, NOT with category: "asset" — so it's a regular property read from asset.properties.offline after decorateAsset populates propertyContainer via createProperties. The lightweight selection skipped that path entirely.... on Device { properties } to the childLink's childAsset selection in use-asset.ts. Now the offline property (and any other primary the composable might read in future) is populated on decoration.BUILDING_ASSETS_KEY inject path (dashboard / floorplan / report) resolves the sensor from the building-wide assets array, which is loaded via useBuildingAssets with includePropertyCategories: { properties: true }.63cf21340 on feature/fridges-as-ubx-devices.9e92c7757 on feature/fridges-as-ubx-devices.<span> body. Barry liked the original two-column content (sensor name left, color-coded "Online" / "Offline" right) — only the position / delay / open-on-click props needed to match. Restored the flex-row content layout and the scoped CSS while keeping location="bottom" + :z-index="10001" from the prior commit.8213dfae6 on feature/fridges-as-ubx-devices.PropertyValue.vue renders its hover tooltip: location="bottom", :z-index="10001", and a plain <span> body with <br>-separated lines.fe988ee0e on feature/fridges-as-ubx-devices.useCompoundAssetOfflineStatus to return the per-sensor breakdown alongside the anyOffline summary. New return shape: { anyOffline: boolean, sensors: Array<{ sensor, offline }> } | null.v-icon in SmartAssetName.vue with a v-tooltip. Tooltip shows one row per linked sensor: display name on the left, "Online" / "Offline" on the right with the same green / grey-lighten-1 color vocabulary as the dot. Uses the existing statuses.online / statuses.offline i18n keys (en.json:2872-2873).da97e274c on feature/fridges-as-ubx-devices.d2ceb1362 and 6cecf15c3 (the threshold-severity rollup attempt). Two problems with that direction: (a) the dot was invisible on the dashboard card because linked sensors had no thresholds set, so the rollup returned MetricIndicatorType.Plain which renders as black-darken-1 at 10px on a light card; (b) semantic mismatch with the rest of the UI — every other title dot is presence (green/grey), not severity, and per-reading severity is already shown by <property-item> inside the inspector.useCompoundAssetOfflineStatus (renamed for clarity): returns true if any linked child sensor's offline builtin is truthy, false if all are online, null for non-compound assets or compound assets with no resolvable links. Reads getProperty(sensor, "offline").value on each sensor — same mechanism the existing smart-asset dot uses.SmartAssetName.vue consults the composable first; when non-null, the dot color uses the same grey/green offline helper as the existing smart-asset path. Falls through to the existing isSmart + offline-builtin path unchanged when the composable returns null. Icon is plain mdiCircle in both cases — matches every other surface that uses SmartAssetName (13 surfaces total: dashboard cards, asset inspector, asset dashboard header, floorplan pin label and tooltip, sensor data report row, AssetsTable in other reports, etc.).offline updates into the cache, the per-sensor getProperty read inside the composable's computed re-fires, the dot color flips without a reload.f2ad96e72 (revert), 649d8aa56 (revert), 329a0c48c (redo) on feature/fridges-as-ubx-devices.SmartAssetName to use the shaped severity icon set (mdiCheckCircle / mdiAlert / mdiAlertCircle / mdiMinusCircle) via indicatorIcon(), matching how <property-item> renders the same indicator types inline. The smart-asset online/offline path keeps the plain mdiCircle — that dot represents presence, not severity, so the shape distinction is intentional.useCompoundAssetStatus: it assumes the parent loaded the fridge with the full childAsset fragment (via useAsset / useLinkedAssets). Surfaces using the lightweight BuildingAssets { assetUuid } selection already provide BUILDING_ASSETS_KEY, so the fallback is only exercised on the asset-detail path. If a future surface adds a lightweight query without the map, decorateAsset will silently produce a property-less asset and the rollup degrades to Stale — visible incorrectness, not a crash.worstOf clarifying the no-initial-value reduce pattern (relies on the length-check above to ensure non-empty input).6cecf15c3 on feature/fridges-as-ubx-devices.useCompoundAssetStatus(asset) in modules/vue-ui/src/composables/use-compound-asset-status.ts. Dispatches by knownAssetUuid (today only fridges; extend by adding more branches). For fridges: builds a two-stage resolver (inject BUILDING_ASSETS_KEY map first, fall back to decorateAsset(link.childAsset)), calls fridgeSnapshotFrom, computes each reading's MetricIndicatorType via determinePropertyStatus, and reduces with worst-wins (Critical > Stale > Warning > Nominal > Plain). Returns null for non-compound assets and for compound assets with no linked sensors.SmartAssetName.vue consults the composable first. When it returns a non-null indicator the dot uses indicatorColor() + indicatorIcon() for the rolled-up state. Falls through to the existing smart-asset online/offline color (mdiCircle) when no compound status applies. Other UBX device types are unchanged — they keep the existing online/offline dot semantics.memory:project_fridge_aggregate_status.d2ceb1362 on feature/fridges-as-ubx-devices.<property-value>, which prints the property's display name above the value (e.g. "Temperature 69 °F"). Other sections of the same report use AssetsTablePropertyValue, which renders just the value with the threshold-color class — the unit lives in the column header.<assets-table-property-value :asset :property>; removed the <property-value> import.aa404529a on feature/fridges-as-ubx-devices.buildSnapshot pure function inside the component, duplicating the linkType-switch logic in useFridgeProperties. Extracted that switch into an exported fridgeSnapshotFrom(childLinks, resolveAsset) helper in the composable file; the composable now wraps it in computed(), and FridgesReportTable.vue calls the helper directly (can't use the composable inside a .map over rows).en.json but the component still used hardcoded strings. Switched BUCKET_COLUMNS to declare titleKey per bucket and resolve via i18n.t() at render time.708a8e891 on feature/fridges-as-ubx-devices.BuildingSensorDataReport had a card-per-fridge layout (with stacked property rows inside each card) — visually inconsistent with sibling sections (IAQ, energy, etc.) which all use tabular views.FridgesReportTable.vue: a <v-data-table> modelled on AssetsTable's chrome (asset name with click → inspector / double-click → asset page, serial, location), plus one column per linkType bucket (Temperature / Humidity / Door / Avg Energy Compressor / Avg Energy Evaporator). Each bucket cell renders the first reading in that bucket; em-dash when empty.BUILDING_ASSETS_KEY is consumed via inject at setup top-level then passed as an explicit resolver into a per-row fridgeSnapshotFrom call (inject inside a computed().map() would trigger a Vue warning).FridgeReportRow.vue (the card layout it replaced).8cb056729 on feature/fridges-as-ubx-devices.BuildingSensorDataReport: detected fridge groups by knownAssetUuid and rendered a <fridge-report-row> per fridge — a clickable card with the fridge name and a <property-item> list of its linked-sensor readings.AssetTableReport.vue (the shared shell) opted into includeChildLinks: true on its useBuildingAssets call and provides BUILDING_ASSETS_KEY (computed from the unfiltered allAssets ref, so widgets can resolve linked sensors that may have been filtered out of the visible list).filterSmartOnly predicate in AssetTableReport to a.smart || !!a.config?.components?.DashboardWidget — same broadening already applied in DashboardOverview — required to let fridges through here. Documented side-effect: BuildingDeviceHealthReport (the other consumer of filterSmartOnly) will now also surface fridges as empty rows until the aggregated-status follow-up lands ([project_fridge_aggregate_status](project_fridge_aggregate_status.md)).475f2e455 hoisted the FRIDGE_KNOWN_ASSET_UUID constant into known-asset.ts to match the existing SWITCH_KNOWN_ASSET_UUID / PLUG_KNOWN_ASSET_UUID pattern.8c8951d6a, 475f2e455 on feature/fridges-as-ubx-devices.pickHeroReadings) showed one reading per non-empty bucket — produced rows like 73°F · 48% · Closed · -- A · -- A which was too busy for a glance affordance.pickHeroReadings helper in use-fridge-properties.ts; doc comment rewritten to match the new behaviour.c426c18e8 on feature/fridges-as-ubx-devices.AssetPinTooltip.vue used to render <property-value> on config.primaryProperties[0] for the hero. Fridges have no single property to point at — the hero needs to come from composed child-sensor readings.<asset-component component-name="FloorplanPopupHero"> override slot, falling back to the existing <property-value> when no override is registered. The outer hero <div> is gated on a new hasHeroContent computed (checks getAssetConfig(knownAssetUuid).components.FloorplanPopupHero or heroProperty) so empty hero regions don't leave a margin gap.FridgeFloorplanHero.vue — renders the popup hero as a compact ·-separated row of <property-value> components, one per reading from pickHeroReadings.BuildingFloorplansTab.vue opted into includeChildLinks: true on its useBuildingAssets call and provides BUILDING_ASSETS_KEY so the hero component can resolve linked sensors against the building's flat assets array.dff12cdaf on feature/fridges-as-ubx-devices.FridgeInspectorStatus.vue and registered it under components.AssetInspectorStatus in ubx-fridge.ts. Matches the visual pattern used by tstat and contact AssetInspectorStatus components: <v-row><v-col><v-card><v-list><property-item :small> for the readings list, falling back to a tonal info alert when no sensors are linked.useLinkedAssets(asset.assetUuid, { includeChildLinks: true }) (same as the existing FridgeDashboardTab), with a local resolver that calls decorateAsset(link.childAsset) since useFridgeProperties expects fully decorated assets.AssetInspector.vue:101's :key="asset.assetUuid" on the <v-window>, so the static-at-composition useLinkedAssets call is safe here.d2dd790f1 on feature/fridges-as-ubx-devices.DashboardOverview.vue's asset filter was a.smart, which excluded fridges — they come back from the backend with smart: false because they don't emit direct sensor data (their readings are synthesized from linked sensors). With the new FridgeDashboardWidget registered they need to make it through the filter.a.smart || !!a.config?.components?.DashboardWidget. Neither baseDeviceConfig nor baseAssetConfig registers a DashboardWidget, so this only surfaces assets that have explicitly opted in via their own known-asset config — fridges today; any future synthesized type would surface automatically.smartAssets → dashboardAssets to match the broader meaning.b41a61c77 on feature/fridges-as-ubx-devices.FridgeDashboardWidget.vue: wraps <dashboard-widget> with #hero + #properties slots populated from useFridgeProperties + flattenSnapshot. First reading is the hero (rendered via <property-value>), the rest are small <property-value :small> entries; falls back to the "no sensors linked" tonal info alert when !snapshot.hasAnySensors.components.DashboardWidget in ubx-fridge.ts — the dashboard's <asset-component> resolution picks it up automatically.DashboardOverview.vue opted into includeChildLinks: true on its useBuildingAssets call and provides a BUILDING_ASSETS_KEY (a Ref<Map<string, DecoratedAsset>> built once per assets change) so the widget can resolve child-link stubs to full decorated assets without a per-fridge round-trip.ffc5cc572 cleaned up Task 3 review notes (added ABOUTME header to ubx-fridge.ts; moved the BUILDING_ASSETS_KEY import into the @/composables/ group).a2bd95b6e, ffc5cc572 on feature/fridges-as-ubx-devices.includeChildLinks?: MaybeRef<boolean> to UseBuildingAssetsOptions (defaults false). When true, the BuildingAssets GQL query selects childLinks { linkType, childAsset { assetUuid } } via an @include directive — minimal join shape, since the child asset itself is already in the top-level building-assets array.false so existing callers are unaffected. DashboardOverview, BuildingFloorplansTab, and AssetTableReport opt in in subsequent commits.b0037463d on feature/fridges-as-ubx-devices.modules/vue-ui/src/refrigeration/composables/use-fridge-properties.ts — pure derivation that walks a fridge's childLinks and produces a typed FridgeSnapshot of its linked-sensor readings, bucketed by linkType (temp / humidity / door / compressor / evap).FridgeDashboardTab rules: comparablePropertiesForAsset(sensor.config, "degrees_f") for temp, rh_pct for humidity (guarded on sensor.config.properties.rh_pct), magnet_chan1 for door, average_energy for compressor / evaporator.childAsset) are resolved via either a caller-supplied resolver or the provide/inject BUILDING_ASSETS_KEY token. The composable is shape-agnostic; surfaces (dashboard, inspector, floorplan, report) wire up whichever resolver fits their data source.flattenSnapshot (concatenate buckets in priority order), pickHeroReadings (first reading from each non-empty bucket, in priority order — later refined to temp/humidity/power-fallback only).351c1b92a applied code-review cleanup: fixed flattenSnapshot's doc comment to match the concatenation behaviour (it had been describing an obsolete interleaved order), removed a spurious ?. on link.childAsset.assetUuid (the field is non-optional in the type), pluralised pushHumidityReading → pushHumidityReadings for symmetry with pushTempReadings, and dropped a speculative getOptionalProperty re-export that no downstream consumer needed.51c7282b7, 351c1b92a on feature/fridges-as-ubx-devices.<dashboard-widget> slots, <v-card>+<v-list>+<property-item> in the inspector, AssetPinTooltip's hero region) rather than needing a shared <fridge-summary> component; then by replacing the spec's floorplanHeroText string function with a components.FloorplanPopupHero slot to match the project's existing override pattern.docs/superpowers/specs/2026-06-01-fridges-as-ubx-devices-design.md and docs/superpowers/plans/2026-06-01-fridges-as-ubx-devices.md. Both passed their respective reviewer subagents.5b5a7399d, a2d521bf2, b0159669f, 3ce9ad4e2 on feature/fridges-as-ubx-devices.docs/auth-overview.md referenced vs-staging.us.auth0.com in two places, which is environment-specific and would mislead readers viewing the doc from a dev/test/prod context.VUE_APP_AUTH0_DOMAIN, with vs-<env>.us.auth0.com as an example shape).abd5bf172 on fix/auth0-resilience (PR #1492).cacheLocation: "localstorage" enables refresh-token flows but persists the refresh token in localStorage, increasing XSS impact compared to the default in-memory cache. The existing comment touched on rotation as a mitigation but didn't spell out the underlying risk.main.ts:42-50 (the Auth0Plugin config) to call out the security tradeoff explicitly: what the cost is (XSS can now exfiltrate the RT), the mitigations in place (Auth0 tenant has Refresh Token Rotation + idle-15d / max-30d lifetimes), and the implication that new XSS surface area (inline event handlers, unsanitized v-html, third-party script tags) needs to be reviewed against this risk.ff4786c04 on fix/auth0-resilience.onRedirectCallback's fallback path used window.location.pathname alone, dropping ?query and #hash from the URL whenever appState.targetPath wasn't set. That's the case on first interactive login (no prior nav state) or any redirect path that doesn't go through bounceToLogin's targetPath. Users would lose filter params or anchor fragments across login.pathname + search + hash) instead.5e27e61a7 on fix/auth0-resilience.bounceToLogin in vue-apollo.ts: 1. loginWithRedirect() returns undefined from the Auth0 SPA SDK when its internal auth0Client isn't initialized yet (init failure, storage blocked, plugin not mounted, etc.). The previous ?.catch() bypassed the rejection handler when the result was undefined, so redirectingToLogin stayed true forever, hanging every subsequent getAuthorizationHeader() caller. Detect the undefined return and reset the flag synchronously.
2. targetPath previously concatenated only pathname + search, dropping window.location.hash. Deep-link routes that rely on the URL fragment lost it across the Auth0 round-trip. Include hash too.
void-swallowing loginWithRedirect() rejections were already addressed by 46a56e870 (the dedup commit).36e9d6849 on fix/auth0-resilience.useNavigationContext running its own lightweight AssetParents query for breadcrumbs, and ParentAssetLinks running useLinkedAssets for its back-link rendering. Both fired unconditionally, even though the vast majority of assets in the app have no parents at all.parentLinks { parentAsset { assetUuid, name } }) into useAsset's existing query — unconditional, but the empty-array case for non-parented assets adds only a few bytes. useNavigationContext now derives breadcrumbs from context.asset.parentLinks and drops its own AssetParents query entirely. ParentAssetLinks accepts an optional parents prop and skips the useLinkedAssets call when provided. AssetHeader pre-computes a parent summary from asset.parentLinks and threads it through to both <parent-asset-links> instances.AssetInspector (assets sourced from useBuildingAssets, which doesn't fetch parentLinks) keeps falling back to useLinkedAssets — but that query only fires when the inspector is open for that specific asset, not for every asset in the building.modules/vue-ui/src/composables/use-asset.ts, composables/use-navigation-context.ts, components/ParentAssetLinks.vue, components/AssetHeader.vue.90c38ada2 on feature/parent-asset-back-links (PR #1479).initialAssetSelectionDone one-shot flag short-circuited the asset-selection watch after its first run, so subsequent deep-link URL changes (browser back/forward, router-link to another floorplan in the same building) updated currentFloor but never applied the new target.route.query.assetUuid as an explicit dependency, skip when floorplanStore.selectedAssetUuid already matches the target. Same mount-time behaviour; now responds correctly to any later URL change.assetUuid is present; otherwise treat any floor change as user-initiated and clear the previous selection as before.78f6e2f32 on feature/parent-asset-back-links (PR #1479).3bde0ae2e on fix/auth0-resilience.loginWithRedirect, the SSO timers (much shorter than RT timers in our tenant — 72h vs 15d, 7d vs 30d) mean any RT failure after >72h of true inactivity lands on the actual login form rather than a silent bounce-back.26d550fdb on fix/auth0-resilience.docs/auth-overview.md previously described the SSO session as "irrelevant once refresh tokens are in use", which contradicted the "Two layers" subsection right below it. Rewrite to say what it actually controls: whether loginWithRedirect lands the user on the login form (session expired) or bounces silently back with a fresh code (session still valid).aa81282be on fix/auth0-resilience.AUTH_RECOVERABLE_ERROR_CODES set with a single check that any error object carrying a string error field is recoverable (the canonical Auth0 OAuth-error shape).error field and continue to propagate so the user sees the real problem.modules/vue-ui/src/plugins/vue-apollo.ts:44-60 (net -27/+14 lines).e4e369ab5 on fix/auth0-resilience. Deployed to staging.getAuthorizationHeader's catch called bounceToLogin and then re-threw, the throw propagated through the authLink to Apollo's query observable, the parent composable set loadingError = true, AppLayout rendered the messages.loading_failed_generic alert — all in the ~50–200ms gap between window.location.assign and the page tear-down.bounceToLogin, return a never-resolving promise instead of re-throwing. The browser is about to leave the page; the promise just dies with it. Apollo's error handler never runs, no UI flash. If loginWithRedirect itself fails, bounceToLogin's existing catch resets the redirect flag so subsequent actions can retry — only this one in-flight request is parked.modules/vue-ui/src/plugins/vue-apollo.ts:62-78.71bb1ff2c on fix/auth0-resilience. Deployed to staging.missing_refresh_token. The SDK throws MissingRefreshTokenError when getAccessTokenSilently runs with useRefreshTokens=true but no RT in the localStorage cache — the common path being a logout in another window, which wipes @@auth0spajs@@::* keys for the origin.missing_refresh_token, mfa_required, access_denied to AUTH_RECOVERABLE_ERROR_CODES. (Allowlist was simplified to a shape check shortly after — see 12:09 entry.)4a09144f9 on fix/auth0-resilience. Deployed to staging.docs/auth-overview.md between the lifetime table and the per-env config toggles. Walks through what each layer is, where it's configured, and an ASCII flow diagram showing how a refresh-token failure either silently re-auths via the SSO session or lands on the login form. Key takeaway: the user only sees the login form when both layers have expired./authorize is invisible; with SSO session dead too, it lands on the login form.6877d135b on fix/auth0-resilience.POST /oauth/token 403s, a canceled /authorize, and a brief "System is busy" flash before the successful code exchange — even though the user-facing outcome was correct. Cause: multiple Apollo queries in flight at the moment of RT expiry, each independently hitting getAuthorizationHeader → invalid_grant → loginWithRedirect, with concurrent navigation attempts canceling each other.redirectingToLogin flag and a bounceToLogin helper. Once one caller has fired the redirect, subsequent callers short-circuit: bounceToLogin is a no-op if the flag is set; getAuthorizationHeader returns a never-resolving promise when the flag is set (parking in-flight callers until the page navigates). The flag is reset if loginWithRedirect itself rejects, so a one-off SDK error can't permanently strand the app.modules/vue-ui/src/plugins/vue-apollo.ts:12-50.46a56e870 on fix/auth0-resilience. Deployed to staging.login_required / consent_required catches missed the actual error code the Auth0 SDK throws when a refresh token is expired or revoked: invalid_grant. Caught during staging verification — with the Idle Refresh Token Lifetime dropped to 600s, the SDK returned a 403 from POST /oauth/token with invalid_grant, the existing catches didn't match, and the error propagated as an opaque network error with no redirect.isAuthRecoverableError helper and added invalid_grant and interaction_required to the set. Both getAuthorizationHeader and errorLink now use the same predicate.c9d268952 on fix/auth0-resilience. Deployed to staging.docs/auth-overview.md.0c761f10c on fix/auth0-resilience.docs/auth-overview.md covering: PKCE login flow, token storage, attachment, logout; the silent-refresh-via-iframe (deprecated for us) vs refresh-token-grant (current) mechanisms; the Auth0 timer settings in a single table; the per-environment "Allow Offline Access" + "Refresh Token" grant configuration requirement; backend JWT verification flow (JWKS, claims, userinfo cache, permissions); and a "recent changes" section explaining each of the three commits and why.3e2bfa11d on fix/auth0-resilience.errorLink in modules/vue-ui/src/plugins/vue-apollo.ts:76-86 was defined but never composed into ApolloLink.from() (line 109 only used [authLink, link]). Errors hit console.error and had no operational effect.ApolloLink.from([errorLink, authLink, link]) so it sees failures from both HTTP and WebSocket operations.statusCode === 401 from the backend, or the Auth0 SDK's login_required / consent_required shape leaking through (e.g., from the WS connectionParams path that doesn't go through getAuthorizationHeader) — and trigger loginWithRedirect with the current location preserved. Permission-level GraphQL errors (the "Not Authorized" shape via utils/graphql-errors.ts) intentionally still pass through so feature UI can render them.login_required from the Auth0 SDK after Safari ITP partitioned the third-party SSO cookie used by the silent-refresh iframe. Refresh tokens (prior commit) prevent the silent-refresh failure entirely in the normal case; this errorLink wiring + the prior getAuthorizationHeader catch handle every residual edge case.d3162c432 on fix/auth0-resilience.getAuthorizationHeader() in modules/vue-ui/src/plugins/vue-apollo.ts:12-24 previously let getAccessTokenSilently() throws propagate as opaque Apollo network errors. With the errorLink unwired, this produced a stuck UI ("502") rather than a clean re-auth flow.try/catch. When the caught error has error: "login_required" or error: "consent_required" (the Auth0 SDK's specific shape for a silent-refresh failure), call loginWithRedirect with the current pathname + search so the user lands back where they were after re-authenticating. Other errors still propagate so genuine network failures aren't swallowed.a98b7509d on fix/auth0-resilience./authorize?prompt=none returns login_required even when the tenant session is still valid; the unhandled throw from getAccessTokenSilently() surfaces as a stuck UI that gets reported as "502". No HTTP request to the GraphQL server is ever made — the failure is entirely inside the Auth0 SDK's iframe communication. (Confirmed via captured /authorize response body containing error: login_required + simultaneous Blocked a frame with origin "https://vs-staging.us.auth0.com" console error.)POST /oauth/token. No third-party cookie required; Safari ITP doesn't interfere.useRefreshTokens: true + cacheLocation: "localstorage" to the Auth0Plugin config in modules/vue-ui/src/main.ts:42-50. cacheLocation change is required because refresh tokens need persistent storage; the SDK won't accept useRefreshTokens with the default memory cache.~/.claude/plans/i-am-trying-to-glistening-pinwheel.md.603707323 on fix/auth0-resilience (cut from origin/main at 376cc61e0).variant="flat" color="success" (filled green) when all steps are done, stays variant="outlined" otherwise. color="success" matches the step checkmarks and progress bar already in the wizard, so the cue is internally consistent.allStepsDone from inside proceedWithMarkComplete into a top-level computed so the button styling and the no-confirm-dialog shortcut share one definition. Switched the source from installStatus.completedSteps (server) to effectiveCompletedSteps/effectiveTotalSteps so optimistic in-session step completions update the cue and the click behaviour immediately — no waiting for the parent's no-cache refetch.febb0f286 on fix/install-wizard-bugs. Deploying to staging.UnsavedChangesDialog.vue — StandardDialog wrapper with two buttons (Discard + Save). No Cancel button; the StandardDialog X handles dismissal. Emits save / discard.InstallWizardBody exposes hasDirtyChanges, saveDirty(), discardDirty() via defineExpose. saveName() now returns Promise<boolean> so saveDirty() can tell the caller whether the network save actually succeeded — only the name has a dirty surface today; photos and floorplan placement commit inline on action.InstallWizard holds a template ref to the body. onCloseClicked / onMarkCompleteClicked first check bodyRef.value?.hasDirtyChanges; if dirty, set a pendingAction ("close" or "markComplete") and open the dialog. onUnsavedSave snapshots the queued action before awaiting saveDirty() (the dialog's own auto-close path resets pendingAction via a watch). If saveDirty returns false the action is aborted so the user can retry from the still-open wizard. onUnsavedDiscard reverts the field via discardDirty() and proceeds.confirmingUnsavedChanges clears pendingAction whenever the dialog closes — covers the X-dismiss path.install_wizard.unsaved_changes_title, unsaved_changes_body, unsaved_save, unsaved_discard.002530635 on fix/install-wizard-bugs.1f1a2ae15 on fix/install-wizard-bugs. Branch is fix/install-wizard-bugs cut from latest origin/main (376cc61e0).a532037bc), BuildingFloorplansTab.currentFloor now prefers route.query.floorUuid over the index-based ?floor=N. That shadowed the floor selector: clicking a card set ?floor=N via router.replace({ query: { ...route.query, floor: ... } }), but the existing floorUuid stayed in the spread and kept winning. Result: floor selector appeared broken once you'd arrived from an asset page.navigateToFloor() now constructs the next query with floorUuid and assetUuid deleted before setting floor. Also clears assetUuid so a previously-highlighted asset doesn't get re-selected on a floor it isn't on.isCurrentFloor now also matches by floorUuid when the query has one, so the floor selector correctly highlights the deep-linked floor on arrival from an asset page (previously Number(undefined) → NaN → no card highlighted).useRouter, useRoute, LocationQuery from one vue-router import instead of two.7c49bfa8d on feature/parent-asset-back-links. Deploying to staging.inspector-title-stack underneath the name and subtitle rows, instead of sitting as a separate white band below the toolbar.:height="hasSubtitle ? 64 : 48" prop (and the hasSubtitle computed). Toolbar auto-sizes via CSS overrides: .inspector-toolbar.v-toolbar { height: auto !important } and .inspector-toolbar .v-toolbar__content { height: auto !important; min-height: 48px; padding: 8px 0 }..inspector-parents now mirrors .inspector-subtitle alignment (padding-left: 20px; margin-top: 2px) — the standalone background-white treatment is gone since the parents row inherits the toolbar's white background.<parent-asset-links> — the component's default render is already "Part of" plain since the earlier de-bolding work.2b016e5af on feature/parent-asset-back-links./assets/:assetUuid/photos route (named assetPhotos) registered between Dashboard and Config in AssetViewContents.vue. Icon: mdiImageMultiple. activeTab computed handles the /photos segment. New tabs.photos: "Photos" i18n key.AssetPhotosTab.vue wraps AssetImageCarousel in an outlined v-card styled to match DashboardWidget: 1.5px #0000001a border, #f8f9fa background, 10px radius, 12px 14px 13px padding. Registered in config/base-asset.ts so AssetComponent can resolve it.AssetImageCarousel.vue gained a height prop (number | string, default 200). Replaced every hardcoded 200/200px reference (v-carousel, v-img, loading state, empty-state placeholder, and the "add another" item) with the prop-derived value so the existing inspector layout is unchanged while the Photos tab can pass a taller value.useDisplay()'s viewport height minus navbar (48/90), an approximated 168px page-header (AssetHeader + tabs row), the v-container padding (pt-12 pb-16 = 112 desktop / pt-6 pb-10 = 80 mobile), and the card's vertical padding (25). Clamped to a 400px minimum. Passing a real pixel number sidesteps the broken height: 100% propagation through AssetComponent's wrapper div.fullHeightMain: true + hideFooter: true only; dropped fullWidth/noPadding so the standard AppLayout v-container (responsive max-width + pt-12 pb-16 padding) wraps the tab, matching the other tabs' visual treatment.343495e6e on feature/parent-asset-back-links. Deploying to staging.AssetHeader and AssetInspector with a small mdi-map-marker pin. Separator ( · ) matches the friendly-name/serial-number divider above. Label de-bolded to match the friendly-name weight; text-uppercase removed and copy reverted to sentence case (Part of).<router-link> to buildingFloorplans with ?floorUuid=<id>&assetUuid=<id>. Same visual treatment as the parent back-links (shared parent-asset-links__link class — inherits default <a> styling, no custom color).BuildingFloorplansTab.currentFloor now prefers route.query.floorUuid for selection (gap-resilient — works even if floor position values aren't 1..N contiguous), falls back to the existing index-based floor query. The auto-redirect-to-top-floor watch skips when floorUuid is set.?assetUuid=... highlights the asset's marker on the floorplan without opening the inspector. Added inspectorOpen to stores/floorplan.ts; selectAsset takes an optional openInspector (default true), deselectAsset resets it. Inspector v-ifs now gate on both selectedAsset && inspectorOpen. Clicking the marker (or its tooltip) still opens the inspector as before. Esc / close clears everything.BuildingFloorplansTab keyed on [currentFloor, assets] with an initialAssetSelectionDone flag so it fires exactly once per mount; existing currentFloor deselect watch skips the very first null→floor transition when an assetUuid is in the query so the auto-highlight isn't clobbered.useAsset GraphQL query gained floor { floorUuid name } (was only fetching floorUuid) — needed by AssetHeader since it's used on AssetView where the inspector's existing floor data isn't available.a532037bc on feature/parent-asset-back-links. Deployed to staging.DB.execute() was called outside a with DB: block. ConfigDatabaseConnection.__exit__ (hvac2/hvac2/db.py:142-174) is where commit/rollback happen on the outermost nested exit. The UPDATE was currently only persisting by accident — the subsequent get_asset() call entered its own with DB: and that exit committed everything queued on the shared connection. A read failure or refactor would silently break the mutation.with DB:. Matches the pattern used throughout hvac2/hvac2/ubx/asset.py (e.g. soft_delete_asset, undelete_asset).2ef481846 on feature/install-complete (sbp-mono PR #853).<button> styled as link wasn't picking up the project's <a> rule in styles.scss (which targets .v-theme--light a:not(...)). Color was inheriting from the medium-emphasis utility class.color: #0076bc directly on .mark-incomplete-link (matches the literal used in styles.scss). Removed the text-medium-emphasis utility class from the template. The wizard always renders on white, so the unconditional color works in both themes.0aba905ee on feature/install-complete-status (sbg-webapp PR #1490).Copilot code review on PR #1490 flagged:
1. effectiveInstalled was derived from effectiveSteps.value.name (the implicit-old gate) while the new Mark Completed UI keyed off installStatus.installed (the explicit flag). A named-but-unmarked asset would render with a green check icon + no SetUpChip + a Mark Completed button — contradictory.
- Switched effectiveInstalled to (installedOverride ?? installStatus?.installed).
- Added an installedOverride ref that markComplete() / onMarkIncomplete() set right after the mutation so the header icon, chip, and close() dismiss logic react instantly before the parent refetches. Resets on assetUuid change alongside the existing dismissed reset.
- Mark Completed button + Mark as incomplete link both now key off effectiveInstalled so they participate in the same optimistic flip.
2. The <a href="#"> for Mark as incomplete was less accessible than a button (no Space activation, no :disabled semantics). Replaced with a <button> styled to read as an inline link — keeps the visual, gains real button semantics, supports :disabled during in-flight mutation. CSS strips native button chrome and adds a disabled state.
3. Comma splice in the confirm-dialog body flagged but left as-is (UI copy iterated on twice; Barry's call).
7dd9461c2 on feature/install-complete-status (sbg-webapp PR #1490).installed=true in every state (because the test asset is non-UBX and the bypass overrides install_complete). It was a smoke test that the mutation doesn't raise, nothing more.tests/test_compute_install_status.py. Schema introspection separately verifies the mutation exists in the GraphQL schema.known_asset_uuid set); the framework's createAsset doesn't expose that. Not worth building right now.c34f6703c on feature/install-complete (sbp-mono PR #853).createAsset() which doesn't accept a known_asset_uuid, so the test asset is non-UBX. After the bypass added in 58e90163c, installed=True for the entire test (whether the mutation is called with complete: true or false).set_asset_install_complete.expected.yaml: CreateTestAsset installed=true, completedSteps=1; both MarkInstallComplete and MarkInstallIncomplete now expect installed=true. Added a top-of-file comment explaining that the actual toggle semantics live in tests/test_compute_install_status.py.4137ccf5e on feature/install-complete (sbp-mono PR #853).SetUpChip was appearing on non-UBX devices like LG_Paxcounter_B because their install_complete defaulted to FALSE (no wizard ever flips it). Non-UBX assets are identified by known_asset_uuid IS NULL — confirmed by Barry._compute_install_status, short-circuit installed = True for assets with known_asset_uuid IS NULL. Per-step bits still reflect implicit state, but installed being true makes the chip / wizard mount / dashboard banner all skip these assets (they all already read installStatus.installed).test_non_ubx_asset_is_always_installed covers the branch. All 4 tests in the file pass.58e90163c on feature/install-complete (sbp-mono PR #853).4cd16f25d on feature/install-complete-status (sbg-webapp PR #1490).2bf6ebfc6), so the message can speak directly to that situation instead of describing general behaviour.fcfe92785 on feature/install-complete-status (sbg-webapp PR #1490).setAssetInstallComplete(true) resolves, show a success-colored snackbar via useSnackbarStore with the new key install_wizard.mark_complete_success = "Setup complete". Follows the pattern used by EditFloorDialog.vue and BuildingAlertsIndex.vue.9b0dce668 on feature/install-complete-status (sbg-webapp PR #1490).onMarkCompleteClicked: if installStatus.completedSteps >= totalSteps, fire the mutation directly; otherwise show the confirmation dialog. Confirming "set up" without completing the wizard still gets an explicit yes/no; finishing every step and clicking Mark Completed is just the obvious final step and goes straight through.markComplete() helper called from both the direct-fire path and the post-confirm path.2bf6ebfc6 on feature/install-complete-status (sbg-webapp PR #1490).v-btn variant="text" with a real <a> link styled via text-caption text-medium-emphasis Vuetify utility classes + a small .mark-incomplete-link scoped rule (underline + offset, thicker underline on hover). Click-handler uses @click.prevent so the href="#" doesn't navigate.556d3af9f on feature/install-complete-status (sbg-webapp PR #1490).?install=true since the wizard normally hides), the Mark Completed button is replaced with a quiet text-variant link "Mark as incomplete" that calls setAssetInstallComplete(complete=false).refresh-asset fires but close() is not called — the user has just transitioned back to "needs setup", so the wizard staying open is the right behaviour.install_wizard.mark_incomplete = "Mark as incomplete".64848b374 on feature/install-complete-status (sbg-webapp PR #1490).v-if="!props.asset.installStatus?.installed" on the Mark Completed button so it disappears once the asset is marked complete. Matters when the wizard is forced open via ?install=true for an already-installed device — previously the button rendered and clicking it would have been a redundant mutation.befb054c2 on feature/install-complete-status (sbg-webapp PR #1490).761fff994 on feature/install-complete-status (sbg-webapp PR #1490).$assetUuid: UUID!, but the schema only has ID (no UUID scalar). Request failed with Unknown type 'UUID'. Did you mean 'ID' or 'Void'?$assetUuid: ID! — matches how the schema types asset UUIDs.b2425ff75 on feature/install-complete-status (sbg-webapp PR #1490).[Close] [Mark Completed]; now [Mark Completed] [Close] — Close (primary/highlighted) sits on the right where the default action conventionally lives in Vuetify dialogs.ef4e87359 on feature/install-complete-status (sbg-webapp PR #1490)._get_assets's SELECT row was unpacked into AssetBase/Device constructors via **row, but the new install_complete column wasn't declared on the type, so runtime queries hit Device.__init__() got an unexpected keyword argument 'install_complete'.install_complete: strawberry.Private[bool] = False on AssetBase (matches the pattern used by property_aliases, account_uuid, s3_path). The field is present on the dataclass for _compute_install_status to read, but not exposed via GraphQL — installStatus.installed is the user-visible projection.e0a44f2e4 on feature/install-complete (sbp-mono PR #853).column "ass.install_complete" does not exist. The migration only touches existing DBs; the Docker stack builds its DB from strawberry-gql/data_model/create-ubx-tables.sql (via docker/init-test-db.sh and ubx_pave.sql), and that file didn't have the column.install_complete BOOLEAN NOT NULL DEFAULT false to the ubx_asset CREATE TABLE next to installation_date so fresh DBs (test stack, new envs) get the column.5cfe15759 on feature/install-complete (sbp-mono PR #853).setAssetInstallComplete(complete=true) → emit refresh-asset → close wizard. Loading state on the button; markingComplete ref reset in finally so the spinner clears on error.defineEmits return as const emit (the previous code discarded it; needed for the new handler to call emit("refresh-asset") from script setup).0cad473f8 on feature/install-complete-status (PR ventacity/sbg-webapp#1490).install_wizard.complete) with two buttons: Close (color="primary", variant="flat" — the highlighted/default action) and Mark Completed (variant="outlined" — secondary).MarkInstallCompleteDialog.vue follows the DeleteAssetDialog.vue pattern (StandardDialog + useDialogState).install_wizard.complete → install_wizard.close, added mark_completed, mark_complete_confirm_title, mark_complete_confirm_body, mark_complete_cancel. Confirmed no other code referenced the old key.d6c19900e on feature/install-complete-status.src/gql/set-asset-install-complete-mutation.ts — default-export function (assetUuid: string, complete: boolean) => Promise<AssetInstallStatus> using apolloClient.mutate(...). Follows update-asset-mutation.ts exactly (Apollo, not urql; inline gql literal; .then(response => response.data.<mutationName>.<field>)).installStatus from the response since that's all the callers care about.c3a28ffad on feature/install-complete-status.gql_server/mutations/asset.py: - complete: true → install_complete=TRUE, installation_date=now() (overwrites — fresh install on re-mark after move/repurpose)
- complete: false → install_complete=FALSE, installation_date=NULL (cleared so a future re-mark starts clean)
queue_mutation_event captures the calling user, so the journal is the audit trail — no install_completed_by_uuid column needed.mutations.yaml (roles >=editor).gql_tests/integration/scripts/set_asset_install_complete.{graphql,expected.yaml} (run in the Docker test stack alongside the others).update_asset's convention exactly — fresh BuildingLoader(info.context) for the pre-event result and info.context['building_loader'] for the returned actual_result. No .clear() calls (the codebase doesn't use them).a1b9aeb0a on feature/install-complete (sbp-mono PR #853, stacked on feature/asset-install-status).installed = bool(asset_dict.get('install_complete')) — replaces the "soft gate" (installed = name_filled). Explicit-only: no implicit OR with all-steps-done, no auto-flip.completed_steps/total_steps still describe implicit progress so the wizard fill bar works before the user marks done.hvac2/hvac2/ubx/asset.py's explicit SELECT list in _get_assets needed ass.install_complete added between ass.known_asset_uuid and typecat.asset_type_uuids — without it the loader would have stripped the column.strawberry-gql/tests/test_compute_install_status.py. Required an importlib.util.spec_from_file_location workaround for the function-under-test because gql_server.objects.__init__.py eagerly imports modules that need native extensions (psycopg2, googlemaps, modbus_lorawan) the test env doesn't have. Fragile but functional; same 3 pre-existing test-collection errors as the baseline.0d73fc5e5 on feature/install-complete.ubx_asset.install_complete BOOLEAN NOT NULL DEFAULT FALSE.install_complete = TRUE so no asset regresses on day one. installation_date is not touched by the backfill (it would otherwise reset every UBX device's reporting window for asset.py:36-46's clipping code).queue_mutation_event from the next-task mutation) — no install_completed_by_uuid column.jaded-newt-3229.g95.cockroachlabs.cloud:26257/jaded-newt-3229.defaultdb. File at strawberry-gql/data_model/migrations/add-ubx-asset-install-completed.sql.697679c31 on feature/install-complete (sbp-mono, branched off feature/asset-install-status since the original installStatus work isn't yet on develop).FloorplanPreviewCard.vue: render a placeholder spinner while the building/floor is still loading; hide the card if the asset's referenced floor is missing; preload the floorplan image via the app store (matching the pattern BuildingFloorplansTab and InstallWizardBody use) so the embedded preview doesn't sit stuck in its loading state.useBuilding: opt-in cache option that swaps fetchPolicy to cache-and-network so multiple consumers of the same building don't refetch.f88fba3e3 on feature/asset-dashboard-floorplan-preview..github/workflows/claude-code-review.yml (auto-ran Claude review on every PR open / sync / reopen). Copilot will handle PR reviews going forward via the native GitHub setting (no workflow file)..github/workflows/claude.yml so @claude mentions on issues and PRs still work for on-demand asks.ci/remove-claude-review (PR #1488, open).webapp@ubxdev / webapp@ubxstage) has no SSH key of its own and can't reach github.com. Local deploys work via the developer's forwarded ssh-agent; the runner had no equivalent, so cap aborted at git:check with Permission denied (publickey).gha-deploy); registered the public half on the repo and stored the private half as repo secret GH_DEPLOY_PRIVATE_KEY.webfactory/ssh-agent@v0.9.0 and the runner's ssh config now sets ForwardAgent yes for the ubxstage / ubxdev hosts, so net-ssh forwards the agent over the cap connection. The server's git wrapper then auths to github with the forwarded key.ci/forward-agent (PR #1487, open)./deploy run, post a PR comment with the branch name and a link to the deployed environment, e.g. Deployed \claude/issue-1450-…\ to staging → https://staging.ubxsystems.com/.staging.ubxsystems.com / dev.ubxsystems.com); same template covers both environments. 🚀 reaction on the trigger comment unchanged.ci/deploy-comment-link (PR #1486, open).gem install ed25519 failed on the runner because ed25519 1.4+ requires Ruby 3.0 and the workflow uses Ruby 2.7 (matching the project's Capistrano setup).1.3.0, the last Ruby-2.7-compatible version. Split bcrypt_pbkdf to its own gem install line because gem install -v applies the version constraint to every gem listed.ci/pin-ed25519 (PR #1485, open)./deploy dev reached the workflow as dev\r, fell through the case match, and exited 78 with Ignoring unsupported deploy target 'dev<CR>'.tr -d '\r' before awk so the parser sees clean text.ci/deploy-strip-cr (PR #1484, open).net-ssh, which needs the ed25519 and bcrypt_pbkdf gems to read ed25519 public keys.NotImplementedError: unsupported key type ssh-ed25519, which made Cap exit non-zero even when the deploy itself succeeded on the surviving thread(s) — observed end-to-end with code reaching the server but the workflow finishing with 👎 + a stack-trace-filled log..github/workflows/deploy.yml..github/workflows/deploy.yml: listens for issue_comment events on PRs, parses /deploy staging or /deploy dev from collaborator+ commenters, authenticates to AWS via OIDC, pushes an ephemeral ed25519 key to the target instance with aws ec2-instance-connect send-ssh-public-key, tunnels via open-tunnel, and runs BRANCH=<head> cap <env> deploy.staging and dev map to instance IDs; anything else exits with a ::notice:: and no failure noise.sbg-webapp-gha-deploy (ec2-instance-connect:SendSSHPublicKey scoped to the two instance ARNs with ec2:osuser = webapp condition; ec2-instance-connect:OpenTunnel on instance-connect-endpoint/*; ec2:DescribeInstances + ec2:DescribeInstanceConnectEndpoints on *), repo secret AWS_DEPLOY_ROLE_ARN. EIC permissions were tuned across two iterations after seeing UnauthorizedOperation and missing-EICE errors at runtime.FloorplanMap zoomed and centered on the asset's marker, so operators on long maintenance sessions don't lose spatial context. Clicking the card routes to the building's floorplan view with the asset pre-selected via floorplanStore.selectAsset. Self-hides for assets with floorUuid: null.FloorplanMap.vue — new optional props focusAssetUuid, focusZoom (default 1), interactive (default true). When focusAssetUuid is set, fitFloorplan() does setView(focused.floorRealY, focused.floorRealX, focusZoom) instead of the existing auto-fit-to-bounds; a watcher re-fits on prop change. When interactive is false, the L-map options disable dragging/scrollWheelZoom/doubleClickZoom/boxZoom/keyboard/touchZoom and the l-control-zoom is v-ifed off, so the preview reads as a passive thumbnail.FloorplanPreviewCard.vue (new) — takes asset, calls useBuilding(buildingUuid) to find the floor that matches asset.floorUuid, renders the embedded map (200px tall, single-pin :assets=[asset], locked, disable-tooltip, interactive=false). Small floor-name + open-in-new icon badge in the top-right corner as the affordance; whole card is clickable.AssetViewContents.vue — mounts the card above the <router-view> gated on activeTab === "dashboard", so it appears for every asset-type dashboard (fallback property table, fridge, tstat, IAQ, etc.) without modifying any of the per-type dashboard components. Stays out of the way on Config / Documents / History / Journal / Debug tabs.feature/asset-dashboard-floorplan-preview.UBX-TSTAT-101) already shows ambient_temperature to 0.1° via format: "decimal1dd"; the remote sensor was rendering whole-degree.UBX-TSTAT-RMT1 — the only thermostat-remote sensor is UBX-TSTAT-EXT1 (knownAssetUuid e6d28bcf-…d9b3, friendlyName "Thermostat Remote Sensor", reportingSection thermostats). Targeted that.UBX-TSTAT-EXT1 shared its config (ubx-temp-rh-100) with UBX-TEMP-RH-100 (cold-storage Temp/RH Sensor, integer precision intentional). Editing the shared config would have changed cold-storage too. Split with a thin extension instead.tstat/config/ubx-tstat-ext1.ts (new): mergeAssetConfig-extends ubxTempRh100Config with format: "decimal1dd" on both temp_c (degrees_c base) and altUnits.degrees_f, mirroring the structure used by ubx-tstat-101.ts for ambient_temperature.config/known-asset.ts: imports ubxTstatExt1Config and points the UBX-TSTAT-EXT1 registry entry at it. UBX-TEMP-RH-100 entry unchanged.feature/rmt1-temperature-precision.@dragstart="closeHoverPopup" did close the popup once at drag start, but Leaflet's marker stays under the cursor as it follows the drag and re-emits mouseover repeatedly, which immediately re-opened the popup.AssetMarker.vue — added an isDragging ref toggled by @dragstart/@dragend on <l-marker>. openHoverPopup short-circuits when isDragging is true, so once a drag begins no further mouseover during that drag can re-open the popup. Resumes normal hover behavior once @dragend clears the flag.feature/floorplan-tooltip-enhancements.AssetPin.vue): .pin-container and .asset-label-container were registering mouseover on their full wrapper boxes (including any internal empty space), so hovering near a pin without being over it opened the tooltip. Set pointer-events: none on the wrappers and pointer-events: auto only on the visible pin SVG, the badge icon, and the label text. Only the actual glyphs trigger the popup now.AssetMarker.vue): Previously only Leaflet's autoClose / closeOnClick would dismiss, so the popup lingered over neighboring pins. Added scheduleCloseHoverPopup with a 120 ms grace timer driven by @mouseout on <l-marker>. The <asset-pin-tooltip> now also listens for @mouseenter (cancel timer) and @mouseleave (re-arm), so the user can drift from the pin into the tooltip body to click it without the popup vanishing.AssetMarker.vue): Pin drag on desktop showed the tooltip the whole time, hiding the pins the user was trying to place between. Added @dragstart="closeHoverPopup" so the tooltip clears the moment a drag begins. Mobile was already fine — touch doesn't fire mouseover.feature/floorplan-tooltip-enhancements.#item slot branches on item.parents. New breadcrumbs.parents_count i18n key.use-navigation-context.ts — adds a minimal AssetParents GraphQL query fetched via Apollo useQuery keyed reactively on the current assetUuid (enabled gated on non-null uuid). Dedupes parents by assetUuid and feeds them into setBreadcrumbs. The existing resolveContext watchEffect re-runs automatically when the cb reads the new parentAssets.value.AssetHeader.vue — moves the parent-links row out of the expandable details and onto its own line directly below the friendly-name + serial-number row for UBX assets (or below the title for non-UBX). Visible without expanding. Refactored the non-UBX branch from a v-else on the v-expand-transition to a <template v-else> wrapper so the parent-links row can sit outside the expand transition.AssetInspector.vue — unchanged from prior commit; ParentAssetLinks stays as a sibling row between toolbar and body.feature/parent-asset-back-links.parentLinks multiple times — once per property-level linkType — so a child sensor wired to its fridge for both SYSTEM_TEMP and SYSTEM_HUMIDITY was rendering the fridge twice in the "Part of" row.ParentAssetLinks.vue — dedupe parentAssets by assetUuid (uniqBy) in a uniqueParents computed, iterate that in the template. Chose to dedupe locally rather than in the shared getParentAssets/useLinkedAssets helper: existing callers (VRF outdoor unit, fridge dashboard) filter by a single linkType so they wouldn't have observed the duplication; widening the helper's contract risks unknown side effects.feature/parent-asset-back-links.AssetHeader) and in the sidebar (AssetInspector) so child assets in a compound assembly (fridge temperature/humidity/door sensors, tstat external-temperature sensors, etc.) expose a clickable path back up the hierarchy. Any parentLink qualifies — no linkType filter, future-proof as new compound kinds are added. Renders all parents when there's more than one, separated by · .ParentAssetLinks.vue — new presentational component. Takes assetUuid and self-fetches via useLinkedAssets(uuid, { includeParentLinks: true }). Renders nothing when there are no parents (so empty space fully collapses in both hosts). Label is a #label slot that defaults to <strong class="text-uppercase"> reading the new general.part_of i18n key — uppercase suits the header's existing label/value chips; inspector overrides the slot to lowercase to match the 12px subtitle.<asset-inspector> mount turned up seven call-sites all driven by useBuildingAssets (which has no includeParentLinks flag), and useAsset doesn't take one either. Self-fetching keeps every existing query untouched; Apollo dedupes if both surfaces ever appear together for the same asset.AssetHeader.vue — mounted as the first chip in both UBX and non-UBX expandable detail wraps, inheriting the existing text-body-2 text-grey mr-3 flex chip styling.AssetInspector.vue — mounted as a sibling row between the v-toolbar and inspector-body with a new .inspector-parents style block (12px text, 36px left padding to align with the asset name). Sits outside the height-constrained toolbar so the inspector header height stays fixed.en.json — new general.part_of: "Part of" next to general.unnamed.feature/parent-asset-back-links.assetDisplayName introduction.AssetSelector.vue — in multiple mode the group rows were sorted by raw asset.name but displayed via assetDisplayName(asset). Blank-name assets sorted to the top (empty string sorts first) while the user saw "Unnamed", so the visible order didn't match the sort key. Single-mode already mapped to items first then sortBy(items, "text"); multi-mode now does the same.SmartAssetName.vue and use-navigation-context.ts — both already had inline isBlank(asset.name) ? i18n.t("general.unnamed") : asset.name copies. Switched them to import assetDisplayName from @/config/asset so the blank-name behavior lives in one place. Dropped the isBlank and i18n imports in use-navigation-context.ts (the local assetDisplayName was their only consumer) and the same two imports in SmartAssetName.vue.bugfix/alert-create-unnamed-asset-labels.AssetSelector.vue set chip/list text to asset.name directly in three call sites (single-mode items, multiple-mode items, group "select all"). AlertForm.vue:189 did the same for edit-mode pre-population. When asset.name is null/empty/whitespace, the v-autocomplete had nothing to render.assetDisplayName(asset) helper in @/config/asset returns i18n.t("general.unnamed").toString() for blank names, matching the existing pattern in SmartAssetName.vue and use-navigation-context.ts (which both already used isBlank(name) ? i18n.t("general.unnamed") : name). Replaced the four direct asset.name references with the helper. Same fallback applies to alerts/edit chips (AlertForm.vue:189), which had the same blank-chip bug.bugfix/alert-create-unnamed-asset-labels.FormField.vue — two fixes for the same underlying problem (vee-validate's reactive-useField doesn't cope when a paramsSelectorFn rebinds the path on a mode change): 1. Path rebind, no unmount (Heat↔Cool on a dual setpoint device). Vee-validate's internal pathStateLookup migrates to the new path but formValues[newPath] is left empty. form.validate() then validates against an undefined value and emits "Required field" even though our model has the data and the displayed input has the right value. Fix: a watch(fullName, async () => { await nextTick(); resetField(...); validateField(); }) re-seeds value + initialValue from the model after vee-validate's own nextTick migration, and validateField() triggers a fresh validateWithStateMutation so withLatest dedup suppresses the rules watcher's stale-value result.
2. Path rebind WITH unmount (Cool→Auto on a dual setpoint device). v-if/v-else swaps the single target_temperature form-field out for the two-field [4]/[5] pair. Vee-validate's internal path-migration watcher lives in the FormField's effectScope and is cancelled before it can migrate pathStateLookup. useField's own onBeforeUnmount then looks up the new path, finds nothing (matchesId false), takes an early-return, and never calls removePathState. The leaked state survives in pathStates.value with a stale path Ref that resolves to [3], and every subsequent form.validate() reports "Required field" on a phantom target_temperature[3]. Fix: inject FormContextKey, capture useField's id as fieldId, register our own onBeforeUnmount after useField's, scrub any leaked state whose id === fieldId straight out of getAllPathStates().
fullName. That fixed Heat↔Cool but didn't help Cool→Auto, because Vue stops pre-flush watchers before the v-if unmount and our watcher never even fires for the unmounting field. Adding [ValidatedForm] submit blocked logging surfaced the duplicate target_temperature_min[3] entries (id 6 + id 36) which made it obvious the leak was structural in useField's cleanup path, not in our resync.bugfix/tstat-mode-change-validation. Issue: ventacity/sbg-webapp#1473.CumulativeDashboardTab.vue — added <install-wizard> at the top, gated by useInstallWizardMount(asset) (the composable from earlier today). Declared refresh-asset emit so the wizard's save-completion event reaches AssetView.refetch through the router-view chain. Used defineAsyncComponent for the wizard import to keep heavy floorplan / leaflet deps off the initial bundle (matches DeviceDashboardLayout's pattern).OccupancyDaily.vue owns its own internal date range picker (OccupancyDaily.vue:55-58). Wrapping it in DeviceDashboardLayout would render a second, non-functional GraphTimeframeSyncer toolbar above it. Mount the wizard directly, leave the rest of the dashboard alone.DeviceDashboardLayout.bugfix/install-wizard-on-hubsgateway/components/DeviceDashboardTab.vue — rewrote on top of DeviceDashboardLayout with #status (the existing StatusSection) and #graphs (the existing mobile RSSI graph) slots. :show-history="isMobile" preserves the prior behavior of only showing the history section for cellular hubs. This is the actual fix for the reported bug — UBX-HUB001/100/101 now mount <InstallWizard> because the layout does it for them.startDate / endDate refs, DateTime and useAppStore imports. Date range now comes from the shared useDateRangeStore via the layout's useDeviceDashboard composable — same path every other device dashboard uses.synched-timeframe and :suppress-loading="isRefreshing" to the PropertyGraph so it participates in the layout's GraphTimeframeSyncer (matches tstat / contact / water).bugfix/install-wizard-on-hubsDeviceDashboardLayout.vue — replaced the four inline computeds (installRequested, isInstalled, showInstallWizard, installWizardDefaultExpanded) and the useRoute import with a single call to useInstallWizardMount(asset). Destructured at script level (const { show: showInstallWizard, defaultExpanded: installWizardDefaultExpanded } = ...) so the existing template bindings (v-if="showInstallWizard", :default-expanded="installWizardDefaultExpanded") stay unchanged — net diff against main is small and script-local.asset ref from toRefs(props) at line 149 instead of creating a second one via toRef(props, "asset"). (First pass at this refactor used toRef; code review caught the duplication.)bugfix/install-wizard-on-hubscomposables/use-install-wizard-mount.ts — new composable that encapsulates the install-wizard mount logic previously inline in DeviceDashboardLayout.vue:127-131. Surface is two ComputedRef<boolean>s: show (?install=true OR not yet installed) and defaultExpanded (?install=true AND not yet installed). Preserves the asymmetric ?? true fallback when installStatus is undefined — the layout's prior semantic, intentionally distinct from InstallWizard.vue:122 which defaults the other way.MaybeRef<DecoratedAsset> + unref(), matching the codebase convention (use-occupancy-trend, use-models, use-energy-consumption, etc.). MaybeRef imported from @/types. First pass used Ref<DecoratedAsset>; code review pushed to align with convention.tests/composables/use-install-wizard-mount.spec.ts: full 2×2 of installRequested × isInstalled, installStatus === undefined, reactivity to asset changes, plain (non-ref) input. Mocks vue-router the same way use-device-dashboard.spec.ts does.CumulativeDashboardTab) was about to need the same gate without using DeviceDashboardLayout.bugfix/install-wizard-on-hubsMostly floorplan-tooltip iteration plus a handful of orthogonal fixes.
AssetMarker.vue — restored the @mouseover="openHoverPopup" template handler. A few days back the disable-tooltip refactor accidentally renamed it to onMarkerMouseOver (no such function). Vue silently no-ops undefined template handlers, so hovering a pin did nothing. Also added pointer-events: none on the outer .leaflet-popup and re-enabled them on the visible content wrapper — the popup wrapper sits at its Leaflet layout position above the marker, so the empty area there was stealing hover events from any marker stacked above an open popup. Stripped the default Leaflet popup chrome (white bg / its own border / padding) so the inner card carries the visual styling; kept the soft drop shadow + transform for elevation and below-marker positioning.AssetPinTooltip.vue — replaced the bespoke two-cell status row with the shared PropertyValue component used by the dashboard widget hero, so the tooltip's hero reads identically to the matching card. Wrapped the whole tooltip in card chrome (gray bg, 10px padding, 1.5px border, 10px radius, soft shadow), centered the hero block via align-self: center, indented the friendly-name + serial subtitle 18px to align past the status dot, and pulled the photo edge-to-edge with negative margins (-12px / +24px) past both the padding and the subpixel border. Asset name now uses Vuetify text-primary to match the dashboard card's blue.PropertyValue.vue — added a no-tooltip opt-out so the floorplan tooltip can suppress the timestamp v-tooltip. The Vuetify overlay was throwing "Slot 'default' invoked outside of the render function" warnings every hover because it was rendered inside Leaflet's DOM (portal slot timing breaks).AssetChip.vue — on xs breakpoint, clicking an unplaced-asset chip no longer opens the inspector. The inspector covers the floorplan on phones, which made dragging the chip onto a floor impossible.InstallWizardBody.vue, removed the orphan primarySummary computed in AssetHeader.vue left over from the mono-serial split-span refactor, swapped the index-based v-for key in BuildingSensorDataReport.vue for section.id:group.label, and dropped the dead install-wizard__header--clickable class binding (no matching CSS).autoClose + closeOnClick, so the popup persists until the user hovers another marker or clicks the map — long enough to read the body and click through.174151637.feature/surface-name-and-serialThree coupled fixes so dashboards / breadcrumbs / inspector reflect in-wizard edits without manual refresh.
AssetComponent.vue — root-cause fix. Cached the wrapped async component per loader fn in a WeakMap. The previous defineAsyncComponent(loader) inside a computed created a brand-new component definition every time asset.value got a new reference, which made Vue's :is resolve to a different component on every refetch and unmounted/remounted the entire dashboard subtree (visible as a full page reload, with subscriptions tearing down and re-establishing). The cache means same loader → same wrapper → no remount.useNavigationContext — breadcrumb / tab-title staleness. Dropped the legacy "resolve once" latch on the internal watchEffect (originally an unwatch() pattern, later switched to a let resolved = false flag in PR #1349) so the title and breadcrumbs re-run when asset.name changes mid-session. While we were there, lifted useTitle(TITLE_PREFIX) to the composable top so we update title.value instead of spinning up another watcher every reactive tick.InstallWizardBody → AssetView event chain. Replaced an experimental provide("refetchAsset", refetch) / inject pattern with idiomatic emit-up: body emits refresh-asset after each successful save (name, photos, floor placement / move / clear); InstallWizard and DeviceDashboardLayout declare the emit and forward; the 16 dashboard tabs need no change (single-root + no declared emits = listener falls through automatically). AssetView was already listening via @refresh-asset on <asset-component> and calling refetch.provide/inject indirection.f4f69353a to staging.feature/surface-name-and-serialSet up chip on every widget, and the passive collapsed wizard on every asset dashboard. Painful for production accounts; not what we wanted that signal to mean.feature/asset-install-status 0c8b8332c): _compute_install_status in building.py now sets installed = name_filled. Building also drops out of the progress count — total_steps shifts from 3/4 to 2/3 and completed_steps no longer adds has_building. steps.building stays in the response shape (no schema change).3f7c124ef): InstallWizard.vue mirrors the new derivations — effectiveInstalled = effectiveSteps.value.name, effectiveTotalSteps = floorplan === null ? 2 : 3, building dropped from effectiveCompletedSteps. InstallWizardBody.vue deletes the entire Select-building step (template, refs, useAccountBuildings / moveAssetsMutation / GqlBuilding imports, selectedBuildingUuid, onBuildingSelected, etc.). stepNumbers shrinks to { name: 1, photos: 2, floorplan: 3 }. The floorplan step's useBuilding now reads props.asset.building?.buildingUuid directly (was sourced from the now-removed selector). Other surfaces (banner, chips, passive mount, latch) all read installStatus.installed from the server response and pick up the relaxed gate automatically.feature/surface-name-and-serialv-chip next to the wizard title when the asset isn't fully installed; sits in the same slot as the green checkmark and swaps with it based on effectiveInstalled. Reuses the same Vuetify props/dimensions the dashboard widget already uses (size="x-small", color="warning", variant="flat", font-weight 600), so the chip on a card and the chip in the wizard header read as the same affordance.building.dashboard.set_up_cta i18n key — no new translations.492902099 to staging.feature/surface-name-and-serial?install=true. The passive case mounts the wizard collapsed; only the explicit ?install=true flow auto-expands.defaultExpanded prop on InstallWizard.vue; parent (DeviceDashboardLayout.vue) computes installRequested && !isInstalled and passes it. Wizard snapshots the value on mount so completing the final step inside an open wizard still doesn't auto-collapse.9fcb9433b to staging.feature/surface-name-and-serialindex.html via Google Fonts and added a global .ubx-serial { font-family: "Roboto Mono", monospace; } class in styles/styles.scss.AssetsTable column (via cellClass).{ friendlyName · serial }), the join now renders as two spans in the template so only the serial portion picks up the mono face. The friendly name and the · separator stay in the regular sans face.f44aa05b0 to staging.feature/surface-name-and-serialDashboardWidget.vue now shows a small warning-coloured "Set up" chip in the title row when asset.installStatus.installed === false. Click routes to that asset's dashboard with ?install=true so the install wizard mounts open. @click.stop keeps the card-level select handler from firing."1 device … | {count} devices …") used elsewhere via $t(key, count) (mirrors portfolio.portfolio_map.headings.map). Earlier _one/_other keys are gone.installStatus rebased onto a fresh branch (feature/asset-install-status) off latest origin/develop in sbp-mono and pushed.71455fb0c to staging.feature/surface-name-and-serialinstallStatus resolver on AssetBase with InstallSteps/InstallStatus Strawberry types. A new DataLoader batches the photo-count check (ubx_attachment.get_assets_with_image_attachments) and the building-floorplan-presence check (ubx_floor.get_buildings_with_floorplans), so a dashboard query for N assets fires exactly 2 extra SQL queries regardless of N.installStatus.installed === false. Click → ?filter=uninstalled. With the filter active, the banner switches to a blue info "Showing N uninstalled · Show all" affordance that clears the filter on click. The asset grid filtering reuses the existing search-filter pipeline (added one more .filter call on smartAssets).useAssetInstallStatus composable. InstallWizard.vue reads asset.installStatus directly and maintains a tiny stepOverrides: Partial<AssetInstallSteps> bag for snappy in-session feedback. InstallWizardBody.vue accepts a :steps prop for the per-step checkmarks and emits step-update events on each save (name/building/photos/floorplan) instead of the previous local-update/photos-changed partial-asset shape. No more documents-query or building-query firing on wizard mount — installStatus comes inline with the asset query response.feature/surface-name-and-serialserialNumber, header "Serial #"). Both reports now identify devices by the UBX-printed serial that field techs read off the physical label.components/AssetsTable.vue (the shared base for all three reports) from devEui → serialNumber. InventoryAssetsTable.vue reintroduces devEui via additional-headers; it picks up the base table's existing <eui-value> rendering slot for formatting/copy-on-click. No GraphQL or i18n changes needed.feature/surface-name-and-serialwatchEffect that fires when loading becomes false); from then on only the user's click toggles it. Already-installed assets still mount collapsed, partially-installed assets still mount expanded.feature/surface-name-and-serialdisableTooltip prop on FloorplanMap.vue and AssetMarker.vue and gated the <l-popup> with v-if="!disableTooltip". Wizard's <floorplan-map> usage in InstallWizardBody.vue passes disable-tooltip so the asset hover card doesn't fight with placement interactions inside the wizard.false everywhere, so the dashboard floorplan keeps the tooltip.feature/surface-name-and-serialuseAssetInstallStatus(asset, { enabled? }) composable derives a per-asset "installed" boolean from four checks: name present, building assigned, at least one photo, and (when the building has floorplans) placed on a floorplan. Returns { installed, steps, completedSteps, totalSteps, loading }. Reusable across surfaces (next likely consumer is the building dashboard cards). The enabled flag forwards to useAssetImages.loadImages() and useBuilding, so callers can defer fetches.InstallWizard.vue split into a thin header component (always renders the title strip) and a heavy InstallWizardBody.vue (the four-step UI, drag-and-drop floorplan, account-buildings selector, etc.). Body is mounted lazily via v-if="expanded", so its queries only fire when the wizard is open.install_wizard.title) with a mdiCheckCircle green check when fully installed. Each step row gets the same checkmark next to its number badge when its requirement is met. Replaced the "All steps optional" chip with {completed} of {total} Steps complete text + a thin success-coloured v-progress-linear.feature/surface-name-and-serialinteractive: true).<l-popup> for persistent open/close behavior (stays open until the user clicks the map or hovers another pin), opened on marker @mouseover via markerRef.leafletObject.openPopup() with autoClose: true + closeOnClick: true..leaflet-popup-content-wrapper by translateY(calc(100% + 21px)). The Leaflet wrapper element itself stays where Leaflet positioned it (above marker); only the visible body shifts. The default downward-pointing tip is hidden since it'd point at empty space now..leaflet-popup.asset-marker__tooltip styles the body as a Vuetify-card (white background, rounded corners, soft shadow) instead of Leaflet's default speech-bubble look.feature/surface-name-and-serial:two-columns="false" override on AssetInspector and restored the original cols="12" single-column layouts for contact, tilt, gateway (Mobile pair), and the tstat humidity placement (back to a single card with humidity stacked under temperature when present).feature/surface-name-and-serialprimaryProperties[0]. For UBX-TSTAT-101 (ambient_temperature + humidity) and any other asset with two featured properties, the second is now rendered inline after a separator dot. Capped at two for compactness; assets with three or more primary properties show the first two.feature/surface-name-and-serialAssetInspectorStatus was packing ambient_temperature and humidity into a single v-list inside a single full-width card, so the two properties stacked vertically rather than rendering side-by-side. Split into one card per property and wrap each in a half-width v-col when humidity is shown (UBX-TSTAT-101). When humidity is gated off (UBX-TSTAT-100), the temperature card takes full width.feature/surface-name-and-serialAssetInspector.vue dropped the hardcoded :two-columns="false" override on <asset-component component-name="AssetInspectorStatus" />. Per-type status components and the generic fallback already default to side-by-side rendering when there are 2+ featured properties; the override was the blocker.contact, tilt, and gateway per-type AssetInspectorStatus components: dropped their hardcoded :sm="12" single-column layout for their property card pairs and switched to cols="6" so the featured properties render side-by-side on every viewport.AssetMarker.vue floorplan tooltip now uses direction: 'bottom' with offset: [0, 0] so the hover card floats just below the pin tip rather than above.feature/surface-name-and-serial<v-tooltip> didn't capture hover when its activator lived inside <l-icon>. Leaflet detaches/serializes the icon DOM into its marker pane, and the activator's mouseenter/mouseleave handlers never fired.<l-tooltip> (Leaflet-native, lives inside <l-marker> alongside <l-icon>). interactive: true makes the tooltip body clickable; custom className lets us override Leaflet's default black tooltip styling to look like a Vuetify card.feature/surface-name-and-serialAssetPinTooltip.vue renders a Google-Maps-style hover card with photo (lazy-fetched on first hover), asset name (with "Unnamed" fallback), friendlyName · serialNumber subtitle, and a status row with the offline-keyed dot + the asset's first primaryProperty value (e.g. "Normal"/"Leak" for water sensors).AssetMarker.vue now wraps the pin in a Vuetify <v-tooltip> (location="top", 200ms open delay) whose content is the new tooltip body. Clicking anywhere on the tooltip body opens the inspector via the same floorplanStore.selectAsset path the pin's click handler uses — no new entry into the inspector flow.useAssetImages.loadImages() is called only once the tooltip first mounts (Vuetify mounts tooltip content lazily), so the floor doesn't issue N image queries on initial render. Apollo caches subsequent hovers.feature/surface-name-and-serialreportingSection reads better as a generic categorical grouping for any report-style consumer.types/index.ts, config/known-asset.ts, config/dashboard-categories.ts. No behavior change.feature/surface-name-and-serialAssetsThresholdsReport.vue now mirrors the sensor-data report layout: an <h3> section header per non-empty dashboard category (canonical order from getDashboardCategories()), then within each section a model group keyed by knownAssetUuid (config-coherent) with title Friendly Name (UBX-XXX) via getKnownAsset(...).friendlyName + KNOWN_ASSETS.name. Drops the invalid text-heading-6 class and matches the sensor-data report's lighter text-subtitle-1 weight.AssetsTable name column now uses SmartAssetName's displayName slot prop so blank asset names show "Unnamed", matching the rest of the surface. Same change in the asset-name span on AssetsThresholdsReport.vue.Friendly Name (UBX-XXX) when the model code is available.mb-12 (48px) in both reports for clearer visual separation.feature/surface-name-and-serialknownAssetUuid templates that share a friendlyName (e.g. UBX-TSTAT100 + UBX-TSTAT101 both "Smart Thermostat") into one table. PropertiesAssetsTable derives its primary-properties config from firstAsset only, so two templates with different primaryProperties in the same table threw a render error.knownAssetUuid (or model name for non-UBX assets) — guarantees a single config per table. Title still uses the friendly name, so distinct templates that share a friendly name now render as adjacent tables with identical titles. User-facing grouping intent is preserved; table internals stay consistent.feature/surface-name-and-serialBuildingSensorDataReport.vue now groups results by dashboard category at the top level (h3 per non-empty section, in canonical order from getDashboardCategories(); unmapped assets fall into "Other") and by friendly name within each section. Friendly name resolves via getKnownAsset(...).friendlyName; fallback is asset.assetModel?.name for non-UBX assets. Models that share a friendly name (e.g. UBX-IAQ101 + UBX-IAQ101A both "Air Quality Sensor") merge into a single table.groupAssetsByCategory<T>(assets) + DashboardCategoryWithAssets<T> to dashboard-categories.ts so other reports can reuse the canonical-ordering grouping logic.feature/surface-name-and-serialuse-dashboard-config.ts into dashboard-categories.ts so other consumers (reports, selectors) can reuse the canonical category list without depending on dashboard-only state.dashboard-categories.ts exports getDashboardCategories(): DashboardCategory[] — the ordered { id, label, knownAssetUuids } list, derived from KNOWN_ASSETS.dashboardSection walked in DASHBOARD_SECTIONS order, with unknown ids auto-appended.DashboardCategory shape excludes the dashboard-only collapsed flag so report code doesn't carry irrelevant state.use-dashboard-config.ts:getCodebaseDefault() is now a thin layer that overlays DASHBOARD_SECTIONS' collapsed defaults onto the categories.feature/surface-name-and-serialmodules/vue-ui/src/config/dashboard-categories.json keyed by knownAssetUuid. With friendlyName already on KnownAsset and a new product spec defining both fields per UBX model from the same source, dual files were awkward. Co-located the per-asset mapping with the rest of the per-model metadata.KnownAsset gains optional dashboardSection: string. The JSON is replaced by dashboard-categories.ts which exports DASHBOARD_SECTIONS (an ordered list of { id, label, collapsed }) at the top of the file for easy amending. The knownAssetUuids arrays are gone — now derived.KNOWN_ASSETS gets dashboardSection set to its previous JSON section id, preserving current dashboard behavior. Cross-checked against the new product spec PDF — all 32 mappings agree.use-dashboard-config.ts:getCodebaseDefault() now derives sections by walking DASHBOARD_SECTIONS in declared order, gathering knownAssetUuid from every KNOWN_ASSETS entry whose dashboardSection matches. Section ids referenced from KNOWN_ASSETS but missing from DASHBOARD_SECTIONS are auto-appended at the end with a humanized label and collapsed: false — so adding a new section is a one-line edit on a model entry.DashboardConfig / DashboardSection in server preferences) is unchanged; existing customized accounts are unaffected. No GraphQL changes.KNOWN_ASSETS yet (UBX-IAQ102A, UBX-NRG102/104/105/201/202, UBX-OCC101, UBX-TEMP-RH100A, UBX-TSTAT-RMT1, plus the n/a and blank-category ones). Barry is taking the missing-KA list to product separately.feature/surface-name-and-serialknownAssetUuid resolves to a friendlyName), the secondary line now reads <friendlyName> · <serialNumber> to match the inspector and dashboard cards. A caret next to the edit button toggles an expansion panel showing Manufacturer, Model, and Device EUI in the same text-body-2 text-grey + bold uppercase label format as the previous detail row. Non-UBX assets keep the original Category/Type/Manufacturer/Model/Serial Number row with the existing mobile-collapse behavior. Caret shows on UBX always; on non-UBX only on mobile (unchanged).devEui to use-asset.ts inside the ... on Device inline fragment. Earlier I'd placed it on the top-level asset selection, which raised "Cannot query field 'devEui' on type 'AssetBase'" since devEui lives on Device/DeviceBase.{{ asset.name }} from AssetControlPanel, AssetConfigAssignmentList, and TaskScheduleAssignmentList so SmartAssetName's default-slot fallback applies.assetDisplayName helper in use-navigation-context.ts so the browser tab title and breadcrumb text also show "Unnamed" for nameless assets.feature/surface-name-and-serial{{ asset.name }} from <smart-asset-name> in AssetHeader.vue so the default-slot fallback (displayName → "Unnamed" when name is blank) renders, matching the inspector title and dashboard cards.feature/surface-name-and-serialisUbxDevice = !isBlank(knownAssetUuid) was wrong: KNOWN_ASSETS includes non-UBX entries (VS-series ERV/HRV, VRF ODU/IDU, Fujitsu) that have a knownAssetUuid but no friendlyName, so any asset linked to one of those templates fell into the friendly-name branch and produced an empty primarySummary.friendlyName itself as the discriminator. When getKnownAsset(...).friendlyName resolves, show <friendlyName> · <serialNumber>; otherwise fall through to <manufacturer> · <model>. Drops the unused isUbxDevice computed.feature/surface-name-and-serialAssetInspector title now uses SmartAssetName's default-slot fallback so an unnamed asset reads "Unnamed" in the inspector header (matches the rest of the surface).AssetDashboardTab.vue: wrapped the loose <tr> rows inside <table> in a <tbody> to silence Vite's "<tr> cannot be child of <table>" hydration warning. Browsers were already inserting a tbody implicitly, so no visible change.feature/surface-name-and-serialtext-body-2 font-weight-medium on the new primarySummary line with a dedicated .primary-summary rule: font-size: 16px and color: rgba(0, 0, 0, 0.78) so weight and tone can be tuned independently of Vuetify's text-grey utility.feature/surface-name-and-serialAssetInspector.vue with a single contextual line: <friendlyName> · <serialNumber> for UBX devices (asset.knownAssetUuid set, friendly name looked up via getKnownAsset), <manufacturer> · <model> for non-UBX assets.manufacturer / model / serialNo / category / type / missingValue computeds and the i18n import.feature/surface-name-and-serialSmartAssetName.vue. Added a displayName computed (asset.name or i18n.t("general.unnamed") when blank) exposed both as default-slot content and as a displayName slot prop. Callers opt in by either dropping slot content (new fallback renders) or reading the slot prop. Existing callers that pass their own slot content ({{ asset.name }} mustache, styled spans, etc.) are unchanged..smart-asset-name__icon class so the status dot has a small consistent gap from the name. The previous zero-default left character-bearing variation as the entire visible gap (capital U vs lowercase p looked very different). Existing per-context workarounds — DashboardWidget's &__title .v-icon { margin-right: 4px } rule and the pl-1 spans in AssetsTable / CopyThresholdsDialog / AssetsThresholdsReport — keep working: the higher-specificity rules / the additional padding layer on top of the new default for places that wanted extra space.DashboardWidget now consumes the displayName slot prop instead of its own local fallback computed.AssetPin dropped its local identifier computed and lets SmartAssetName render the fallback, so unnamed assets show "Unnamed" on the floorplan pin instead of a blank label.AssetChip (unplaced-assets list) is now two lines: asset name (with "Unnamed" fallback) on row 1; friendlyName · serialNumber on row 2 in smaller (11px) lighter (85% opacity) text, indented when the status dot is present so it lines up with the name.feature/surface-name-and-serialDashboardWidget.vue) now show the asset's serial number (asset.serialNumber — the unified read-only field that resolves to mfgSerialNumber for non-UBX assets and ubxSerialNumber for UBX devices) on the right side of the title row, in small muted text. Capped at 40% width with ellipsis so a long SN can't crowd out the name.KnownAsset.friendlyName) is shown as a v-tooltip anchored to the SN span only, not the whole title. Set open-on-click so it surfaces on tap; added @click.stop on the SN so the tap doesn't bubble to the title/card click handlers and bring up the asset inspector on mobile.general.unnamed i18n key and use it as the fallback for asset.name when blank.DashboardOverview.vue:assetMatchesSearch already tests asset.serialNumber, the same unified field shown on the card.feature/surface-name-and-serialfriendlyName field to the KnownAsset type and populated it for 36 UBX-branded entries in KNOWN_ASSETS using the user-friendly model labels from UBX's master model list (e.g. UBX-IAQ101 → "Air Quality Sensor", UBX-NRG100 → "CT 100A", UBX-OCC100 → "Occupancy Sensor").friendlyName — they aren't on the UBX label list and carry their own marketing names. Three rows missing or red-flagged in the source PDF (UBX-HUB100/101, UBX-OCC104, UBX-TSTAT100) got sensible defaults that mirror their siblings; same pattern for four entries that aren't in the PDF at all (UBX-IAQ100, UBX-NRG200A, UBX-TSTAT-EXT1, UBX-FRIDGE).feature/surface-name-and-serial — the next step is surfacing the friendly name alongside the model number in the UI.feature/surface-name-and-serial (new, off main bb1a3585b) 1. Alert Home (white card): config + recipients (unchanged AlertConfigSummary)
2. Incidents (NEW): Gantt-style Highcharts xrange chart with one row per scope asset; each bar is a firing. Bars include firings on the scope asset itself and on its linked child sensors (resolved via the asset's childLinks GraphQL field). Date range picker (shared with Details) sits above.
3. Details (only when an instance is selected via timeline click or ?ai=): a single PropertyGraph for the associated scope asset's property + the existing AlertInstanceBanner.
PropertyGraph loop and the AlertRecentInstances table that lived below the graphs.AlertIncidentsTimeline.vue (new), AlertConfigDashboard.vue, en.json. Deleted AlertRecentInstances.vue.feature/simulate-alert-firings, commit c740182, pushed.resolved_at, so the UI extended their pending_started_at to now(). Old logic placed ACTIVE/PENDING anywhere in the 90-day window, so an ACTIVE that landed 60 days back rendered as a 60-day bar.pending_started_at is anchored to the last 4h via new recent_anchor_offset helper; if PENDING, anchored within the dwell window (<5min ago for default dwell). All earlier firings are RESOLVED with normal slot timing. Verified: regenerated f5adf2c3 (1 ACTIVE, 1.59h old) and 4067f334 (1 PENDING, 3.6 min old).N * 0.10 / N * 0.05 to K * 0.10 / K * 0.05 (K = num assets). With 4-5 assets you'll typically see 0-1 of each per run; re-roll the seed for variety.feature/simulate-alert-firings, commit 2e7107c, pushed.pending_started_at with per-asset slot timing: for an asset with n firings, divide the window into n equal slots and jitter each offset within the part of its slot where a max-length arc still fits. Eliminates RESOLVED-vs-RESOLVED overlap on the same asset (verified: 0 overlaps across 329 consecutive pairs after re-running both definitions).--clear flag that deletes existing instances + transitions for the definition before inserting. Used it to regenerate f5adf2c3 (200/200 instances) and 4067f334 (200/200 instances).slot_offsets; 20 tests total, all green.feature/simulate-alert-firings, commit b03d432, pushed.deploy/ubx-serial+cache-isolation was the wrong home for the simulator (it's a deploy branch and was force-pushed remote-side, so my commit was floating on stale history).Gemfile.lock, branched off latest origin/develop, cherry-picked 9dc3b78 as 70fe620 onto feature/simulate-alert-firings, pushed with upstream tracking. Switched back to deploy/ubx-serial+cache-isolation and popped the stash.deploy/ubx-serial+cache-isolation still has the orphan 9dc3b78 and pre-rebase history vs origin (3784 ahead / 3783 behind). Left untouched pending a separate decision on how to reconcile.simulate_alert_firings.py + test_simulate_alert_firings.py (449 insertions) on deploy/ubx-serial+cache-isolation as commit 9dc3b78. Not pushed.strawberry-gql/dev-scripts/simulate_alert_firings.py plus pure-function tests in test_simulate_alert_firings.py (16 cases). Takes an existing v2 alert_definition_uuid and writes N ubx_alert_instance + ubx_alert_instance_transition rows over a configurable window (default 90 days).none→pending→active→resolved arc), 10% active, 5% pending; firings stratified across all in-scope assets (every asset gets floor(N/K) ± 1) instead of per-firing random.additional_context.affected_properties is copied from the fire trigger's backend_data.affected_properties — same shape the UI consumes at gql_server/objects/alertv2.py:391. No regex/CEL parsing needed.ubx_alert_instance, and the only existing ubx_alert_instance_transition insert is in close_alert_definition_instances (hvac2/hvac2/ubx/alertv2.py:469-516) on definition disable/delete. So the simulator writes directly to those tables. Out of scope (production v2 doesn't touch them either): last_alert_instance_uuid on subscriptions, ubx_event_history, MQTT publish.f5adf2c3-8d90-419f-978e-b649e3dc446a: 220 instances / 619 transitions inserted across a 20-row + 200-row run. State distribution, asset distribution (5×44), 90-day time window, and per-state transition counts (active=2, resolved=3) all matched expectations. Sample instance had correct dwell (5 min) and recovery delay.deploy/ubx-serial+cache-isolation — not yet committed.run-dev-script.sh at the standalone-dev-env top level. Mirrors start-mac.sh's prelude (activates gql-py3 venv, sources gql_env), then exec python on the named script under gql-repos/sbp-mono/strawberry-gql/dev-scripts/. 26 lines, basename lookup, clear errors for missing script (exit 66) and missing args (exit 64)../run-dev-script.sh simulate_alert_firings.py <uuid> --count 200 --seed 1.feature/dev-script-runner (off feature/mac-os-support), commit 89c697b, pushed to origin.AlertConfigSummary up to AlertConfigDashboard so the dashboard can orchestrate the order: name → instance banner → cards#f8f9fa bg, 1.5px soft border, 10px radius, no title bar + dividerCONDITION PROPERTY / rh_pct hero, then compact inline metrics row for Dwell Time + Active Periods chipsRECIPIENTS label with the Snooze all button inline on the right, followed by per-recipient rows separated by a subtle dividerAlertConfigDashboard.vue, AlertConfigSummary.vue1px severity-colored outline on transparent backgroundopen-in-new iconAlertConfigSummary.vue, AlertInstanceBanner.vuev-col cols="12" md="6"):- Condition card: condition property, dwell time, active periods, assets
- Recipients card: each recipient as a row with email/SMS delivery icons (muted when disabled), per-recipient Snooze and work-schedule icon stubs, collective "Snooze all" button in the card header
snackbarStore.show({ key: "building.alerts.stub_not_implemented" }) — UX preview only; no data model exists for alert snooze or user work schedulesAlertInstanceBanner component appears above the cards when the dashboard is opened with ?ai=xxx (i.e. via /alert-instance/... redirect); shows severity chip, state chip, fired timestamp, resolved-or-ongoing indicator, and duration (Luxon diff formatted d/h/m)building.alerts.config_summary.cards.*, building.alerts.stub_not_implemented, and building.alerts.instance.*AlertConfigSummary.vue, AlertInstanceBanner.vue (new), AlertConfigDashboard.vue, en.jsonbuildingAlertsRecent route) instead of the generic alerts landingAlertConfigDashboard.vueasset_uuid)displayedAssetUuids, excluding the sensor's instance from the fridge's graphalertsAcrossAllAssets prop on PropertyGraph — when true, passes null for assetUuids in the alert instances query (caller is expected to have already narrowed scope via alertDefinitions):alerts-across-all-assets="true"PropertyGraph.vue, AlertConfigDashboard.vue, use-alert-instances.ts:start-date="undefined" explicitly (when no ?ai= param) appeared to disrupt the PropertyGraph's date range store wiring. Use v-bind on a conditional object so no date props are attached when there's no instance to zoom to — restores pre-redirect behavior for direct dashboard linksAlertConfigDashboard.vue<v-btn prepend-icon="mdi-arrow-left"> passed a string name, but this codebase uses @mdi/js SVG path binding. Console error: <path> attribute d: Expected number, "mdi-arrow-left"mdiArrowLeft and bind with :prepend-icon="mdiArrowLeft"AlertConfigDashboard.vue/alert-instance/:alertInstanceUuid route: resolves buildingUuid and alertDefinitionUuid from the instance (via getAlertInstance GraphQL query, extended to return buildingUuid) and redirects to the canonical dashboard URL with ?ai=xxx/alert/:alertDefinitionUuid route: resolves buildingUuid from the alert definition and redirects, preserving any existing query paramsAlertConfigDashboard now reads ?ai=xxx — fetches the instance, derives a zoomed date window (pendingStartedAt ?? activatedAt − 12h to resolvedAt ?? now + 12h in building timezone), and passes :start-date / :end-date to each property-graphbuilding.alerts.not_found i18n string used by both redirect components for the "Alert not found" fallbackreference_v2_alert_notification_url.md)feat/alert-config-dashboardAlertInstanceRedirect.vue (new), AlertRedirect.vue (new), AlertConfigDashboard.vue, use-alert-instance.ts, router/index.ts, en.jsonOpsAgentsConfig.vue and OpsAgentsOverview.vue: 1. Chevrons stacking below the row on the Available Agents and Active Customers lists — the icons were in v-list-item's default slot where Vuetify 3 stacks children vertically. Moved them into #append.
2. Status chip and action buttons vertically stacked on the Configured Agents list. The chip was wrapped in a second v-list-item-subtitle (new line) and the three buttons each took their own row. Put the chip inline with the title and moved the buttons into a .agent-actions flex row below the subtitle (wraps on narrow widths).
3. All list-item link text rendering in brand blue — the global .v-theme--light a:not(...) rule in styles.scss painted every anchor blue, and Vuetify 3's v-list-item with :to renders as <a>, so list-item titles and subtitles inherited the blue. Extended the :not() exclusion to skip .v-list-item and its descendants, matching the rule's original intent (V2 list items weren't anchors).
feature/install-wizardOpsAgentsConfig.vue rendered an apparently-empty v-autocomplete dropdown even though opsAccounts was returning four real accounts from the backend. The #item slot was reading item.name / item.uuid directly, but in Vuetify 3 the slot prop is a wrapper — the original data lives under item.raw, and the slot also exposes a props object meant to be bound onto v-list-item. Both fields were undefined, so every row rendered with an empty title and subtitle.<v-list-item v-bind="props" :subtitle="item.raw.uuid" />. The item-title / item-value props on the autocomplete still resolve correctly for the chip and v-model.feature/install-wizard.feature/install-wizardUserInfo is cached per-auth_id in UserCache and shared across every concurrent request for the same user. Its inherited_access_map was mutated in-place by Account.groups and expected to survive until Group.buildings read it later in the same request — but every sibling request's has_permission → update_cache reset the map mid-flight, so Group.buildings fell into the restrictive filter branch and returned []. Manifested as the sbg-webapp "first-call-empty, refetch-populated" bug.[RACE] instrumentation: on the failing request, iam_id flipped twice between Account.groups AFTER_MUTATE and Group.buildings READ, wiping the mutation both times.inherited_access_map onto info.context (request-scoped) seeded in HVAC2Permission.has_permission as copy.deepcopy(user_info.access_map). Moved update_inherited_access_map to a module-level helper in hvac2.ubx.permissions.permissions. Removed the attribute and duplicated method from both UserInfo and AgentInfo. Test updated to exercise the module-level helper against a request-scoped snapshot.fix/inherited-access-map-raceallBuildings was empty, even while the Account query was still in flight — so users saw a flash of error-looking empty state on every open. Show a small primary-colored progress spinner (20px, 2px stroke) during loading instead.cache-and-network, loading stays true until the network round-trip completes, so the spinner persists through the backend's first-call-returns-empty race (see backend investigation prompt).feature/install-wizardfloorsWithFloorplans watcher in InstallWizard re-selected the original floor after the user explicitly cleared it. The guard only checked whether selectedFloorUuid was empty, and since props.asset.floorUuid never refreshes (Apollo no-cache on useAsset), switching buildings and coming back would trip the watcher and undo the clear. Added !removedFromFloor.value to the guard and moved the removedFromFloor / localPlacement declarations above the watcher for top-to-bottom readability.useAssetBySerial's parameter from serialNumber to ubxSerialNumber so it matches the query variable and the /d/:serialNumber route semantics. No caller changes needed (positional).feature/install-wizardserialNumber is now a read-only helper returned from queries (it reflects mfgSerialNumber on non-UBX assets and ubxSerialNumber on UBX assets). The asset form reads and writes mfgSerialNumber directly, and the field is hidden entirely for UBX devices (assets with a knownAssetUuid).updateAsset sends mfgSerialNumber, and createAsset's $serialNumber parameter was swapped to $mfgSerialNumber. Neither sends serialNumber any more. ubxSerialNumber is never sent from the client.mfgSerialNumber to the Asset type, to use-asset, and to the four queries in use-assets (kept serialNumber alongside for display contexts — inventory tables, dashboards).useAccountBuildings switched from no-cache to cache-and-network (the previous change refetched the full account/groups tree on every mount of 10+ callers); and stripped dead alert-config i18n keys (alerts.headings.config_dashboard, alerts.config_summary) that had leaked in from another branch.feature/install-wizardwidth: fit-content rule introduced for the hero (so card whitespace clicks fall through to the inspector) was applied to .dashboard-widget .property-value, which also constrained the secondary properties grid.&__hero so it only targets the hero property-value. Secondary properties go back to their natural grid sizing.fix/hero-property-fit-content (off latest main, post-merge of feature/dashboard-card-double-click).<dashboard-widget> inside <asset-component> had its own @dblclick handler, but native dblclick bubbles up to <asset-component>'s root <div> which also has @dblclick — so router.push to the asset dashboard ran twice for assets without a registered widget.@dblclick from the inner element; bubble covers it. @select stays because it's a Vue custom emit (doesn't bubble through DOM).feature/dashboard-card-double-clickconsole.log lines from onCardClick after confirming the mobile click behavior works end-to-end.feature/dashboard-card-double-click.property-value element was always full card width with content left-aligned, leaving a wide whitespace area inside the box. The new card-click origin check caught clicks on that whitespace and suppressed the card handler, so users couldn't tap padding to open the inspector..dashboard-widget .property-value { width: fit-content; } so the property-value collapses to its content. Whitespace clicks now fall through to the card handler and open the inspector. Visual rendering of the property text is unchanged (the content was already left-aligned within the wider box)..dashboard-widget so other consumers (control panels, tables) keep their existing layout.feature/dashboard-card-double-clickDashboardOverview.vue's wrapper <div>s around <property-value> (added while iterating on the card-click fix) added an extra DOM level that broke DashboardWidget.vue's nested CSS selectors (e.g. &__hero > div > div > div:first-child), causing the hero value to render full width..property-value lives on PropertyValue's own root div.feature/dashboard-card-double-click.dashboard-widget__noselect) only applied to the generic fallback path in DashboardOverview.vue. Custom dashboard widgets (IaqDashboardWidget, FridgeDashboardWidget, TstatDashboardWidget, etc.) wrap DashboardWidget and pass <property-value> directly, so the origin check never matched for real assets..property-value class on PropertyValue.vue's root <div>. DashboardWidget.onCardClick now checks closest(".property-value, .dashboard-widget__noselect").console.debug → console.log so the diagnostic line is visible without enabling the Verbose filter in Chrome.feature/dashboard-card-double-clickAssetMarker.vue (floorplan pins): added @dblclick → navigates to asset dashboard. Handler calls event.originalEvent.stopPropagation() to suppress Leaflet's map-level doubleClickZoom from firing through the marker.AssetsTable.vue (drives Inventory / Device Health / Sensor Data reports): added @dblclick on the name cell.AssetsThresholdsReport.vue: added @dblclick on the asset-name-container; added useRouter import.smAndUp — on mobile breakpoint, double-tap anywhere could navigate. Left deliberately as-is pending mobile device testing.feature/dashboard-card-double-click (extends prior commit)<div> around <property-value> wasn't reliably stopping v-card's click — emulator breakpoint showed handlers on Vuetify's div.overlay-content fire and the v-card handler still fires afterward.DashboardWidget.vue now uses onCardClick(event) that bails if event.target.closest('.dashboard-widget__noselect') — doesn't depend on bubble semantics.DashboardOverview.vue wrappers now carry the .dashboard-widget__noselect marker class instead of @click.stop.console.debug in the handler logs the origin element for verification.feature/dashboard-card-double-click@click.stop on the <property-value> component root wasn't intercepting the bubble as intended.<property-value> in DashboardOverview.vue (hero slot and properties slot) in an explicit <div @click.stop> — the interception point is now on a DOM element we control rather than relying on component fallthrough.feature/dashboard-card-double-clickDashboardWidget.vue: dropped smAndUp gate on the v-card @click so tapping anywhere on the card (not just the title) opens the asset inspector on mobile.DashboardOverview.vue: added @click.stop on the <property-value> instances used in the hero and properties slots. The tooltip activator still fires on tap, but the click no longer bubbles to the card — so tapping a property value shows only the tooltip without also opening the inspector.PropertyValue wraps its content in v-tooltip, whose activator opens on tap on touch devices — previously we gated the card-level click on desktop only to avoid hijacking that tooltip tap.feature/dashboard-card-double-click/d/:serialNumber redirects to /assets/:uuid?install=true for field techsuse-asset-by-serial.ts composable — temporarily uses UUID lookup until backend ships assetBySerialNumber queryInstallRedirectView.vue — loading spinner while resolving, error state if not foundinstall_wizard and install_redirectalertConfigDashboard route that referenced a file only on an unmerged branchuseAsset query uses fetchPolicy: "no-cache", so props.asset doesn't update after the updateAssetMutation succeeds — assetPlacedOnSelectedFloor stays false, assetsOnFloor returns [], no pin renderslocalPlacement ref to track floorUuid/floorX/floorY after successful mutation, used in the computeds that drive pin rendering (mirrors existing removedFromFloor pattern)onMoveAsset (drag) and onFloorSelected (clear) to maintain localPlacementDashboardOverview.vue: added @dblclick handler on both <asset-component> and the fallback <dashboard-widget> that navigates to the full asset dashboard ({ name: "asset", params: { assetUuid } })feature/dashboard-card-double-click/impeccable teach to gather design context through a structured interview.impeccable.md at project root with full Design Context: users (mixed operators + field techs), brand personality (calm, competent, clinical), aesthetic direction (Linear/Height × Honeywell Forge), locked brand palette, WCAG 2.1 AA target, and 5 design principlesCLAUDE.md at project root with a condensed summary so the design context loads into every Claude sessionLightboxCarouselDocumentDeleteDialog)LightboxCarousel at the clicked imagemin-height: 64px on photo container to prevent layout jump between statesselectedBuildingUuid instead of stale props.asset.buildingprops.asset.building (stale after move) instead of selectedBuildingUuid (updates immediately)feature/install-wizard/connect to /callback127.0.1 to localhostDEV_PORT fallback for vite port config (PORT → DEV_PORT → 8090)feature/portless-supportuseAccountBuildings was returning empty groups after navigating around the app — cache-first default was serving a null result from an earlier failed queryfetchPolicy: "no-cache" to the queryuse-account-buildings.ts - Multiple: dropdown selector that moves the asset via moveAssetsMutation on selection
- Single: plain text (non-editable)
- Empty: "No buildings loaded." with a reload link — added for testing while the backend race condition is unresolved
navStore.contextAccountUuid to props.asset.accountUuid (computed ref) so it works on fresh page load without relying on nav stateInstallWizard.vue, en.jsonfeature/portless-support had accidentally picked up a chain of earlier install-wizard commits on top of the portless commita63b41e7 (the portless commit)--force-with-leaseuse-dashboard-config.ts: collapsed/expanded state of reorderable sections is no longer persisted to the server UI preferencecollapsed from server preference is ignored; in-memory state is preserved across preference updates (so reorder saves don't stomp local collapse state)toggleCollapsed mutates config directly instead of routing through updateSection — no backend round-trip on expand/collapserestoreCollapsedState no longer calls save(); reorder-mode collapse-all/restore flow is now purely localfix/dashboard-ephemeral-collapsemoveAssetsMutation to reassign the device when a new building is selectedInstallWizard.vue, en.jsonwindow.location.origin instead of env var (VUE_APP_AUTH0_CALLBACK_URL)PORT and HOST from environment in vite config (previously DEV_PORT only)InstallWizard.vue — inline wizard shown at top of asset dashboard when ?install=trueDocumentUploadDialog), place on floorplan (inline FloorplanMap with click-to-place)install query param to dismissFloorplanMap.vue to pass Leaflet click event coordinates through click-floorplan emitDeviceDashboardLayout.vue to conditionally render wizard via defineAsyncComponentfloorUuid to use-asset.ts GQL query for floorplan pre-selectionwidth: 100%; height: 100% on FloorplanMap (parent must provide dimensions)flex: 0 0 80pxappStore.preloadImage() to avoid loading spinnercaretOnly prop)title=""), hid date picker (synched-timeframe=true), passed explicit start/end dates matching the Recent Alerts date range, preserved alert event overlaystext-decoration-none)groupInstances() helper in BuildingAlertsRecent.vue to DRY up active/recent grouping logicformatInstanceRow functionAlertInstanceGroupTable.vue, BuildingAlertsRecent.vuetable-layout: fixed with percentage widthsAlertConfigDashboard.vue — new read-only page showing alert config summary + property graphs per asset with alert event overlaysAlertConfigSummary.vue — reusable read-only display of alert config fields (name, severity, property, dwell, assets, periods, recipients)alertConfigDashboard route at alerts/config/:alertDefinitionUuidAlertInstanceGroupTable.vue from v-data-table to v-table for full row control; added condensed PropertyGraph (150px) in group expansion above instance rowsfeat/alert-config-dashboard, 8 commits, all 417 tests passingAlertConfigDashboard.vue, AlertConfigSummary.vue, AlertInstanceGroupTable.vue, BuildingAlertsIndex.vue, BuildingAlertsRecent.vue, router/index.ts, en.jsondate.ts: cast toHuman({ unitDisplay: "short" }) to Intl.NumberFormatOptions — @types/luxon 2.x doesn't include it in ToHumanDurationOptionsnumber.ts: replaced VueI18n.NumberFormatOptions with Intl.NumberFormatOptions — Vue I18n 9 no longer exports that typevee-validate/utils.ts: FieldValidationMetaInfo no longer exported — derived from GenericValidateFunction parameters; added explicit cast to ExtendedValidationRuleSchema for type narrowingdate.ts, number.ts, vee-validate/utils.tskillall aws after deploy was killing ALL AWS CLI processes, preventing parallel deployspkill -f targeting only the tunnel for the specific deploy target's instance IDssh -G <host> to match the correct open-tunnel processconfig/deploy.rbhandleSelectAllInGroup AND Vuetify's internal item selection via v-bind="listItemProps""group-xxx") which overwrote the real selectionv-bind="listItemProps" from group header v-list-item; also extracted emitSelection helper to ensure both update:modelValue and update:selectedAssets are always emitted togetherAssetSelector.vueRecipientSelector.vue and AssetSelector.vue used item.text/item.description in #item slots — Vuetify 3 wraps slot items, raw data is at item.raw:filter prop renamed to :custom-filter in Vuetify 3, function signature changed to (value, query, item)RecipientSelector.vue, AssetSelector.vuecategoryGroups was empty, falling through to "no assets" alert and hiding the search boxhasSmartAssets computed (pre-search check) to gate the search/toolbar areamessages.no_results i18n keyDashboardOverview.vue, en.jsonupdateAsset splice against findIndex returning -1 (DashboardOverview.vue, AssetTableReport.vue)escapeRegExp to prevent invalid regex crashes (DashboardOverview.vue, AssetTableReport.vue, BuildingAlertsThresholds.vue)<a> with <v-btn> for reorder toggle (DashboardOverview.vue)schedule_enabled is not a visible form field — it's inferred from schedule dataSchedulePlan descriptors caused spurious failure icons from CONFIRM_TIMEOUT states the user can't seetimeDescriptors in SchedulePlan.vueSchedulePlan.vueFormExpansionPanel was centered instead of next to the titleflex-grow-1 on header div so internal spacer pushes details right, matching production layoutFormExpansionPanel.vuesyncPendingStateManagementRequests checked asset.deviceLock (truthy if object exists) instead of asset.locked (false when expired)asset.locked instead of asset.deviceLockstate-management.tspending: true, show failure status instead of pending spinnerasset-form.tspending: true from the server even after device lock expiredFormExpansionPanel header aggregated all field statuses (including 48 schedule time descriptors), showing a spinnercopySinglePropertyToForm now takes a locked parameter; when not locked, pending is treated as falseasset-form.ts, tests/config/asset-form.spec.ts.isValid check on parsed expiration to guard against malformed date stringsasset.tsreadBanksFromSchedule crashed with TypeError when schedule_days was null (occurs for banks in RETRY_PENDING/CONFIRM_TIMEOUT states)(dayNames ?? []).map(...) to safely handle null valuesschedule-conversion.tsdecorateAsset was treating any device with a deviceLock.expiration as locked, even if the expiration was in the pastDateTime.fromISO(expiration) > DateTime.now() so expired locks are ignoredasset.tsresetForm() resets field values back to initial, unlike v3 which only cleared flagsresetForm({ values: { ...values } }) so they become the new baselineValidatedForm.vuev-model and @click handler on activator to dismiss tooltip when a field is clickedFieldStatusTooltip.vuesurface-variant background and 10px default offset vs Vuetify 2's grey-darken-2 at 90% opacity and ~4pxstyles.scss with !important (needed because vite-plugin-vuetify auto-import injects component styles after global styles)FieldStatusTooltip.vue; Vuetify 3 defaults config doesn't reliably override VTooltip propsstyles.scss, FieldStatusTooltip.vuedefineRule callback provides field context as a third ctx parameter; installRule wasn't capturing itctx.field, lookupRuleMessage fell back to the rule name as the field name, producing non-existent i18n keys like fields.tstat_y1_conditions.tstat_y1_conditionsctx: FieldValidationMetaInfo and pass _field_: ctx.field into the message lookup valuesinstallRule, not just tstat wiringlookupRuleMessage and installRule field name propagationvee-validate/utils.ts, tests/plugins/vee-validate-utils.spec.ts#append slot (Vuetify 3 requirement), only show on selected presetdensity="compact" only affects paddingDateRangeMobile.vuebody-1 -> text-body-1, body-2 -> text-body-2, subtitle-1 -> text-subtitle-1, caption -> text-caption across 19 filesen.jsonz-index: 10001 to both v-tooltip instancesPropertyValue.vue, FieldStatusTooltip.vueposition: absolute to position: fixed with top: 0; bottom: 0 to match dashboard layoutBuildingFloorplansTab.vuev-toolbar-title__placeholder so the title link truncates with ellipsis while the open-in-new icon never shrinksAssetInspector.vuefalse as empty for required validation (v3 did not)false, failing required validation and cancelling the mutationswitch_status (ubx-switch-100) and socket_status (ubx-plug-100)FormField.vue, SwitchField.vuev-toolbar-title flex rule collapsed it; fixed with flex: 1 1 autostopPropagation was always firing, now only on mobileAssetInspector.vue, DashboardWidget.vuedashboard-categories.json, DashboardOverview.vuev-chip size="small" color="grey-lighten-3" to match tstat schedule day count styleDashboardOverview.vueuseDisplay smAndUp breakpoint to conditionally bind click handlersDashboardWidget.vuefull-screen-on-mobile was closing when clicking v-select dropdown itemspersistent: true to fullscreen mobile dialog props — user closes via X button onlyStandardDialog.vuetext prop to variant="text"asset-property-menu before the floor buttonFloorplanTopBar.vue?edit=true query paramd-flex flex-wrap) with CSS grid (repeat(auto-fill, minmax(280px, 1fr))) for equal-width columnsheight: 100% pass-through from v-lazy wrapper to card for equal-height rowsalign-content: end (was align-content: stretch default, inflating rows when card height exceeded content)toggleCollapsed per section, each firing a SaveUIPreference mutation; responses arriving at different times caused sections to flicker open/closedsetCollapsedAll (local-only, no save) and restoreCollapsedState (single save) to useDashboardConfighasAlerts prop, left-side red shading, #alerts slot from DashboardWidget and all 8 device-specific widgetsDashboardOverviewen.json under reports_tabBuildingReportsTab and page headings in report components to use $t()AssetTableReport.vue shared wrapper with search, selection, inspector slideout, and move dialog; exposes data via scoped default slot and toolbar via named slotBuildingSensorDataReport.vue, BuildingDeviceHealthReport.vue, BuildingAssetInventoryReport.vue/buildings/:buildingUuid/reports/{sensor-data,device-health,asset-inventory}BuildingReportsTab.vue; made BuildingReportLinkCard image prop optionalBuildingAssetsTab.vue down to just <dashboard-overview>DashboardOverview.vue its own inspector slideout (no longer emits select-asset)router-link navigation in DashboardWidget with a select emit on card clickDashboardOverview bubbles the event as select-asset to BuildingAssetsTabBuildingAssetsTab wires it to the existing selectAsset() / AssetInspector flowv-dialog with a fixed slideout panel from the right (v-slide-x-reverse-transition), matching the floorplan tab patternDashboardWidget.vue card shell with named slots (hero, properties)DashboardOverview.vue to use widget cards instead of AssetPropertyCardAssetPropertyCard.vueAlertForm.vue: unused celUiData variable — wired it up to populate condition_property in edit mode.eslintrc.js: added defineOptions to globals (Vue 3.3+ compiler macro not recognized)BuildingFloorplansTab.vue: removed extra blank line before closing </div>BuildingEnergyReport.vue: removed unused router import/assignment1b760882... and e57a0ac1... missing config) — not a code fix<span> in the append-inner slot, which Vuetify 3 constrains. Switched to using Vuetify 3's native :suffix prop on v-text-field, reserving append-inner for the status icon only.Not found 'properties.switch.switch_status.options.OFF'. The switch config defines options: ["ON", "OFF"] but locale file had no options block. Added "ON": "On" and "OFF": "Off" translations.assetDataChanges subscription data arriving as object with numeric keys instead of array. Added merge: false type policy for Subscription.assetDataChanges in InMemoryCache config to prevent Apollo from trying to normalize the union-type array.chrome-devtools-mcp for the project to enable browser navigation, screenshots, and console error inspection from within Claude Code. Requires Chrome launched with --remote-debugging-port=9222 --user-data-dir=~/.chrome-debug-profile.OccupancyCurrent.vue passed :loading to MetricItem, which doesn't declare it as a prop. The attribute fell through to Vuetify 3's VHover, which doesn't accept it either, triggering Vue 3 extraneous attribute warnings on all cumulative occupancy sensors (OCC100, OCC102, OCC107, OCC400). Removed the unused :loading bindings.AssetComponent.vue called defineAsyncComponent() inside a computed(), making the returned component object deeply reactive. This triggered Vue 3 markRaw warnings on VS1000RTh and VS400CMh (VRF device types). Wrapped with markRaw() to prevent unnecessary reactivity tracking on component definitions.AssetSelector lacked inheritAttrs: false, so FormField's type prop leaked through as a fallthrough attr to v-autocomplete's inner <input>, causing number spinners. Same pattern as the FileField fix from the March 3 session.v-if="value" was a leftover from Vuetify 2 naming. Changed to v-if="isOpen" to match the useDialogState composable.#prepend slot auto-wiring issue as the alert copy-to-days fix. PropertySelector used v-model:selected on v-list with v-checkbox-btn in #prepend — Vuetify's internal selection overrode manual state.v-model:selected from v-list, managed checkbox state explicitly via props and toggle events through PropertySelectorGroup → PropertySelectorRow.v-hover isHovering starts as null, not false. SettingCard.vue passed it to PropertyTimestamp's boolean showTimestamp prop. Added ?? false coercion."building" was on the floorplans child route instead of the dashboard (default) child route. Breadcrumbs, portfolio cards, map markers, and journal links all landed on floorplans."building" → dashboard route, "buildingFloorplans" → floorplans route. Updated all references across 6 files.buildingTStatSchedules after it was renamed to buildingConfigTStatSchedules in the router — caused navigation errors on journal schedule links and device config page.style="mt-2" → class="mt-2" on dashboard settings button in BuildingAssetsTab.vue.AlertForm.vue initialized assetLinks with text: a.assetUuid instead of text: a.name, and set condition_property before propertyOptions were available.buildingAssets watch — asset links and property are now populated once building assets load, so selects have options to resolve display names.ManageAlertActivePeriods.vue, the "copy to days" dropdown used :model-value (one-way binding) on checkboxes in v-list-item #prepend slots. Vuetify 3's list selection auto-wiring overrode the manual state.v-model="pendingCopyDays" + :value="dayOption.modelValue" — the standard Vuetify 3 array checkbox pattern.dayOption.value → dayOption.modelValue to match the CopyDayOption interface (was resolving to undefined).BuildingEnergyReport.vue used router.currentRoute.params.buildingUuid — in Vue Router 4, currentRoute is a ShallowRef requiring .value.useRoute() composable (route.params.buildingUuid), consistent with the rest of the codebase.App.vue: two occurrences of router.currentRoute.name → router.currentRoute.value.name.v-date-picker emits Date objects instead of ISO strings. DateField.vue expected strings throughout.pickerValue computed to convert incoming ISO string → Date for the picker, and handleInput to convert emitted Date → ISO string for the model.AssetForm.vue: changed default installationDate from null to DateTime.now().startOf("day").setZone("utc") so new assets default to today.JournalItemSubtitle had a fallback { name: "home" } route that doesn't exist — the route was renamed to portfolio during the Vue 3 migration. Caused a crash when rendering account-level journal events (no building/floor/asset).model-value="true" on v-chip passes the string "true" in Vue 3, not the boolean. Changed to :model-value="true" in AlertSubscriptionMutationPresenter, JournalAlertRecipient, and AlertRecipient.Several drag-and-drop issues on the Building Floorplans page after the Vue 3 migration:
opacity prop on l-marker. Moved opacity from the marker to a wrapper div inside l-icon in AssetMarker.vue.<img> elements triggered the browser's native drag instead of Leaflet's marker drag. Added draggable="false" to both images in AssetPin.vue..pin-container was display: block (full-width), causing the clickable/draggable area to extend across the map. Changed to inline-block so it sizes to the pin content..__drag-image container rendered in-flow instead of off-screen because vue-easy-dnd's scoped styles are lost in the pre-built dist bundle. Added global styles to position it fixed at -10000px.Added the ability to remove an asset from a floorplan by dragging its pin off the edge of the floorplan image. When a marker's moveend fires with coordinates outside the [0, sizeY] × [0, sizeX] bounds, a confirmation dialog appears (reusing the existing "Remove from Floor" dialog strings from the asset inspector). On confirm, the asset is unplaced; on cancel, the pin clamps to the nearest floorplan edge via forceCoordsOntoFloorplan.
Updated ~/.claude/bin/generate-worklog-rss.py to support optional HH:MM timestamps in worklog headings. Headings can now be ## YYYY-MM-DD HH:MM: title — the time flows through to RSS pubDate and HTML display. Entries without timestamps remain backward compatible (default to midnight).
Documented the 3 steps needed to backport fridge dashboard alert overlays to the Vue 2 branch, in case we need to ship this feature before the Vue 3 migration lands:
1. Add useBuildingAlerts to use-asset-alerts.ts and call from AssetView.vue (building-level definitions instead of asset-level)
2. Collect all displayed asset UUIDs in PropertyGraph.vue (not just primary asset)
3. Change assetUuid → assetUuids in useAlertInstancesForProperties
Also notes what NOT to port (DeviceDashboardLayout refactor, store rename, Vue 3 reactivity fixes).
Doc location: ~/.claude/projects/-Users-barry-Projects-sbg-webapp-vue3/memory/reference_fridge_alerts_vue2_port.md
useAssetAlertsStore → useAlertDefinitionStore (store file assetAlerts.ts → alertDefinitionStore.ts)use-asset-alerts.ts into use-alert-store.ts as populateFromBuildingAlerts()use-asset-alerts.ts (both useAssetAlerts and useBuildingAlerts were dead code or moved)AssetView.vue and use-alert-instances.tsv-progress-linear loading indicator to FridgeDashboardTab while linked assets load (was showing blank area)useBuildingAlerts composable alongside existing useAssetAlerts — queries buildingAlerts(buildingUuid) instead of assetAlerts(assetUuid), same storeALERT_DEFINITION_FIELDS fragment to avoid duplication between the two queriesAssetView now calls useBuildingAlerts instead of useAssetAlerts — the store holds definitions for all assets in the building, so every PropertyGraph can find relevant definitions for linked assetsuseBuildingAlerts accepts nullable buildingUuid and gates the query via Apollo's enabled option until the asset (and its building) loadsTested multi-asset alert overlays on the fridge dashboard on staging. Initial deploy only had the instance query changes — discovered that alert *definitions* were still only fetched for the primary asset (AssetAlerts query), so linked asset alerts never appeared. Added useBuildingAlerts composable to fetch building-level definitions, redeployed to staging for further testing.
Built a design document for integrating an LLM-powered chat agent into the platform. Users would ask natural-language questions about their buildings' HVAC systems and get data-driven suggestions.
Key design challenge: LLMs are stateless, so every request requires full context reconstruction — user identity, current building, session history, and specialist agent selection (scheduling, energy, air quality). Solution is a layered context assembly system: platform knowledge → building config → persistent building memory → specialist agent → conversation history, assembled per call and stored in the DB.
Data access runs through existing Strawberry resolvers so auth, tenant isolation, and permissions are inherited.
Phase 1 is read-only — the agent observes and advises but doesn't modify setpoints or schedules. Future phases: autonomous conversations (e.g. triggered by alerts) and tool usage (making changes with user approval).
Resolved the "Maximum recursive updates exceeded" crash that had been blocking the fridge dashboard since 2026-03-12. This was a multi-session, multi-iteration debugging effort — at least 4-5 attempted fixes across previous sessions before landing on the working approach.
Vue 3's Object.is() reactivity check treats every computed re-evaluation as a "change" when the computed returns a new object, even if all field values are identical. The PropertyGraph component tree (Series → Group → Graph) uses exposeDataToParent to pass data upward via use-component-tree. Each level's data computed created a fresh object every evaluation, which the parent saw as changed, which triggered its own recomputation, cascading back down — an infinite reactive loop.
seriesDetails and data with shallowRef + watch that compares individual fields before updating the refregisterChild/unregisterChild calls via nextTick so multiple children mounting in the same tick produce only one reactive updateoptionsWithDefaults binding with a stableOptions ref that only updates after the chart instance exists — prevents highcharts-vue's deep watcher from crashing on null chart during mountGraphTimeframeSyncer / PropertyGraphList / date state with DeviceDashboardLayout, which provides alert overlays and centralized timeframe managementhasSensors computed + v-alert for the no-sensors caseinitialLoading was checking s.series.value === undefined || s.series.value.data.length === 0 — this stays true forever for series that legitimately have no data (e.g. energy graphs), keeping the loading spinner stucks.loading.value which correctly distinguishes "still fetching" from "fetched but empty"useAlertInstancesForProperties now accepts assetUuids: string[] instead of assetUuid: string — removed the internal single-to-array conversion, passes array straight throughPropertyGraph.vue: new displayedAssetUuids computed collects UUIDs from all non-navigator groups, context selections, and compared selections (deduplicated via uniq)displayedPropertyNames no longer filters to primary asset only — includes property names from all displayed seriesExtracted all 36 alert conditions from the existing NewRelic instance managed by Mission Cloud, in preparation for decommissioning that instance and moving to a self-hosted NewRelic deployment.
item-key renamed to item-value in Vuetify 3; without this, v-model with show-select stores undefined values instead of row identifiersreturn-object so v-model stores full objects (matching Vuetify 2 behavior and existing code expectations)item-key → item-value only (uses show-expand, not show-select)selected ref initialized to {} — Vuetify 3's v-select with return-object renders empty object as [object Object]{} to null; added optional chaining on invalid computedeffectiveOptions returned SelectOption objects to ComboboxField which expects string[]options prop is provided, normalizedOptions short-circuited the combobox string extraction.text from normalizedOptions when displayType is comboboxsmall-chips and deletable-chips props removed in Vuetify 3 — replaced with chips and closable-chips#item slot itemProps includes title which renders automatically — destructured it out to prevent duplicate text alongside manual v-list-item-title#default to #prepend slotTwo Vue 3 rendering errors when navigating to a fridge asset's dashboard tab. First error fixed; second still under investigation.
Cannot read properties of null (reading 'emitsOptions')highcharts-vue's options watcher fires before mounted() creates the Highcharts instance — this.chart.update() on undefinedHighchartsGraph.vue: added chartReady ref that defers <highcharts-chart> creation by one tick via onMounted → nextTick, gating render with v-if="initialLoading || !chartReady"Maximum recursive updates exceeded in component <PropertyGraphList>use-component-tree, parent watches that data to update contextgraphData → normalGraphData → categoriesToGraphIds → watch sets selectionsForExistingGraphs → children read context → children's data computed creates new object → graphData invalidated → cyclenotifyOnNetworkStatusChange: true in useLinkedAssets may be contributing by firing multiple onResult callbacks during flushshallowRecordEqual guard in PropertyGraphList's watch, nextTick batching in use-component-tree.ts's updateSortedChildrenDashboardSettingsDialog.vueisCustomized is true (user has a saved preference that isn't already the account default)promoteToAccountDefault from useDashboardConfig through DashboardOverview to the dialog via prop/event~/.claude/plans/drifting-discovering-prism.md, tracked in task list~/.claude/bin/generate-worklog-rss.py — parses all worklog.md files into RSS 2.0 XML~/.claude/bin/serve-feed.py — HTTP server on 127.0.0.1:8384 serving ~/.claude/feed/~/Library/LaunchAgents/com.claude.feed-server.plist — launchd service (auto-start, keep-alive)tailscale serve --bg to proxy port 8384 at /feedindex.html) alongside RSS for browser viewinghttps://macbook-pro-3.tail89f5bf.ts.net/feed/worklog.rss<DropList> + <Drag> (vue-easy-dnd) for reordering sections in DashboardOverview#drag-image slot shows just the section label while draggingDashboardSettingsDialog.vue — section CRUD: add, edit label, remove, drag-to-reorderKnownAssetPicker.vue — multi-select grouped by device type, disables UUIDs in other sectionsmdiViewDashboardEditOutline) in BuildingAssetsTab toolbar, left-justifieddefineExpose({ showSettings }) on DashboardOverview.dnd-ghost { z-index: 10000 } to global styles for drag image above dialog overlay<v-lazy> for viewport-based renderingRef, unref, computed importsUseDashboardConfigResult return type interfaceDB.fetchone check_none=False bug in get_ui_preferencefeature/ui_user_preferences branch from develop, committed and pushedupsert_ui_preference: ON CONFLICT doesn't fire when user_uuid is NULL (NULL != NULL in SQL unique constraints). Changed to UPDATE-then-INSERT pattern for NULL case; non-NULL path keeps ON CONFLICT.delete_ui_preference: user_uuid = %(user_uuid)s won't match NULL. Changed to use user_uuid IS NULL for NULL case.get_ui_preference and promote_to_account_default needed no changes..asset-control-panel > .d-flex > .v-spacer { display: none } was hiding spacers in all control panels on the dashboard (tstat, switch, plug), breaking their layoutclass="asset-property-card" to AssetPropertyCard's root element.asset-property-card > .d-flex > .v-spacer so it only applies to property cardsuiPreference query to queries.yaml — ubx_account, any rolesaveUiPreference and deleteUiPreference mutations to mutations.yaml — ubx_account, >=viewerpromoteUiPreferenceToAccountDefault mutation — kind: custom (admin check in resolver)hvac2.db at sys.modules level to avoid psycopg2 dependency## Known Issues to Investigate
/assets/:assetUuid/configSlot "default" invoked outside of the render function in VDefaultsProvider > VIcon > VBtn > VBtnGroup > VBtnToggleButtonsField > FieldStatusTooltip > FormField > DeviceConfigTab.small-field and .medium-field classes only had max-width — added explicit width to matchflex-grow: 1 and width: 100% from .v-input__control, so control columns inside flex-wrap rows collapsed to content size instead of filling to max-widthdashboard-overview scoping class.control-row column-gap to 6px, .asset-name max-width to 270px.small-property margin-left: 10pxPropertyGrid componentPropertyGrid uses flex: 1 1 400px — fills space beside image/name, wraps to own line when < 400px availablev-spacer in dashboard context so grid fills available space<div> wrappers with <control-column> using responsive small-field/w-100 classesubx_ui_preference table (SQL migration + create-ubx-tables.sql)hvac2/hvac2/ubx/ui_preference.py — get, upsert, delete, promotegql_server/objects/ui_preference.pygql_server/queries/ui_preference.pygql_server/mutations/ui_preference.py (save, delete, promote)DB.fetchone needing check_none=False for nullable queryuse-ui-preference.ts (generic CRUD), use-dashboard-config.ts (dashboard-specific)dashboard-categories.json — added collapsed: false to each entryDashboardOverview.vue — replaced static categories with useDashboardConfig, persisted collapse state, drag-to-reorder sections (vue-easy-dnd), gear icon for settingsDashboardSettingsDialog.vue — section CRUD: add, edit label, remove, reorder, reset to defaultKnownAssetPicker.vue — multi-select grouped by device type, disables UUIDs assigned to other sectionsschema.graphql from live server introspectionbuilding.dashboard.settings.*flex-wrap to flex-columnbuildingDashboardexact prop to Dashboard v-tab to prevent prefix route matching on child routesunits.milliamps ("mA") locale keybuildingTStatSchedules → buildingConfigTStatSchedules route name with required buildingUuid param in 3 tstat schedule componentsv-toolbar bled into menu's v-card → added theme="light" on v-cardv-list-item → moved to #prepend slotscentered-icon class to align icons under user avatarv-model:value → v-model on LightboxCarousel (Vue 3 v-model:value binds to value prop, not modelValue)@click="openFileChooser" handler and upload template ref<input> had static accept="acceptedMimes" instead of bound :accept, no @change handler, no :multipleopenFileChooser() and onHiddenInputChange() functions#selection slot: Vuetify 3 provides { fileNames, totalBytes, totalBytesReadable } (one call for all files) instead of Vuetify 2's per-file { index, text }color="white" to <v-toolbar flat> in DocumentList, SavedScheduleList, SavedSwitchScheduleList, TaskScheduleAssignmentList, AssetConfigAssignmentList (V3 defaults to grey)variant="flat" to primary action buttons inside those toolbars (V3 VToolbar sets variant: 'text' on child buttons, making them transparent).v-data-table .v-toolbar .v-toolbar__content via global styles.scss (V3 sets padding: 0 on toolbar content; needed 3-level selector for specificity)margin-inline-start on .v-toolbar-title inside data-table toolbars (V3 adds 20px default)text-primary cursor-pointer classespadless prop with pa-0 class (prop removed in V3)width="150" to logo v-img (collapsed to 0x0 in flex container without it)flex-grow-0 to prevent footer from expanding (V3 v-footer defaults to flex: 1 1 auto)title out of itemProps since V3 includes it from item-title prop, and we render it manually via v-list-item-title slotz-index from 2500 to 10001 on fullscreen v-dialog — asset inspector container on floorplans page has z-index: 10000.v-dialog--active to .v-dialog--fullscreen.v-overlay--active (V3 renamed the active class to .v-overlay--active; .v-dialog--active no longer exists)style="z-index: 99999" from v-dialog — caused child v-select dropdowns to render behind the dialog (V3 manages overlay z-index stacking automatically)textFieldType was unconditionally mapping dataType to HTML input type, causing select fields with dataType: "number" (e.g. timezone) to get type="number" on their internal input — browser warned "Pacific Standard Time cannot be parsed"#0076bc via .v-theme--light a:not(:where(.v-theme--dark *, .v-btn, .v-btn *)) — excludes dark-themed areas and button elements.v-theme--dark .v-list { color: #fff }.v-theme--light .v-card { color: rgba(0,0,0,0.87) }class="text-white" to theme="dark" so the global link color rule excludes it via :not(:where(.v-theme--dark *))theme="dark" and color="primary" on v-expansion-panel-texton-primary class (doesn't exist in V3)__wrap → __wrappermt-1 margin from inner divbg-grey-lighten-3 on asset list containervariant="flat", fixed .primary CSS selectorclass="primary" → class="bg-primary" on sidebar columns<template> wrapper around group namevariant="text" and density="comfortable"light → theme="light"color="primary" from expansion panel text.expand-icon class only (not whole actions container)exact-path and exact props on v-tab are silently ignored in Vuetify 3activeTab computed based on route path, explicit value on each v-tab, model-value on v-tabsbackground-color and slider-color props from v-tabs, added activeTab prop - Root cause: FormField passes type: undefined as fallthrough attr; Vuetify's _mergeProps overrides type="file" with undefined
- Fix: Added defineOptions({ inheritAttrs: false }) to FileField.vue
- Root cause: Vuetify 3 v-file-input emits single File (not File[]) when multiple is not set. onFilesChanged tried files.length on a File object → undefined → emitted null
- Fix: Handle both File and File[] in onFilesChanged via instanceof File check. Removed unnecessary filesArray array adapter — pass modelValue directly to v-file-input
- Root cause: validateOnMount was passed as a computed() ref to vee-validate's useField(). vee-validate checks if (validateOnMount) which evaluates the ref OBJECT (always truthy), never accessing .value
- Fix: Changed to pass plain boolean instead of computed ref
- Also: Skip validation on initial value sync watcher (setFieldValue(newVal, false) on first call)