Skip to content

Commit 2fefa14

Browse files
committed
feat(push): notificaciones Web Push VAPID con 4 alertas de energia
- Server: motor push.js (i18n es/en/zh server-side, preferencias por tipo, quiet hours con cola consolidada, borrado 404/410), rutas subscribe/ unsubscribe/vapid-public-key/preferences, 4 tablas nuevas (CREATE IF NOT EXISTS, datos existentes intactos) - Motor de alertas con flancos y anti-rebote: inversor offline (2 min), corte de red (heuristica grid~0 + consumo desde bateria/PV, 3 min), bateria baja (SOC <= reserva con histeresis), resumen diario 21:00 - Front: sw.js minimo (solo push, sin cache), usePush, seccion Notificaciones en Ajustes con toggles por tipo, i18n x3 - Dormido sin HTTPS/localhost o sin VAPID (misma decision que Deltos) - Tests 27/27, lint 0 errores, build ok; v0.3.0
1 parent 40eff56 commit 2fefa14

17 files changed

Lines changed: 1440 additions & 5 deletions

File tree

‎app/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "helios",
33
"private": true,
4-
"version": "0.2.0",
4+
"version": "0.3.0",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

‎app/public/sw.js‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Helios — service worker (notificaciones push).
3+
* Mínimo a propósito: SIN caché ni fetch handler (la app siempre va a red;
4+
* el SW existe solo para Web Push). Solo se registra en contextos seguros
5+
* (HTTPS o localhost); en LAN HTTP `navigator.serviceWorker` es undefined.
6+
* Actualización: SKIP_WAITING desde la UI (Ajustes → Acerca de) si se añade.
7+
*/
8+
9+
self.addEventListener('install', () => self.skipWaiting())
10+
self.addEventListener('activate', (event) => {
11+
event.waitUntil(self.clients.claim())
12+
})
13+
14+
self.addEventListener('message', (event) => {
15+
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting()
16+
})
17+
18+
// === PUSH: recepción =========================================================
19+
// REGLA CRÍTICA: TODO evento push termina en showNotification() — si no,
20+
// Chrome muestra un aviso genérico y Safari REVOCA el permiso.
21+
// El servidor envía payload híbrido: campos planos (title/body/url/tag) para
22+
// este handler + bloque "web_push" (Declarative Web Push, Safari/iOS 18.4+).
23+
self.addEventListener('push', (event) => {
24+
event.waitUntil(
25+
(async () => {
26+
let datos = {}
27+
try {
28+
datos = event.data ? event.data.json() : {}
29+
} catch {
30+
datos = {} // payload corrupto: se muestra el fallback igualmente
31+
}
32+
await self.registration.showNotification(datos.title || 'Helios', {
33+
body: datos.body || 'Tienes una alerta de energía',
34+
icon: '/pwa-icon-192.png',
35+
badge: '/pwa-icon-192.png',
36+
tag: datos.tag || 'default', // coalescing: mismo tag reemplaza la anterior
37+
renotify: true,
38+
data: { url: datos.url || '/' },
39+
// NO actions/image/requireInteraction: no soportados en iOS.
40+
})
41+
})(),
42+
)
43+
})
44+
45+
// === PUSH: click en la notificación ==========================================
46+
self.addEventListener('notificationclick', (event) => {
47+
event.notification.close()
48+
const url = (event.notification.data && event.notification.data.url) || '/'
49+
event.waitUntil(
50+
clients
51+
.matchAll({ type: 'window', includeUncontrolled: true })
52+
.then((lista) => {
53+
for (const cliente of lista) {
54+
if (cliente.url === url && 'focus' in cliente) return cliente.focus()
55+
}
56+
return clients.openWindow(url)
57+
}),
58+
)
59+
})
60+
61+
// === PUSH: renovación automática de la suscripción ===========================
62+
// Red de seguridad (cobertura irregular entre navegadores): re-suscribe y
63+
// re-envía al servidor (upsert por endpoint). La higiene principal es el
64+
// borrado por 404/410 en el sender.
65+
self.addEventListener('pushsubscriptionchange', (event) => {
66+
event.waitUntil(
67+
self.registration.pushManager
68+
.subscribe({
69+
userVisibleOnly: true,
70+
applicationServerKey:
71+
event.oldSubscription && event.oldSubscription.options
72+
? event.oldSubscription.options.applicationServerKey
73+
: undefined,
74+
})
75+
.then((sub) =>
76+
fetch('/api/push/subscribe', {
77+
method: 'POST',
78+
headers: { 'Content-Type': 'application/json' },
79+
body: JSON.stringify(sub),
80+
}),
81+
),
82+
)
83+
})

