Skip to content

Commit cbbf11f

Browse files
committed
fix(ci): app/src/data estaba ignorada por el patron data/ del gitignore — el frontend nunca compilaba en CI (solo en local, donde los ficheros existian sin trackear)
1 parent 9539a98 commit cbbf11f

4 files changed

Lines changed: 485 additions & 2 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ app/dist/
1313
*.log
1414

1515
# Data
16-
data/
17-
server/data/
16+
/server/data/
1817
*.db
1918
*.db-shm
2019
*.db-wal
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
2+
import type { ReactNode } from 'react';
3+
import { getTimes } from 'suncalc';
4+
import type { ConnectionStatus, DayKpis, HistoryDay, LivePower, PowerPoint } from '@/data/types';
5+
import { CO2_KG_PER_KWH, PRICE_EXPORT_EUR, PRICE_IMPORT_EUR, STEP_MIN, SUNRISE_MIN, SUNSET_MIN } from '@/data/types';
6+
import { useEnergySettings } from '@/hooks/useEnergySettings';
7+
import { apiFetch } from '@/data/api-client';
8+
9+
export interface EnergyDataApi {
10+
connectionStatus: ConnectionStatus;
11+
today: Date;
12+
nowMin: number;
13+
now: Date;
14+
liveTick: number;
15+
sunriseMin: number;
16+
sunsetMin: number;
17+
refresh: () => void;
18+
getLivePower: (atMin?: number, jitterSeed?: number) => LivePower;
19+
getDaySeries: (date?: Date) => PowerPoint[];
20+
isDayEstimated: (date?: Date) => boolean;
21+
getKpis: (date?: Date, untilMin?: number) => DayKpis;
22+
getHistory: (range?: { from?: Date; to?: Date }) => HistoryDay[];
23+
}
24+
25+
const EnergyDataContext = createContext<EnergyDataApi | null>(null);
26+
27+
const EMPTY_LIVE: LivePower = {
28+
production: 0,
29+
consumption: 0,
30+
batteryPower: 0,
31+
soc: 0,
32+
grid: 0,
33+
solis: 0,
34+
fox: 0,
35+
at: 0,
36+
};
37+
38+
const EMPTY_KPIS: DayKpis = {
39+
productionKwh: 0,
40+
consumptionKwh: 0,
41+
gridImportKwh: 0,
42+
gridExportKwh: 0,
43+
batteryChargedKwh: 0,
44+
batteryDischargedKwh: 0,
45+
soc: 0,
46+
autoconsumoPct: 0,
47+
autosuficienciaPct: 0,
48+
ahorroEur: 0,
49+
co2EvitadoKg: 0,
50+
peakProductionKw: 0,
51+
peakAt: 14 * 60,
52+
};
53+
54+
function dateKey(d: Date): string {
55+
const y = d.getFullYear();
56+
const m = String(d.getMonth() + 1).padStart(2, '0');
57+
const day = String(d.getDate()).padStart(2, '0');
58+
return `${y}-${m}-${day}`;
59+
}
60+
61+
function integrateSeries(series: PowerPoint[], untilMin: number, socFallback: number): DayKpis {
62+
const dtH = STEP_MIN / 60;
63+
let productionKwh = 0;
64+
let consumptionKwh = 0;
65+
let gridImportKwh = 0;
66+
let gridExportKwh = 0;
67+
let batteryChargedKwh = 0;
68+
let batteryDischargedKwh = 0;
69+
let directFvKwh = 0;
70+
let peakProductionKw = 0;
71+
let peakAt = 14 * 60;
72+
let soc = socFallback;
73+
74+
for (const p of series) {
75+
if (p.t >= untilMin) break;
76+
productionKwh += p.production * dtH;
77+
consumptionKwh += p.consumption * dtH;
78+
if (p.grid > 0) gridImportKwh += p.grid * dtH;
79+
else gridExportKwh += -p.grid * dtH;
80+
if (p.batteryPower > 0) batteryChargedKwh += p.batteryPower * dtH;
81+
if (p.batteryPower < 0) batteryDischargedKwh += -p.batteryPower * dtH;
82+
directFvKwh += Math.min(p.production, p.consumption) * dtH;
83+
if (p.production > peakProductionKw) {
84+
peakProductionKw = p.production;
85+
peakAt = p.t;
86+
}
87+
if (p.soc > 0) soc = p.soc;
88+
}
89+
90+
const autoconsumoPct = productionKwh > 0 ? Math.min(100, ((productionKwh - gridExportKwh) / productionKwh) * 100) : 0;
91+
const autosuficienciaPct =
92+
consumptionKwh > 0 ? Math.min(100, ((consumptionKwh - gridImportKwh) / consumptionKwh) * 100) : 0;
93+
return {
94+
productionKwh,
95+
consumptionKwh,
96+
gridImportKwh,
97+
gridExportKwh,
98+
batteryChargedKwh,
99+
batteryDischargedKwh,
100+
soc,
101+
autoconsumoPct,
102+
autosuficienciaPct,
103+
ahorroEur: (directFvKwh + batteryDischargedKwh) * PRICE_IMPORT_EUR + gridExportKwh * PRICE_EXPORT_EUR,
104+
co2EvitadoKg: productionKwh * CO2_KG_PER_KWH,
105+
peakProductionKw,
106+
peakAt,
107+
};
108+
}
109+
110+
export function EnergyDataProvider({ children }: { children: ReactNode }) {
111+
const [version, setVersion] = useState(0);
112+
const [now, setNow] = useState(() => new Date());
113+
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('reconnecting');
114+
const [settings] = useEnergySettings();
115+
116+
const [liveData, setLiveData] = useState<LivePower | null>(null);
117+
const dayCache = useRef(new Map<string, PowerPoint[]>());
118+
const dayEstimated = useRef(new Map<string, boolean>());
119+
const kpisCache = useRef(new Map<string, DayKpis>());
120+
const historyRef = useRef<HistoryDay[]>([]);
121+
const pending = useRef(new Set<string>());
122+
const refreshRef = useRef<() => void>(() => {});
123+
124+
const bump = () => setVersion((v) => v + 1);
125+
126+
useEffect(() => {
127+
const clock = window.setInterval(() => {
128+
if (document.hidden) return; // pestaña oculta: no re-renderizamos en vivo
129+
setNow(new Date());
130+
}, 1000);
131+
return () => window.clearInterval(clock);
132+
}, []);
133+
134+
useEffect(() => {
135+
let es: EventSource | null = null;
136+
try {
137+
es = new EventSource('/api/solar/stream');
138+
es.onopen = () => setConnectionStatus('connected');
139+
es.onmessage = (ev) => {
140+
try {
141+
const msg = JSON.parse(ev.data) as { type: string; data: LivePower & { connected?: boolean } };
142+
if (msg.type === 'live') {
143+
setLiveData(msg.data);
144+
setConnectionStatus('connected');
145+
bump();
146+
}
147+
} catch {
148+
/* mensaje no parseable */
149+
}
150+
};
151+
es.onerror = () => setConnectionStatus('reconnecting');
152+
} catch {
153+
setConnectionStatus('reconnecting');
154+
}
155+
return () => es?.close();
156+
}, []);
157+
158+
useEffect(() => {
159+
const load = async (key: string, url: string, apply: (data: never) => void) => {
160+
if (pending.current.has(key)) return;
161+
pending.current.add(key);
162+
try {
163+
const data = await apiFetch(url);
164+
apply(data as never);
165+
bump();
166+
} catch {
167+
/* reintento en el próximo ciclo */
168+
} finally {
169+
pending.current.delete(key);
170+
}
171+
};
172+
173+
const refresh = () => {
174+
const today = dateKey(new Date());
175+
void load(`day:${today}`, `/api/solar/day?date=${today}`, (res: { points: PowerPoint[]; estimated?: boolean }) => {
176+
dayCache.current.set(today, Array.isArray(res?.points) ? res.points : []);
177+
dayEstimated.current.set(today, !!res?.estimated);
178+
});
179+
void load(`kpis:${today}`, `/api/solar/kpis?date=${today}`, (k: DayKpis) => {
180+
kpisCache.current.set(today, k);
181+
});
182+
void load('history', '/api/solar/history', (res: { days: (Omit<HistoryDay, 'date'> & { date: string })[] }) => {
183+
historyRef.current = res.days.map((d) => ({ ...d, date: new Date(d.date + 'T00:00:00') }));
184+
});
185+
};
186+
187+
refreshRef.current = refresh;
188+
refresh();
189+
const timer = window.setInterval(refresh, 5 * 60 * 1000);
190+
const onVisible = () => {
191+
if (!document.hidden) {
192+
setNow(new Date());
193+
refresh();
194+
}
195+
};
196+
document.addEventListener('visibilitychange', onVisible);
197+
return () => {
198+
window.clearInterval(timer);
199+
document.removeEventListener('visibilitychange', onVisible);
200+
};
201+
}, []);
202+
203+
const value = useMemo<EnergyDataApi>(() => {
204+
void version;
205+
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
206+
const todayKey = dateKey(now);
207+
const nowMin = now.getHours() * 60 + now.getMinutes() + now.getSeconds() / 60;
208+
209+
const ensureDay = (key: string) => {
210+
if (dayCache.current.has(key) || pending.current.has(`day:${key}`)) return;
211+
pending.current.add(`day:${key}`);
212+
apiFetch<{ points: PowerPoint[]; estimated?: boolean }>(`/api/solar/day?date=${key}`)
213+
.then((res) => {
214+
dayCache.current.set(key, res.points);
215+
dayEstimated.current.set(key, !!res.estimated);
216+
bump();
217+
})
218+
.catch(() => {})
219+
.finally(() => pending.current.delete(`day:${key}`));
220+
};
221+
222+
const ensureKpis = (key: string) => {
223+
if (kpisCache.current.has(key) || pending.current.has(`kpis:${key}`)) return;
224+
pending.current.add(`kpis:${key}`);
225+
apiFetch<DayKpis>(`/api/solar/kpis?date=${key}`)
226+
.then((k) => {
227+
kpisCache.current.set(key, k);
228+
bump();
229+
})
230+
.catch(() => {})
231+
.finally(() => pending.current.delete(`kpis:${key}`));
232+
};
233+
234+
const live = liveData ?? EMPTY_LIVE;
235+
const sunTimes =
236+
settings.locationLat !== null && settings.locationLon !== null
237+
? getTimes(now, settings.locationLat, settings.locationLon)
238+
: null;
239+
const sunriseMin = (() => {
240+
if (sunTimes?.sunrise && !Number.isNaN(sunTimes.sunrise.getTime())) {
241+
return sunTimes.sunrise.getHours() * 60 + sunTimes.sunrise.getMinutes();
242+
}
243+
const nr = live.sun?.nextRising ? new Date(live.sun.nextRising) : null;
244+
if (nr && dateKey(nr) === todayKey) return nr.getHours() * 60 + nr.getMinutes();
245+
return SUNRISE_MIN;
246+
})();
247+
const sunsetMin = (() => {
248+
if (sunTimes?.sunset && !Number.isNaN(sunTimes.sunset.getTime())) {
249+
return sunTimes.sunset.getHours() * 60 + sunTimes.sunset.getMinutes();
250+
}
251+
const ns = live.sun?.nextSetting ? new Date(live.sun.nextSetting) : null;
252+
if (ns && dateKey(ns) === todayKey) return ns.getHours() * 60 + ns.getMinutes();
253+
return SUNSET_MIN;
254+
})();
255+
256+
return {
257+
connectionStatus,
258+
today: todayStart,
259+
nowMin,
260+
now,
261+
liveTick: version,
262+
refresh: () => {
263+
dayCache.current.clear();
264+
kpisCache.current.clear();
265+
historyRef.current = [];
266+
refreshRef.current();
267+
},
268+
getLivePower: (atMin?: number) => {
269+
if (atMin !== undefined && atMin < nowMin - 2) {
270+
const series = dayCache.current.get(todayKey);
271+
if (series && series.length > 0) {
272+
const idx = Math.min(series.length - 1, Math.max(0, Math.round(atMin / STEP_MIN)));
273+
const p = series[Math.min(idx, series.length - 1)];
274+
return {
275+
production: p.production,
276+
consumption: p.consumption,
277+
batteryPower: p.batteryPower,
278+
soc: p.soc,
279+
grid: p.grid,
280+
solis: p.solis,
281+
fox: p.fox,
282+
at: p.t,
283+
sun: live.sun,
284+
weather: live.weather,
285+
};
286+
}
287+
}
288+
return { ...live, at: live.at || nowMin };
289+
},
290+
getDaySeries: (date?: Date) => {
291+
const key = date ? dateKey(date) : todayKey;
292+
ensureDay(key);
293+
const v = dayCache.current.get(key);
294+
return Array.isArray(v) ? v : [];
295+
},
296+
isDayEstimated: (date?: Date) => {
297+
const key = date ? dateKey(date) : todayKey;
298+
return dayEstimated.current.get(key) === true;
299+
},
300+
getKpis: (date?: Date, untilMin?: number) => {
301+
const key = date ? dateKey(date) : todayKey;
302+
if (untilMin !== undefined && key === todayKey && untilMin < nowMin - 2) {
303+
const series = dayCache.current.get(key);
304+
if (series && series.length > 0) return integrateSeries(series, untilMin, live.soc || 0);
305+
}
306+
ensureKpis(key);
307+
return kpisCache.current.get(key) ?? EMPTY_KPIS;
308+
},
309+
getHistory: (range?: { from?: Date; to?: Date }) => {
310+
const days = historyRef.current;
311+
if (!range?.from && !range?.to) return days;
312+
return days.filter((d) => (!range.from || d.date >= range.from) && (!range.to || d.date <= range.to));
313+
},
314+
sunriseMin,
315+
sunsetMin,
316+
};
317+
}, [version, now, connectionStatus, liveData, settings.locationLat, settings.locationLon]);
318+
319+
return <EnergyDataContext.Provider value={value}>{children}</EnergyDataContext.Provider>;
320+
}
321+
322+
export function useEnergyData(): EnergyDataApi {
323+
const ctx = useContext(EnergyDataContext);
324+
if (!ctx) throw new Error('useEnergyData debe usarse dentro de <EnergyDataProvider>');
325+
return ctx;
326+
}

0 commit comments

Comments
 (0)