← all branches

feat/ui-impact

needs attentionviewing older commit
ded4e61 · incrementalpre-PRreviewed 2026-07-24 14:22 UTC2H · 3M · 9L · 4I
The branch
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)
The changes (whole branch)
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+32001domains/utility/src/finding+32060apps/platform/src/api+4130packages/api/src/schemas+1491packages/database/src/schema+1210packages/database/drizzle+410
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.
demo-data-client-only no-i18n-on-energia-module pre-PR
CI· No PR — push-triggered review on pre-PR branch; no gh pr checks availableCodeRabbit· No .coderabbit.yaml in repo

Findings · 17

correctness3

medium

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); }, []).

low

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.

info

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

low

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.

info

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

high

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.

high

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.

medium

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.

low

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.

info

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

medium

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().

low

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.

low

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

low

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).

low

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.

info

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.

info

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

  1. 96ca7d7needs attentionincremental0H · 9M · 12L2026-07-25 16:40
  2. 25516f7needs attentionincremental5H · 7M · 8L2026-07-25 03:29
  3. a7f8d64needs attentionincremental0H · 4M · 5L2026-07-25 01:56
  4. 7fc4ef0needs attentionincremental2H · 6M · 8L2026-07-24 21:04
  5. 027e5eaneeds attentionincremental4H · 9M · 8L2026-07-24 20:04
  6. 95a101eneeds attentionincremental3H · 6M · 5L2026-07-24 16:09
  7. 5d0d186needs attentionincremental1H · 3M · 5L2026-07-24 15:36
  8. c3c5121needs attentionincremental2H · 1M · 5L2026-07-24 15:17
  9. ded4e61needs attentionincremental2H · 3M · 9L2026-07-24 14:22current
  10. 312a1f4needs attentionincremental1H · 4M · 4L2026-07-24 04:01
  11. b48af56needs attentionincremental1H · 6M · 6L2026-07-24 03:14
  12. 6d07cc8needs attentionincremental2H · 4M · 5L2026-07-24 00:50
  13. 261b55eneeds attentionincremental5H · 11M · 6L2026-07-24 00:38
  14. 5b8a252needs attentionincremental3H · 6M · 9L2026-07-24 00:19
  15. f29cc5bneeds attentionfull9H · 17M · 11L2026-07-23 23:07