diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index f5d8ac049a..d9320554ef 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,11 +1,10 @@ import { defineConfig } from 'vitepress' -import { withMermaid } from 'vitepress-plugin-mermaid' // teslasync.dev serves the site from /. Forks without a custom domain can // set DOCS_BASE to their GitHub Pages project path (e.g. /teslasync/). const base = process.env.DOCS_BASE || '/' -export default withMermaid(defineConfig({ +export default defineConfig({ title: 'TeslaSync Docs', titleTemplate: ':title — TeslaSync Docs', description: @@ -76,6 +75,15 @@ export default withMermaid(defineConfig({ }, markdown: { + config(md) { + const fence = md.renderer.rules.fence! + md.renderer.rules.fence = (tokens, index, options, env, self) => { + if (tokens[index].info.trim() === 'mermaid') { + return `` + } + return fence(tokens, index, options, env, self) + } + }, theme: { light: 'github-light', dark: 'github-dark' }, image: { lazyLoading: true }, }, @@ -219,16 +227,4 @@ export default withMermaid(defineConfig({ lastUpdatedText: 'Updated', }, - mermaid: { - theme: 'neutral', - themeVariables: { - fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif', - primaryColor: '#f5f4f0', - primaryTextColor: '#1c1917', - primaryBorderColor: '#e82127', - lineColor: '#78716c', - secondaryColor: '#fafaf9', - tertiaryColor: '#ffffff', - }, - }, -})) +}) diff --git a/docs/.vitepress/theme/MermaidDiagram.vue b/docs/.vitepress/theme/MermaidDiagram.vue new file mode 100644 index 0000000000..d505d44d6e --- /dev/null +++ b/docs/.vitepress/theme/MermaidDiagram.vue @@ -0,0 +1,47 @@ + + + diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index ad7d2569be..1a669b54f2 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -3,6 +3,7 @@ import { h } from 'vue' import './theme.css' import DocTools from './DocTools.vue' import HomePage from './home/HomePage.vue' +import MermaidDiagram from './MermaidDiagram.vue' function installConsoleNote() { // eslint-disable-next-line no-console @@ -134,6 +135,7 @@ export default { }, enhanceApp({ app }: { app: { component: (name: string, comp: unknown) => void } }) { app.component('HomePage', HomePage) + app.component('MermaidDiagram', MermaidDiagram) if (typeof window === 'undefined') return installConsoleNote() installDiagramOverlay() diff --git a/docs/ci-handoff.md b/docs/ci-handoff.md index b6d2ef4911..a14cd76ba7 100644 --- a/docs/ci-handoff.md +++ b/docs/ci-handoff.md @@ -1,4 +1,129 @@ -# CI handoff — PR #198 (ui/simplification) +# CI handoff — post-merge PR #202 + +## CI — 2026-10-21 final source is remotely green + +Draft [PR #202](https://github.com/ev-dev-labs/teslasync/pull/202), +`fix/post-merge-followups` to `main`, head `f84aad0ce7`: +**199 successful checks, one existing conditional skip, zero failures or pending checks**. +Frontend Quality run `39534812045`, CI `39534812154`, Security `39534812081` +and Performance `39534812123` all completed successfully. No merge performed. + +Three pushed source heads were needed: the first exposed the strict 400KB +startup budget; the second exposed 44px action targets and late mocked-request +teardown in WebKit. Lazy tour loading, shared 44px header targets in a 28px +line, restored body space and complete navigation/request settling fixed the +causes. The third also includes newly available Vite6.4.4 and Go1.27.3 security +patches. No workflow changes, reduced thresholds, new skips or tolerance changes. + +A1–7 and the shared widget-header item are implemented. Export is hidden because +the existing API does not guarantee secret redaction; Duplicate creates a disabled +copy and opens it. Insurance "0..60" remains an explicit owner decision, with +backend/form disagreement and audit origin recorded in `followups-plan.md`. +The separate Tailwind v4 draft is eligible now that PR202 is green. + +Final local security-environment rerun: all57,191 unit tests pass (four existing +pending), complete lint/audits and route/catalog/contracts checks pass; Go build, +tests, route generator and CI-pinned lint pass. All16 quality shards pass2,029 tests (two existing skips), all10 Windows visual +shards pass49 (41 existing profile skips), a11y13 and Firefox/WebKit6 pass. +Every browser wrapper reports zero proxy escapes; zero retries in this fresh +sweep. Eight375/768/1280/1920 light/dark dashboard captures are saved in +`D:/tmp/dashboard-widget-header-review.html`. Isolated performance9/9 also passes, zero proxy escapes. +All temporary logs, screenshots and comparisons remain outside Git in `D:/tmp`. + +The sections below preserve earlier checkpoints; this CI section is authoritative. + +## Post-merge continuation — 2026-10-20 + +PR #198 was squash-merged as `c353a59399`. Active work now starts from +`origin/main` (`e6c401fba1`) on `fix/post-merge-followups`. Do not merge or +cherry-pick any former redesign branches. The owner supplied the remaining +product decisions; `followups-plan.md` is the active execution plan. + +### Latest security refresh — 2026-10-21 02:05 UTC + +Vite6.4.4 now replaces6.4.3 in web and the docs override, addressing the +[Windows existence-check advisory](https://github.com/advisories/GHSA-587h-7wmj-h5pm), +[Windows argument-escaping advisory](https://github.com/advisories/GHSA-698v-4r3x-3vcv) +and [editor argument advisory](https://github.com/advisories/GHSA-r825-w8mm-5pxw). +A final refresh had raised audits to web10/docs2; after sequential single-package +updates with all tests/previews stopped, web returns9 (7 high/2 moderate), docs0. +Only Vite versions changed; npm also normalized three web lockfile dev flags. +Patched private/mock/docs builds and full strict perf:check pass (399.6KB/400KB). +All20 Mermaid diagrams on seven pages render, zero browser errors; no snapshots +changed for dependency patches. + +A refreshed Go scan newly reports reachable GO-2026-6809 in Go1.27.2 x509, +fixed in1.27.3. Raised go.mod to1.27.3 and the automatic Windows toolchain is +verified. All workflows already use go-version-file; Docker follows1.27, so +no workflow edits. Go build/test and route generation pass. CI-pinned +golangci-lint2.15.0 rebuilt with1.27.3 reports0 issues; govulncheck now +reports0 reachable/0 imported-package/1 uncalled-module vulnerabilities. +x/crypto0.58.0 remains latest; uncalled OpenPGP GO-2026-5932 has no fixed version. +The previous fully passing header-source unit/browser sweep is preserved in +the progress log; the full replica repeats after these environment patches. +Tailwind v4 still waits for PR202 to pass every remote check. + +### Latest local correction — 2026-10-21 01:20 UTC + +The44px target correction exposed two driving/1440 catalogue failures: +Speed discipline was clipped because the header line grew16px. The shared +action cluster now keeps its28px title line while44px hit targets fit inside +the existing header padding. This retains body space, target size, tight +spacing and dot alignment. Eight responsive guards also check action bounds. +Focused66 tests pass; translations/source fingerprints are generator-verified. +Fresh mock build, all726 registry rows, eight responsive cases and eight +repeated driving cases pass, zero escapes. Five final dashboard PNGs are byte- +identical to the reviewed f6d6be4106 versions; restored them after comparison, +discarding the intermediate taller-header captures. Dashboard6/6 pass. Full +replica continues; the32-worker unit run hit two30s source-scan timeouts, so +the entire affected shard repeats under its two-worker limit. No timeout +change. The previous full units were +57,191/0/4 existing pending, direct lint/Go/strict private perf all green +(399.7KB/400KB), but previous-artifact browsers do not count for this correction. +No per-widget patch, assertion relaxation or workflow change. + +Product/security work is implemented; draft PR #202 remains open to main. +Its first pushed head ce1e14ddff completed 198 passing checks, one existing +conditional skip, and one failure: Lighthouse's strict startup-size preflight +(400.5 KB versus the unchanged 400 KB budget). Commits 6aadcb5913 and cad1237f5f +load the tour launcher on request and regenerate verified preservation pins. +The current private build and complete perf:check pass at 399.7 KB after the +additional widget work. No workflow, budget, coverage or tolerance change. + +Owner-added widget-header work takes priority before the next push: remove +DashboardGrid's pr-28 reservation and independent edit/expand overlays; put +controls in the sole shared header before its edge-aligned dot. VehicleHero's +body dot and status decoration are removed, preserving status/provenance. +Static widgets get a neutral, untimestamped indicator rather than fabricated +freshness. Title-less shells now use the same translated fallback header. +The ownership regression fails before and passes after; affected units 230/230. +All 121 widgets pass six min/default/large x view/edit configurations, including +exactly one dot, <=2px inset/centering deltas and <=8px action gaps. Eight new +375/768/1280/1920 light/dark tests also pass hover/edit geometry and save shots; +all wrappers report zero proxy escapes. Original before/after captures and +review output remain in D:/tmp, not Git. + +Full local replica is repeating before the next push. Remote head f6d6be4106 +is on fix/post-merge-followups, with main only the draft PR202 merge target. +The preceding local full Vitest passed57,191/0 (four existing pending cases), +all10 Windows visual shards, a11y and Firefox/WebKit passed; quality15/16 +shards passed. Quality3 exposed the existing44px Expand requirement. Shared +Expand/edit controls now retain44px targets with8px gaps. The existing +collision guard follows the owner's new icon-before-dot order while retaining +hit testing, focus, readable text and minimum spacing, adding maximum8px. +Focused230 units and all four workspace collision cases pass; registry and +full-suite reruns continue. Verified source checkpoints are generator-produced. + +Reviewed and regenerated exactly five dashboard baselines again for the +intentional44px header height:390dark/light,1440dark/light,forced-colors. +Degraded unchanged. No masks, tolerances or coverage changed. Comparisons and +captures remain outside Git. The remote WebKit3/3 job118970569625 passed its +assertions but failed the strict proxy guard on late GET /api/v1/settings. +The mobile navigation test lacked the mock-completion guard already used by +its sibling tests; added that assertion/drain before teardown and repeating +both engines, retaining the strict preview escape check. +Tailwind v4 remains a separate later draft PR, conditional on A/B PR fully green. +Historical PR #198 details below are evidence, not checkout/push instructions. ## Final CI closeout — 2026-10-20 03:25 UTC - Verified implementation head **f0c63853df84a507212ac637d1a6a481aa4ee586**, pushed only to `ui/simplification`: `gh pr checks 198 --json state` reports **204 SUCCESS / 3 SKIPPED**, with no pending or failed checks. All five branch-protection checks pass. Worktree was clean before this documentation closeout. Earlier pending/failure entries below are chronological history, not current blockers. diff --git a/docs/followups-plan.md b/docs/followups-plan.md index b0223c842a..f0214b1f3c 100644 --- a/docs/followups-plan.md +++ b/docs/followups-plan.md @@ -1,6 +1,273 @@ # Post-redesign follow-ups — plan -## Current status — 2026-10-20 CI closeout +## Post-merge execution — 2026-10-20 + +Base: `origin/main` at `e6c401fba1` (PR #198 squash `c353a59399`). +Branch: `fix/post-merge-followups`; draft [PR #202](https://github.com/ev-dev-labs/teslasync/pull/202). +No old branch merges or cherry-picks. +The historical branch instructions and decision options below are superseded +by the owner's explicit post-merge task. Draft PR only; never merge it. + +- A1–3: implemented; full local verification passed — header-owned, server-bounded vehicle cost, energy flow, + science lab; retain 90-day / 3650-day / 30-day ceilings respectively. +- A4: implemented, full local verification passed — independent future planning; remove historical header coupling. +- A5: implemented, full local verification passed — translate stable categories, preserve supplied names/unknown codes. +- A6: implemented, full local Go/frontend verification passed — typed facts localize in both + the timeline and dashboard widget, with unit conversion and legacy prose fallback. +- A7: implemented — disabled duplicate through create API, then open its editor. + Export hidden: the existing portable endpoint forwards arbitrary + `CommandParams` and `action_set_setting` text without secret redaction + (`internal/api/automation/handler.go`, `automationStepRawMessage`). A JSON + download cannot currently guarantee the requested secret-free contract. + No non-working Export control remains. +- A8: NEEDS OWNER DECISION — Insurance "0..60" policy remains unresolved; + its audit origin is traced below. No guessed change. +- B: re-check completed: web 9 (7 high, 2 moderate), docs 2 low to 0. Published + braces 3.0.3, micromatch 4.0.8 and x/crypto v0.58.0 still have no newer fix. + Mermaid plugin 2.0.17 still peers only Mermaid 10/11; replaced it with the + verified direct integration below. Tailwind belongs exclusively to Part C. +- Additional owner item: DONE; PR202 remote CI GREEN, final local browser refresh PASS — shared widget-header freshness ownership, corner/action geometry, all-widget guard, reviewed Windows snapshots and eight light/dark responsive dashboard captures. Complete before the remaining CI loop and Part C. +- C: IN PROGRESS — chore/tailwind-v4 branches from unmerged PR202 head f84aad0ce7, with199 successful checks and one existing conditional skip. Separate draft PR targets main and depends on #202. + +Insurance origin (owner decision still required): audit F08 in +`docs/frontend-audits/phase6-qa-ownership-insurance-telematics.json` (introduced +by `acb9513fe2`) means maximum telematics discount. Current backend +`internal/app/ownershipintelsvc/insurance.go:671` validates 0..60; current form +`web/src/features/ownership/pages/InsuranceTelematicsPage.tsx:738` allows 100. +No insurance issues were returned by the GitHub issue search. The owner +explicitly requested no implementation, so neither bound was changed. + +Security re-check evidence (2026-10-20): docs Mermaid 11.17.2 to 12.2.0 via a +small direct Vue/markdown integration; removed the Mermaid plugin. Kept neutral +theme tokens, explicitly retained Dagre layout, strict rendering and keyboard +zoom. `npm run docs:build` passes; all 20 diagrams on seven pages produce SVGs, +zero browser errors. Docs npm audit: 2 low to 0. Web remains 9 (7 high, 2 +moderate); no published braces/micromatch fix, Tailwind reserved for Part C. +x/crypto v0.58.0 remains latest; govulncheck reports 0 affecting / 1 uncalled +module vulnerability ([GO-2026-5932](https://pkg.go.dev/vuln/GO-2026-5932), +unmaintained OpenPGP package, fixed version N/A). No forced audit fixes or workflow changes. +The direct integration follows the [Mermaid render API](https://mermaid.js.org/config/usage.html) +and [VitePress theme component extension](https://vitepress.dev/guide/extending-default-theme). + +Dependabot has 2 open alerts (web medium, docs low), fewer than the historical +inventory. Alerts track main; the docs alert can close after owner merge and rescan. Only open Dependabot PR: [#200 nginx +1.31.5 to 1.31.7](https://github.com/ev-dev-labs/teslasync/pull/200), a patch +upgrade with no expected configuration migration. Backend/database/race/build +checks passed (latest inventory: 42 passing checks, 1 failure); Security Scan +failed on 12 old Go stdlib vulnerabilities in +run39164557505. Recommendation: update/retest against current main, then merge +only after green CI; do not merge while that security check is red. Not merged. + +Verification: full local CI replica required before each push, followed by +remote checks on the exact pushed commit. Temporary reports stay outside Git. + +### C migration setup — 2026-10-21 + +Branch `chore/tailwind-v4` starts at `f84aad0ce7`, the verified unmerged A/B +branch. Separate draft PR to main; owner must merge #202 first. The official +[Tailwind v4 upgrade guide](https://tailwindcss.com/docs/upgrade-guide) and +published4.3.3 tool/plugin versions were checked. Use the official upgrader, +CSS-first configuration and native container queries. Keep all tokens, exact +accent behavior and themes; do not accept default v4 palette/shape drift. +The complete v3 resolved configuration, source CSS/config/dependencies and49 +Windows baseline PNGs are saved under `D:/tmp/tailwind-v3-reference` before +any dependency changes. Browser previews and backend are stopped. + +A/B final production build and complete strict perf:check pass399.6KB/400KB. +Isolated performance9/9 passes with zero proxy escapes. The full local replica +is complete on the source this planning commit inherits; only documentation +changes follow it. No workflow or test threshold changes. + +### A/B outcome and final verification — 2026-10-21 + +PR202 is green at `f84aad0ce7`:199 successful checks and one existing conditional +skip. CI, Frontend Quality, Security and Performance workflows all succeeded. +A1–6 are complete with backend/frontend regression coverage; A7 Duplicate is +complete and intentionally disabled on creation. A7 Export is safely hidden +because the API forwards arbitrary values without redaction. A8 remains an +owner decision; no range policy was guessed. The shared dashboard header item +is complete with one header-owned dot,44px icon targets directly to its left, +16px corner inset and a28px title line that preserves widget body space. +All121 registry widgets pass min/default/large x view/edit geometry (726 rows), +and all eight375/768/1280/1920 light/dark captures are saved outside Git. + +Fresh local security-environment results:57,191 units pass (four existing +pending);2,029 quality pass (two existing skips);49 Windows visual pass +(41 existing profile skips); a11y13, Firefox/WebKit6 and isolated performance9 pass. Zero retries, +failures or proxy escapes. Complete lint/audits, TypeScript, route registries, +contracts, catalog checks, Go1.27.3 build/test/generator and pinned Go lint pass. +Exactly five intended dashboard baselines were reviewed before regeneration; +the corrected28px header produces byte-identical images to those reviewed +baselines. No tolerance, test skip, workflow or coverage changes. + +Security before/after: initial web9/docs2 low became9/0 after Mermaid12/direct +rendering. Newly published Vite advisories temporarily raised this to10/2 +(1 high/1 moderate in docs); Vite6.4.4 restores9/0. The remaining web development +chains are unpatched braces3.0.3/micromatch4.0.8 and Tailwind3 dependencies; +Tailwind is handled separately in C, with no unsafe downgrade/override. +The refreshed Go1.27.2 scan found one reachable x509 issue; Go1.27.3 restores +zero reachable/imported findings, with one uncalled OpenPGP module finding +and no published x/crypto fix. Dependabot #200 remains patch-safe in scope but +not merge-ready:42 passing checks and one old-Go Security Scan failure. +Recommendation: rebase/retest against the security-fixed branch after owner +merge, then merge only when green. No Dependabot PR was merged. + +The A/B source head stays unchanged and green. Its documentation closeout is +carried into the separate C planning commit, rather than changing the already +verified source head. C branches from this unmerged A/B head, and its draft PR +targets main to run the complete workflow matrix. No old branch merge or +cherry-pick is involved. Insurance discount bounds need the owner's decision; +Export needs a backend redaction contract before it can be restored. + +### Latest security refresh — 2026-10-21 02:05 UTC + +Vite6.4.4 now replaces6.4.3 in web and the docs override, addressing the +[Windows existence-check advisory](https://github.com/advisories/GHSA-587h-7wmj-h5pm), +[Windows argument-escaping advisory](https://github.com/advisories/GHSA-698v-4r3x-3vcv) +and [editor argument advisory](https://github.com/advisories/GHSA-r825-w8mm-5pxw). +A final refresh had raised audits to web10/docs2; after sequential single-package +updates with all tests/previews stopped, web returns9 (7 high/2 moderate), docs0. +Only Vite versions changed; npm also normalized three web lockfile dev flags. +Patched private/mock/docs builds and full strict perf:check pass (399.6KB/400KB). +All20 Mermaid diagrams on seven pages render, zero browser errors; no snapshots +changed for dependency patches. + +A refreshed Go scan newly reports reachable GO-2026-6809 in Go1.27.2 x509, +fixed in1.27.3. Raised go.mod to1.27.3 and the automatic Windows toolchain is +verified. All workflows already use go-version-file; Docker follows1.27, so +no workflow edits. Go build/test and route generation pass. CI-pinned +golangci-lint2.15.0 rebuilt with1.27.3 reports0 issues; govulncheck now +reports0 reachable/0 imported-package/1 uncalled-module vulnerabilities. +x/crypto0.58.0 remains latest; uncalled OpenPGP GO-2026-5932 has no fixed version. +The previous fully passing header-source unit/browser sweep is preserved in +the progress log; the full replica repeats after these environment patches. +Tailwind v4 still waits for PR202 to pass every remote check. + +### Additional owner item — shared widget freshness header + +Implemented on `fix/post-merge-followups`; complete local/remote verification +is still in progress before the next push. Root cause: DashboardGrid reserved +`pr-28` for a separate absolute expand target, while edit controls and title-less +freshness used independent overlays. The shared shell now spans the widget, +uses one horizontal inset, and owns the sole dot after one compact action cluster. +The grid supplies controls through context, including inline edit actions. +VehicleHero keeps its state badge/provenance without its body freshness or badge +dot. Static widgets show a neutral untimestamped dot; title-less widgets use the +same translated fallback header. These choices avoid inventing observation times. + +- Extended original overflow guard: all121 registry widgets x min/default/large + x view/edit pass (726 rows), with exactly one dot, <=2px right-inset/centering + deltas, <=8px action gaps, zero missing widgets and zero proxy escapes. +- Added body import-ownership and real charging-hero regressions, fetching-dot + continuity and inline grid-header unit coverage. The ownership test and full + header guard were red before the change. Consumer tests preserve loading, + retained-data, error-color and refresh assertions under the new header contract. +- Added eight responsive smoke cases with screenshots at375/768/1280/1920, + light/dark, asserting hover and edit alignment. First complete run8/8, proxy0. +- Compared original/new dashboard frames before regeneration. Updated only five + intended Windows baselines: visual-390-dark, visual-390-light, + visual-1440-dark, visual-1440-light and visual-forced-colors. Differences are + the shared corner indicator and consistent header-line height (body links move + with that line). Degraded profile unchanged. Targeted regeneration5/5, proxy0. +- Visual fixture correction: its legacy8.5rem freshness text-width pin distorted + new widget icon geometry. Scoped that pin to non-widget controls; widget + snapshots now use actual production geometry. Masks, threshold0.1, + maxDiffPixels500 and all assertions remain unchanged. No workflow edits. +- Private production build and complete strict perf:check pass at399.7KB startup, + with the400KB limit unchanged. Generated translations/source checkpoints use + repository generators and LF normalization. No temporary captures/reports added + to Git. Final full-unit and browser-matrix results will be recorded below. + +### Latest local correction — 2026-10-21 01:20 UTC + +The44px target correction exposed two driving/1440 catalogue failures: +Speed discipline was clipped because the header line grew16px. The shared +action cluster now keeps its28px title line while44px hit targets fit inside +the existing header padding. This retains body space, target size, tight +spacing and dot alignment. Eight responsive guards also check action bounds. +Focused66 tests pass; translations/source fingerprints are generator-verified. +Fresh mock build, all726 registry rows, eight responsive cases and eight +repeated driving cases pass, zero escapes. Five final dashboard PNGs are byte- +identical to the reviewed f6d6be4106 versions; restored them after comparison, +discarding the intermediate taller-header captures. Dashboard6/6 pass. Full +replica continues; the32-worker unit run hit two30s source-scan timeouts, so +the entire affected shard repeats under its two-worker limit. No timeout +change. The previous full units were +57,191/0/4 existing pending, direct lint/Go/strict private perf all green +(399.7KB/400KB), but previous-artifact browsers do not count for this correction. +No per-widget patch, assertion relaxation or workflow change. + +### Header verification correction — 2026-10-21 00:50 UTC + +The first full sweep exposed an existing44px Expand target requirement in four +catalogue-workspace cases. Restored44px Expand and edit controls with8px gaps, +and changed the collision guard to the requested icons-left/dot-right order. +The guard retains44px minimums, keyboard/no-hover visibility, exact native hit +targets, readable text and minimum8px spacing, adding maximum8px spacing. +All four cases pass. All726 registry rows and eight responsive capture cases +pass again with zero proxy escapes after the correction. + +Compared the five affected Windows dashboard frames before regeneration again: +the only additional difference is the intentional44px header-line height and +dependent content displacement. Updated390light/dark,1440light/dark and +forced-colors; degraded unchanged. Comparison and eight final responsive shots +are in D:/tmp/widget-touch-* outside Git. No tolerance or mask changes. + +Remote f6d6be4106 WebKit3/3 job118970569625 passed assertions but the preview +correctly rejected a late settings request. Its mobile navigation test omitted +the existing strict mock-completion guard used by its siblings. Added that +assertion/drain; both engines repeated three times pass18/18, zero escapes. +Full local matrices are repeating before the next explicit branch push. +No workflow changes. + +### A/B local verification — 2026-10-20 15:38 UTC + +- All 16 quality shards: 2,021 passed, 2 existing conditional skips, no flaky + or failed tests; all 10 Windows visual shards: 49 passed, 41 existing + profile skips, no baseline or tolerance changes. Every wrapper reports + zero proxy escapes. Backend/Docker services remain stopped. +- Accessibility 13/13, Firefox/WebKit 6/6, isolated performance 9/9, and Edge + guard 35/35 on the seven affected routes at 375/414/768/1280/1920 passed. +- Full Vitest: 57,184 passed, 0 failed, 4 existing pending. TypeScript, full + lint and audits, production/postbuild, hermetic mock build, route generator + checks, route/scenario/harness contracts and discovery passed. Go build, + full tests and golangci-lint passed. Generated freshness verified all 281 + registered paths without mutation. Docs build/assets/diagrams passed. +- Browser corrections preserve all assertions: Science pins the shared + header's exact “Last 30 days” label and all five API bounds. Telemetry holds + every live request through its explicit release, so background polling + cannot consume the refresh barrier. Affected full shards pass with retries + disabled. Playwright cache garbage collection interrupted an earlier run; + restored the exact pinned official browsers and reran every affected shard. +- Frozen producer/catalog fingerprints were regenerated by repository tools + after English/Arabic/Hebrew verification; no hashes were edited manually. + No workflow, coverage, test-skip, visual-tolerance or baseline changes. +- Pushed ce1e14ddff after its stable-HEAD production rebuild/postbuild passed. + Remote validation: CI39450664946 / Frontend Quality39450664732 running. + Part C remains gated on fully green A/B CI. + +### PR #202 CI correction — 2026-10-20 16:08 UTC + +Initial head ce1e14ddff completed with 198 passing checks, one existing +conditional skip and one failure. Lighthouse's strict preflight found startup +JavaScript400.5KB over the unchanged400KB budget; actual Lighthouse measurements +had not run. The exact local private-map build reproduced it. + +Deferred the closed tour launcher using the existing command-palette event/initialOpen +pattern; the first opening request, close/reopen and seen state remain covered. +The new regression failed before and passed after; focused layout/launcher110 +passed. Complete private build and perf:check now pass at399.6KB, including +translation locality and duplicate-module gates. No budget/workflow changes. +The catalog generator moved11 unchanged tour strings into their existing lazy +bundle (1262 startup keys). Verified translations and regenerated both source +and shell-preservation pins with repository generators, LF-normalized. + +Full unit rerun:57,185 passed with one stale Layout pin; after generator refresh, +all8,110 i18n tests pass. Other unit source files remain unchanged. The complete +browser replica is running again against the committed application source before +the next push; interrupted resource-contention attempts do not count as passes. + +## Historical CI closeout — 2026-10-20 CI closeout Current working branch is `ui/simplification`, draft PR #198; the branch note and baseline results below describe historical work, not a new checkout instruction. diff --git a/docs/package-lock.json b/docs/package-lock.json index 4558cebb3e..85351d441c 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -9,9 +9,8 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "mermaid": "^11.17.2", - "vitepress": "^1.6.4", - "vitepress-plugin-mermaid": "^2.0.17" + "mermaid": "^12.2.0", + "vitepress": "^1.6.4" } }, "node_modules/@algolia/abtesting": { @@ -343,10 +342,45 @@ "dev": true, "license": "MIT" }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-13.2.0.tgz", + "integrity": "sha512-bA9dvWlhAUcHkNwU+8w9yiUxJPlj/Lm3VqS7MD+NbIR/7LmpNLuK/AWNFdCo/T3SK5ND0/UHf3IJvl/Jn/i6yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "13.2.0", + "@chevrotain/types": "13.2.0" + } + }, + "node_modules/@chevrotain/gast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-13.2.0.tgz", + "integrity": "sha512-bCARdAsG+eEbOveSXcOAC7pVxA04I1ionzeMOwIYf5NX7SaS7zgn4Dgug2BSqoL+iyKeMNSorQjsFSHJHsCNwg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "13.2.0" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-13.2.0.tgz", + "integrity": "sha512-kxpUkLtiaK09ph+dib+Yvm+3sWSnuwlo25gqfKRgTPNmF6H7uW4gVkMjaRdtnN783S9sM6vUNWh9CsxYX4QwKw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@chevrotain/types": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", - "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-13.2.0.tgz", + "integrity": "sha512-7qrYHRlKfWf2AvBfJi5eVOrKAkbxvMFsy/iIHTGIiApNcEecCaVIdR+mIQhyCBsTDJxBz4JWXHMo6krbhR9hVg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-13.2.0.tgz", + "integrity": "sha512-SzKYY68rseOrb9KILVzPsl6SjkQtdWw01SRGB5aCVI5kFcHltQsOYV3JB0Ffst72DgkvHCyDQ0NLwxtmCCf97Q==", "dev": true, "license": "Apache-2.0" }, @@ -879,39 +913,17 @@ "dev": true, "license": "MIT" }, - "node_modules/@mermaid-js/mermaid-mindmap": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@mermaid-js/mermaid-mindmap/-/mermaid-mindmap-9.3.0.tgz", - "integrity": "sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@braintree/sanitize-url": "^6.0.0", - "cytoscape": "^3.23.0", - "cytoscape-cose-bilkent": "^4.1.0", - "cytoscape-fcose": "^2.1.0", - "d3": "^7.0.0", - "khroma": "^2.0.0", - "non-layered-tidy-tree-layout": "^2.0.2" - } - }, - "node_modules/@mermaid-js/mermaid-mindmap/node_modules/@braintree/sanitize-url": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz", - "integrity": "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/@mermaid-js/parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", - "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-2.0.1.tgz", + "integrity": "sha512-j37dj/h6p6pots1L7pLPPNuABEEu9WP4OYg9pW1g+nYG331j0+3canE9OagtYdytYvdSAMG+2fKU5cxep6LgjA==", "dev": true, "license": "MIT", "dependencies": { - "@chevrotain/types": "~11.1.2" + "@chevrotain/types": "~13.2.0" + }, + "engines": { + "node": ">=22.12.0" } }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { @@ -2083,6 +2095,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chevrotain": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-13.2.0.tgz", + "integrity": "sha512-GTouk5YzgBxlTPCaDn5q0q2EJKkrYmnPF+HmF8v0Qo4r3iyPG7HJivkN1wthCXZcm3sIcZFpGTRovLPNdJNRCA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "13.2.0", + "@chevrotain/gast": "13.2.0", + "@chevrotain/regexp-to-ast": "13.2.0", + "@chevrotain/types": "13.2.0", + "@chevrotain/utils": "13.2.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -2726,6 +2755,13 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "dev": true, + "license": "EPL-2.0" + }, "node_modules/emoji-regex-xs": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", @@ -2808,16 +2844,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fastdom": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz", - "integrity": "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==", - "dev": true, - "license": "MIT", - "dependencies": { - "strictdom": "^1.0.1" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2959,9 +2985,9 @@ } }, "node_modules/katex": { - "version": "0.16.47", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", - "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "version": "0.18.10", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.10.tgz", + "integrity": "sha512-/B6p9eY9DX7aHBfpkHdpirDTZ5QH9xTwL9y837PWuzuv41O5jFdqNQwVQEQsimY0/iVNkwBY4+4hMfhQ7d4Dbw==", "dev": true, "funding": [ "https://opencollective.com/katex", @@ -2969,20 +2995,20 @@ ], "license": "MIT", "dependencies": { - "commander": "^8.3.0" + "commander": "^15.0.0" }, "bin": { "katex": "cli.js" } }, "node_modules/katex/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 12" + "node": ">=22.12.0" } }, "node_modules/khroma": { @@ -3058,17 +3084,18 @@ } }, "node_modules/mermaid": { - "version": "11.17.2", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.17.2.tgz", - "integrity": "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-12.2.0.tgz", + "integrity": "sha512-w7nr/SFrLO7BaVTs3dQTvYSV6nYSBj0Yy9DjBhsIJtAcHqRzXSq4Pg32ujHZ/AdWsAMNEvvhbm5dt9E2tOiHzw==", "dev": true, "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.2.1", + "@mermaid-js/parser": "^2.0.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", + "chevrotain": "~13.2.0", "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", @@ -3076,16 +3103,19 @@ "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.21", - "dompurify": "^3.3.3", + "dompurify": "^3.4.12", + "elkjs": "^0.9.3", "es-toolkit": "^1.45.1", - "fastdom": "1.0.12", - "katex": "^0.16.47", + "katex": "^0.18.0", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + }, + "engines": { + "node": ">=22.12.0" } }, "node_modules/micromark-util-character": { @@ -3215,14 +3245,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/non-layered-tidy-tree-layout": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz", - "integrity": "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/oniguruma-to-es": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", @@ -3467,14 +3489,6 @@ "dev": true, "license": "MIT" }, - "node_modules/search-insights": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", - "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/shiki": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", @@ -3523,13 +3537,6 @@ "node": ">=0.10.0" } }, - "node_modules/strictdom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz", - "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==", - "dev": true, - "license": "MIT" - }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -3738,9 +3745,9 @@ } }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3854,20 +3861,6 @@ } } }, - "node_modules/vitepress-plugin-mermaid": { - "version": "2.0.17", - "resolved": "https://registry.npmjs.org/vitepress-plugin-mermaid/-/vitepress-plugin-mermaid-2.0.17.tgz", - "integrity": "sha512-IUzYpwf61GC6k0XzfmAmNrLvMi9TRrVRMsUyCA8KNXhg/mQ1VqWnO0/tBVPiX5UoKF1mDUwqn5QV4qAJl6JnUg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "@mermaid-js/mermaid-mindmap": "^9.3.0" - }, - "peerDependencies": { - "mermaid": "10 || 11", - "vitepress": "^1.0.0 || ^1.0.0-alpha" - } - }, "node_modules/vue": { "version": "3.5.43", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", diff --git a/docs/package.json b/docs/package.json index b71dcd3a44..63c2e855a1 100644 --- a/docs/package.json +++ b/docs/package.json @@ -14,11 +14,10 @@ "license": "ISC", "type": "module", "overrides": { - "vite": "^6.4.3" + "vite": "^6.4.4" }, "devDependencies": { - "mermaid": "^11.17.2", - "vitepress": "^1.6.4", - "vitepress-plugin-mermaid": "^2.0.17" + "mermaid": "^12.2.0", + "vitepress": "^1.6.4" } } diff --git a/docs/progress-log.md b/docs/progress-log.md index 87fd18e066..47ddbaaeab 100644 --- a/docs/progress-log.md +++ b/docs/progress-log.md @@ -326,3 +326,114 @@ time | phase | running | done | pending | failed | blocked | commits pushed in l - Quality elapsed29m28s; all64 responsive,64 Windows visual,13 a11y and6 engine workers succeeded. Responsive105–387s, mean199.9s; approximately20 concurrent runners drained the queue without quota/permission blockers. Kept user-requested64, no32 fallback necessary. Existing assertions/retries/coverage/thresholds and singleton hermetic build remain intact. - Final handoff now separates actual green evidence from historical failures and records all fixes, workflow reasons, major release mechanism and remaining security/product-decision blockers. Not claiming every product followup complete. Restored required draft status after discovering ready state; no merge/release publication. - No owned helper containers/servers remain; removed the unused owned Linux dependency volume, preserving all pre-existing previews/Compose services. Explicit three-document closeout commit follows the clean verified implementation head; successor checks must complete before final report. + +## 2026-10-20 07:00 UTC — post-merge followups started +- Fetched origin, confirmed PR #198 squash merge and clean main; created fix/post-merge-followups from origin/main e6c401fba1. No old branches merged or cherry-picked. Stopped five leftover Vite previews; no active test runners observed. GitHub authentication confirmed. +- Owner supplied decisions for the eight deferred product items. Insurance 0..60 remains explicitly undecided. Live registry/audit results: web9, docs2low, no newer braces/micromatch/x/crypto fix; Mermaid plugin still excludes12. Full local replica and remote CI required before completion; no workflow relaxation. + +## 2026-10-20 07:30 UTC — post-merge product changes and security re-check +- Implemented header-owned exact server bounds for vehicle cost and energy flow; science consumes the shared header and retains its server-enforced30-day maximum. Unsupported spans show translated messages and do not query. Cache keys include the exclusive end. Energy selects completed drives by exact start instants, avoiding UTC-day rounding. Regression failures captured on original handlers/pages before fixes. +- Charge advisor history/planning now uses its own visit clock/coverage window; never calls the shared range hook. Tomorrow-first seven-day planning is labeled explicitly. Known charger category codes localize; supplied names/unknown codes stay raw. Anomaly HTTP projection adds typed numeric explanation facts; English message remains exact; frontend converts units and localizes known codes with prose fallback. Added/reviewed18 English/Arabic/Hebrew keys; generators verify42199 known keys and preserve translations. +- Automation Duplicate fetches the complete definition, projects only create fields, POSTs disabled, then opens the returned ID. Export deliberately hidden: current backend portable serialization forwards arbitrary command parameters/setting text without secret redaction. No dead Export action. Insurance origin found: audit F08 means maximum telematics discount; backend60 vs form100. Owner explicitly forbids guessing/implementation; recorded needs owner decision. +- Docs security: replaced the incompatible Mermaid plugin with a small direct Vue/markdown integration and Mermaid12.2.0; preserved neutral tokens/Dagre/keyboard zoom. Build passed and all20 diagrams across7 pages rendered SVG with0 browser errors; docs audit2low to0. Web audit remains9 (7H/2M), braces3.0.3/micromatch4.0.8 remain latest. Go x/crypto0.58 remains latest; govulncheck0 affecting, uncalled GO-2026-5932 has no fix. Dependabot now2 open alerts, only PR200: patch nginx upgrade, old security scan red on superseded Go vulnerabilities; recommend retest on main before merge, not merged. +- Full Go build/test and route generation/contracts/discovery passed. Targeted product/hooks336/336 passed before the additional unknown-code regression. Full Vitest8-shard replica, complete lint/audits and production/mock builds in progress. Stopped the five named TeslaSync Docker containers, preserving volumes; proxy target8080 no longer served. No workflow/test/threshold relaxation; no push yet, pending the full local browser replica. + +## 2026-10-20 08:00 UTC — final source frozen; complete replica running +- First complete unit pass exposed only stale contracts: energy hook day-count operands, four imported copies of VehicleCost's old frozen call count, and the locale detail-namespace total. Kept exact assertions, migrated operands to both instants, ran the repo source-checkpoint generator on VehicleCost after translation verification, and explicitly asserted the new automations fallback bundle. No hashes hand-edited. Catalog fallback suite131/131; focused final pages/card149/149. +- Browser checks exposed Charge advisor's old seven-day request pins. Kept strict query names/values and fixed the planning clock; evidence now spans its own180-day analysis window through the visit instant, with a separate complete-day calendar query. Updated both empty and qualified-source fixtures; no value/evidence assertions removed. Science browser controls now operate the real workspace header, with exact bounds sent to all five domains. +- Found and corrected an actual obsolete Energy flow disclosure claiming custom dates were ignored. It now publishes its applied half-open range with coverage still unknown, and distinguishes live snapshot power. Reviewed two additional English/Arabic/Hebrew strings (20 new keys total); generator catalog leaves32997,42199 known producer keys verified. +- All seven affected routes passed Edge guard35/35 at375/414/768/1280/1920 before the final energy disclosure correction; repeat that surface on the final build. First Windows visuals49pass/41existing profile skips, zero proxy escapes, no baselines changed. Earlier quality1/2/4/7/8 passed, quality3 passed with1 clock-sensitive source-age retry and2existing conditional skips; strict fixture mismatches halted5/6 for repair. Final full runs restart against the completed source; earlier passes are historical, not the final result. +- Final production build/postbuild and mock build pass under CI=true/TZ=UTC. Full eight Vitest shards restarted with those environment values; all16quality shards,10visual shards, accessibility, engines and isolated performance follow on that same mock build. Live mutation audit now explicitly classifies Duplicate as live-only (105 live-only total), preserving the request guard and UI block. No workflow/tolerance/coverage changes, no push before the full local replica. + +## 2026-10-20 08:29 UTC — committed fixes and final contract completion +- Committed the range/planning, charger labels, anomaly facts, automation actions, docs security, generated translations/checkpoints, and execution evidence separately. Initial push remains gated on the complete local replica; grouping the first push keeps dependent locale artifacts and producer fingerprints coherent. No old branches merged/cherry-picked, no workflow edits, no baselines changed. +- All eight full Vitest shards passed after the page/range changes. Final consumer inventory then found the dashboard anomaly tip still using English prose: extracted the shared unit-aware formatter and wired both widget and timeline.198 consumer/catalog tests pass, including actual Arabic/Hebrew catalogs and unknown-code fallback. Regenerated and verified both affected frozen producers using the repo generator;183 lazy namespace fallbacks now explicitly asserted. +- Qualified Charge advisor contracts passed8/8 with retries0 after preserving exact evidence-through-visit and complete-calendar request receipts. Main battery contracts pass with exact historical end bounds; remaining science controls now target the shared header. Windows a11y13/13 and prior visual49/41existing profile skips pass with0 proxy escapes. Full committed-tree unit/browser verification is running again before the first push. +- Go build/test and golangci-lint2.15.0 pass (0 issues); docs custom-domain verifier passes17 root asset references and6 screenshots. Committed build caught one client type omission: the detector already supplies unit, so AnomalyEntry now declares that optional field. This is a type repair, with runtime conversion tests already passing; rebuild resumes on the committed head. No test/coverage/tolerance weakening. + +## 2026-10-20 15:12 UTC — resumed final committed-head browser replica +- Working tree remains clean on d045ecb259 before this status entry. All eight full Vitest shards completed: 57,184 passed, 0 failed, 4 existing pending. Production/mock builds, Go build/test/lint, full frontend lint/audits, generated catalog and route contracts passed. +- Final quality shards1–8 completed successfully: 1,013 passed, 2 existing conditional skips, zero proxy escapes. Started shards9–16 on the same frozen mock build, isolated previews and outputs; no backend listens on the proxy target. Earlier visual/a11y/Edge results remain historical until repeated against this artifact. No push or draft PR yet; first push remains gated on the complete local replica. + +## 2026-10-20 15:38 UTC — complete A/B local replica green +- All16 quality shards:2,021 passed/2 existing conditional skips/0 failures/0 flakes; Windows visual10shards:49 passed/41 existing profile skips, no PNG/tolerance changes. All wrappers proxy0. A11y13, Firefox/WebKit6, isolated performance9 and Edge affected-route guard35 all passed. Full Vitest57,184/0 with4 existing pending; TypeScript, lint/audits, builds, route/harness/scenario contracts, discovery, generated non-mutation281 paths, Go build/test/lint and docs gates passed. +- Corrected exact Science header label (Last30days); all five API bounds remain asserted. Replaced telemetry's one-request hold with an explicit all-request barrier through release, preventing a background poll/manual-refresh cancellation race. Affected shards9/10/11 reran fully with retries0; no weakened assertions. +- Environment repair: Playwright installer removed unmarked Chromium cache while downloading missing Firefox; Node CDN downloads timed out. Restored pinned Chromium153/headless1243 and FFmpeg1011 from the existing official archives, downloaded exact Firefox1543/WebKit2359/Winldd1007 via official CDN with curl, installed completion markers, and reran every affected shard13/14/15 and all10 visuals. All completed under20min. No application source or mock-build changes during runs. +- First push follows this verification record and a stable-HEAD production rebuild. Remote CI must be fully green before starting the separate Tailwind draft. + +## 2026-10-20 15:54 UTC — PR202 strict startup-budget correction +- Pushed ce1e14ddff and opened draft PR202; never merged. Remote Lighthouse job118724287891 in39450665105 fails before measurements: startup JS400.5KB exceeds the unchanged400KB strict budget. Reproduced with the exact private-source-map build and perf:check; timebox15:44–16:29UTC. Other remote checks continue; no unrelated failures observed. +- Source-map attribution found the closed TourLauncher dialog in the eager shell. Added an on-demand host using the established CommandPaletteHost event/initialOpen pattern, preserving the first invocation, close/reopen and seen-state behavior. The new first-invocation regression failed before and passes after; focused Layout/host/launcher110/110. Updated the existing registry mock with the real event export. No user-facing text changed. +- Verified unchanged Layout translations and regenerated its source checkpoint with the repository tool. Catalog split moves11 tour-dialog keys from the startup shell into their existing deferred bundle (1262 shell keys;183 namespace fallbacks;0 missing); no translations removed, no hash editing. Private strict build, new mock build and full8-shard units running before the complete browser replica. No workflow/budget/tolerance/test relaxation. + +- 15:59UTC correction validated: private build/postbuild and complete perf:check pass. Startup399.6KB (was400.5; budget remains400), cold NotFound0 deferred locale requests, runtime locality and duplicate-module gates pass. The implementation is corrected within15min of diagnosis. Paused all local browser shards when concurrency slowed full Vitest; interrupted attempts are not counted. Restart the complete browser replica after units complete, preserving the20min per-command cap. Restarted the long-running gh watch before20min; no remote jobs cancelled. + +## 2026-10-20 16:08 UTC — strict fix committed; full test-file coverage restored +- Committed6aadcb5913 for on-demand tour UI and generated catalog/source checkpoint. Full8-shard Vitest rerun completed under20min per command:57,185 pass/1 stale Layout hash/4 existing pending. Verified unchanged translations, ran blessed repin-shell-preservation.mts, and all20 i18n files8,110 tests pass. Source and predecessor pins remain exact and generator-produced; no hand editing. Generated pin correction committed separately. +- Initial remote202 headce1e14ddff finished198 pass/1 existing conditional skip/1 strict startup-size failure. All backend, frontend units/coverage, quality/browser/visual, security and other observed gates pass. The strict failure is fixed locally (399.6KB/400KB); full perf:check passes. Final mock artifact rebuilt at6aadcb5913; full browser sweep restarted after units released their workers. Lint/contracts/Go/generated checks are repeating; all previous interrupted browser attempts are excluded. No further application source changes. + + +## 2026-10-20 16:31 UTC — owner added shared widget-header contract +- Paused the repeated browser sweep before changing source; interrupted shards are excluded from final verification. New priority is exactly one header-owned freshness indicator, shared corner alignment, inline icon/edit controls, and stronger all-121-widget geometry/body ownership guards. +- Root cause confirmed: DashboardGrid reserves `pr-28` in every view-mode header for an absolute expand button; edit controls are an independent absolute overlay. Title-less widgets use a separate dot overlay, and VehicleHero renders an additional FreshnessDot plus a status decoration. Capturing the old mock build before implementing the shared correction. No backend or test runner remains active; authenticated GitHub fetch completed. + + +## 2026-10-20 16:49 UTC — shared header geometry passes; full replica repeating +- Shared shell now owns one dot for all widgets, including static/title-less content. Grid supplies inline expand/edit controls via context; removed pr-28 and absolute toolbar reservations. VehicleHero preserves its charging label/provenance without either body freshness or decorative badge dot. Widget fetching retains its single painted dot and accessible busy state. +- New ownership regression was red before the body removal. Affected14 files230 tests pass after; generator verified all changed translations and seven source checkpoints, aligned two pre-existing English fallback casing mismatches to the catalog, and regenerated catalog pins. No manual hashes or skipped assertions. +- Extended121-widget guard: all726 min/default/large x view/edit rows pass, zero missing/nonrendered widgets or header/body geometry violations, proxy0. New responsive guard8/8 passes at375/768/1280/1920, light/dark, including hover and edit corner invariance; before/after screenshots saved outside Git. +- Strict private build/full perf:check pass at399.7KB startup (400KB unchanged); Go build/test, Go/Node route generation, browser contracts/scenarios and discovery2,083 pass. Full8-shard units and lint continue before the complete final browser sweep. +- Reviewed original dashboard visual differences before changing baselines. The fixture still imposed136px text-control widths on icon-only widget dots; scoped that old pin to non-widget controls so snapshots exercise real header geometry. No mask, tolerance or assertion changes; re-review and intentional baseline regeneration pending. No runtime changes after the verified source checkpoint. Interrupted browser attempts remain excluded. + + +## 2026-10-20 17:11 UTC — complete unit rerun green; reviewed baseline update +- Full8-shard Vitest rerun:57,191 passed/0 failed/4 existing pending. The first run exposed18 consumer assertions for the old header/loader or pre-existing fallback casing; migrated exact translated labels and retained the loading, retained-data, red error-tier and refresh contracts. Body skeleton assertions remain strict, with separate explicit header-dot continuity assertions. No runtime changes after the already verified build. +- Re-reviewed old/new snapshots using real widget geometry, then regenerated exactly five intended dashboard PNGs:390dark/light,1440dark/light,forced-colors. Degraded unchanged. Targeted5/5 pass, proxy0; original buffers/comparison image outside Git. Eight responsive capture/hover/edit tests and all726 registry size/mode rows passed before the full matrix. Source/translation pins are generator-produced. +- Full browser sweep uses the final rebuilt mock artifact, CI=true/TZ=UTC, no backend. Quality15 and16 pass (127 and126), both proxy0; remaining quality, all10 visual shards, a11y and engines continue. Performance runs separately after browsers finish. Strict private perf preflight399.7KB/400KB and all Go/contracts/generation gates remain green. No workflow, mask or tolerance changes. + + +## 2026-10-21 00:45 UTC — resumed on the correct follow-up branch +- Confirmed local/tracking branch fix/post-merge-followups and draft PR202 targeting main; no direct main push. Remote includes f6d6be4106 and CI is running. Further pushes use an explicit follow-up branch destination. +- Completed prior local sweep: full Vitest57,191 pass/0 fail/4 existing pending; all10 visual shards, a11y and Firefox/WebKit pass with zero proxy escapes. Quality15/16 shards pass; quality3 exposed four existing44px Expand target assertions. One catalogue signals retry is being investigated. No failed or interrupted run is counted as passing. +- Restored44px Expand/edit targets and made shared icon gaps8px. Updated the existing collision guard to the requested Expand-left-of-freshness order, retaining target size, hit testing, keyboard focus, readable text and minimum spacing; added maximum8px spacing. Focused230 tests pass, generated source checkpoints verified, final mock build completed. Registry geometry and workspace collision cases are running before the full replica repeats. No workflow, tolerance or coverage changes. + +- 01:05UTC checkpoint: all8 full unit shards pass57,191/0 (four existing pending); each completes under17min. All726 widget configurations, eight final responsive captures, four collision cases,12 signals/tire repetitions and18 final engine repetitions pass, zero escapes. Go build/test and route/contract/catalog/TypeScript gates pass. Private build and full strict perf:check pass399.7KB/400KB; remote Lighthouse also succeeds. +- Exact remote responsive10 error confirms the same44px versus28px issue (job118970572066), corrected locally without relaxing it. WebKit navigation now waits for the actual Explore heading, full harness readiness and strict request completion before teardown. All source audit steps passed; a trailing shell logging token failed after the last check, so rerunning npm run lint directly for a clean command result. Browser sweep continues; no push yet. + + +## 2026-10-21 01:20 UTC — retain body space with44px header targets +- Complete corrected-source units pass57,191/0/4 existing pending, Go build/test pass, direct lint/audits exit0, and private production/full perf:check pass399.7KB/400KB. The browser sweep exposed two driving/1440 light/dark failures in quality3: Speed discipline is clipped by the body (body398px, content445px), not a competing overlay. All header44px collision cases now pass; no pending-request retry recurred. +- Root cause:44px buttons increased the shared title row by16px. Fixed the shared action cluster to a28px title line, with44px targets contained by the existing12px top/8px bottom padding; preserves prior body allocation and the required target sizes. Added action-bounds coverage to the eight responsive guards. No per-widget override or relaxed hit/visibility assertion. +- Focused66 shell/grid tests pass. Generator re-verifies translations and refreshes source fingerprints. Repeating the full replica after rebuilding; old-artifact runs are retained only as diagnostic evidence. Full Vitest is partitioned16 ways with two workers each, preserving all test files while browsers are drained. No install or backend is running. Windows baseline review/regeneration will reflect only the final shared-header geometry. + + +## 2026-10-21 01:31 UTC — clipping correction verified; fresh replica running +- Fresh mock build passed. All726 registry rows and eight responsive cases (including header action bounds) pass; repeated driving acceptance8/8 passes at390/1440 light/dark, zero escapes. The44px hit areas remain, with a28px title line and restored body space. +- Compared new/old frames before baseline changes. All five new dashboard PNGs are byte-identical to the originally reviewed f6d6be4106 files; restored those versions, discarding the intermediate taller-header snapshots. Dashboard confirmation6 pass/4 existing profile skips, proxy0. No tolerance/mask changes. +- Full16-shard units:15 shards pass; shard5 has two unchanged30s source-tree scan timeouts in pwaContractOwnership.test.ts during32-worker contention. Repeating the entire affected shard with two workers after the other shards drained, preserving all files, assertions and timeouts. No application/test relaxation for resource contention. Full browser matrix is repeating against the final mock source; lint repeats directly. + + +## 2026-10-21 02:04 UTC — final security refresh finds patched Vite and Go issues +- Final pre-patch browser matrix completed all28 commands: quality2,029 pass/2 existing skips/0 failures/0 flaky; Windows visuals48 pass/41 existing skips/1 loading-readiness retry, all jobs exit0/proxy0; a11y13 and Firefox/WebKit6 pass. All57,191 units and static/Go gates pass on the final shared-header source. The visual retry is large-fleet page-load skeleton readiness under concurrency; no baseline mismatch, assertions unchanged. Repeating after the security changes with a smaller browser pool and no unit/browser overlap. +- Fresh audit metadata now reports Vite/launch-editor advisories: web10 (8 high/2 moderate), docs2 (1 high/1 moderate), despite the earlier9/0 inventory. Verified primary advisories identify Vite6.4.4/launch-editor2.14.2 as patched. Waited for every test/preview to stop, then updated only Vite in web and docs sequentially (web dev requirement and docs transitive override). Audits return web9 (7 high/2 moderate), docs0; remaining braces3.0.3/micromatch4.0.8 still latest with no patch. No force or unrelated version upgrades. +- Patched docs build passes and all20 diagrams on seven pages render with zero browser errors. Private/mock builds and complete strict perf preflight pass with the patch. No snapshots changed for dependency updates. +- Refreshed govulncheck now reports reachable GO-2026-6809 in Go1.27.2 crypto/x509, fixed in1.27.3. Updated the single Go requirement; all workflows already read go.mod, Docker uses the1.27 minor line, so no workflow edits. Automatic toolchain download/build/test/scan verification is underway. x/crypto0.58.0 is still latest; the pre-existing uncalled OpenPGP issue remains separate. +- Dependabot refresh still finds only PR200, nginx1.31.5 to1.31.7 patch:42 success/1 Security Scan failure. Recommendation unchanged: update/retest current main and merge only once green; not merged. No push of the new local commits yet; PartC remains gated on PR202 fully green. + + +## 2026-10-21 02:13 UTC — security environment fixes verified +- Go1.27.3 build, full tests, route generation and CI-pinned golangci-lint2.15.0 all pass (0 lint issues). Refreshed govulncheck moves1 reachable standard-library finding to0; the sole uncalled OpenPGP module finding remains. No workflows changed; their existing go-version-file uses the corrected floor. +- Vite6.4.4 web/docs audits restore9/0. Patched mock/private/docs builds and full strict perf:check pass399.6KB/400KB; all20 docs diagrams render without browser errors. Node installs were sequential single-package updates with every test/server stopped. Vite patch committed8d1a90396f. +- Starting a fresh complete frontend replica on the final security environment. Full units use8 shards/two workers with no browser jobs in parallel; browser pool follows with at most6 commands, keeping each required shard and its worker flags. The earlier large-fleet readiness retry remains recorded, never hidden or counted as a clean first pass. No timeout, snapshot tolerance, skip or audit threshold changed. + + +## 2026-10-21 05:44 UTC — resumed; PR202 is green +- Confirmed fix/post-merge-followups tracks its own remote branch; current head f84aad0ce7 is already pushed. PR202 has199 successful checks and one existing conditional skip, zero failures or pending checks. No merge performed. +- Fresh security-environment units completed57,191 pass/0 fail/4 existing pending. Static gates, complete lint/audits, Go1.27.3 build/test/route/lint and vulnerability checks pass. Continuing the remaining hermetic Windows browser shards and isolated performance run before final local closeout. Saved browser jobs resumed with actual process creation times checked; no backend/proxy listener exists. +- PartC is now eligible after remote green; reviewed the official Tailwind v4 upgrade guide. No migration or dependency install while current browser verification runs. + + +## 2026-10-21 06:01 UTC — A/B fully green; separate Tailwind branch starts +- Final security-environment local replica passes57,191 units/2,029 quality/49 Windows visuals/13 a11y/6 secondary-engine/9 isolated performance, all browser proxy checks0 and no retries. Existing skips/pending remain unchanged. Full lint/audits, TypeScript, route generators/contracts/catalogs, Go build/test/lint/vulnerability gates pass. Private production and full strict perf:check pass399.6KB/400KB. +- Confirmed remote f84aad0ce7 has199 successful checks and one existing conditional skip. Updated PR202 description/title with complete outcomes; it remains draft and unmerged. Shared-header screenshots refreshed at375/768/1280/1920 light/dark; gallery outside Git. +- Created chore/tailwind-v4 from that unmerged verified A/B head; documentation closeout travels in its initial planning commit. Draft PR targets main so all existing CI workflows run, and explicitly depends on #202. No merge/cherry-pick. Saved all49 old Windows baselines and resolved v3 tokens/CSS/dependencies outside Git. Official guide/tool4.3.3 checked; dependency work starts only after all tests/previews stop. diff --git a/go.mod b/go.mod index 759843ec29..9bb3391190 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ev-dev-labs/teslasync -go 1.27.2 +go 1.27.3 require ( cloud.google.com/go/storage v1.61.3 diff --git a/internal/api/anomaly/explanation_test.go b/internal/api/anomaly/explanation_test.go new file mode 100644 index 0000000000..e3e9537e1c --- /dev/null +++ b/internal/api/anomaly/explanation_test.go @@ -0,0 +1,73 @@ +package anomaly + +import ( + "encoding/json" + "math" + "testing" + + aitools "github.com/ev-dev-labs/teslasync/internal/ai/tools/anomaly" +) + +func TestExplanationFactsPreserveProseAndTypedMeasurements(t *testing.T) { + for _, tc := range []struct { + kind, signal string + value, baseline float64 + code string + }{ + {"z_score", "PackVoltage", 450, 400, "z_score"}, + {"range", "ModuleTempMax", 60, 17.5, "above_maximum"}, + {"range", "ModuleTempMax", -25, 17.5, "below_minimum"}, + {"trend", "PackVoltage", 410, 400, "trend_increased_percent"}, + {"trend", "PackVoltage", 390, 400, "trend_decreased_percent"}, + {"trend", "PackVoltage", 10, 0, "trend_increased"}, + {"trend", "PackVoltage", -10, 0, "trend_decreased"}, + {"future", "PackVoltage", 1, 2, ""}, + {"range", "UnknownSignal", 1, 2, ""}, + } { + a := anomalyEntry{Type: tc.kind, Signal: tc.signal, Value: tc.value, Baseline: tc.baseline, ZScore: 3.5, Message: "Legacy prose remains exact."} + addExplanationFacts(&a) + if a.ExplanationCode != tc.code || a.Message != "Legacy prose remains exact." { + t.Fatalf("%+v", a) + } + if tc.code != "" && (a.ExplanationParams == nil || a.ExplanationParams.Value != tc.value || a.ExplanationParams.Baseline != tc.baseline) { + t.Fatalf("lost measurements: %+v", a) + } + raw, err := json.Marshal(a) + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err = json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if tc.code != "" { + if _, ok := decoded["explanation_params"].(map[string]any)["value"].(float64); !ok { + t.Fatalf("untyped values: %s", raw) + } + } + } +} +func TestRangeExplanationKeepsExactLimitsAndZero(t *testing.T) { + a := anomalyEntry{Type: "range", Signal: "ModuleTempMax", Value: 0, Baseline: 17.5} + addExplanationFacts(&a) + if a.ExplanationCode != "" { + t.Fatal("normal measurement must not invent a violation") + } + a.Value = 60 + addExplanationFacts(&a) + if a.ExplanationParams == nil || a.ExplanationParams.Maximum == nil || *a.ExplanationParams.Maximum != 55 { + t.Fatalf("lost safe maximum: %+v", a) + } + a = anomalyEntry{Type: "z_score", Value: math.NaN()} + addExplanationFacts(&a) + if a.ExplanationParams != nil { + t.Fatal("non-finite facts must not reach JSON") + } +} + +func TestHTTPProjectionIncludesExplanationFacts(t *testing.T) { + out := anomalyContextResultToResponse(&aitools.AnomalyContextResult{Anomalies: []aitools.AnomalyContextEntry{{Type: "range", Signal: "ModuleTempMax", Value: 60, Baseline: 17.5, Message: "Original prose."}}}) + if len(out.Anomalies) != 1 || out.Anomalies[0].ExplanationCode != "above_maximum" || out.Anomalies[0].ExplanationParams == nil || out.Anomalies[0].Message != "Original prose." { + t.Fatalf("lost HTTP facts: %+v", out) + } +} diff --git a/internal/api/anomaly/handler.go b/internal/api/anomaly/handler.go index 3184263e84..0fae8eafff 100644 --- a/internal/api/anomaly/handler.go +++ b/internal/api/anomaly/handler.go @@ -59,16 +59,29 @@ type anomalyResponse struct { DetectorStages map[string]string `json:"detector_stages"` } +// Explanation parameters retain canonical signal units and numeric types. +// Pointers distinguish inapplicable facts from measured zero. +type anomalyExplanationParams struct { + Value float64 `json:"value"` + Baseline float64 `json:"baseline"` + ZScore *float64 `json:"z_score,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + PercentChange *float64 `json:"percent_change,omitempty"` +} + type anomalyEntry struct { - Signal string `json:"signal"` - Type string `json:"type"` - Severity string `json:"severity"` - Value float64 `json:"value"` - Baseline float64 `json:"baseline"` - ZScore float64 `json:"z_score"` - DetectedAt string `json:"detected_at"` - Message string `json:"message"` - Unit string `json:"unit,omitempty"` + ExplanationCode string `json:"explanation_code,omitempty"` + ExplanationParams *anomalyExplanationParams `json:"explanation_params,omitempty"` + Signal string `json:"signal"` + Type string `json:"type"` + Severity string `json:"severity"` + Value float64 `json:"value"` + Baseline float64 `json:"baseline"` + ZScore float64 `json:"z_score"` + DetectedAt string `json:"detected_at"` + Message string `json:"message"` + Unit string `json:"unit,omitempty"` } // Predefined safe ranges for critical signals in canonical signal_log units. @@ -203,9 +216,54 @@ func anomalyContextResultToResponse(r *aitools.AnomalyContextResult) anomalyResp Unit: anomalySignalUnit(a.Signal), }) } + for i := range out.Anomalies { + addExplanationFacts(&out.Anomalies[i]) + } return out } +func addExplanationFacts(a *anomalyEntry) { + if !finiteMeasurements(a.Value, a.Baseline, a.ZScore) { + return + } + params := &anomalyExplanationParams{Value: a.Value, Baseline: a.Baseline} + switch a.Type { + case "z_score": + a.ExplanationCode = "z_score" + params.ZScore = &a.ZScore + case "range": + bounds, ok := safeRanges[a.Signal] + if !ok { + return + } + if a.Value < bounds[0] { + a.ExplanationCode = "below_minimum" + params.Minimum = &bounds[0] + } else if a.Value > bounds[1] { + a.ExplanationCode = "above_maximum" + params.Maximum = &bounds[1] + } else { + return + } + case "trend": + a.ExplanationCode = "trend_increased" + if a.Value < a.Baseline { + a.ExplanationCode = "trend_decreased" + } + if a.Baseline != 0 { + percent := math.Abs(a.Value-a.Baseline) / math.Abs(a.Baseline) * 100 + if math.IsInf(percent, 0) || math.IsNaN(percent) { + return + } + params.PercentChange = &percent + a.ExplanationCode += "_percent" + } + default: + return + } + a.ExplanationParams = params +} + // DetectAnomalies runs the full detection pipeline (Z-score outliers, // range violations, trend deltas) for one vehicle over the last `days` // days and returns the deduplicated, severity-sorted result. diff --git a/internal/api/energy/handler.go b/internal/api/energy/handler.go index 420020d263..2a1c295b5c 100644 --- a/internal/api/energy/handler.go +++ b/internal/api/energy/handler.go @@ -57,6 +57,31 @@ func (h *EnergyHandler) Stats(w http.ResponseWriter, r *http.Request) { return } + // Supplying end selects exact half-open instants; legacy start-only/date + // and days callers retain their existing trailing-window behavior. + if r.URL.Query().Has("end") { + start, startErr := time.Parse(time.RFC3339, r.URL.Query().Get("start")) + end, endErr := time.Parse(time.RFC3339, r.URL.Query().Get("end")) + if startErr != nil || endErr != nil || !end.After(start) || end.Sub(start) > maxDays*24*time.Hour { + httpx.WriteError(w, http.StatusBadRequest, "start and end must define an increasing window of at most 3650 days") + return + } + calculator, ok := h.energySvc.(interface { + CalculateStatsRange(context.Context, int64, time.Time, time.Time) (*service.EnergyStats, error) + }) + if !ok { + httpx.WriteError(w, http.StatusInternalServerError, "bounded energy statistics are unavailable") + return + } + stats, err := calculator.CalculateStatsRange(r.Context(), vehicleID, start, end) + if err != nil || stats == nil { + httpx.WriteError(w, http.StatusInternalServerError, "failed to get energy stats") + return + } + httpx.WriteJSON(w, http.StatusOK, energyStatsResponse(stats)) + return + } + days := defaultStatsDays if s := r.URL.Query().Get("start"); s != "" { if t, perr := time.Parse("2006-01-02", s); perr == nil { diff --git a/internal/api/energy/handler_test.go b/internal/api/energy/handler_test.go index 7d19fea82c..8f3d082bae 100644 --- a/internal/api/energy/handler_test.go +++ b/internal/api/energy/handler_test.go @@ -585,3 +585,47 @@ func TestNewEnergyHandler(t *testing.T) { t.Fatal("NewEnergyHandler did not wire the energy service") } } + +// Explicit history bounds must reach the calculator unchanged; a trailing day +// count cannot represent a historical end or a timezone boundary. +type boundedStatsCalculator struct { + fakeStatsCalculator + start, end time.Time +} + +func (f *boundedStatsCalculator) CalculateStatsRange(_ context.Context, id int64, start, end time.Time) (*service.EnergyStats, error) { + f.gotVID = id + f.start = start + f.end = end + return sampleStats(), nil +} +func TestStatsExplicitHistoryBounds(t *testing.T) { + f := &boundedStatsCalculator{} + h := &EnergyHandler{energySvc: f} + router := chi.NewRouter() + router.Get("/vehicles/{vehicleID}/energy", h.Stats) + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/vehicles/42/energy?start=2026-01-02T03:00:00Z&end=2026-01-04T03:00:00Z", nil)) + if w.Code != 200 || !f.start.Equal(time.Date(2026, 1, 2, 3, 0, 0, 0, time.UTC)) || !f.end.Equal(time.Date(2026, 1, 4, 3, 0, 0, 0, time.UTC)) || f.gotVID != 42 || f.calls != 0 { + t.Fatalf("bounds lost: status=%d start=%v end=%v id=%d trailingCalls=%d", w.Code, f.start, f.end, f.gotVID, f.calls) + } +} +func TestStatsRejectsInvalidExplicitBounds(t *testing.T) { + for _, query := range []string{ + "start=garbage&end=2026-01-04T00:00:00Z", + "start=2026-01-04T00:00:00Z&end=2026-01-04T00:00:00Z", + "start=2026-01-05T00:00:00Z&end=2026-01-04T00:00:00Z", + "start=2000-01-01T00:00:00Z&end=2026-01-04T00:00:00Z", + "end=2026-01-04T00:00:00Z", + } { + f := &boundedStatsCalculator{} + h := &EnergyHandler{energySvc: f} + router := chi.NewRouter() + router.Get("/vehicles/{vehicleID}/energy", h.Stats) + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/vehicles/42/energy?"+query, nil)) + if w.Code != http.StatusBadRequest || f.calls != 0 || !f.start.IsZero() { + t.Errorf("%s: status=%d calls=%d start=%v", query, w.Code, f.calls, f.start) + } + } +} diff --git a/internal/app/adminobssvc/service.go b/internal/app/adminobssvc/service.go index 150deb4a40..f4a9bbd65d 100644 --- a/internal/app/adminobssvc/service.go +++ b/internal/app/adminobssvc/service.go @@ -36,7 +36,7 @@ type hypertablePort interface { // ingestXRayPort is the narrow per-vehicle-cost surface Service depends // on. type ingestXRayPort interface { - VehicleCostReport(ctx context.Context, since time.Time, limit int) (*dbobs.VehicleCostReport, error) + VehicleCostReport(ctx context.Context, since time.Time, limit int, until ...time.Time) (*dbobs.VehicleCostReport, error) } // rotationPort is the narrow secret-rotation surface Service depends on. @@ -173,14 +173,14 @@ func (s *Service) SlowQueries(ctx context.Context, orderBy dbobs.SlowQueryOrderB } // VehicleCost returns the per-vehicle cost report. -func (s *Service) VehicleCost(ctx context.Context, since time.Time, limit int) (*dbobs.VehicleCostReport, error) { +func (s *Service) VehicleCost(ctx context.Context, since time.Time, limit int, until ...time.Time) (*dbobs.VehicleCostReport, error) { if s == nil || s.ingestXRay == nil { return nil, ErrNotConfigured } if since.IsZero() { since = s.now().Add(-30 * 24 * time.Hour) } - return s.ingestXRay.VehicleCostReport(ctx, since, limit) + return s.ingestXRay.VehicleCostReport(ctx, since, limit, until...) } // DiskForecast returns per-hypertable size + days-to-quota. diff --git a/internal/app/adminobssvc/service_test.go b/internal/app/adminobssvc/service_test.go index cf9f31a1eb..dc4287ca6d 100644 --- a/internal/app/adminobssvc/service_test.go +++ b/internal/app/adminobssvc/service_test.go @@ -72,7 +72,7 @@ type fakeIngestXRay struct { gotLimit int } -func (f *fakeIngestXRay) VehicleCostReport(_ context.Context, since time.Time, limit int) (*dbobs.VehicleCostReport, error) { +func (f *fakeIngestXRay) VehicleCostReport(_ context.Context, since time.Time, limit int, until ...time.Time) (*dbobs.VehicleCostReport, error) { f.calls++ f.gotSince = since f.gotLimit = limit diff --git a/internal/database/energy/repo.go b/internal/database/energy/repo.go index e066cd588d..9f233c4713 100644 --- a/internal/database/energy/repo.go +++ b/internal/database/energy/repo.go @@ -234,3 +234,33 @@ func (r *EnergyStatsRepo) GetTotalEnergy(ctx context.Context, vehicleID int64, d } return energy, cost, distance, nil } + +// Exact half-open bounds select completed drives at their recorded start, as +// cagg_fleet_stats does, without rounding either request bound to a UTC day. +const energyDailyRangeSQL = `SELECT + TO_CHAR(started_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date, + COALESCE(SUM(energy_used_wh), 0), + COALESCE(SUM(distance_m), 0), + CASE WHEN COALESCE(SUM(distance_m), 0) > 0 + THEN COALESCE(SUM(energy_used_wh), 0) / SUM(distance_m) ELSE 0 END, + 0 AS cost + FROM drives + WHERE vehicle_id = $1 AND started_at >= $2 AND started_at < $3 AND ended_at IS NOT NULL + GROUP BY 1 ORDER BY 1` + +func (r *EnergyStatsRepo) GetDailyRange(ctx context.Context, vehicleID int64, start, end time.Time) ([]*energymodel.EnergyStatsRow, error) { + rows, err := r.db.Pool.Query(ctx, energyDailyRangeSQL, vehicleID, start, end) + if err != nil { + return nil, fmt.Errorf("query bounded energy breakdown: %w", err) + } + defer rows.Close() + stats := make([]*energymodel.EnergyStatsRow, 0) + for rows.Next() { + row := &energymodel.EnergyStatsRow{} + if err := rows.Scan(&row.Date, &row.EnergyWh, &row.DistanceM, &row.EfficiencyWhPerM, &row.Cost); err != nil { + return nil, err + } + stats = append(stats, row) + } + return stats, rows.Err() +} diff --git a/internal/database/observability/ingest_xray_repo_test.go b/internal/database/observability/ingest_xray_repo_test.go index 9af196e4be..b243a89106 100644 --- a/internal/database/observability/ingest_xray_repo_test.go +++ b/internal/database/observability/ingest_xray_repo_test.go @@ -421,3 +421,26 @@ func TestIngestXRayRepo_VehicleCostReportNoDLQ(t *testing.T) { func costRow(id int64, name string, rows, bytes int64, rate float64, fails int64) func(dest ...any) error { return scanRow(id, ptr(name), rows, bytes, rate, fails, tref) } + +func TestVehicleCostHistoricalBoundsAlsoReachMissingDLQFallback(t *testing.T) { + for _, missing := range []bool{false, true} { + f := &fakeDBTX{} + if missing { + f.pushQuery(nil, &pgconn.PgError{Code: "42P01"}) + } + f.pushQuery(rowsFrom(), nil) + start := time.Date(2026, 1, 2, 3, 0, 0, 0, time.UTC) + end := start.Add(48 * time.Hour) + r := &IngestXRayRepo{exec: f} + if _, err := r.VehicleCostReport(context.Background(), start, 100, end); err != nil { + t.Fatal(err) + } + q := f.lastQuery() + if len(q.Args) != 3 || !q.Args[0].(time.Time).Equal(start) || !q.Args[2].(time.Time).Equal(end) { + t.Fatalf("lost bounds: %v", q.Args) + } + if !strings.Contains(q.SQL, "ts >= $1 AND ts < $3") || strings.Contains(q.SQL, "now()") { + t.Fatalf("unbounded SQL: %s", q.SQL) + } + } +} diff --git a/internal/database/observability/vehicle_cost_repo.go b/internal/database/observability/vehicle_cost_repo.go index 95f95ded7c..0503d6199c 100644 --- a/internal/database/observability/vehicle_cost_repo.go +++ b/internal/database/observability/vehicle_cost_repo.go @@ -47,7 +47,7 @@ type VehicleCostReport struct { // `pg_stat_user_tables.n_tup_ins` × avg row size). It is an // estimate not a measurement to avoid a per-call `pg_total_relation_size` // scan; the admin UI labels it accordingly. -func (r *IngestXRayRepo) VehicleCostReport(ctx context.Context, since time.Time, limit int) (*VehicleCostReport, error) { +func (r *IngestXRayRepo) VehicleCostReport(ctx context.Context, since time.Time, limit int, until ...time.Time) (*VehicleCostReport, error) { if r == nil || r.exec == nil { return nil, fmt.Errorf("database: VehicleCostReport: nil repo or pool") } @@ -58,6 +58,11 @@ func (r *IngestXRayRepo) VehicleCostReport(ctx context.Context, since time.Time, limit = 1000 } + end := time.Now().UTC() + if len(until) > 0 { + end = until[0] + } + // Single query joins signal_log (cost) ⟕ vehicles (name) ⟕ // signal_log_failures (DLQ count). LEFT JOIN preserves vehicles // with zero DLQ entries. @@ -66,15 +71,15 @@ WITH agg AS ( SELECT vehicle_id, COUNT(*)::bigint AS row_count, MAX(ts) AS last_seen, - (COUNT(*) FILTER (WHERE ts >= now() - interval '24 hours'))::float8 / (24.0 * 60.0) AS rate_24h + (COUNT(*) FILTER (WHERE ts >= $3 - interval '24 hours'))::float8 / (24.0 * 60.0) AS rate_24h FROM signal_log - WHERE ts >= $1 + WHERE ts >= $1 AND ts < $3 GROUP BY vehicle_id ), dlq AS ( SELECT vehicle_id, COUNT(*)::bigint AS fail_24h FROM signal_log_failures - WHERE first_seen >= now() - interval '24 hours' + WHERE first_seen >= GREATEST($1, $3 - interval '24 hours') AND first_seen < $3 GROUP BY vehicle_id ) SELECT a.vehicle_id, @@ -90,13 +95,13 @@ SELECT a.vehicle_id, ORDER BY a.row_count DESC LIMIT $2` - rows, err := r.exec.Query(ctx, sql, since, limit) + rows, err := r.exec.Query(ctx, sql, since, limit, end) if err != nil { // Older installations might not have signal_log_failures. // Surface the report without DLQ counts rather than failing // the row-count view. if isMissingRelationError(err) { - return r.vehicleCostReportNoDLQ(ctx, since, limit) + return r.vehicleCostReportNoDLQ(ctx, since, limit, end) } return nil, fmt.Errorf("database: VehicleCostReport: query: %w", err) } @@ -125,15 +130,19 @@ SELECT a.vehicle_id, // vehicleCostReportNoDLQ runs the cost report against installations // that lack signal_log_failures. Mirrors VehicleCostReport but skips // the DLQ CTE. -func (r *IngestXRayRepo) vehicleCostReportNoDLQ(ctx context.Context, since time.Time, limit int) (*VehicleCostReport, error) { +func (r *IngestXRayRepo) vehicleCostReportNoDLQ(ctx context.Context, since time.Time, limit int, until ...time.Time) (*VehicleCostReport, error) { + end := time.Now().UTC() + if len(until) > 0 { + end = until[0] + } const sql = ` WITH agg AS ( SELECT vehicle_id, COUNT(*)::bigint AS row_count, MAX(ts) AS last_seen, - (COUNT(*) FILTER (WHERE ts >= now() - interval '24 hours'))::float8 / (24.0 * 60.0) AS rate_24h + (COUNT(*) FILTER (WHERE ts >= $3 - interval '24 hours'))::float8 / (24.0 * 60.0) AS rate_24h FROM signal_log - WHERE ts >= $1 + WHERE ts >= $1 AND ts < $3 GROUP BY vehicle_id ) SELECT a.vehicle_id, @@ -147,7 +156,7 @@ SELECT a.vehicle_id, LEFT JOIN vehicles v ON v.id = a.vehicle_id ORDER BY a.row_count DESC LIMIT $2` - rows, err := r.exec.Query(ctx, sql, since, limit) + rows, err := r.exec.Query(ctx, sql, since, limit, end) if err != nil { return nil, fmt.Errorf("database: vehicleCostReportNoDLQ: query: %w", err) } diff --git a/internal/handler/v1/admin_observability_handler.go b/internal/handler/v1/admin_observability_handler.go index e9f8c5e816..38ce65d8cb 100644 --- a/internal/handler/v1/admin_observability_handler.go +++ b/internal/handler/v1/admin_observability_handler.go @@ -81,7 +81,16 @@ func (h *AdminObservabilityHandler) VehicleCost(w http.ResponseWriter, r *http.R since = t } } - rep, err := h.svc.VehicleCost(r.Context(), since, limit) + var until []time.Time + if r.URL.Query().Has("until") { + end, err := time.Parse(time.RFC3339, r.URL.Query().Get("until")) + if err != nil || since.IsZero() || !end.After(since) || end.Sub(since) > 90*24*time.Hour { + httputil.RespondError(w, http.StatusBadRequest, "INVALID_DATE_RANGE", "since and until must define an increasing window of at most 90 days") + return + } + until = []time.Time{end} + } + rep, err := h.svc.VehicleCost(r.Context(), since, limit, until...) if h.handleNotConfigured(w, err, "vehicle_cost") { return } diff --git a/internal/handler/v1/admin_observability_range_test.go b/internal/handler/v1/admin_observability_range_test.go new file mode 100644 index 0000000000..b2ad0fb97f --- /dev/null +++ b/internal/handler/v1/admin_observability_range_test.go @@ -0,0 +1,23 @@ +package v1 + +import ( + "net/http/httptest" + "testing" +) + +func TestVehicleCostRejectsInvalidBounds(t *testing.T) { + h := NewAdminObservabilityHandler(nil) + for _, query := range []string{ + "since=nope&until=2026-01-03T00:00:00Z", + "since=2026-01-03T00:00:00Z&until=2026-01-03T00:00:00Z", + "since=2026-01-04T00:00:00Z&until=2026-01-03T00:00:00Z", + "since=2025-01-01T00:00:00Z&until=2026-01-03T00:00:00Z", + "until=garbage", + } { + w := httptest.NewRecorder() + h.VehicleCost(w, httptest.NewRequest("GET", "/vehicle-cost?"+query, nil)) + if w.Code != 400 { + t.Errorf("%s status=%d want400", query, w.Code) + } + } +} diff --git a/internal/service/energy_service.go b/internal/service/energy_service.go index a0df807515..d4737800e2 100644 --- a/internal/service/energy_service.go +++ b/internal/service/energy_service.go @@ -2,6 +2,9 @@ package service import ( "context" + "fmt" + "math" + "time" energymodel "github.com/ev-dev-labs/teslasync/internal/models/energy" @@ -69,3 +72,26 @@ func (s *EnergyService) CalculateStats(ctx context.Context, vehicleID int64, day DailyBreakdown: breakdown, }, nil } + +// CalculateStatsRange computes totals from the same bounded rows used by the +// charts, so a historical end cannot leak later drives into either surface. +func (s *EnergyService) CalculateStatsRange(ctx context.Context, vehicleID int64, start, end time.Time) (*EnergyStats, error) { + if !end.After(start) || end.Sub(start) > 3650*24*time.Hour { + return nil, fmt.Errorf("invalid energy window") + } + rows, err := s.energyRepo.GetDailyRange(ctx, vehicleID, start, end) + if err != nil { + return nil, err + } + stats := &EnergyStats{VehicleID: vehicleID, PeriodDays: int(math.Ceil(end.Sub(start).Hours() / 24)), DailyBreakdown: rows} + for _, row := range rows { + stats.TotalEnergy += row.EnergyWh + stats.TotalDistance += row.DistanceM + stats.TotalCost += row.Cost + } + if stats.TotalDistance > 0 { + stats.AvgEfficiency = stats.TotalEnergy / stats.TotalDistance + } + stats.CO2Saved = stats.TotalEnergy / 1000 * CO2PerKWh + return stats, nil +} diff --git a/web/e2e/.snapshots/visual-1440-dark/visual.spec.ts/dashboard.png b/web/e2e/.snapshots/visual-1440-dark/visual.spec.ts/dashboard.png index f5f7880ea5..f278973e2a 100644 Binary files a/web/e2e/.snapshots/visual-1440-dark/visual.spec.ts/dashboard.png and b/web/e2e/.snapshots/visual-1440-dark/visual.spec.ts/dashboard.png differ diff --git a/web/e2e/.snapshots/visual-1440-light/visual.spec.ts/dashboard.png b/web/e2e/.snapshots/visual-1440-light/visual.spec.ts/dashboard.png index 5b2680303a..c7f9ec1d36 100644 Binary files a/web/e2e/.snapshots/visual-1440-light/visual.spec.ts/dashboard.png and b/web/e2e/.snapshots/visual-1440-light/visual.spec.ts/dashboard.png differ diff --git a/web/e2e/.snapshots/visual-390-dark/visual.spec.ts/dashboard.png b/web/e2e/.snapshots/visual-390-dark/visual.spec.ts/dashboard.png index 10e23dcc3d..bfa90731c6 100644 Binary files a/web/e2e/.snapshots/visual-390-dark/visual.spec.ts/dashboard.png and b/web/e2e/.snapshots/visual-390-dark/visual.spec.ts/dashboard.png differ diff --git a/web/e2e/.snapshots/visual-390-light/visual.spec.ts/dashboard.png b/web/e2e/.snapshots/visual-390-light/visual.spec.ts/dashboard.png index 86e0523864..567a9c9a87 100644 Binary files a/web/e2e/.snapshots/visual-390-light/visual.spec.ts/dashboard.png and b/web/e2e/.snapshots/visual-390-light/visual.spec.ts/dashboard.png differ diff --git a/web/e2e/.snapshots/visual-forced-colors/visual.spec.ts/dashboard.png b/web/e2e/.snapshots/visual-forced-colors/visual.spec.ts/dashboard.png index cc14f8239b..f8bcca498f 100644 Binary files a/web/e2e/.snapshots/visual-forced-colors/visual.spec.ts/dashboard.png and b/web/e2e/.snapshots/visual-forced-colors/visual.spec.ts/dashboard.png differ diff --git a/web/e2e/cross-browser.smoke.spec.ts b/web/e2e/cross-browser.smoke.spec.ts index ed236ee221..9e8ec42697 100644 --- a/web/e2e/cross-browser.smoke.spec.ts +++ b/web/e2e/cross-browser.smoke.spec.ts @@ -48,4 +48,7 @@ test('installed mobile navigation opens and follows a shortcut in secondary engi await expect(page).toHaveURL(/\/explore$/); await expect(sheet).toHaveCount(0); await expect(page.getByRole('button', { name: 'Open sidebar' })).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: 'Explore features', exact: true })).toBeVisible(); + await waitForHarnessReady(page, mockApi); + await assertMockApiComplete(page, mockApi); }); diff --git a/web/e2e/dashboard-widget-headers.smoke.spec.ts b/web/e2e/dashboard-widget-headers.smoke.spec.ts new file mode 100644 index 0000000000..ca4834f25f --- /dev/null +++ b/web/e2e/dashboard-widget-headers.smoke.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { seedBrowserState, installApiMocks, waitForHarnessReady } from './mockApi'; +for (const width of [375,768,1280,1920]) for (const theme of ['light','dark'] as const) { + test(`header review ${width} ${theme}`, async ({page}, testInfo) => { + test.setTimeout(120000); + await page.setViewportSize({width,height:1000}); + await page.clock.setFixedTime(new Date('2026-09-13T12:00:00Z')); + await seedBrowserState(page, theme, '/', {preserveDashboardState:true}); + await page.addInitScript(() => { + const ids=['vehicle-hero','battery-gauge','charge-status','security-status','climate-status','recent-drives']; + const widgets=ids.map((widgetId,i)=>({id:`review-${i}`,widgetId,config:{vehicleId:1}})); + const layouts=Object.fromEntries([['lg',4],['md',3],['sm',2],['xs',1]].map(([bp,c])=>[bp,ids.map((_,i)=>({i:`review-${i}`,x:(i%Number(c)),y:Math.floor(i/Number(c))*6,w:1,h:6}))])); + localStorage.setItem('teslasync-dashboards',JSON.stringify([{id:'review',name:'Fleet operations',widgets,layouts,createdAt:'2026-09-13',updatedAt:'2026-09-13',isDefault:true}])); + localStorage.setItem('teslasync-active-dashboard','review'); + localStorage.setItem('teslasync-row-height-version','2'); + }); + const mocks=await installApiMocks(page,'populated',theme); + await page.goto('/'); await waitForHarnessReady(page,mocks); + const measure = () => page.evaluate(() => Array.from(document.querySelectorAll('[data-widget-id^="review-"]')).map(root => { + const header = root.querySelector('[data-role="widget-shell-header"]')!; + const title = header?.querySelector('[data-role="widget-header-title"]'); + const dots = root.querySelectorAll('[data-role="freshness-dot"]'); + if (!header || !title || dots.length !== 1) return { id: root.dataset.widgetId, valid: false }; + const dot = dots[0].getBoundingClientRect(); + const headerBox = header.getBoundingClientRect(); + const text = title.getBoundingClientRect(); + const padding = parseFloat(getComputedStyle(header).paddingLeft); + const controls = Array.from(header.querySelectorAll('button, a[href]')).filter(el => !el.contains(dots[0]) && el.querySelector('svg')).map(el => el.getBoundingClientRect()).filter(rect => rect.width > 0).sort((a,b)=>a.left-b.left); + const gaps = controls.map((rect,i)=>(controls[i+1]?.left ?? dot.left)-rect.right); + return { id: root.dataset.widgetId, valid: header.contains(dots[0]) && Math.abs(root.getBoundingClientRect().right-padding-dot.right)<=2 && Math.abs((text.top+text.bottom-dot.top-dot.bottom)/2)<=2 && gaps.every(gap=>gap>=-2 && gap<=8) && controls.every(rect=>rect.top>=headerBox.top && rect.bottom<=headerBox.bottom), right: dot.right }; + })); + const view = await measure(); + expect(view).toHaveLength(6); + expect(view.every(row=>row.valid), JSON.stringify(view)).toBe(true); + await page.screenshot({path:testInfo.outputPath(`dashboard-${width}-${theme}.png`),fullPage:true,animations:'disabled'}); + for (const panel of await page.locator('[data-widget-id^="review-"]').all()) await panel.hover(); + expect(await measure()).toEqual(view); + await page.getByRole('button',{name:'Customize',exact:true}).click(); + const edit = await measure(); + expect(edit.every(row=>row.valid), JSON.stringify(edit)).toBe(true); + expect(edit.map(row=>row.right)).toEqual(view.map(row=>row.right)); + }); +} diff --git a/web/e2e/dashboard-widget-overflow.smoke.spec.ts b/web/e2e/dashboard-widget-overflow.smoke.spec.ts index 07e702a156..a62ac5bdc8 100644 --- a/web/e2e/dashboard-widget-overflow.smoke.spec.ts +++ b/web/e2e/dashboard-widget-overflow.smoke.spec.ts @@ -241,6 +241,7 @@ interface WidgetRecord { bodyScroll: number | null bodyClient: number | null clipped: ClipDetail[] + headerFailures: string[] } async function measureAll(page: Page): Promise { @@ -256,9 +257,40 @@ async function measureAll(page: Page): Promise { } return false } + const headerFailures = (root: Element): string[] => { + const errors: string[] = [] + const dots = root.querySelectorAll('[data-role="freshness-dot"]') + const header = root.querySelector('[data-role="widget-shell-header"]') + const title = header?.querySelector('[data-role="widget-header-title"]') + if (dots.length !== 1) errors.push(`expected exactly one freshness dot, got ${dots.length}`) + if (!header || !title) return [...errors, 'missing shared header/title line'] + if (dots.length !== 1) return errors + const dot = dots[0] + if (!header.contains(dot)) errors.push('freshness dot is outside shared header') + const bounds = dot.getBoundingClientRect() + const titleBounds = title.getBoundingClientRect() + const widgetBounds = root.getBoundingClientRect() + const padding = Number.parseFloat(getComputedStyle(header).paddingLeft) + const rightDelta = Math.abs(bounds.right - (widgetBounds.right - padding)) + if (rightDelta > 2) errors.push(`freshness right inset differs by ${rightDelta.toFixed(2)}px (padding ${padding})`) + const centerDelta = Math.abs((bounds.top + bounds.bottom - titleBounds.top - titleBounds.bottom) / 2) + if (centerDelta > 2) errors.push(`freshness/title centers differ by ${centerDelta.toFixed(2)}px`) + const cluster = header.querySelector('[data-role="widget-header-actions"]') + if (!cluster) return [...errors, 'missing shared action cluster'] + const controls = Array.from(cluster.querySelectorAll('button, a[href]')) + .filter(control => !control.contains(dot) && control.querySelector('svg') && control.getBoundingClientRect().width > 0) + .map(control => control.getBoundingClientRect()).sort((a, b) => a.left - b.left) + for (let i = 0; i < controls.length; i++) { + const nextLeft = i + 1 < controls.length ? controls[i + 1].left : bounds.left + const gap = nextLeft - controls[i].right + if (gap > 8 || gap < -2) errors.push(`header action gap ${gap.toFixed(2)}px is outside 0..8px`) + } + if (cluster.getBoundingClientRect().right > bounds.right + 2) errors.push('reserved space or action to the right of freshness') + return errors + } return ids.map((id, index): WidgetRecord => { const root = document.querySelector(`[data-widget-id="overflow-guard-${index}"]`) - if (!root) return { id, status: 'missing', bodyScroll: null, bodyClient: null, clipped: [] } + if (!root) return { id, status: 'missing', bodyScroll: null, bodyClient: null, clipped: [], headerFailures: ['missing widget'] } const body = root.querySelector('[data-testid="widget-body"]') if (!body) { const failed = /failed to load/i.test(root.textContent ?? '') @@ -268,6 +300,7 @@ async function measureAll(page: Page): Promise { bodyScroll: null, bodyClient: null, clipped: [], + headerFailures: headerFailures(root), } } const record: WidgetRecord = { @@ -276,6 +309,7 @@ async function measureAll(page: Page): Promise { bodyScroll: body.scrollWidth, bodyClient: body.clientWidth, clipped: [], + headerFailures: headerFailures(root), } if (body.scrollWidth > body.clientWidth + 1) { record.status = 'body-overflow' @@ -397,7 +431,7 @@ async function measureSettled(page: Page): Promise { const candidate = sample[index] if (STATUS_RANK[candidate.status] > STATUS_RANK[worst.status]) worst = candidate } - return worst + return { ...worst, headerFailures: [...new Set(samples.flatMap(sample => sample[index].headerFailures))] } }) } @@ -429,6 +463,12 @@ test('widget bodies never overflow horizontally at min, default and large sizes } const rows = await measureSettled(page) records.push({ size, mode, rows }) + for (const row of rows) { + for (const failure of row.headerFailures) { + violations.push(`${size}/${mode} ${row.id}: ${failure}`) + console.log(`[header-guard] VIOLATION ${size}/${mode} ${row.id}: ${failure}`) + } + } // Incremental record: a later config must never lose an earlier one. await writeFile( testInfo.outputPath(`records-${size}-${mode}.json`), diff --git a/web/e2e/dashboardCatalogueHeaderCollision.ts b/web/e2e/dashboardCatalogueHeaderCollision.ts index 33cb74a005..bea705569a 100644 --- a/web/e2e/dashboardCatalogueHeaderCollision.ts +++ b/web/e2e/dashboardCatalogueHeaderCollision.ts @@ -13,7 +13,7 @@ async function headerGeometry(expand: Locator) { } const panel = container.querySelector(':scope > .widget-panel'); const title = panel?.querySelector('h3:not(.sr-only)'); - const header = title?.parentElement?.parentElement?.parentElement; + const header = panel?.querySelector('[data-role="widget-shell-header"]'); if (!header || !panel?.contains(header)) throw new Error('Missing exact FleetPosture shell header'); const rect = (node: Element) => { const box = node.getBoundingClientRect(); @@ -36,7 +36,7 @@ async function headerGeometry(expand: Locator) { const controls = [...header.querySelectorAll('button')].filter(visible).map(button => { const box = rect(button); const hit = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2); - return { text: button.textContent?.trim(), label: button.getAttribute('aria-label'), box, + return { text: button.textContent?.trim(), label: button.getAttribute('aria-label'), box, isExpand: button === element, verticalOverlap: Math.max(0, Math.min(box.bottom, expandBox.bottom) - Math.max(box.top, expandBox.top)), gap: expandBox.left - box.right, hitOwnButton: hit?.closest('button') === button }; }); @@ -77,11 +77,12 @@ function assertGeometry(frame: Awaited>, keybo expect(frame.title).toBe('Fleet posture'); const refresh = frame.controls.filter(control => control.label?.startsWith('Refresh data')); expect(refresh, 'Visible Refresh must not be dropped to avoid the collision').toHaveLength(1); - expect(refresh[0].box.right).toBeLessThanOrEqual(frame.expand.left); - expect(refresh[0].gap, 'Actual Refresh/Expand gap must be at least8px').toBeGreaterThanOrEqual(8); + expect(frame.expand.right).toBeLessThanOrEqual(refresh[0].box.left); + expect(refresh[0].box.left - frame.expand.right, 'Actual Expand/Refresh gap must be at least8px').toBeGreaterThanOrEqual(8); + expect(refresh[0].box.left - frame.expand.right, 'Expand/Refresh must form one tight cluster').toBeLessThanOrEqual(8); for (const control of frame.controls) { expect(control.hitOwnButton, 'Each visible header control center must retain its own button').toBe(true); - if (control.verticalOverlap > 0) expect(control.gap, 'Vertically overlapping controls require8px gap') + if (control.verticalOverlap > 0 && !control.isExpand && control !== refresh[0]) expect(control.gap, 'Vertically overlapping controls require8px gap') .toBeGreaterThanOrEqual(8); } expect(frame.readings.some(reading => reading.text === 'Fleet posture')).toBe(true); diff --git a/web/e2e/operationalbrief-contracts/battery-charge-advisor.supported.fixtures.ts b/web/e2e/operationalbrief-contracts/battery-charge-advisor.supported.fixtures.ts index afd62fb161..5755e0bf2f 100644 --- a/web/e2e/operationalbrief-contracts/battery-charge-advisor.supported.fixtures.ts +++ b/web/e2e/operationalbrief-contracts/battery-charge-advisor.supported.fixtures.ts @@ -95,7 +95,7 @@ export async function installSupportedAdvisorSources( await installBatteryEndpoint(page, mocks, '/drives', qualifiedAdvisorDrives, [ { vehicle_id: '7', limit: '1000', - start: '2026-05-27T00:00:00.000Z', end: '2026-06-03T00:00:00.000Z', + start: '2025-12-05T00:00:00.000Z', end: ADVISOR_NOW, }, { vehicle_id: '7', limit: '1000', offset: '0', @@ -104,7 +104,7 @@ export async function installSupportedAdvisorSources( ]); await installBatteryEndpoint(page, mocks, '/charging', qualifiedAdvisorCharging, { vehicle_id: '7', limit: '1000', - start: '2026-05-27T00:00:00.000Z', end: '2026-06-03T00:00:00.000Z', + start: '2025-12-05T00:00:00.000Z', end: ADVISOR_NOW, }); await installBatteryEndpoint(page, mocks, '/signals/7/live', getLive, {}); } diff --git a/web/e2e/operationalbrief-contracts/battery-charge-advisor.supported.spec.ts b/web/e2e/operationalbrief-contracts/battery-charge-advisor.supported.spec.ts index b6f00d6e86..7333eafacc 100644 --- a/web/e2e/operationalbrief-contracts/battery-charge-advisor.supported.spec.ts +++ b/web/e2e/operationalbrief-contracts/battery-charge-advisor.supported.spec.ts @@ -139,12 +139,12 @@ async function finish(page: Page, mocks: MockApiController, diagnostics: ReturnT // literals. const query = (entries: ReadonlyArray) => new URLSearchParams(entries.map(([key, value]) => [key, value] as [string, string])).toString(); - const week = query([['vehicle_id', '7'], ['limit', '1000'], ['start', '2026-05-27T00:00:00.000Z'], ['end', '2026-06-03T00:00:00.000Z']]); + const evidence = query([['vehicle_id', '7'], ['limit', '1000'], ['start', '2025-12-05T00:00:00.000Z'], ['end', ADVISOR_NOW]]); const accounting = query([['vehicle_id', '7'], ['start', '2025-12-05T00:00:00.000Z'], ['end', '2026-06-02T00:00:00.000Z'], ['limit', '1000'], ['offset', '0']]); for (const request of [ - `GET /drives?${week}`, + `GET /drives?${evidence}`, `GET /drives?${accounting}`, - `GET /charging?${week}`, + `GET /charging?${evidence}`, 'GET /signals/7/live', ]) expect(mocks.seen.has(request), request).toBe(true); await expectNoHorizontalOverflow(page); diff --git a/web/e2e/operationalbrief-contracts/battery.smoke.spec.ts b/web/e2e/operationalbrief-contracts/battery.smoke.spec.ts index 1cff0d59ca..7301bc1607 100644 --- a/web/e2e/operationalbrief-contracts/battery.smoke.spec.ts +++ b/web/e2e/operationalbrief-contracts/battery.smoke.spec.ts @@ -272,7 +272,9 @@ for (const theme of ['light', 'dark'] as const) { test(`energy flow preserves returned period denominator and SI energy/distance/efficiency ${width} ${theme}`, async ({ page }) => { const path = '/energy-flow?from=2026-08-04&to=2026-08-06'; const mocks = await setup(page, theme, width, path); - await installBatteryEndpoint(page, mocks, '/vehicles/7/energy', energyStats, { days: '3' }); + await installBatteryEndpoint(page, mocks, '/vehicles/7/energy', energyStats, { + start: '2026-08-04T00:00:00.000Z', end: '2026-08-07T00:00:00.000Z', + }); await installBatteryEndpoint(page, mocks, '/vehicles/7/energy/flow', vehicleFlow, {}); const diagnostics = monitorPage(page); await ready(page, mocks, path, theme); @@ -290,9 +292,8 @@ for (const theme of ['light', 'dark'] as const) { // The returned 30-day denominator is not the two visible daily rows or // the requested 3-day window. The source limitation remains explicit. await reading(drivers, 'average-per-day', /0\.40\s*kWh/); - // Trailing-window label is period scope: drawer-only without showRange. - await review(page, overview, /Trailing 3 days/); - await review(page, overview, /custom calendar bounds are not applied by this source/i); + await review(page, overview, /Selected workspace range/); + await review(page, overview, /Completed drives are filtered by the workspace start and exclusive end/i); await review(page, drivers, /Estimated avoided CO₂, not a vehicle emissions measurement/i); await finish(page, mocks, diagnostics); }); @@ -505,15 +506,19 @@ for (const theme of ['light', 'dark'] as const) { }); test(`charge advisor retains separate current snapshot and empty-history count evidence ${width} ${theme}`, async ({ page }) => { + const planningNow = new Date('2026-08-27T12:00:00.000Z'); + await page.clock.setFixedTime(planningNow); + const completeHistory = trailingHistoryWindow(180, planningNow.getTime()); + const planningEvidence = { start: completeHistory.start, end: planningNow.toISOString() }; const mocks = await setup(page, theme, width, '/charge-advisor'); await installExistingEnergySources(page, mocks); await installBatteryEndpoint(page, mocks, '/charging', [], - { vehicle_id: '7', limit: '1000', ...trailingWeekWindow() }); - // The advisor issues both the 7-day evidence window and the 180-day - // accounting window (exclusive of partial today) for drives. + { vehicle_id: '7', limit: '1000', ...planningEvidence }); + // Planning owns its clock and history. The evidence query includes + // today's observations; the calendar separately certifies complete days. await installBatteryEndpoint(page, mocks, '/drives', [], [ - { vehicle_id: '7', limit: '1000', ...trailingWeekWindow() }, - { vehicle_id: '7', limit: '1000', offset: '0', ...trailingHistoryWindow() }, + { vehicle_id: '7', limit: '1000', ...planningEvidence }, + { vehicle_id: '7', limit: '1000', offset: '0', ...completeHistory }, ]); const diagnostics = monitorPage(page); await ready(page, mocks, '/charge-advisor', theme); diff --git a/web/e2e/operationalbrief-contracts/science.fixtures.ts b/web/e2e/operationalbrief-contracts/science.fixtures.ts index 3c3d97ab7e..4da5bc2cf0 100644 --- a/web/e2e/operationalbrief-contracts/science.fixtures.ts +++ b/web/e2e/operationalbrief-contracts/science.fixtures.ts @@ -7,9 +7,9 @@ import type { ScienceWeather, } from '../../src/api/types'; -export const SCIENCE_START = '2026-08-19T16:00:00.000Z'; -export const SCIENCE_END = '2026-08-26T16:00:00.000Z'; -export const SCIENCE_ROUTE = `/science?vehicle_id=7&days=7&start=${SCIENCE_START}&end=${SCIENCE_END}`; +export const SCIENCE_START = '2026-08-20T00:00:00.000Z'; +export const SCIENCE_END = '2026-08-27T00:00:00.000Z'; +export const SCIENCE_ROUTE = '/science?vehicle_id=7&from=2026-08-20&to=2026-08-26'; const envelope = { vehicle_id: 7, start: SCIENCE_START, end: SCIENCE_END }; diff --git a/web/e2e/operationalbrief-contracts/science.smoke.spec.ts b/web/e2e/operationalbrief-contracts/science.smoke.spec.ts index 919e25d165..95021cb72c 100644 --- a/web/e2e/operationalbrief-contracts/science.smoke.spec.ts +++ b/web/e2e/operationalbrief-contracts/science.smoke.spec.ts @@ -84,6 +84,7 @@ async function bootScience( units: DisplayUnits = 'metric', pendingWeather?: Promise, ) { expect(ROUTE_REGISTRY.find((route) => route.path === '/science')?.name).toBe('ScienceLab'); + await page.clock.setFixedTime(new Date('2026-08-27T12:00:00.000Z')); await page.setViewportSize({ width, height: 1000 }); await page.emulateMedia({ colorScheme: theme, reducedMotion: 'reduce' }); await seedBrowserState(page, theme, '/science'); @@ -410,15 +411,19 @@ for (const theme of ['light', 'dark'] as const) { await expect(drawer).toContainText('0.18 kWh…0.52 kWh'); await closeReview(page, 'science-tires-brief'); const windowControls = page.getByRole('group', { name: 'Window', exact: true }); - await expect(windowControls.getByRole('button', { name: 'Last 7d', exact: true })).toHaveAttribute('aria-pressed', 'true'); - await windowControls.getByRole('button', { name: 'Last 30d', exact: true }).click(); + await expect(windowControls.getByRole('button')).toHaveCount(0); + await page.getByRole('button', { name: /^Analysis window:/ }).click(); + const settings = page.getByRole('dialog', { name: 'View settings' }); + await settings.getByRole('button', { name: '30 days', exact: true }).click(); + await settings.getByRole('button', { name: 'Close view settings' }).click(); await waitForHarnessReady(page, controller); - await expect(windowControls.getByRole('button', { name: 'Last 30d', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByRole('button', { name: 'Analysis window: Last 30 days', exact: true })).toBeVisible(); const params = new URL(page.url()).searchParams; - expect(params.get('days')).toBe('30'); - const start = params.get('start'); - const end = params.get('end'); - if (!start || !end) throw new Error('Window control lost its URL bounds'); + const from = params.get('from'); + const to = params.get('to'); + if (!from || !to) throw new Error('Workspace header lost its URL bounds'); + const start = new Date(`${from}T00:00:00Z`).toISOString(); + const end = new Date(Date.parse(`${to}T00:00:00Z`) + 86400000).toISOString(); expect(Date.parse(end) - Date.parse(start)).toBe(30 * 86400000); for (const domain of SCIENCE_DOMAINS) { expect(requests.some((request) => request.domain === domain && request.start === start && request.end === end), diff --git a/web/e2e/operationalbrief-contracts/telemetry.fixtures.ts b/web/e2e/operationalbrief-contracts/telemetry.fixtures.ts index 8b541594be..1986f27c79 100644 --- a/web/e2e/operationalbrief-contracts/telemetry.fixtures.ts +++ b/web/e2e/operationalbrief-contracts/telemetry.fixtures.ts @@ -53,7 +53,7 @@ export interface TelemetryFixtureControls { historyRequests: URL[]; agreementRequests: URL[]; liveRequests: number; - holdNextLive: () => void; + holdLiveRequests: () => void; releaseLive: () => void; emptyHistory: boolean; agreementMode: 'measured' | 'insufficient_overlap'; @@ -67,12 +67,16 @@ export async function installTelemetryFixtures( ): Promise { if (!mocks) throw new Error('OperationalBrief telemetry contracts require strict mocked E2E mode'); let holdLive = false; - let release: (() => void) | undefined; + const pendingLive = new Set<() => void>(); const controls: TelemetryFixtureControls = { historyRequests: [], agreementRequests: [], liveRequests: 0, emptyHistory: false, agreementMode: 'measured', brokerEmpty: false, brokerUnavailable: false, - holdNextLive: () => { holdLive = true; }, - releaseLive: () => { holdLive = false; release?.(); release = undefined; }, + holdLiveRequests: () => { holdLive = true; }, + releaseLive: () => { + holdLive = false; + for (const release of pendingLive) release(); + pendingLive.clear(); + }, }; await page.route('**/api/v1/signals/7/available', route => { expect(route.request().method()).toBe('GET'); @@ -111,8 +115,10 @@ export async function installTelemetryFixtures( expect(route.request().method()).toBe('GET'); controls.liveRequests += 1; if (holdLive) { - holdLive = false; - await new Promise(resolve => { release = resolve; }); + // A background poll can reach this route before the explicit refresh, + // which cancels/replaces it. Keep every request behind the same barrier + // until the retained-state assertions finish. + await new Promise(resolve => { pendingLive.add(resolve); }); } const now = Date.now(); const data: VehicleLiveSignalsResponse = { diff --git a/web/e2e/operationalbrief-contracts/telemetry.smoke.spec.ts b/web/e2e/operationalbrief-contracts/telemetry.smoke.spec.ts index 204b1f4cd3..88972f7c94 100644 --- a/web/e2e/operationalbrief-contracts/telemetry.smoke.spec.ts +++ b/web/e2e/operationalbrief-contracts/telemetry.smoke.spec.ts @@ -315,7 +315,7 @@ for (const layout of LAYOUTS) { await reviewBrief(page, brief, '2 of 4 signals arriving'); await reviewBrief(page, brief, 'a sleeping vehicle can be stale without being unhealthy'); const beforeRefresh = fixtures.liveRequests; - fixtures.holdNextLive(); + fixtures.holdLiveRequests(); try { await page.getByRole('button', { name: 'Refresh signals', exact: true }).click(); await expect.poll(() => fixtures.liveRequests).toBeGreaterThan(beforeRefresh); diff --git a/web/e2e/visual.spec.ts b/web/e2e/visual.spec.ts index 2448998602..5a8c5da311 100644 --- a/web/e2e/visual.spec.ts +++ b/web/e2e/visual.spec.ts @@ -163,8 +163,10 @@ for (const route of VISUAL_ROUTES) { [aria-label^="API connection status"] { min-width: 8rem !important; } - [aria-label^="Refresh data"], - [aria-label^="Data freshness:"] { + /* Widget headers use the real dot/action geometry. The historical + text-width pin applies only to freshness controls outside widgets. */ + [aria-label^="Refresh data"]:not([data-role="widget-shell-header"] *), + [aria-label^="Data freshness:"]:not([data-role="widget-shell-header"] *) { width: 8.5rem !important; min-width: 8.5rem !important; max-width: 8.5rem !important; diff --git a/web/package-lock.json b/web/package-lock.json index ddf1ec38c7..7f606231bc 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -78,7 +78,7 @@ "sharp": "0.35.5", "tailwindcss": "^3.4.1", "typescript": "^5.4.2", - "vite": "^6.4.3", + "vite": "^6.4.4", "vite-plugin-pwa": "^1.2.0", "vitest": "^4.1.11" }, @@ -4699,12 +4699,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -12594,7 +12596,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12936,9 +12938,9 @@ } }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", "dev": true, "license": "MIT", "dependencies": { diff --git a/web/package.json b/web/package.json index cd4de6e7ac..a391692951 100644 --- a/web/package.json +++ b/web/package.json @@ -172,7 +172,7 @@ "sharp": "0.35.5", "tailwindcss": "^3.4.1", "typescript": "^5.4.2", - "vite": "^6.4.3", + "vite": "^6.4.4", "vite-plugin-pwa": "^1.2.0", "vitest": "^4.1.11" }, diff --git a/web/scripts/audit-live-mutations.mjs b/web/scripts/audit-live-mutations.mjs index bb3a532ca9..013b6de9e7 100644 --- a/web/scripts/audit-live-mutations.mjs +++ b/web/scripts/audit-live-mutations.mjs @@ -34,6 +34,7 @@ const LIVE_ONLY_MUTATIONS = { 'useBulkAutomationsUpdate', 'useTestRunAutomation', 'useCreateAutomationFull', + 'useDuplicateAutomation', 'useUpdateAutomationFull', ], 'hooks/useCharging.ts': ['useApplySchedule', 'useBulkDeleteCharging', 'useAutopilotRun'], diff --git a/web/src/api/hooks/__tests__/useAutomationDuplicate.test.tsx b/web/src/api/hooks/__tests__/useAutomationDuplicate.test.tsx new file mode 100644 index 0000000000..9d3ff71d93 --- /dev/null +++ b/web/src/api/hooks/__tests__/useAutomationDuplicate.test.tsx @@ -0,0 +1,50 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { request } from '../../client'; +import type { AutomationFull } from '../../types'; +import { automationCopyInput, useDuplicateAutomation } from '../useAutomations'; +vi.mock('../../client',()=>({request:vi.fn()})); +vi.mock('../_toastHelpers',()=>({useMutationToast:()=>({success:vi.fn(),error:vi.fn()})})); +const source = { + id:42,name:'Morning charge',description:'Only at home.',vehicle_id:7,enabled:true,token:'never-copy-metadata', + execution_count:99, + triggers:[{id:10,automation_id:42,step_id:55,kind:'trigger_schedule',cron_expr:'0 7 * * *',timezone:'UTC'}], + conditions:[],actions:[{id:11,automation_id:42,kind:'action_command',command_name:'charge_start',command_params:{charging_amps:16}}], +} as unknown as AutomationFull; +beforeEach(()=>vi.clearAllMocks()); +describe('disabled automation copies',()=>{ + it('projects only the create contract, strips identity/state, and detaches nested data',()=>{ + const input=automationCopyInput(source,'Morning charge (copy)'); + expect(input).toEqual({name:'Morning charge (copy)',description:'Only at home.',vehicle_id:7,enabled:false, + triggers:[{kind:'trigger_schedule',cron_expr:'0 7 * * *',timezone:'UTC'}],conditions:[], + actions:[{kind:'action_command',command_name:'charge_start',command_params:{charging_amps:16}}]}); + expect(JSON.stringify(input)).not.toContain('never-copy-metadata'); + expect(source.enabled).toBe(true); + expect(input.actions[0]).not.toBe(source.actions[0]); + }); + it('fetches the full definition then creates a disabled copy through POST /automations',async()=>{ + vi.mocked(request).mockResolvedValueOnce(source).mockResolvedValueOnce({...source,id:99,enabled:false}); + const client=new QueryClient({defaultOptions:{mutations:{retry:false},queries:{retry:false}}}); + const wrapper=({children}:{children:ReactNode})=>{children}; + const {result}=renderHook(()=>useDuplicateAutomation(),{wrapper}); + const open=vi.fn(); + await act(async()=>{result.current.mutate(42,{onSuccess:open});}); + await waitFor(()=>expect(result.current.isSuccess).toBe(true)); + expect(request).toHaveBeenNthCalledWith(1,'/automations/42'); + expect(request).toHaveBeenNthCalledWith(2,'/automations',expect.objectContaining({method:'POST',requiresLiveMode:true})); + const options=vi.mocked(request).mock.calls[1][1]; + expect(JSON.parse(String(options?.body))).toEqual(automationCopyInput(source,'Morning charge (copy)')); + expect(open.mock.calls[0][0]).toEqual(expect.objectContaining({id:99,enabled:false})); + }); + it('does not submit a partial copy or open anything if detail loading fails',async()=>{ + vi.mocked(request).mockRejectedValueOnce(new Error('Detail failed')); + const client=new QueryClient({defaultOptions:{mutations:{retry:false}}}); + const wrapper=({children}:{children:ReactNode})=>{children}; + const {result}=renderHook(()=>useDuplicateAutomation(),{wrapper});const open=vi.fn(); + await act(async()=>{result.current.mutate(42,{onSuccess:open});}); + await waitFor(()=>expect(result.current.isError).toBe(true)); + expect(request).toHaveBeenCalledTimes(1);expect(open).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/api/hooks/useAnomalies.ts b/web/src/api/hooks/useAnomalies.ts index d73bf34b09..4f3d56b11f 100644 --- a/web/src/api/hooks/useAnomalies.ts +++ b/web/src/api/hooks/useAnomalies.ts @@ -12,7 +12,20 @@ export interface AnomalyData { anomalies_last_24h: number; } +export interface AnomalyExplanationParams { + value: number; + baseline: number; + z_score?: number; + minimum?: number; + maximum?: number; + percent_change?: number; +} + export interface AnomalyEntry { + /** Canonical unit supplied by the detector; absent on older deployments. */ + unit?: string; + explanation_code?: string; + explanation_params?: AnomalyExplanationParams; signal: string; type: 'z_score' | 'range' | 'trend'; severity: 'critical' | 'warning' | 'info'; diff --git a/web/src/api/hooks/useAutomations.ts b/web/src/api/hooks/useAutomations.ts index d45475b039..5678b98b67 100644 --- a/web/src/api/hooks/useAutomations.ts +++ b/web/src/api/hooks/useAutomations.ts @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { request } from '../client'; import { INTERVALS, STALE_TIMES } from '@/lib/constants'; @@ -572,3 +573,46 @@ export function useInstallRoutine() { onError: (err) => error(err, 'toast.automations.routine.error', 'Failed to install routine'), }); } + +/** Copies only the typed create contract, never IDs or execution state. */ +export function automationCopyInput(full: AutomationFull, name: string): AutomationFullInput { + const fields: Record = { + trigger_signal:['signal','op','value_num','value_text','value_bool'], + trigger_geofence:['place_id','event','dwell_minutes'], + trigger_schedule:['cron_expr','timezone'], trigger_event:['event_type'], + condition_signal:['signal','op','value_num','value_text','value_bool','value_min','value_max'], + condition_time_window:['start_time','end_time','timezone','days_of_week'], + condition_geofence:['place_id','state'], condition_other_automation:['other_automation_id','state'], + action_command:['command_name','command_params'], action_notify:['channel_id','template'], + action_wait:['duration_s'], action_set_setting:['setting_key','value_num','value_text','value_bool'], + action_call_automation:['target_automation_id'], + }; + const copyStep = (step: AutomationStepInput): AutomationStepInput => { + const allowed = fields[step.kind]; + if (!allowed) throw new Error('Unsupported automation step kind'); + const record = step as unknown as Record; + return structuredClone(Object.fromEntries(['kind','step_order',...allowed] + .filter(key => record[key] !== undefined).map(key => [key,record[key]]))) as AutomationStepInput; + }; + return {name,description:full.description ?? undefined,vehicle_id:full.vehicle_id,enabled:false, + triggers:full.triggers.map(copyStep) as AutomationTriggerInput[], + conditions:full.conditions.map(copyStep) as AutomationConditionInput[], + actions:full.actions.map(copyStep) as AutomationActionInput[]}; +} + +export function useDuplicateAutomation() { + const qc = useQueryClient(); + const { t } = useTranslation(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: async (id: number) => { + const full = await request(`/automations/${id}`); + const input = automationCopyInput(full, t('automations.copyName', '{{name}} (copy)', {name:full.name})); + return request('/automations', {method:'POST',requiresLiveMode:true, + headers:{'Content-Type':'application/json'},body:JSON.stringify(input)}); + }, + networkMode:'always', + onSuccess:()=>{invalidateAndBroadcast(qc,{queryKey:automationKeys.all});success('toast.automation.create.success','Automation created');}, + onError:(err)=>error(err,'toast.automation.create.error','Failed to create automation'), + }); +} diff --git a/web/src/api/hooks/useEnergy.test.tsx b/web/src/api/hooks/useEnergy.test.tsx index 6a9da0834d..3d2b6afa7a 100644 --- a/web/src/api/hooks/useEnergy.test.tsx +++ b/web/src/api/hooks/useEnergy.test.tsx @@ -190,6 +190,20 @@ describe('useEnergyStats', () => { expect(result.current.data).toBeUndefined(); }); + it('sends exact upper bounds and isolates caches when only the end changes', async () => { + mockedRequest.mockResolvedValue({total_wh:1000,daily_breakdown:[]}); + const start='2026-01-01T08:00:00.000Z'; + const {result,rerender}=renderHook(({end})=>useEnergyStats('7',{start,end}),{ + initialProps:{end:'2026-01-03T08:00:00.000Z'},wrapper:makeWrapper(makeClient()), + }); + await waitFor(()=>expect(result.current.isSuccess).toBe(true)); + const first=new URL(String(mockedRequest.mock.calls[0][0]),'https://example.test'); + expect(first.searchParams.get('start')).toBe(start);expect(first.searchParams.get('end')).toBe('2026-01-03T08:00:00.000Z'); + rerender({end:'2026-01-04T08:00:00.000Z'}); + await waitFor(()=>expect(mockedRequest).toHaveBeenCalledTimes(2)); + expect(new URL(String(mockedRequest.mock.calls[1][0]),'https://example.test').searchParams.get('end')).toBe('2026-01-04T08:00:00.000Z'); + }); + it('is disabled (no request) when vehicleId is null', async () => { const { result } = renderHook(() => useEnergyStats(null), { wrapper: makeWrapper(makeClient()) }); await new Promise((r) => setTimeout(r, 10)); diff --git a/web/src/api/hooks/useEnergy.ts b/web/src/api/hooks/useEnergy.ts index d0f908fa36..774fd44c2c 100644 --- a/web/src/api/hooks/useEnergy.ts +++ b/web/src/api/hooks/useEnergy.ts @@ -28,7 +28,7 @@ import type { TOUSettingsPayload, } from '@/types/energy'; -export type EnergyStatsWindow = number | { start: string }; +export type EnergyStatsWindow = number | { start: string; end?: string }; /** Live-state power is already normalized to watts; EnergyRemaining has no verified unit. */ export interface EnergyFlowSnapshot { @@ -46,10 +46,10 @@ export function useEnergyStats( vehicleId: string | null, window: EnergyStatsWindow = 30, ) { - const windowKey = typeof window === 'number' ? `days:${window}` : `start:${window.start}`; + const windowKey = typeof window === 'number' ? `days:${window}` : `start:${window.start}${window.end ? `:end:${window.end}` : ""}`; const query = typeof window === 'number' ? `days=${window}` - : `start=${encodeURIComponent(window.start)}`; + : `start=${encodeURIComponent(window.start)}${window.end ? `&end=${encodeURIComponent(window.end)}` : ""}`; return useQuery({ queryKey: ['energy-stats', vehicleId, windowKey], diff --git a/web/src/api/hooks/useOperatorConfidence.test.tsx b/web/src/api/hooks/useOperatorConfidence.test.tsx index 83d44e31f7..dfb45e0862 100644 --- a/web/src/api/hooks/useOperatorConfidence.test.tsx +++ b/web/src/api/hooks/useOperatorConfidence.test.tsx @@ -484,6 +484,20 @@ describe('useVehicleCost', () => { expect(url).toContain('since=2025-01-02T03%3A04%3A05.000Z'); }); + it('includes the exclusive end in the wire query and cache identity', async () => { + requestMock.mockResolvedValue(envelope(payload)); + const since=new Date('2026-01-01T08:00:00Z'); + const {result,rerender}=renderHook(({until})=>useVehicleCost(since,100,until),{ + initialProps:{until:new Date('2026-01-03T08:00:00Z')},wrapper:makeWrapper(), + }); + await waitFor(()=>expect(result.current.isSuccess).toBe(true)); + const url=new URL(firstUrl(),'https://example.test'); + expect(url.searchParams.get('since')).toBe(since.toISOString());expect(url.searchParams.get('until')).toBe('2026-01-03T08:00:00.000Z'); + rerender({until:new Date('2026-01-04T08:00:00Z')}); + await waitFor(()=>expect(requestMock).toHaveBeenCalledTimes(2)); + expect(operatorConfidenceKeys.vehicleCost(since.toISOString(),100,'2026-01-03T08:00:00.000Z')).not.toEqual(operatorConfidenceKeys.vehicleCost(since.toISOString(),100,'2026-01-04T08:00:00.000Z')); + }); + it('does NOT crash render for an invalid Date — degrades to no since filter', async () => { requestMock.mockResolvedValueOnce(envelope(payload)); // new Date('nonsense') yields an Invalid Date; an unguarded diff --git a/web/src/api/hooks/useOperatorConfidence.ts b/web/src/api/hooks/useOperatorConfidence.ts index ffed8c9c9b..7f6ae581b6 100644 --- a/web/src/api/hooks/useOperatorConfidence.ts +++ b/web/src/api/hooks/useOperatorConfidence.ts @@ -78,8 +78,9 @@ export const operatorConfidenceKeys = { schemaDrift: ['admin', 'observability', 'schema-drift'] as const, slowQueries: (orderBy: SlowQueryOrderBy, limit: number) => ['admin', 'observability', 'slow-queries', orderBy, limit] as const, - vehicleCost: (sinceISO: string | null, limit: number) => - ['admin', 'observability', 'vehicle-cost', sinceISO, limit] as const, + vehicleCost: (sinceISO: string | null, limit: number, untilISO: string | null = null) => + untilISO ? ['admin', 'observability', 'vehicle-cost', sinceISO, limit, untilISO] as const + : ['admin', 'observability', 'vehicle-cost', sinceISO, limit] as const, diskForecast: ['admin', 'observability', 'disk-forecast'] as const, secretRotation: ['admin', 'observability', 'secret-rotation'] as const, dataQuality: ['admin', 'observability', 'data-quality'] as const, @@ -147,18 +148,21 @@ export function useSlowQueries(orderBy: SlowQueryOrderBy = 'mean_time', limit = * the last 30 days when `since` is null. Polled at SLOW (60 s) — * costs evolve over minutes, not seconds. */ -export function useVehicleCost(since: Date | null = null, limit = 100) { +export function useVehicleCost(since: Date | null = null, limit = 100, until: Date | null = null, enabled = true) { const sinceISO = toISOStringOrNull(since); + const untilISO = toISOStringOrNull(until); + const untilParam = untilISO ? `&until=${encodeURIComponent(untilISO)}` : ""; const sinceParam = sinceISO ? `&since=${encodeURIComponent(sinceISO)}` : ''; return useQuery({ - queryKey: operatorConfidenceKeys.vehicleCost(sinceISO, limit), + queryKey: operatorConfidenceKeys.vehicleCost(sinceISO, limit, untilISO), queryFn: ({ signal }) => fetchEnvelope( request( - `/admin/observability/vehicle-cost?limit=${limit}${sinceParam}`, + `/admin/observability/vehicle-cost?limit=${limit}${sinceParam}${untilParam}`, { signal }, ), ), + enabled, staleTime: STALE_TIMES.STANDARD, refetchInterval: INTERVALS.SLOW, refetchIntervalInBackground: false, diff --git a/web/src/components/data-display/FreshnessDot.tsx b/web/src/components/data-display/FreshnessDot.tsx index ad0342b13b..919217ee65 100644 --- a/web/src/components/data-display/FreshnessDot.tsx +++ b/web/src/components/data-display/FreshnessDot.tsx @@ -45,6 +45,10 @@ export interface FreshnessDotProps { manual?: boolean; /** Smaller dot and touch target for title-less widget overlays. */ compact?: boolean; + /** Widget headers align the painted dot with their shared right inset. */ + edgeAligned?: boolean; + /** Static content has no observation clock; never label it up to date. */ + untracked?: boolean; } export type FreshnessStatus = 'fresh' | 'fetching' | 'stale' | 'error'; @@ -70,9 +74,9 @@ const STATUS_CONFIG = { const STATUS_LABEL = { fresh: { key: 'freshness.fresh', fallback: 'Up to date' }, - fetching: { key: 'freshness.updating', fallback: 'Updating…' }, + fetching: { key: 'freshness.updating', fallback: 'updating…' }, stale: { key: 'freshness.stale', fallback: 'Stale' }, - error: { key: 'freshness.error', fallback: 'Error' }, + error: { key: 'freshness.error', fallback: 'error' }, } as const; // Centralize this once the shared @@ -117,6 +121,8 @@ export function FreshnessDot({ refreshIntervalMs, manual = false, compact = false, + edgeAligned = false, + untracked = false, }: FreshnessDotProps) { const { t } = useTranslation(); const { reduce } = useMotionPreference(); @@ -172,7 +178,9 @@ export function FreshnessDot({ const sourceTitle = source ? t('freshness.source', 'Source: {{source}}', { source }) : null; - const statusLabel = t(STATUS_LABEL[status].key, STATUS_LABEL[status].fallback); + const statusLabel = untracked + ? t('freshness.neverUpdated', 'Never updated') + : t(STATUS_LABEL[status].key, STATUS_LABEL[status].fallback); const stateLabel = ageLabel ? t('freshness.ariaLabel', '{{status}} · {{age}}', { status: statusLabel, @@ -189,12 +197,13 @@ export function FreshnessDot({ ); } @@ -228,6 +238,7 @@ export type FreshnessQuery = Pick< >; export interface FreshnessDotAutoProps { + edgeAligned?: boolean; /** Pass the entire TanStack Query result (the object returned by `useQuery`). */ query: FreshnessQuery; /** @@ -268,6 +279,7 @@ export interface FreshnessDotAutoProps { * `useQuery()` result handy. . */ export function FreshnessDotAuto({ + edgeAligned, query, refetchable = true, forceStaleAfterMs, @@ -278,6 +290,7 @@ export function FreshnessDotAuto({ }: FreshnessDotAutoProps) { return ( 0 ? query.dataUpdatedAt : null} isFetching={query.isFetching} isStale={query.isStale} diff --git a/web/src/components/data-display/StatusBadge.tsx b/web/src/components/data-display/StatusBadge.tsx index 0d03346623..2cc78422bf 100644 --- a/web/src/components/data-display/StatusBadge.tsx +++ b/web/src/components/data-display/StatusBadge.tsx @@ -14,6 +14,7 @@ interface StatusBadgeProps { className?: string; /** Localized presentation only; status still owns the FSM lookup. */ label?: string; + showDot?: boolean; } const sizes = { @@ -35,7 +36,7 @@ const restrainedDot = [ '[&.bg-gray-400]:bg-[var(--text-secondary)]', ].join(' '); -export function StatusBadge({ status, size = 'md', className, label: presentationLabel }: StatusBadgeProps) { +export function StatusBadge({ status, size = 'md', className, label: presentationLabel, showDot = true }: StatusBadgeProps) { const s = sizes[size]; // Fail closed: coerce nullish (and trim whitespace-only) status to '' so // getStateDefinition — which lowercases the state name — never throws on a @@ -56,7 +57,7 @@ export function StatusBadge({ status, size = 'md', className, label: presentatio )} > {/* Decorative colour cue — the adjacent text already names the status. */} -