feat/ui-impact
needs attention96ca7d7 · incrementalpre-PRreviewed 2026-07-25 16:40 UTC0H · 9M · 12L · 4I- 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
- 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+7510−2domains/utility+4824−1packages/database+13480−0domains/core+1−1packages/api+153−1docs/specs+122−0
- 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.
Findings · 22
correctness4
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.
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.
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.
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
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.
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
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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
- 96ca7d7needs attentionincremental0H · 9M · 12L2026-07-25 16:40current
- 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: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