‎app/src/hooks/usePush.ts‎

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// usePush — Web Push: detección de soporte, suscripción y baja.
2+
// Reglas (skill web-push-alerts): el prompt nativo SOLO tras gesto del usuario;
3+
// iOS necesita la PWA instalada; sin secure context (LAN HTTP) el push está
4+
// dormido y se activará solo cuando la app se sirva por HTTPS.
5+
// Helios no tiene modo demo en server: no hay estado 'demo'.
6+
import { useCallback, useEffect, useState } from 'react';
7+
import { apiFetch, apiPost } from '@/data/api-client';
8+
9+
export type SoportePush =
10+
| 'ok' // se puede suscribir aquí y ahora
11+
| 'requiere-https' // LAN HTTP: dormido hasta servir por HTTPS
12+
| 'ios-necesita-instalacion' // iOS sin PWA en Home Screen
13+
| 'no-configurado' // el servidor no tiene claves VAPID
14+
| 'no-soportado';
15+
16+
export interface EstadoPush {
17+
permiso: NotificationPermission | 'desconocido';
18+
suscrito: boolean;
19+
cargando: boolean;
20+
error: string | null;
21+
}
22+
23+
function esStandalone(): boolean {
24+
return (
25+
window.matchMedia('(display-mode: standalone)').matches ||
26+
(navigator as { standalone?: boolean }).standalone === true
27+
);
28+
}
29+
30+
function esIOS(): boolean {
31+
return (
32+
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
33+
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
34+
);
35+
}
36+
37+
function soportaPush(): boolean {
38+
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
39+
}
40+
41+
function urlBase64ToUint8Array(base64: string): Uint8Array {
42+
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
43+
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
44+
const raw = atob(b64);
45+
const salida = new Uint8Array(raw.length);
46+
for (let i = 0; i < raw.length; i++) salida[i] = raw.charCodeAt(i);
47+
return salida;
48+
}
49+
50+
export function usePush(): {
51+
soporte: SoportePush;
52+
estado: EstadoPush;
53+
activar: () => Promise<boolean>;
54+
desactivar: () => Promise<void>;
55+
} {
56+
const [soporte, setSoporte] = useState<SoportePush>('no-soportado');
57+
const [estado, setEstado] = useState<EstadoPush>({
58+
permiso: 'desconocido',
59+
suscrito: false,
60+
cargando: true,
61+
error: null,
62+
});
63+
64+
useEffect(() => {
65+
let cancelado = false;
66+
(async () => {
67+
// Sin contexto seguro (http://192.168.x.x): no hay SW ni PushManager
68+
// funcional. NO es un bug: se activará al servir la app por HTTPS.
69+
if (!window.isSecureContext) {
70+
if (!cancelado) {
71+
setSoporte('requiere-https');
72+
setEstado((e) => ({ ...e, cargando: false }));
73+
}
74+
return;
75+
}
76+
if (!soportaPush()) {
77+
if (!cancelado) {
78+
setSoporte('no-soportado');
79+
setEstado((e) => ({ ...e, cargando: false }));
80+
}
81+
return;
82+
}
83+
if (esIOS() && !esStandalone()) {
84+
if (!cancelado) {
85+
setSoporte('ios-necesita-instalacion');
86+
setEstado((e) => ({ ...e, permiso: Notification.permission, cargando: false }));
87+
}
88+
return;
89+
}
90+
try {
91+
await apiFetch('/api/push/vapid-public-key');
92+
const reg = await navigator.serviceWorker.ready;
93+
const sub = await reg.pushManager.getSubscription();
94+
// Re-sincronización: si el navegador tiene suscripción, asegurar que el
95+
// servidor la conoce (cubre pushsubscriptionchange perdidos).
96+
if (sub) {
97+
apiPost('/api/push/subscribe', sub).catch(() => {});
98+
}
99+
if (!cancelado) {
100+
setSoporte('ok');
101+
setEstado({ permiso: Notification.permission, suscrito: sub !== null, cargando: false, error: null });
102+
}
103+
} catch {
104+
// 503 del servidor: sin claves VAPID configuradas
105+
if (!cancelado) {
106+
setSoporte('no-configurado');
107+
setEstado((e) => ({ ...e, cargando: false }));
108+
}
109+
}
110+
})();
111+
return () => {
112+
cancelado = true;
113+
};
114+
}, []);
115+
116+
// LLAMAR SOLO DESDE UN GESTO DEL USUARIO (onClick).
117+
const activar = useCallback(async (): Promise<boolean> => {
118+
if (soporte !== 'ok') return false;
119+
setEstado((e) => ({ ...e, cargando: true, error: null }));
120+
try {
121+
const permiso = await Notification.requestPermission();
122+
if (permiso !== 'granted') {
123+
setEstado({ permiso, suscrito: false, cargando: false, error: null });
124+
return false;
125+
}
126+
const { publicKey } = await apiFetch<{ publicKey: string }>('/api/push/vapid-public-key');
127+
const reg = await navigator.serviceWorker.ready;
128+
const sub = await reg.pushManager.subscribe({
129+
userVisibleOnly: true, // contrato: todo push visible
130+
applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource,
131+
});
132+
await apiPost('/api/push/subscribe', sub);
133+
setEstado({ permiso, suscrito: true, cargando: false, error: null });
134+
return true;
135+
} catch (err) {
136+
setEstado((e) => ({
137+
...e,
138+
cargando: false,
139+
error: err instanceof Error ? err.message : 'error',
140+
}));
141+
return false;
142+
}
143+
}, [soporte]);
144+
145+
const desactivar = useCallback(async (): Promise<void> => {
146+
setEstado((e) => ({ ...e, cargando: true, error: null }));
147+
try {
148+
const reg = await navigator.serviceWorker.ready;
149+
const sub = await reg.pushManager.getSubscription();
150+
if (sub) {
151+
await apiFetch('/api/push/unsubscribe', {
152+
method: 'DELETE',
153+
headers: { 'Content-Type': 'application/json' },
154+
body: JSON.stringify({ endpoint: sub.endpoint }),
155+
}).catch(() => {});
156+
await sub.unsubscribe();
157+
}
158+
setEstado((e) => ({ ...e, suscrito: false, cargando: false }));
159+
} catch (err) {
160+
setEstado((e) => ({
161+
...e,
162+
cargando: false,
163+
error: err instanceof Error ? err.message : 'error',
164+
}));
165+
}
166+
}, []);
167+
168+
return { soporte, estado, activar, desactivar };
169+
}

