← all branches

feat/ui-impact

needs attentionviewing older commit
5d0d186 · incrementalpre-PRreviewed 2026-07-24 15:36 UTC1H · 3M · 5L · 1I
The branch
Purpose
Build the full energia (energy management) UI module for Batu Enterprise — a comprehensive multi-screen analytics dashboard using real Grupo Axo consumption data (anonymized) as the demo dataset. Covers findings detection, Comparativo (like-for-like comparison), Demanda, Hallazgos, Resumen, and Pagos views.
Goal
Ship a production-ready energia module: findings domain entity (FCIS), detection engine (8 detectors), read-only findings API, and a demo-safe UI that prospective enterprise customers can navigate. This commit specifically adds portfolio/branch scope + date-range preset controls to Comparativo.
Sub-goals
  • SG-1: Findings domain entity — decisions, queries, shells, mapper, types (FCIS)
  • SG-2: Detection engine — 8 corpus-verified detectors (DoP, demand spike, FP, tariff mismatch, etc.)
  • SG-3: Read-only findings API — ts-rest handler + contract, energia-entitled
  • SG-4: Comparativo UI — like-for-like real consumption, DiD normalization, store selection, CSV export
  • SG-5: Comparativo scope/date-range controls — portfolio vs sucursal, preset (ytd/12m/all/custom)
The changes (whole branch)
What
Added `scope` (sucursal/portafolio) toggle and date-range preset controls (ytd/12m/all/custom) to the Comparativo page. Portfolio scope shows aggregated totals across all stores; sucursal scope shows single-store DiD analysis. Range filter applied to charts, tables, and CSV exports.
Why
Prospects need to see the full portfolio view (total consumption, anomaly count) before drilling into a specific store — the previous sucursal-only view required knowing which store to inspect. Date-range controls let them focus the comparison on a fiscal year or trailing 12 months.
Areas
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo+5470domains/utility/src/finding+33830apps/platform/src/api+4130apps/platform/src/app/[locale]/(dashboard)/energia+31930packages/api/src/schemas+1491packages/database/src/schema+1330
Blast
69 files, +25,427 lines net — a large branch. The incremental change (this review) is 1 file, +117/-10. The broader branch ships the full findings FCIS + energia UI.
demo-data-only — STORES is hardcoded anonymized production data, no live API calls in the Comparativo page pre-PR branch
typecheck· not run in this invocationtests· not run in this invocationci· no PR — pre-PR branch

Findings · 10

correctness4

high

portfolioStats.anomalias ignores date range — KPI misleads

apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:212

anomalias calls analyzeStore() with no range argument and counts stores where deviationPct > 15 unconditionally. The result is identical regardless of what period the user selects. The memo depends on [inRange] but anomalias never uses it. The KPI sub-label implies range context ('>15% sobre su base'). Either scope the count to the selected range (filter anomalies whose breakoutYm falls within the range), or reword the label to be honest ('en el dataset completo'). As-is the number is a constant displayed as if it responds to the date picker.

medium

Recharts Tooltip formatter returns [value, name] — store count won't render as intended

apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:407

The portfolioChart Tooltip formatter returns an array: [fmtKwh(v), `${n} tiendas`]. Recharts interprets this as [displayValue, displayName] for the single 'total' series entry — the store count appears as the series name label, not as a separate row. The intent (two rows: kWh + store count) requires a custom <Tooltip content={...}> component. Currently the kWh value displays correctly but '${n} tiendas' shows in the wrong position/style.

medium

Custom date range from > to silently empties all charts and tables

apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:274

The two <input type='month'> fields have min/max HTML attributes but no cross-field validation. When customFrom > customTo, inRange becomes (ym) => ym >= from && ym <= to which is always false. Every chart and table renders empty and the empty-state message 'Sin datos en el periodo seleccionado' gives no hint about the inverted range. Minimal fix: inside resolveRange, return { from: min(from,to), to: max(from,to) } for the custom case, or show a visible warning.

low

Table rows keyed by array index — stale reconciliation on range filter change

apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:456

Rows use key={i} (array index). When the date range changes and rows are filtered, React reuses DOM nodes by position, which can cause brief visual glitches. Use a stable composite key: `${r.Tienda}-${r.Periodo}` is unique per store+period in both scope modes.

conventions2

medium

fmtKwh duplicates fmtCfe — one should be removed

apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:133

fmtCfe (line 44) and fmtKwh (line 133) are byte-for-byte identical: both produce Math.round(n).toLocaleString('es-MX') + ' kWh'. fmtKwh was added in this diff without noticing the existing formatter. Keep fmtKwh (clearer, domain-generic), remove fmtCfe, update the two call sites. Two identical formatters with different names will silently diverge if one is updated.

low

Single-letter memo variable `a` for anomaly result

apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:155

const a = useMemo(() => analyzeStore(storeId), [storeId]) — used in 9 places. A descriptive name (storeAnalysis, analysis, or anomaly) communicates intent at each call site without needing to trace back to the declaration.

tests2

low

subMonths and resolveRange have no unit tests; year-boundary edge uncovered

apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx

subMonths is pure with a non-obvious arithmetic path: t = y*12 + (m-1) - n. At year boundaries (e.g. subMonths('2023-01', 1) → '2022-12') the calculation is correct, but if t ever goes negative (n > total months from year 0) JS's % operator returns a negative remainder, yielding a malformed month string. The codebase has a clear pattern of co-located unit tests for _lib functions (findingView.test.ts). A __tests__/comparativo.test.ts covering subMonths boundary cases and resolveRange branches would pin the behavior.

low

anomaly.ts pure functions (DiD engine) have no test file

apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/anomaly.ts

analyzeStore contains the core DiD logic: cohort scoping, thin-cohort guard, baseline anchoring, breakoutYm detection (≥2 consecutive months). The anomaly score is the primary KPI surfaced to users. No __tests__/ counterpart exists. Given the codebase's testing pattern, a test file here is expected.

improvement2

low

portfolioRows called redundantly in export button onClick

apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:435

The 'Exportar portafolio' button calls portfolioRows(metric).filter(inRange) inline. When scope === 'portafolio', rows (already memoized) is already the range-filtered portfolio rows — reusing it avoids the redundant call. Only needed as a fallback when scope === 'sucursal' (to export the full portfolio regardless of the selected store).

info

anomalias constant inside a range-dependent memo — extract to module level

apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:212

STORES.map(s => analyzeStore(s.id)).filter(r => r?.deviationPct > 15).length is a module-level constant (analyzeStore has no side effects, STORES is static). Moving it out of the portfolioStats useMemo into module scope makes the intent explicit, eliminates unnecessary recomputation on every range change, and decouples the anomaly count from the date-range dependency it doesn't actually have.

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:36current
  8. c3c5121needs attentionincremental2H · 1M · 5L2026-07-24 15:17
  9. ded4e61needs attentionincremental2H · 3M · 9L2026-07-24 14:22
  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