feat/ui-impact
needs attentionviewing older commitb48af56 · incrementalpre-PRreviewed 2026-07-24 03:14 UTC1H · 6M · 6L · 4I- Purpose
- Improve the energy anomaly detection UI for enterprise multi-site consumers — surface actionable insights with a more defensible statistical model
- Goal
- Replace naive peer-level benchmark (store vs. tariff-cohort median) with difference-in-differences (DiD) so anomalies reflect deviations from a store's own trajectory, not cross-sectional level differences
- Sub-goals
- SG-1: Add DiD engine (`anomaly.ts`) as a pure computation module
- SG-2: Wire ConsumptionFindingContent to DiD (chart + KPIs + explanation copy)
- SG-3: Wire Comparativo page to DiD (chart, ranking, KPIs)
- SG-4: Update findingView labels/descriptions to reflect DiD framing
- What
- New `anomaly.ts` pure module implements DiD: `expected[m] = baseStore × (cohortMean[m] / cohortBase)`. ConsumptionFindingContent and Comparativo page rewritten to use `analyzeStore()`. Ranking now sorts by DiD deviation, not peer-level deviation. ReferenceLine added to mark breakout month. Label/description strings updated in findingView.ts.
- Why
- Cross-sectional benchmarking mis-classifies large stores as anomalous simply because they are big. DiD isolates the climate/seasonal signal via the cohort index, making the anomaly definition store-specific and defensible.
- Areas
- apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib+129−0apps/platform/src/app/[locale]/(dashboard)/energia/comparativo+100−110apps/platform/src/app/[locale]/(dashboard)/energia/_components+80−70apps/platform/src/app/[locale]/(dashboard)/energia/_lib+2−2
- Blast
- 4 files, +311/−182 lines. UI-only — no backend, API, DB, or auth touched. Impact limited to energia dashboard views for demo/prototype data.
Findings · 17
correctness2
deviationPct diluted when series is at minimum length (4 months)
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/anomaly.ts:89
When a store has exactly 4 months (`BASE_WINDOW + 1`), `withExp.slice(-RECENT)` (last 3) overlaps with the 3-month baseline. Since `expected ≈ actual` by construction in the baseline, `deviationPct` is understated for short-series stores. Not a crash, not wrong for longer series; will resolve naturally once stores have more history.
`shortMonth` locale output is stable within a client component (no SSR mismatch risk)
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/anomaly.ts:125
ICU versions differ between Node and browser, but this component is `'use client'` — both axis labels and ReferenceLine x= values are produced in the same JS runtime, so no mismatch can occur today.
security1
No security findings — all data is static, URL param is whitelist-validated
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:44
`storeId` from URL params is validated against `STORES.some(s => s.id === t)` before use. `analyzeStore()` also does its own `STORES.find()`. No XSS surface (outputs go to JSX text nodes / Recharts props, not `dangerouslySetInnerHTML`). No auth, no DB, no secrets touched.
conventions6
18-line block comment violates 'one short line max' rule
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/anomaly.ts:1
The file-top `/** ... */` block explains what DiD is and what the formula computes — this is 'what the code does' documentation, which CLAUDE.md prohibits. The explanation belongs in the PR description or an ADR. Trim to one line or remove; the inline comments (lines 71, 95) already cover the non-obvious WHY.
9-line JSDoc on ConsumptionFindingContent violates comment rules
apps/platform/src/app/[locale]/(dashboard)/energia/_components/ConsumptionFindingContent.tsx:21
The block comment on the component (lines 21-30) re-explains DiD. CLAUDE.md: 'Never write multi-paragraph docstrings or multi-line comment blocks — one short line max.' The component name and its code already convey intent. Remove or reduce to one line.
13-line JSDoc on ComparativoPage violates comment rules
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:20
Same rule violation: 13-line block comment restating the DiD concept. This is 'what the code does' and lives better in the PR description. Trim to one line.
`window.location.search` bypasses `useSearchParams` pattern
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:44
The comment acknowledges avoiding `useSearchParams()` to skip Suspense. The codebase pattern is `useSearchParams()` + `<Suspense>`. `window.location.search` in a `useState` initializer reads stale state if the parent forces a remount. For demo code the risk is low, but it creates a precedent. The correct fix is a small `<Suspense fallback={null}>` wrapper.
`export type { Tarifa }` re-export from `anomaly.ts` creates an indirect type path
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/anomaly.ts:129
`Tarifa` is defined in `realConsumption.ts`; `anomaly.ts` re-exports it. Nothing in the diff imports `Tarifa` from `anomaly.ts` — both consumers import from `realConsumption.ts` directly. The re-export creates an indirect path that could mislead future readers. Remove; callers import from the source.
Hardcoded Spanish strings — i18n debt (pre-existing, carried forward)
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:90
All user-visible strings are hardcoded in Spanish instead of using `next-intl` `useTranslations()`. This is consistent with how the rest of the `energia/` prototype works (pre-existing tech debt), but the new page adds to it. Flag for awareness; not blocking for demo code.
tests4
Stale test label assertion will fail CI
apps/platform/src/app/[locale]/(dashboard)/energia/_lib/__tests__/findingView.test.ts:70
Line 70 asserts `v.title` equals `'Consumo alto vs. tiendas comparables'` (the old cross-sectional label). The commit changed `TYPE_LABEL['peer_benchmark_consumption']` to `'Consumo desviado de su tendencia esperada'`. This test will fail. Fix: update the assertion to match the new string.
No unit tests for `anomaly.ts` — DiD algorithm is untested
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/anomaly.ts
The module is pure (no I/O) and directly unit-testable. No test file exists. Key paths lacking coverage: (1) cohort fallback when <2 regional peers, (2) breakoutYm detection across non-contiguous months (months with no cohort data are excluded from `withExp`, so two over-threshold months may not be consecutive in the array even if consecutive in calendar), (3) baseline anchoring when cohort starts later than the store. Not blocking for demo code, but these branches are invisible without tests.
Missing `anomaly.ts` tests acceptable for demo code — stale assertion is the only blocker
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/anomaly.ts
`anomaly.ts` operates on a static STORES fixture with no side effects; failures are visible in the UI chart. Not blocking for pre-production demo code. The stale test in `findingView.test.ts` is the only active CI failure.
ConsumptionFindingContent and comparativo/page call analyzeStore independently — no redundancy
apps/platform/src/app/[locale]/(dashboard)/energia/_components/ConsumptionFindingContent.tsx:33
These components are never co-rendered in the same React tree (one is a detail drawer, the other a separate route). Each `useMemo` is scoped to its own instance. Correct design.
improvement4
`cohortSize` computed with wrong semantics (all-tariff) vs `a.cohortSize` (DiD peers)
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:54
Line 54-57 computes `cohortSize` as all stores sharing the tariff (includes the selected store). `a.cohortSize` (returned by `analyzeStore`) is the actual DiD peer count (excludes selected store, may narrow to region). Line 131 uses the wrong-semantic `cohortSize` while the KPI at line 162 uses the correct `a.cohortSize`. Replace the useMemo and all usages with `a?.cohortSize`.
`store` useMemo duplicates `a.store` — one extra linear scan of STORES
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:52
`analyzeStore()` already returns `{ store, ... }` so `a?.store` equals `store` when `a` is non-null. The separate `store = STORES.find(...)` useMemo does a redundant scan on every `storeId` change. Replace `store` with `a?.store ?? null` and drop the extra memo.
`shortMonth` constructs a new `Intl.DateTimeFormat` on every call
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/anomaly.ts:125
`new Intl.DateTimeFormat(...)` is created inside `shortMonth()` on every invocation. Hoist to a module-level constant. `Intl.DateTimeFormat` construction involves locale data lookup; reuse is the documented best practice. Trivial fix, negligible impact at prototype scale.
`analyzeStore` called for every cohort store in `ranking` — selected store called twice
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:74
The ranking useMemo calls `analyzeStore(s.id)` for all tariff-cohort stores, including the selected store already computed as `a`. At current static dataset scale (30-40 stores) this is harmless. No action needed for prototype.
History · 15 commits
- 96ca7d7needs attentionincremental0H · 9M · 12L2026-07-25 16:40
- 25516f7needs attentionincremental5H · 7M · 8L2026-07-25 03:29
- a7f8d64needs attentionincremental0H · 4M · 5L2026-07-25 01:56
- 7fc4ef0needs attentionincremental2H · 6M · 8L2026-07-24 21:04
- 027e5eaneeds attentionincremental4H · 9M · 8L2026-07-24 20:04
- 95a101eneeds attentionincremental3H · 6M · 5L2026-07-24 16:09
- 5d0d186needs attentionincremental1H · 3M · 5L2026-07-24 15:36
- c3c5121needs attentionincremental2H · 1M · 5L2026-07-24 15:17
- ded4e61needs attentionincremental2H · 3M · 9L2026-07-24 14:22
- 312a1f4needs attentionincremental1H · 4M · 4L2026-07-24 04:01
- b48af56needs attentionincremental1H · 6M · 6L2026-07-24 03:14current
- 6d07cc8needs attentionincremental2H · 4M · 5L2026-07-24 00:50
- 261b55eneeds attentionincremental5H · 11M · 6L2026-07-24 00:38
- 5b8a252needs attentionincremental3H · 6M · 9L2026-07-24 00:19
- f29cc5bneeds attentionfull9H · 17M · 11L2026-07-23 23:07