‎app/src/i18n/locales/en/translation.json‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@
294294
"datos": "Data",
295295
"usuarios": "Users",
296296
"app": "Take it on your phone",
297+
"notificaciones": "Notifications",
297298
"appShort": "App",
298299
"acerca": "About",
299300
"seguridad": "Security",
@@ -389,7 +390,27 @@
389390
"comfortable": "Comfortable",
390391
"compact": "Compact"
391392
},
392-
"reduceMotion": "Reduce animations"
393+
"reduceMotion": "Reduce animations",
394+
"notif": {
395+
"descripcion": "Get alerts on this device even when the app is closed: inverter, grid outages, battery and the daily summary.",
396+
"activar": "Enable alerts",
397+
"desactivar": "Disable alerts",
398+
"activadas": "Alerts enabled on this device",
399+
"requiereHttps": "Push notifications require HTTPS (or localhost). Over local IP access (http) they stay dormant and will activate by themselves once the app is served over HTTPS.",
400+
"iosInstalacion": "On iPhone/iPad notifications only work with the installed app: open it in Safari and use “Add to Home Screen”.",
401+
"noConfigurado": "The server has no VAPID keys configured; push alerts are disabled.",
402+
"noSoportado": "This browser does not support push notifications.",
403+
"permisoDenegado": "Notification permission is blocked in the browser. Enable it in the site settings to receive alerts.",
404+
"tiposTitulo": "Alerts you want to receive",
405+
"tipos": {
406+
"inversor_offline": "Inverter offline",
407+
"inversor_ok": "Inverter recovered",
408+
"corte_red": "Grid outage",
409+
"corte_red_ok": "Grid restored",
410+
"bateria_baja": "Low battery",
411+
"resumen_diario": "Daily summary (9:00 PM)"
412+
}
413+
}
393414
},
394415
"footer": {
395416
"line1": "Helios · Solar Monitor — local data via Home Assistant",

‎app/src/i18n/locales/es/translation.json‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@
294294
"datos": "Datos",
295295
"usuarios": "Usuarios",
296296
"app": "Llévalo en tu móvil",
297+
"notificaciones": "Notificaciones",
297298
"appShort": "App",
298299
"acerca": "Acerca de",
299300
"seguridad": "Seguridad",
@@ -389,7 +390,27 @@
389390
"comfortable": "Cómoda",
390391
"compact": "Compacta"
391392
},
392-
"reduceMotion": "Reducir animaciones"
393+
"reduceMotion": "Reducir animaciones",
394+
"notif": {
395+
"descripcion": "Recibe avisos en este dispositivo aunque la app esté cerrada: inversor, cortes de red, batería y el resumen del día.",
396+
"activar": "Activar alertas",
397+
"desactivar": "Desactivar alertas",
398+
"activadas": "Alertas activadas en este dispositivo",
399+
"requiereHttps": "Las notificaciones push necesitan HTTPS (o localhost). Con acceso por IP local (http) están dormidas y se activarán solas cuando la app se sirva por HTTPS.",
400+
"iosInstalacion": "En iPhone/iPad las notificaciones solo funcionan con la app instalada: ábrela en Safari y usa «Añadir a pantalla de inicio».",
401+
"noConfigurado": "El servidor no tiene claves VAPID configuradas; las alertas push están desactivadas.",
402+
"noSoportado": "Este navegador no soporta notificaciones push.",
403+
"permisoDenegado": "El permiso de notificaciones está bloqueado en el navegador. Actívalo en los ajustes del sitio para recibir alertas.",
404+
"tiposTitulo": "Alertas que quieres recibir",
405+
"tipos": {
406+
"inversor_offline": "Inversor offline",
407+
"inversor_ok": "Inversor recuperado",
408+
"corte_red": "Corte de red",
409+
"corte_red_ok": "Red recuperada",
410+
"bateria_baja": "Batería baja",
411+
"resumen_diario": "Resumen diario (21:00)"
412+
}
413+
}
393414
},
394415
"footer": {
395416
"line1": "Helios · Monitor Solar — datos locales vía Home Assistant",

‎app/src/i18n/locales/zh-CN/translation.json‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@
292292
"datos": "数据",
293293
"usuarios": "用户",
294294
"app": "手机使用",
295+
"notificaciones": "通知",
295296
"appShort": "应用",
296297
"acerca": "关于",
297298
"seguridad": "安全",
@@ -387,7 +388,27 @@
387388
"comfortable": "舒适",
388389
"compact": "紧凑"
389390
},
390-
"reduceMotion": "减少动画"
391+
"reduceMotion": "减少动画",
392+
"notif": {
393+
"descripcion": "即使应用关闭也能在此设备上接收提醒:逆变器、电网停电、电池和每日总结。",
394+
"activar": "开启提醒",
395+
"desactivar": "关闭提醒",
396+
"activadas": "此设备已开启提醒",
397+
"requiereHttps": "推送通知需要 HTTPS(或 localhost)。通过本地 IP(http)访问时处于休眠状态,应用改为 HTTPS 后会自动启用。",
398+
"iosInstalacion": "在 iPhone/iPad 上,通知仅对已安装的应用有效:请在 Safari 中打开并选择“添加到主屏幕”。",
399+
"noConfigurado": "服务器未配置 VAPID 密钥,推送提醒已停用。",
400+
"noSoportado": "此浏览器不支持推送通知。",
401+
"permisoDenegado": "浏览器已阻止通知权限。请在网站设置中允许以接收提醒。",
402+
"tiposTitulo": "要接收的提醒",
403+
"tipos": {
404+
"inversor_offline": "逆变器离线",
405+
"inversor_ok": "逆变器已恢复",
406+
"corte_red": "电网停电",
407+
"corte_red_ok": "电网已恢复",
408+
"bateria_baja": "电池电量低",
409+
"resumen_diario": "每日总结(21:00)"
410+
}
411+
}
391412
},
392413
"footer": {
393414
"line1": "Helios · 太阳能监控 — 通过 Home Assistant 本地获取数据",

‎app/src/main.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ import App from './App.tsx'
88
// Preferencias (tema/densidad/reduce-motion) antes del primer render.
99
applyBootPreferences()
1010

11+
// Service worker (push): solo producción y solo en contextos seguros
12+
// (HTTPS/localhost). En LAN HTTP navigator.serviceWorker es undefined.
13+
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
14+
window.addEventListener('load', () => {
15+
navigator.serviceWorker.register('/sw.js').catch(() => {})
16+
})
17+
}
18+
1119
// Sin StrictMode: duplicaría los efectos de canvas/animación.
1220
createRoot(document.getElementById('root')!).render(
1321
<BrowserRouter>

0 commit comments

Comments
 (0)