← all branches

feat/ui-impact

needs attention
96ca7d7 · incrementalpre-PRreviewed 2026-07-25 16:40 UTC0H · 9M · 12L · 4I
The branch
Purpose
feat/ui-impact builds the Energia analyst surface for Grupo Axo: real anonymized consumption data, findings API integration, and a Comparativo screen where energy analysts can defend year-over-year numbers in committee.
Goal
Deliver a committee-grade YoY comparison tool: honest exclusion of non-comparable stores, pre-priced period options, tariff-mix insight, and CSV exports auditable outside the app.
Sub-goals
  • Finding domain (API + detection engine + ledger)
  • Hallazgos/Resumen/Demanda wired to real findings API
  • Comparativo: like-for-like YoY with same-store + complete calendar months
  • Comparativo: user-pinned period ranges with pre-priced picker
  • Separation of DiD (detect) from LfL YoY (report) — one screen, one model
The changes (whole branch)
What
Refactored Comparativo page: removed DiD model entirely (moved to ConsumptionFindingContent where it belongs), made the screen purely like-for-like YoY. Added user-pinned period ranges with pre-priced picker showing every option's answer before selection. Added `coverages()` memoization, `lastCompleteMonth()` anchor, `pinned` flag on LfLWindow, `forcedEnd` param on resolveWindow/likeForLike. Moved Unit type to realConsumption.ts. Fixed ConsumptionFindingContent button label. Added tariff-mix insight. Improved CSV export (window-stamped, días as boolean column).
Why
Running two models on one screen (DiD +109% vs LfL +14% for the same store) left users unable to choose. Each model now owns its job: DiD raises findings, LfL YoY reports the committee number. Period picker pre-prices ranges because the range decides the sign of the answer — hiding that doesn't stop cherry-picking, it only stops noticing.
Areas
apps/platform+75102domains/utility+48241packages/database+134800domains/core+11packages/api+1531docs/specs+1220
Blast
71 files, +26271/-5 total; primarily apps/platform (energia analyst surface) and packages/database (seed data). No API contract changes, no schema migrations in this commit.
static-demo-data no-i18n-strings no-pr-yet
ci· no PR — CI status not availablecoderabbit· no .coderabbit.yaml

Findings · 22

correctness4

medium

anchor unit-dependency can silently shift 'cal' preset to a different calendar year

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

anchor = lastCompleteMonth(coverages(STORES, unit)) depends on unit. If kwh anchor is 2026-01 but mxn anchor is 2025-12 (possible when some stores have no MXN data for the most recent month), buildSpecs computes y=2026 for kwh ('Año calendario 2025') but y=2025 for mxn ('Año calendario 2024'). Switching unit changes which prior year 'cal' describes with the same label. ytd months count also shifts. Not a bug with current hardcoded STORES but a latent trap once live data is wired.

low

monthSpan(from > to) silently collapses to 1-month window

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

When customFrom > customTo, monthSpan returns a negative number. Math.max(1, negative) = 1, so months=1. The w.months < 12 warning fires but doesn't explain the reversed range. No input validation prevents from > to entry.

low

lastCompleteMonth and resolveWindow share a hidden threshold coupling

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

Both use >= 2 as the minimum-stores threshold independently. If one is changed without the other, the anchor from lastCompleteMonth could point to a month that resolveWindow's candidate set excludes, making ytd/cal presets anchor on a month that never appears as a viable window end.

info

WeakMap cache acts as permanent cache — correct for frozen data

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

Since STORES holds strong references to all Store objects, WeakMap entries are never GC'd. Correct for the current frozen dataset. Becomes a stale-cache trap if Store objects are ever created dynamically.

security2

medium

URL ?tienda= param used in state before STORES validation check

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

The raw query string value is extracted before the STORES.some guard runs. Confirm the raw param is never interpolated into the DOM before the guard completes. React JSX escaping mitigates direct interpolation but an aria attribute or label assembled from the raw value before validation could be a reflected XSS vector.

low

useFindings data rendered without explicit sanitization gate

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

Findings fetched via useFindings({ limit: 200 }) are used to build a flagged set of store IDs. If any finding field is ever passed to a component using dangerouslySetInnerHTML, server-originated strings could carry XSS payloads. React default escaping covers direct interpolation.

conventions5

medium

Module-level mutable WeakMap cache in nominally-pure module

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

COV_CACHE is a module-level mutable singleton. The cache is never cleared for the module lifetime and test isolation requires object identity, not value equality. Pragmatic for a frozen demo dataset but breaks FCIS purity. Will not survive when STORES becomes a live-fetched array with different identity per org.

low

