feat/ui-impact
needs attentionviewing older commit7fc4ef0 · incrementalpre-PRreviewed 2026-07-24 21:04 UTC2H · 6M · 8L · 3I- Purpose
- Build a full-coverage energy intelligence dashboard for the Grupo Axo enterprise demo — 8 routes covering bills, demand, consumption anomalies, findings, ESG, payment reconciliation, and now like-for-like YoY comparison.
- Goal
- Ship a production-quality multi-site energy management UI that Grupo Axo/McKinsey can use for portfolio-level decisions, with a real findings engine backed by a domain entity and API.
- Sub-goals
- SG-1: Findings ledger domain entity + P1 detection engine (8 corpus-verified detectors)
- SG-2: P2 read-only findings API (ts-rest, energia-entitled)
- SG-3: Comparativo with real Axo consumption data + normalization
- SG-4: Portfolio/branch scope + date-range controls
- SG-5: Gasto (MXN) unit comparison alongside kWh
- SG-6: Like-for-like YoY by complete calendar month + same-store (this commit)
- What
- Added a 'Año vs año' tab to Comparativo implementing like-for-like YoY methodology: calendar-month completeness via half-open day allocation, same-store filtering with explicit exclusion reasons (incompleta, sin_año_previo, cerrada), window anchored at last month complete for 3+ stores. Includes BarChart (monthly), KPI row, comparable-stores table with CSV export, and hides non-applicable controls (Alcance, Métrica, Periodo, Tienda) when in YoY view.
- Why
- Grupo Axo / McKinsey requested like-for-like YoY as the portfolio methodology — raw CFE-period sums mix different store populations month-to-month, making trend analysis unreliable. The calendar-month approach neutralizes CFE's bimonthly billing cycle.
- Areas
- apps/platform/src/app/[locale]/(dashboard)/energia+5200−4apps/platform/src/api+358−0apps/platform/src/lib+660−1domains/utility+60−0packages/database+1−0packages/api+0−0docs/specs+122−0
- Blast
- ~50 files, +26017/-4 lines across energia UI routes, findings API, demo data libs, and domain events. No schema migrations in this commit. All changes are additive.
Findings · 20
correctness3
`monthly` output lacks comparable-store count — silent zeros ambiguous
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/likeForLike.ts:205
When `resolveWindow` returns a window anchored at the last month complete for 3+ stores but earlier months have fewer, the bar chart shows zeros for those months. The `monthly` type has no `n` field, so the UI cannot distinguish 'comparable stores consumed zero' from 'no comparable stores qualified this month'. This violates the honesty contract — the chart title says 'Mes a mes' with no qualification. Add `n: comparable.filter(r => r.curMonths[i] != null).length` to each monthly entry.
Bill supersession sort doesn't clear older bill's tail on partial re-issues
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/likeForLike.ts:75
Bills are sorted by (ps ASC, pe ASC). A corrective shorter bill (same start, shorter end) sorts second and overwrites the prefix correctly, but leaves the original bill's per-day value on the non-overlapping tail days — the older value survives. Coverage count is unaffected; allocated kWh on those tail days comes from the superseded bill. Low probability with bimonthly CFE data, but the doc comment promises more than the algorithm delivers.
t18 has a 30-day billing gap (Mar 23 – Apr 22 2026) — `ym='2026-04'` label is cosmetically misleading
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/realConsumption.ts
The algorithm correctly handles this (Apr 2026 gets only 9 covered days, never complete). The `ym` label for the bill covering Apr 22–May 21 is '2026-04' but the bill is 87% in May. calendarMonths ignores ym entirely, so no algorithmic error — purely cosmetic.
security3
Real Grupo Axo financial data in client-served JS bundle
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/realConsumption.ts
The STORES array contains real MXN bill totals, kWh values, and period dates extracted from production Supabase. This ships in the Next.js client JS chunk and is readable by any authenticated Batu user — not just Grupo Axo. Anonymizing store names is necessary but not sufficient when real financials are included. Either scope the page to Grupo Axo's org only or replace exact MXN/kWh figures with order-of-magnitude-preserving synthetic values.
Real production data now permanent in git history
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/realConsumption.ts
Even if replaced with synthetic data in a future commit, the real Grupo Axo kWh/MXN figures are permanently in commit 7fc4ef0d. If the repo is ever shared externally, a filter-branch or git-filter-repo operation would be needed to expunge them.
`escapeCell` in csv.ts doesn't strip CSV formula-injection prefixes
apps/platform/src/app/[locale]/(dashboard)/energia/_components/csv.ts
Cells starting with `=`, `+`, `-`, or `@` are not escaped. Safe today with hardcoded store names and numeric values, but a latent bug if `exportCsv` is ever called with user-supplied free-text strings.
conventions1
Duplicate `shortMonth`/`monthLabel` in same `_lib/` — `shortMonth` has latent timezone bug
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/likeForLike.ts
`monthLabel` (new) and `shortMonth` in anomaly.ts produce the same `'Mmm YY'` es-MX format. `monthLabel` correctly adds `timeZone: 'UTC'` to prevent local-to-UTC day-1 rounding from mislabeling a month (e.g., 2024-11 → 'oct. 24' in UTC-5 before 05:00). Remove `shortMonth` from anomaly.ts and import `monthLabel` instead.
tests7
`cerrada` exclusion branch (t09 spike) has no test
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/__tests__/likeForLike.test.ts
The CLOSED_FRACTION=0.25 check in analyzeOne — which flags stores that opened/closed mid-window by detecting months below 25% of their median — is completely untested. t09 (Tienda Nuevo León 09) has a 343 kWh month against a ~3000 kWh typical, making it the obvious test fixture. A regression in median() or the fraction constant would go undetected.
`monthly` output shape and values not asserted
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/__tests__/likeForLike.test.ts
The test only asserts `res.monthly.length === 12`. It does not verify that each element has `{mes, actual, previo}` keys, that `mes` matches `res.window.cur[i]`, or that `actual` equals the sum of comparable store values for that index. A broken reduce (wrong index, wrong field) would silently produce a chart full of zeros.
`daysInMonth` leap-year boundary not tested
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/__tests__/likeForLike.test.ts
The indirect invariant test only covers months present in STORES (t01–t16 bimonthly bills don't land on February). A wrong `daysInMonth` for a leap February would mark 28 days as incomplete → never comparable. Add: `daysInMonth('2024-02') === 29` and `daysInMonth('2025-02') === 28`.
`addMonths` year-boundary not tested directly
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/__tests__/likeForLike.test.ts
JS `%` on a negative number returns a negative remainder. The current arithmetic is safe for realistic dates, but there's no direct assertion. Add: `addMonths('2025-01', -1) === '2024-12'` and `addMonths('2024-12', 1) === '2025-01'`.
`resolveWindow` boundary test missing: exactly MIN_STORES_FOR_WINDOW (3) stores
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/__tests__/likeForLike.test.ts
The filter is `n >= MIN_STORES_FOR_WINDOW`. There's no test asserting that exactly 3 stores with a complete month still produces a valid window. Catches a future `>` vs `>=` drift.
Bill supersession not tested with overlapping synthetic fixture
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/__tests__/likeForLike.test.ts
The algorithm's promise that a later bill overwrites the earlier one's days is exercised only implicitly via the invariant tests. A targeted synthetic store with two overlapping bills would pin the behavior explicitly.
`resolveWindow` with custom `months` param not tested
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/__tests__/likeForLike.test.ts
Only the default `months=12` is tested. Add a `months=6` case: `resolveWindow(covs, 6).cur.length === 6` and `prev[0]` is 12 months before `cur[0]`.
improvement6
Double `Map.get` per month in `analyzeOne`
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/likeForLike.ts:150
`cov.get(m)?.complete ? cov.get(m)!.value : null` calls Map.get twice and relies on a non-null assertion. Replace with `const entry = cov.get(m); return entry?.complete ? entry.value : null`.
Non-comparable `StoreYoY` rows carry misleading non-zero `cur`/`prev`
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/likeForLike.ts:155
The `sum` helper uses `?? 0` for null months, so excluded stores have non-zero `cur`/`prev` (the partial-year sum). These fields are never surfaced in the UI but could mislead future consumers of `StoreYoY`. Document that `cur`/`prev` are undefined when `comparable === false`, or zero them out.
Dynamic object keys in `yoyRows` make CSV column shape unit-dependent
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:279
`[unit === 'mxn' ? 'Actual ($)' : 'Actual (kWh)']` creates a different object shape per unit. If `exportCsv` uses `Object.keys(rows[0])` as headers, this is safe for now but fragile. Prefer fixed column names (`'Actual'`, `'Año previo'`) and reflect the unit in the filename only (already done).
`deltaPct` precision inconsistency: KPI `toFixed(1)` vs table `toFixed(0)`
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:729
The headline KPI shows '+2.3%' but the per-store table shows '+2%' for the same value. Align to `toFixed(1)` throughout for consistency.
`ExclusionReason = 'ok'` is redundant with the `comparable` boolean
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/likeForLike.ts:121
`comparable` is derived directly from `reason === 'ok'`, so both fields encode the same information and must be kept in sync. Either drop `'ok'` from `ExclusionReason` and use `ExclusionReason | null`, or drop the `comparable` field.
`deltaPct.toFixed(0)` in table vs `toFixed(1)` in KPI (also in correctness findings)
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx
Cross-referenced with improvement finding above.
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:04current
- 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: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