feat/ui-impact
needs attentionviewing older commit5d0d186 · incrementalpre-PRreviewed 2026-07-24 15:36 UTC1H · 3M · 5L · 1I- 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)
- 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+547−0domains/utility/src/finding+3383−0apps/platform/src/api+413−0apps/platform/src/app/[locale]/(dashboard)/energia+3193−0packages/api/src/schemas+149−1packages/database/src/schema+133−0
- 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.
Findings · 10
correctness4
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.
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.
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.
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
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.
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
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.
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
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).
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
- 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:36current
- 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: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