feat/ui-impact
needs attentionviewing older commit6d07cc8 · incrementalpre-PRreviewed 2026-07-24 00:50 UTC2H · 4M · 5L · 1I- Purpose
- Build out the Energía module UI for Batu for Enterprise — energy management dashboard for multi-site corporate consumers. Includes findings detection engine, bill wiring, and demo-quality screens for the Axo RFP pitch.
- Goal
- Deliver production-ready Energía UI: findings API + detection engine + consumption analytics screens (Hallazgos, Resumen, Comparativo, Demanda) with real data wiring and dollar-layer impact labels.
- Sub-goals
- SG-1: Module scaffold + soft-launch gate
- SG-2: Findings ledger domain entity + detection engine (8 corpus-verified detectors)
- SG-3: Read-only findings API (ts-rest, energia-entitled)
- SG-4: Wire Hallazgos/Resumen to real API, drop hardcoded customer data
- SG-5: Surface $-layer in UI (hard vs. indicative impact labels)
- SG-6: Comparativo de consumo (like-for-like normalization + store selection)
- SG-7: Comparativo with real consumption data + normalization toggle
- SG-8: Visual finding detail — consumption chart + jump to Comparativo (this commit)
- What
- Adds ConsumptionFindingContent component: for store-linked consumption findings, replaces the generic text sheet with a real peer-benchmark chart (store kWh/día vs. tariff-cohort median over time) plus KPI cards and a deep-link button to Comparativo. Wires storeId/storeName from the finding details blob through findingView.ts mapper into the Finding type. Updates FindingRow routing to dispatch store-linked findings to the new component. Updates comparativo/page.tsx to accept a ?tienda= query param so the deep-link pre-selects the anomalous store.
- Why
- Makes 'consume más que sus pares' visible and evidence-backed rather than asserted — the reviewer can see the chart, not just read a label. Closes the insight-to-action loop: finding detail → see the evidence → jump to full Comparativo for the store.
- Areas
- apps/platform/src/app/[locale]/(dashboard)/energia+18−0domains/utility/src+10−0apps/platform/src/api+5−0packages/database/src + packages/api/src+4−0scripts/energia+1−0
- Blast
- 68 files, +24847/-4 across the branch. Incremental diff: 5 UI files (+164/-6). Domain: energia dashboard only. No schema migrations, no API contract changes, no cross-domain effects.
Findings · 12
correctness2
useState initializer reads ?tienda= only on mount — soft-nav deep-link silently ignored
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:53
The lazy initializer in useState(() => { if (typeof window === 'undefined') return 't26'; const t = new URLSearchParams(window.location.search).get('tienda'); ... }) runs exactly once per component mount. On Next.js App Router client-side navigation the ComparativoPage component may already be mounted in the router cache from a previous visit. When the user clicks 'Ver en Comparativo de consumo' (which calls router.push('/energia/comparativo?tienda=<id>')), the URL changes but the initializer does NOT re-run — storeId stays at its previous value. The intent of ConsumptionFindingContent.tsx:116 (deep-link into Comparativo pre-selecting the anomalous store) silently fails. Fix: use useState(null) + useEffect(() => { const t = params.get('tienda'); if (t && STORES.some(s => s.id === t)) setStoreId(t); }, []) — the pattern already used in useSiteSelection.ts.
Division by zero when store has no series data — NaN renders in KPI and % badge
apps/platform/src/app/[locale]/(dashboard)/energia/_components/ConsumptionFindingContent.tsx:61
Line 60: const recent = store.series.slice(-6). If store.series is empty, recent.length === 0. Line 61: mine = recent.reduce(..., 0) / recent.length → NaN. NaN.toFixed(0) renders 'NaN kWh/día' in the KPI card and 'NaN%' in the warning badge. The !store null-guard (line 56) protects against a missing storeId but not against a store with no billing records. Similarly, if any cohort store has an empty series, the per-store average inside cohort.map() also divides by zero. Fix: guard both sides: const mine = recent.length ? recent.reduce((a,p) => a + kwhDia(p), 0) / recent.length : 0.
conventions3
Duplicate median() function — should be exported from shared realConsumption lib
apps/platform/src/app/[locale]/(dashboard)/energia/_components/ConsumptionFindingContent.tsx:20
Identical median(xs: number[]) implementation exists in both ConsumptionFindingContent.tsx (line 20) and comparativo/page.tsx. ConsumptionFindingContent already imports STORES, kwhDia, and MonthPoint from comparativo/_lib/realConsumption — median should be exported there and imported by both consumers. A future correction (e.g. true interpolated median for even-length arrays) currently requires editing two files.
Em dashes in user-facing label strings
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:188
Kpi label prop and CardTitle text contain em dashes (—) in rendered copy (e.g. '${store.nombre} — consumo'). ui-patterns.md prohibits em dashes in user-facing copy. Replace with a colon or period: '${store.nombre}: consumo'.
No i18n — hardcoded Spanish strings throughout (pre-existing pattern, extended)
apps/platform/src/app/[locale]/(dashboard)/energia/_components/ConsumptionFindingContent.tsx:1
All user-facing strings are hardcoded in Spanish instead of sourced from messages/{locale}.json via next-intl. CLAUDE.md ui-patterns requires next-intl for all user-facing strings. Mitigating: the entire energía module shares this deviation — this diff extends an existing non-conforming pattern rather than establishing a new one.
tests4
toFindingView storeId branch untested — action 'Ver consumo' and routing to ConsumptionFindingContent
apps/platform/src/app/[locale]/(dashboard)/energia/_lib/__tests__/findingView.test.ts:73
The new ternary path storeId ? 'Ver consumo' : CATEGORY_ACTION[category] is completely uncovered. A test should pass details: { storeId: 's26', storeName: 'KK Tijuana' } and assert action === 'Ver consumo', storeId === 's26', storeName === 'KK Tijuana'. Without this, a future reordering of the ternary conditions could silently break FindingRow.tsx routing — cotizacion findings with a storeId would route to ConsumptionFindingContent instead of the quote flow.
toFindingView desc uses storeName prefix when present — not tested
apps/platform/src/app/[locale]/(dashboard)/energia/_lib/__tests__/findingView.test.ts:73
When details.storeName is set, desc leads with the store name instead of 'RPU ${rpu}'. The existing tests never assert the desc field. Two cases needed: (1) details: { storeId: 's26', storeName: 'KK Tijuana' } → desc starts with 'KK Tijuana'; (2) details: { storeId: 's26' } (no storeName) → desc falls back to 'RPU ${rpu}'. desc is displayed as the subtitle in both the finding list and the detail sheet.
toFindingView: non-string storeId/storeName in details falls back to undefined — not tested
apps/platform/src/app/[locale]/(dashboard)/energia/_lib/__tests__/findingView.test.ts:73
The defensive guards typeof details.storeId === 'string' and typeof details.storeName === 'string' are untested. A test should pass details: { storeId: 42, storeName: null } and assert storeId === undefined, storeName === undefined, action !== 'Ver consumo'. This prevents a server sending a numeric storeId from silently routing to the ConsumptionFindingContent.
COTIZACION_TYPES priority over storeId interaction untested
apps/platform/src/app/[locale]/(dashboard)/energia/_lib/__tests__/findingView.test.ts:73
If a finding has both a COTIZACION_TYPES type AND details.storeId set, action should be 'Solicitar cotización' (COTIZACION_TYPES check first in the ternary). A brief test makes the priority ordering explicit and guards against future condition reordering.
improvement3
Cohort includes the store being compared — biases the peer median toward the outlier
apps/platform/src/app/[locale]/(dashboard)/energia/_components/ConsumptionFindingContent.tsx:38
cohort = STORES.filter(s => s.tarifa === store.tarifa) includes the current store. When this store is the anomaly being flagged as 'consume más que sus pares', its high consumption value is included in the cohort median computation, systematically understating the gap. The same pattern exists in comparativo/page.tsx (pre-existing), but the finding-detail context makes the bias more visible — the card is supposed to show why this store was flagged. Fix: add && s.id !== store.id to the filter predicate.
Stats block re-derives 6-month average already computed inside chart useMemo
apps/platform/src/app/[locale]/(dashboard)/energia/_components/ConsumptionFindingContent.tsx:44
The chart useMemo iterates all months and the stats block independently slices and averages the last 6 months — same traversal, duplicated logic. Extract avgKwhDia(series, last = 6) and use it from both sites.
median() returns lower-bound element, not a true median for even-length arrays
apps/platform/src/app/[locale]/(dashboard)/energia/_components/ConsumptionFindingContent.tsx:20
Math.floor(s.length / 2) always picks the lower of the two middle elements for even-length arrays. For a 4-element cohort the standard median is the mean of elements [1] and [2]. Low impact with static data but systematically biases results downward for small cohorts.
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:14
- 6d07cc8needs attentionincremental2H · 4M · 5L2026-07-24 00:50current
- 261b55eneeds attentionincremental5H · 11M · 6L2026-07-24 00:38
- 5b8a252needs attentionincremental3H · 6M · 9L2026-07-24 00:19
- f29cc5bneeds attentionfull9H · 17M · 11L2026-07-23 23:07