feat/ui-impact
needs attentionviewing older commit261b55e · incrementalpre-PRreviewed 2026-07-24 00:38 UTC5H · 11M · 6L · 2I- Purpose
- Build out the Batu Energia UI module — a demo-first, sales-facing energy analysis suite for multi-site enterprise customers, showcasing what the platform can surface once real consumption data flows through the Tinybird metrics pipeline.
- Goal
- Complete the Comparativo de consumo view with real Grupo Axo consumption data, a normalization toggle (kWh/día vs kWh/recibo), and per-store selection with cohort comparison.
- Sub-goals
- SG-1: Wire 28 anonymized stores with real kWh + billed-day series into a static _lib module
- SG-2: Normalization toggle (kWh/día default vs kWh/recibo raw) with callout explaining why
- SG-3: Store selection via search + cohort comparison (same-tariff peers)
- SG-4: Ranking table showing deviation from cohort median over last 6 months
- SG-5: Open on t26 by default to showcase the real +220% anomaly
- What
- Added realConsumption.ts (86 lines, 28 anonymized stores from Grupo Axo, kwhDia/sumKwh/meanKwhDia helpers) and rewired page.tsx (~275 lines) from a skeleton to a fully interactive comparativo view with metric toggle, store search, cohort chart, ranking table, and KPI cards.
- Why
- The Comparativo view needs real data to be credible in a sales demo. Grupo Axo's portfolio provides a realistic multi-tariff, multi-region dataset with a genuine anomaly (t26, +220%) that demonstrates the platform's detection value.
- Areas
- apps/platform (energia/comparativo)+360−0
- Blast
- 2 files, +360 lines. Incremental on top of the feat/ui-impact branch which totals ~4,800 lines across the full energia module. No API contracts, no database schema, no shared packages changed in this increment.
Findings · 24
correctness7
cohortMedianNow (median-of-means) doesn't match chart's per-month 'esperado'
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:96
The chart's 'esperado' line computes median(cohortVals for ym) — a per-month cross-store median. The KPI 'Mediana de sus pares' / myPct uses cohortMedianNow = median(ranking[*].mine), where each .mine is the 6-month mean for that store. Median-of-means ≠ mean-of-medians. For a store like t26 with a spike from 2025-10, its elevated .mine shifts cohortMedianNow upward (since t26 is in its own cohort), but the chart's esperado correctly shows the monthly median excluding the spike. The KPI headline therefore references a number that doesn't appear anywhere on the chart, misleading in a demo context.
Cohort includes selected store — self-inclusion bias in median reference
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:57
cohort = STORES.filter(s => s.tarifa === store.tarifa) includes the selected store. cohortMedianNow and ranking are therefore computed over a peer group that includes the subject, so myPct compares a store against a median its own value has shifted. For the t26 anomaly (+220%), this understates the true deviation because t26's elevated values inflate the reference median. Standard peer-benchmark practice excludes the subject from the reference group.
median() floor-index — biased low on even-count cohorts
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:38
Returns s[Math.floor(s.length / 2)], the upper-lower-median for even lengths, which is consistently below the true median. PDBT has 16 stores (even); GDMTH/GDMTO have 6 stores (even). Every deviation % is calculated against a systematically low reference, inflating positive deviations and suppressing negative ones. Also defined inside the page module, so it cannot be unit-tested without mounting the React tree.
T10 data point 1547 kWh in 2025-07 — probable typo vs ~15-20k neighbors
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/realConsumption.ts:48
t10 (Tienda Providencia 10, GDMTO) shows 1547 kWh in 2025-07, surrounded by 20343 (Jun) and 17546 (Aug). A ~92% single-month drop is almost certainly a data-entry error (missing digit: likely 15470 or 17547). This pulls the GDMTO cohort median for 2025-07 sharply downward, creating a false anomaly signal for all GDMTO stores in that month.
Bimonthly stores in cohort distort raw 'kWh/recibo' mode
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:56
Bimonthly stores (t17, t27, t28 in PDBT) have ~60-day periods, so their raw kWh is roughly double a monthly store's. When metric='cfe' (raw mode), these stores appear as severe outliers in the cohort ranking — a false anomaly. The default 'norm' mode (kWh/día) sidesteps this correctly, but toggling to 'cfe' exposes it. Consider filtering bimonthly stores from the cohort or adding a warning badge on their ranking rows in raw mode.
ym.slice(2) produces ambiguous YY-MM labels across year boundaries
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:72
Produces '25-06', '26-07', '24-07'. When a series spans 2024–2026, the chart X-axis doesn't show year changes, making the timeline ambiguous. Consider showing the year on January ticks or using ym.slice(2).replace('-', '/').
Sparse cohort: recent-6 window unevenly weights stores missing recent data
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:81
monthsOf(cohort).slice(-6) may include months where most stores have data but some don't. Stores missing recent months contribute fewer data points to their .mine average, making ranking inconsistently weighted. Bimonthly stores are particularly affected (0-3 of 6 recent months populated), making their ranking position unreliable.
security2
Named Grupo Axo in source — customer attribution + real data in git history
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/realConsumption.ts:4
The module-level comment explicitly names 'Grupo Axo portfolio' as the data source and states kWh values, billed-day lengths, tariffs and regions are REAL. This attribution is now permanent in git history. Any repo access (current or future) exposes that Grupo Axo consumes N kWh/month across named store locations — commercially sensitive under LFPDPPP and standard B2B data-handling obligations, even with RPU omitted. The anomaly comment on line 65 adds interpretive context that makes the data more valuable to a competitor. Fix: strip 'Grupo Axo' from comments (use a generic label like 'sample-portfolio'); consider serving the data from a per-org API endpoint instead of source.
Real consumption data bundled into client JS — visible to all authenticated users
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/realConsumption.ts
Because this is a 'use client' import chain, Next.js bundles the entire STORES array (28 stores, 13 months each) into the browser JS delivered to every authenticated Batu user — not just Grupo Axo's own users. Any authenticated user can read the raw data from their browser dev-tools regardless of what the UI renders. Moving this to a server component or an org-scoped API endpoint would prevent cross-org data exposure.
conventions7
All user-facing strings hardcoded — next-intl not used
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:112
Every label, placeholder, heading, callout, and badge text is inlined in JSX. next-intl is not imported. The ui-patterns.md rule is unambiguous: 'Never hardcode user-facing strings — use next-intl messages/{locale}.json.' This is a systemic violation, not an isolated lapse — it affects the entire page.
Em dashes in user-facing JSX render output
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:182
Lines 182, 192, and 217 use em dashes (—) in rendered label text (Kpi label props and CardTitle template literals). The ui-patterns.md rule bans em dashes in user-facing copy. Replace with periods or commas.
Hardcoded hex colors (#7C3AED, #94A3B8) instead of CSS design tokens
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:207
The chart uses stroke="#94A3B8" and stroke="#7C3AED". XAxis/YAxis already use hsl(var(--muted-foreground)) correctly. Use registered CSS variables (e.g., hsl(var(--brand-heliotrope)) or define a chart palette in the token system).
Inline Kpi component reinvents existing KpiTile primitive
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:264
The inline Kpi component (label, large mono value, sub-caption, accent coloring) duplicates the canonical KpiTile/KpiStat at apps/platform/src/components/kpi/. Product-ux.md P8 mandates composing existing primitives. The deviation coloring (rose/emerald) could be a variant prop on KpiTile.
Search dropdown reinvents Combobox — missing ARIA and keyboard nav
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:130
@batu/ui exports a Combobox component (packages/ui/src/components/combobox.tsx). The hand-rolled Input + absolutely-positioned div dropdown lacks: role='combobox', aria-expanded, role='listbox', role='option', and keyboard navigation (Enter selects, Esc closes). ui-patterns.md explicitly requires these. Replace with the existing Combobox.
Unused exports in realConsumption.ts (sumKwh, meanKwhDia, REGIONS)
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/realConsumption.ts:75
All three are exported but unimported anywhere in the comparativo directory (confirmed). As a _lib/ module scoped to a single route, they add dead surface. Remove until a consumer exists, or mark as @internal.
Anomaly comment on t26 is appropriate and load-bearing
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/realConsumption.ts:65
The comment '// Anomalía real: salto sostenido a partir de 2025-10 (≈ +220% vs su propia base).' explains WHY t26 is the default storeId — exactly the canonical-form.md pattern. No action needed.
tests3
sumKwh / meanKwhDia have no unit tests despite load-bearing edge cases
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/realConsumption.ts:75
Both are pure exported functions. Critical unguarded cases: (1) empty window → sumKwh returns 0, meanKwhDia returns null; (2) bimonthly stores (t17: dias=62) — the kwhDia normalization is the core selling point; (3) window months not in series; (4) single-point window. These are ~10 Vitest assertions colocatable at _lib/__tests__/realConsumption.test.ts, runnable at ~5000/sec.
STORES data integrity: no validation test guards against typos
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/realConsumption.ts:36
The t10 outlier (1547 kWh) would have been caught by a data-integrity assertion: no GDMTO/GDMTH store point has kwh < 5000, or more generally no point deviates > 5x from the store's own median. Also missing: unique ID assertion (new Set(STORES.map(s=>s.id)).size === STORES.length), ym format match (/^\d{4}-\d{2}$/), dias > 0, kwh > 0.
No unit test asserting store ID uniqueness in STORES
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/_lib/realConsumption.ts:36
A copy-paste duplicate id would silently cause the store selector and ranking key to match the wrong store. One assertion guards this permanently: expect(new Set(STORES.map(s=>s.id)).size).toBe(STORES.length).
improvement5
Callout claims 'deduplicamos lecturas estimadas' — not implemented
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:167
The normalization callout reads 'dividimos el consumo entre los días facturados y deduplicamos lecturas estimadas.' The implementation does only division (p.kwh / p.dias); no deduplication of estimated readings occurs anywhere. A factually incorrect claim in a demo-facing page that prospects will scrutinize. Remove 'y deduplicamos lecturas estimadas' or replace with an accurate description.
Ranking table silently hides selected store when ranked > 10th
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:222
ranking.slice(0, 10) is rendered with no indication that further stores exist. The selected store may not appear at all. In a demo, clicking a store and then not seeing it in the ranking is confusing. Fix: pin the selected store at the bottom of the visible list (with a separator) if it would otherwise be cut off, or show its rank position.
connectNulls hides bimonthly store data gaps
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:207
Both chart lines use connectNulls, which draws straight interpolation across bimonthly gaps (t17/t27/t28 have entries only every 2 months). The data-density story is a key demo talking point; visual breaks would naturally spark the billing-cadence discussion. Consider removing connectNulls or using a distinct line style for stores with sparse series.
Search truncates to 12 results silently
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:106
results.slice(0, 12) with no indication that 16 more stores may exist. With 28 stores, a broad query ('Tienda') returns 12 with no 'narrow your search' hint. Violates P5 (honest data). Add a disabled item at the bottom when results.length === 12 && query is truthy.
Kpi.pct prop silently ignored when accent=false
apps/platform/src/app/[locale]/(dashboard)/energia/comparativo/page.tsx:264
The Kpi component accepts pct?: number but only uses it when accent is also true. A caller passing pct without accent gets no coloring with no indication. Consider a discriminated union or renaming to accentValue. Single call-site so low priority.
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: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:38current
- 5b8a252needs attentionincremental3H · 6M · 9L2026-07-24 00:19
- f29cc5bneeds attentionfull9H · 17M · 11L2026-07-23 23:07