Hardcoded user-facing strings — i18n convention not followed

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

All copy is hardcoded in Spanish inline. Convention requires messages/{locale}.json via next-intl. Consistent deviation across the energia module but still a blocker for any future locale addition.

low

COV_CACHE write-then-set is a no-op for existing entries

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

coverages() retrieves perUnit, mutates it, then re-sets it. For an existing entry the COV_CACHE.set call is a no-op (mutation already updated the stored reference). Misleading — reads as copy-on-write semantics that don't exist. Cleaner: check presence first, only set on first use.

info

Unit type correctly moved to realConsumption.ts SSOT

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

Unit was in anomaly.ts; moved to realConsumption.ts (correct — data module owns its vocabulary). Re-export from anomaly.ts preserves backward compat. Dependency direction now flows correctly: likeForLike imports Unit from realConsumption, not anomaly.

info

useFindings on a mostly-static page — correct but creates implicit auth dependency

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

The page is otherwise powered by static STORES constant. useFindings is the only live API call. The status === 200 guard handles failure correctly, but mixing live API state into a static demo surface creates an implicit auth/org context dependency not obvious from the component signature.

tests6

medium

monthSpan() untested — year-boundary arithmetic unverified

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

monthSpan(from, to) computes the inclusive month count for the 'custom' range, feeding directly into likeForLike()'s months param. An off-by-one at year boundaries (e.g. '2025-12' to '2026-01' should be 2) would silently produce the wrong window. No test covers this function.

medium

buildSpecs() untested — all four range spec derivations unverified

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

buildSpecs(anchor, customFrom, customTo) derives all four RangeSpec entries including year arithmetic for 'cal' (y-1) and month count for 'ytd'. A wrong year subtraction or month extraction would silently produce the wrong label and pinned end. No test covers any buildSpecs path.

medium

subMonths() local copy untested — diverges from addMonths with no guard

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

subMonths() in page.tsx is a near-copy of the exported addMonths() from likeForLike.ts with sign flipped. The local copy is untested and could diverge at year boundaries. Import addMonths instead to remove the duplication.

low

mix computation logic untested — financial formula has no unit test

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

yoy.prev * (1 + other.deltaPct / 100) - yoy.cur with noise threshold, same-store-set guard, and unit guard are all untested. The formula is non-trivial; percent vs decimal confusion is a known trap. Extract to _lib and test.

low

windowLabel() — w.cur[0]! access unguarded on empty cur array

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

windowLabel(res) accesses w.cur[0]! without null guard after checking w exists. An empty cur array on a degenerate result (months=0?) would throw at render. No test covers the empty-cur edge case.

info

sameSet check untested — set-equality logic for population coherence unverified

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

sameSet verifies kWh and MXN results cover exactly the same store set before showing the tariff-mix insight. Its correctness gates a financial insight. A targeted test for a case where sets differ by one store would provide a regression anchor.

improvement5

medium

mix.diff formula obscures its intent — needs named intermediate

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

yoy.prev * (1 + other.deltaPct / 100) - yoy.cur computes costAtLastYearPrices - actualCost. Add a named intermediate: const costAtPriorTariffs = yoy.prev * (1 + other.deltaPct / 100) so the subtraction is self-documenting. The current form requires the reader to verify the algebra to trust the sign of the UI sentence.

medium

subMonths duplicates addMonths — import instead

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

subMonths(ym, n) is identical in structure to addMonths(ym, -n) from likeForLike.ts, which is already exported. Replace the local copy with addMonths from _lib/likeForLike. Eliminates two implementations of the same arithmetic that can diverge silently.

medium

signed() uses Unicode minus U+2212, pct() uses ASCII hyphen — inconsistent in financial table

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

signed() emits '−' (U+2212) for negative values; pct() lets n.toFixed() emit '-' (U+002D). In cells showing both formatters side-by-side the glyphs differ. Either adopt Unicode minus in both, or ASCII in both.

low

completeTally duplicated in lastCompleteMonth and resolveWindow

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

Both lastCompleteMonth and resolveWindow (non-pinned branch) build the same tally = Map<ym, storeCount> from covs. Extract a shared helper completeTally(covs) to remove the duplication and couple the >=2 threshold at one site.

low

Period picker's unit-dependency not surfaced to user

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

Switching kWh ↔ MXN can shift the anchor and thus 'ytd'/'cal' labels. The user sees options change with no explanation. A tooltip or note on those rows explaining 'basado en meses completos de la unidad seleccionada' would prevent confusion.

History · 15 commits

  1. 96ca7d7needs attentionincremental0H · 9M · 12L2026-07-25 16:40current
  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: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