feat/ui-impact
needs attentionviewing older commitded4e61 · incrementalpre-PRreviewed 2026-07-24 14:22 UTC2H · 3M · 9L · 4I- Purpose
- Batu Enterprise demo branch — builds a full-featured Energía module (multi-site corporate energy consumption analysis, CFE bill automation, savings + findings) with real anonymized Grupo Axo production data for sales/PMF validation.
- Goal
- Ship an interactive, analytics-grade Energía UI backed by real detection engine and findings API, gated per-org via the soft-launch entitlement system, suitable for enterprise prospect demos.
- Sub-goals
- SG-1: Findings domain entity (FCIS) + detection engine (8 corpus-verified detectors)
- SG-2: Read-only findings API (ts-rest, energia-entitled)
- SG-3: Energía UI module (Resumen, Hallazgos, Demanda, Comparativo, Medición, ESG, Pagos) wired to real findings + real Axo data
- SG-4: Comparativo table view + CSV export (this commit)
- SG-5: Soft-launch gate entitlement-aware (per-org module toggles)
- What
- Added table view and per-store + portfolio CSV export to the Comparativo page. The metric toggle (kWh/día normalized vs CFE raw) now drives chart, table, AND export shape consistently — one control, no drift.
- Why
- Analysts need the like-for-like dataset (normalized kWh/día per store per period with deviation %) in Excel for further analysis. The WYSIWYG principle: what you see in the table is exactly what you download.
- Areas
- apps/platform/src/app/[locale]/(dashboard)/energia+3200−1domains/utility/src/finding+3206−0apps/platform/src/api+413−0packages/api/src/schemas+149−1packages/database/src/schema+121−0packages/database/drizzle+41−0
- Blast
- ~60 files, +9000/−5 lines. Large branch: new domain entity (findings), API surface, 8-screen Energía UI, schema migration, soft-launch gate update. This commit: 1 file, +145/−5.
Findings · 17
correctness3
Hydration mismatch when ?tienda= query param is present
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:108
The useState initializer reads window.location.search on the client but returns 't26' on the server (window is undefined during SSR). 'use client' components are still SSR'd in App Router — server renders storeId='t26', client immediately recomputes from the URL param. When ?tienda=<other-id> is set (the path used when arriving from a finding detail), server and client produce different initial values, causing a React hydration mismatch and visible flash. Fix: move the URL read into a useEffect(() => { const t = ...; if (t) setStoreId(t); }, []).
portfolioRows (norm mode) silently omits stores without anomaly results
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:98
In norm mode, portfolioRows calls normRowsFor(s, analyzeStore(s.id)) for every store. When analyzeStore returns null (< BASE_WINDOW+1 data points), normRowsFor returns []. The stated export contract is 'every store × every period' but stores lacking cohort data contribute zero rows silently. Not triggered by current data (all stores have ≥13 points) but the export's stated guarantee is not met for the edge case.
NaN in Desviación % if expected is exactly 0 (unreachable with current data)
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:75
The filter on line 62 excludes null but not 0. If all baseline kWh values for a store's cohort period were 0, expected = baseStore × (0/cohortBase) = 0, producing Math.round(NaN) = NaN, rendering as 'NaN%'. Not reachable with the real production dataset. See medium finding for the guarded fix.
security2
CSV formula injection not guarded in escapeCell (theoretical; static data only)
apps/platform/src/app/[locale]/(dashboard)/energia/_components/csv.ts:16
escapeCell wraps cells containing quotes, commas, or newlines but does not prefix formula-injection triggers (=, +, -, @). With the current purely-static dataset the risk is zero. However exportCsv is a shared utility — if reused with API-sourced or user-editable data (e.g. store names from a future org-configurable field), leading = or + in a cell would be interpreted as a formula by Excel/LibreOffice. Mitigation for future callers: prefix cells whose string starts with those characters.
Object URL not revoked in finally block — minor blob leak if DOM throws
apps/platform/src/app/[locale]/(dashboard)/energia/_components/csv.ts:6
URL.revokeObjectURL is called after a.click() but not in a try/finally block. If appendChild or click throws (e.g. in a sandboxed iframe), the blob URL leaks for the lifetime of the document. Not exploitable on this dashboard page; cosmetic hygiene.
conventions5
Vista toggle (Gráfica|Tabla) is the rejected view-switcher anti-pattern
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:169
product-ux.md Anti-patterns explicitly rejects 'Multiple view-screens for the same data (Lista | Tabla | Mapa switcher = Hick's-Law tax)'. The Gráfica|Tabla toggle is exactly this pattern. The correct approach per P7+P8: show the table always, and offer the chart as a progressive-disclosure collapse/expand section or a tab in the energy module's tab nav — not a per-control view switcher. The WYSIWYG export motivation is valid; the view-switcher mechanism is what violates the rule.
Bespoke HTML table instead of shared TableRegistry primitives
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:309
product-ux.md Anti-patterns: 'Reinventing the table — bespoke filter/sort/saved-view code per screen. Use the field-registry + table-views machinery.' The table is a hand-built <table> with inline conditional class logic and column derivation from Object.keys. The project provides TableRegistry + FilterControl + SortControl + ColumnHeaderControl in packages/ui/src/components/table-views/. P8 'Reuse over reinvention' and the checklist item 'Composes existing primitives; no bespoke table machinery' are both violated.
All user-facing strings hardcoded in Spanish — i18n rule violated
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx
ui-patterns.md: 'Never hardcode user-facing strings' — requires useTranslations() + messages/{locale}.json. This page has zero next-intl usage; every label, button text, card title, table header, and description is hardcoded Spanish. No carve-out for prototype pages exists in the rules. If a second locale is ever added, this page is unaddressable. Fix: add a namespace under messages/es.json and wire useTranslations. The Spanish text is already correct es-MX; it just needs to move to the message catalog.
Array index used as React key on table rows
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:321
rows.map((r, i) => <tr key={i}>) uses array position as React key. For the current static dataset this causes no visible bug. If rows are ever client-side sorted or filtered, React will reuse DOM nodes incorrectly. A stable key: r['Periodo'] as string (already in every row) costs nothing and eliminates the risk.
Inner normRowsFor block comment repeats what the file-level comment already says
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:49
CLAUDE.md: 'Default to writing no comments. Only add one when the WHY is non-obvious.' The file-level block comment (lines 21-37) already explains the WYSIWYG+metric design. The normRowsFor JSDoc (lines 49-58) repeats this. The inner comment could be trimmed to one sentence or removed.
tests3
Division by zero not guarded in normRowsFor Desviación % calculation
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:75
normRowsFor filters p.expected != null but not p.expected !== 0. If expected is 0 (possible if the cohort median is zero for a period — cohortBase=0 is guarded in anomaly.ts, but a non-zero cohortBase with zero monthly cohort value produces expected=0), the expression ((p.actual - 0) / 0) * 100 yields Infinity, rendering as 'Infinity%' in the table and CSV. Not triggered by current static data, but there is no guard and no test. Fix: add || expected === 0 to the filter, or guard the cell value with isFinite().
No unit tests for any of the five new pure functions
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx
normRowsFor, cfeRowsFor, rowsFor, portfolioRows, and freq are all pure, side-effect-free functions. None have tests. The pattern exists in the codebase (findingView.test.ts covers a similar pure projection in the same energia module). The only mitigation is that this is a demo feature backed by static data — a runtime error can only surface visually.
freq(45) boundary undocumented and untested
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:47
freq uses strict greater-than (> 45), so exactly 45 billed days → 'Mensual'. The real CFE data shows monthly periods 29-34 days and bimonthly 59-65 days, so 45 is dead ground today. But the rule is implicit — if a store with 45-day periods is added, it silently misclassifies. A comment on the boundary or a unit test at freq(45)/freq(46) would make the intent auditable.
improvement4
O(n²) linear scan in normRowsFor: store.series.find() inside .map()
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:64
store.series.find((q) => q.ym === p.ym) runs inside .map() over res.points — O(n×m) total. Both arrays are bounded to ~24 months so this is harmless in practice, but building a Map<ym, SeriesPoint> once before the .map() call is a trivial fix: const byYm = new Map(store.series.map(q => [q.ym, q])); then byYm.get(p.ym).
Column order implicitly tied to object key insertion order in row builders
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:119
tableCols is derived from Object.keys(rows[0]), relying on ES2015+ string key insertion order. This works correctly but silently couples column order to the order properties are written in normRowsFor/cfeRowsFor. A COLUMN_ORDER constant array used in both row-builders and tableCols derivation would make the contract explicit and prevent silent reordering.
Nested ternary in cell renderer — could be a formatCell helper
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:332
The inline expression {isDev ? `...%` : h === 'kWh del recibo' && typeof v === 'number' ? v.toLocaleString('es-MX') : v} has two nesting levels. A small formatCell(h, v) helper would make the render flat and each formatting case independently testable. Not a correctness issue.
analyzeStore called redundantly for the selected store in portfolioRows
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:98
portfolioRows(metric) calls analyzeStore(s.id) for every store including the selected one, whose result is already computed in the useMemo at line 117. With static data this is a negligible duplicate computation. portfolioRows could accept an optional Map<storeId, AnomalyResult> to reuse the already-computed entry.
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:22current
- 312a1f4needs attentionincremental1H · 4M · 4L2026-07-24 04:01
- b48af56needs attentionincremental1H · 6M · 6L2026-07-24 03:14
- 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