← all branches

feat/ui-impact

needs attentionviewing older commit
261b55e · incrementalpre-PRreviewed 2026-07-24 00:38 UTC5H · 11M · 6L · 2I
The branch
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
The changes (whole branch)
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)+3600
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.
Demo-facing, not yet a production data flow Static data hardcoded in client bundle — data isolation concern Customer name (Grupo Axo) appears in git history
ci· No open PR — no CI run availablecoderabbit· No .coderabbit.yaml in repo

Findings · 24

correctness7

high

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.

high

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.

high

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.

medium

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.

medium

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.

low

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('-', '/').

low

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

high

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.

medium

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

high

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.

medium

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.

medium

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).

medium

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.

medium

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.

low

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.

info

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

high

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.

medium

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.

low

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

medium

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.

medium

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.

low

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.

low

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.

info

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

  1. 96ca7d7needs attentionincremental0H · 9M · 12L2026-07-25 16:40
  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:38current
  14. 5b8a252needs attentionincremental3H · 6M · 9L2026-07-24 00:19
  15. f29cc5bneeds attentionfull9H · 17M · 11L2026-07-23 23:07