From e90ca394182de1d38bae958de4cdd7e63d256e6b Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 6 Aug 2026 23:28:34 +0200 Subject: [PATCH 1/4] feat: notificaciones por tipo en Mi perfil, sustituyen al nivel all/important/none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reemplaza el desplegable de 3 niveles (users.notification_level) por toggles individuales por tipo de alerta en el panel de Mi perfil, visibles solo con el push suscrito (patrón Helios/skill web-push-alerts). Las prefs ya existían en notification_preferences y las respetaba el motor; la UI era el único hueco. - SessionCard: carga GET /api/push/preferences y persiste PUT por tipo (rollback optimista), sin desplegable de nivel. - Se elimina users.notification_level (migración 13 DROP COLUMN) y toda su lógica (auth.js updateNotificationLevel, push.js filtro por severidad, index.js schema profile, lib/auth saveNotificationLevel). - Se borra NotificationsCard.tsx huérfano (lógica integrada en SessionCard). - i18n: quitadas las 5 claves notifLevel*, reutilizadas notifTipo.*/notifTipos. Closes #33 --- .../components/settings/NotificationsCard.tsx | 146 ------------------ .../components/settings/settings-cards.tsx | 90 +++++++---- app/src/i18n/locales/en/translation.json | 5 - app/src/i18n/locales/es/translation.json | 5 - app/src/lib/auth.ts | 8 - server/src/auth.js | 16 +- server/src/db.js | 3 + server/src/index.js | 4 +- server/src/push.js | 4 - 9 files changed, 68 insertions(+), 213 deletions(-) delete mode 100644 app/src/components/settings/NotificationsCard.tsx diff --git a/app/src/components/settings/NotificationsCard.tsx b/app/src/components/settings/NotificationsCard.tsx deleted file mode 100644 index ac2a525..0000000 --- a/app/src/components/settings/NotificationsCard.tsx +++ /dev/null @@ -1,146 +0,0 @@ -// NotificationsCard — tarjeta de Ajustes para las notificaciones push. -// Estados: requiere-https (LAN HTTP), iOS sin PWA, demo, sin VAPID, sin -// soporte, y ok (botón Activar/Desactivar + toggles por tipo de alerta). -// Patrón: skill web-push-alerts; mismo diseño que el resto de la shell. -import { useEffect, useState } from 'react'; -import { Bell, Check } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { Card } from './settings-cards'; -import { usePush } from '@/hooks/usePush'; -import { api } from '@/lib/api'; -import { cn } from '@/lib/utils'; - -const TIPOS_NOTIF = [ - 'checkin_hoy', - 'checkout_hoy', - 'reserva_nueva', - 'tedee_offline', - 'tedee_ok', - 'tedee_bateria', - 'limpieza_pendiente', -] as const; - -function Aviso({ children, tono = 'neutro' }: { children: React.ReactNode; tono?: 'neutro' | 'aviso' | 'peligro' }) { - const estilos = { - neutro: { borderColor: 'var(--border)', backgroundColor: 'var(--surface-2)', color: 'var(--text-muted)' }, - aviso: { borderColor: 'rgb(245 158 11 / 0.3)', backgroundColor: 'rgb(245 158 11 / 0.08)', color: '#D97706' }, - peligro: { borderColor: 'rgb(244 63 94 / 0.3)', backgroundColor: 'rgb(244 63 94 / 0.08)', color: '#F43F5E' }, - } as const; - return ( -

- {children} -

- ); -} - -export default function NotificationsCard() { - const { t: tr } = useTranslation(); - const { soporte, estado, activar, desactivar } = usePush(); - const [prefs, setPrefs] = useState | null>(null); - - useEffect(() => { - if (!estado.suscrito) { - setPrefs(null); - return; - } - api<{ prefs: Record }>('/api/push/preferences') - .then((r) => setPrefs(r.prefs)) - .catch(() => setPrefs(null)); - }, [estado.suscrito]); - - const cambiarPref = (tipo: string, enabled: boolean) => { - setPrefs((p) => (p ? { ...p, [tipo]: enabled } : p)); - api('/api/push/preferences', { method: 'PUT', body: JSON.stringify({ tipo, enabled }) }).catch(() => { - setPrefs((p) => (p ? { ...p, [tipo]: !enabled } : p)); // rollback optimista - }); - }; - - return ( - - {estado.cargando ? ( -
- ) : soporte === 'requiere-https' ? ( - {tr('aj.notifRequiereHttps')} - ) : soporte === 'ios-necesita-instalacion' ? ( - {tr('aj.notifIos')} - ) : soporte === 'demo' ? ( - {tr('aj.notifDemo')} - ) : soporte === 'no-configurado' ? ( - {tr('aj.notifNoConfigurado')} - ) : soporte === 'no-soportado' ? ( - {tr('aj.notifNoSoportado')} - ) : ( -
-
- {estado.suscrito ? ( - - ) : ( - - )} - {estado.suscrito && ( - - - {tr('aj.notifActivadas')} - - )} -
- {estado.permiso === 'denied' && {tr('aj.notifPermisoDenegado')}} - {estado.error &&

{estado.error}

} - - {estado.suscrito && prefs && ( -
-

- {tr('aj.notifTipos')} -

- {TIPOS_NOTIF.map((tipo) => ( -
- - {tr(`aj.notifTipo.${tipo}`)} - - -
- ))} -
- )} -
- )} - - ); -} diff --git a/app/src/components/settings/settings-cards.tsx b/app/src/components/settings/settings-cards.tsx index 8acb97b..b31ae99 100644 --- a/app/src/components/settings/settings-cards.tsx +++ b/app/src/components/settings/settings-cards.tsx @@ -1,6 +1,6 @@ // Tarjetas de Ajustes (webapp-shell adaptado a Keynest): // Apariencia (tema con previews pintados con las variables CSS reales, -// densidad, reduce-motion), Mi sesión (idioma, nivel de alertas, push, +// densidad, reduce-motion), Mi sesión (idioma, notificaciones por tipo, push, // cambiar contraseña + cerrar sesión), y Acerca de (versión + repo + PWA + // bloque de sistema tipo NetPulse). import { useEffect, useState } from 'react'; @@ -14,7 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { useTheme } from '@/theme/ThemeProvider'; import type { ThemeMode } from '@/theme/ThemeProvider'; import { api } from '@/lib/api'; -import { cachedUser, logout, saveNotificationLevel } from '@/lib/auth'; +import { cachedUser, logout } from '@/lib/auth'; import { applyLanguage, cachedLanguagePref } from '@/i18n'; import type { AppLanguage } from '@/i18n'; import { useData } from '@/data/useData'; @@ -302,13 +302,15 @@ export function useInstallPrompt() { return { state, install }; } -type NotifLevel = 'all' | 'important' | 'none'; - -const NOTIF_LEVELS: { value: NotifLevel; labelKey: string }[] = [ - { value: 'all', labelKey: 'aj.notifLevelAll' }, - { value: 'important', labelKey: 'aj.notifLevelImportant' }, - { value: 'none', labelKey: 'aj.notifLevelNone' }, -]; +const TIPOS_NOTIF = [ + 'checkin_hoy', + 'checkout_hoy', + 'reserva_nueva', + 'tedee_offline', + 'tedee_ok', + 'tedee_bateria', + 'limpieza_pendiente', +] as const; /* ---------- Tarjeta Mi perfil (canónica webapp-shell): avatar + nombre + email + idioma + notifs + contraseña + logout ---------- Estructura ProfileCard: línea horizontal SIN flex-wrap; nombre/email editables @@ -327,9 +329,21 @@ export function SessionCard({ isDemo }: { isDemo: boolean }) { const [editingName, setEditingName] = useState(false); const [editingEmail, setEditingEmail] = useState(false); const [lang, setLang] = useState(() => user?.language ?? cachedLanguagePref()); - const [notifLevel, setNotifLevel] = useState(() => user?.notification_level ?? 'all'); + const [prefs, setPrefs] = useState | null>(null); const { soporte, estado, activar, desactivar } = usePush(); + // Preferencias por tipo: solo tienen sentido con el push suscrito en este + // dispositivo (patrón Helios/skill web-push-alerts). + useEffect(() => { + if (!estado.suscrito) { + setPrefs(null); + return; + } + api<{ prefs: Record }>('/api/push/preferences') + .then((r) => setPrefs(r.prefs)) + .catch(() => setPrefs(null)); + }, [estado.suscrito]); + const saveProfile = async (field: 'displayName' | 'email', value: string) => { try { await api('/api/auth/profile', { @@ -348,9 +362,11 @@ export function SessionCard({ isDemo }: { isDemo: boolean }) { })(); }; - const changeNotifLevel = (v: NotifLevel) => { - setNotifLevel(v); - void saveNotificationLevel(v).catch(() => setNotifLevel(notifLevel)); + const cambiarPref = (tipo: string, enabled: boolean) => { + setPrefs((p) => (p ? { ...p, [tipo]: enabled } : p)); + api('/api/push/preferences', { method: 'PUT', body: JSON.stringify({ tipo, enabled }) }).catch(() => { + setPrefs((p) => (p ? { ...p, [tipo]: !enabled } : p)); // rollback optimista + }); }; const saveName = async () => { @@ -589,29 +605,11 @@ export function SessionCard({ isDemo }: { isDemo: boolean }) { )} - {/* Panel notificaciones desplegable: nivel + push */} + {/* Panel notificaciones desplegable: push + toggles por tipo */} {showNotif && (
- {/* Nivel de alertas */} -
-
-

{tr('aj.notifLevel')}

-

{tr('aj.notifLevelDesc')}

-
- -
- {/* Push */} {soporte === 'requiere-https' ? (

{tr('aj.pushRequiereHttps')}

@@ -628,6 +626,34 @@ export function SessionCard({ isDemo }: { isDemo: boolean }) { )}
) : null} + + {/* Toggles por tipo: solo con el push suscrito en este dispositivo */} + {estado.suscrito && prefs && ( +
+

{tr('aj.notifTipos')}

+ {TIPOS_NOTIF.map((tipo) => ( +
+ {tr(`aj.notifTipo.${tipo}`)} + +
+ ))} +
+ )}
)} diff --git a/app/src/i18n/locales/en/translation.json b/app/src/i18n/locales/en/translation.json index 02e4cb8..3844581 100644 --- a/app/src/i18n/locales/en/translation.json +++ b/app/src/i18n/locales/en/translation.json @@ -729,11 +729,6 @@ "notifNoSoportado": "This browser does not support push notifications.", "notifPermisoDenegado": "Notification permission is blocked in the browser. Enable it in the site settings to receive alerts.", "notifTipos": "Alerts you want to receive", - "notifLevel": "Alert level", - "notifLevelDesc": "Controls which notifications you receive", - "notifLevelAll": "All", - "notifLevelImportant": "Important only", - "notifLevelNone": "None", "notifTipo": { "checkin_hoy": "Today's check-ins", "checkout_hoy": "Today's check-outs", diff --git a/app/src/i18n/locales/es/translation.json b/app/src/i18n/locales/es/translation.json index 619d53d..9906e9a 100644 --- a/app/src/i18n/locales/es/translation.json +++ b/app/src/i18n/locales/es/translation.json @@ -729,11 +729,6 @@ "notifNoSoportado": "Este navegador no soporta notificaciones push.", "notifPermisoDenegado": "El permiso de notificaciones está bloqueado en el navegador. Actívalo en los ajustes del sitio para recibir alertas.", "notifTipos": "Alertas que quieres recibir", - "notifLevel": "Nivel de alertas", - "notifLevelDesc": "Controla qué notificaciones recibes", - "notifLevelAll": "Todas", - "notifLevelImportant": "Solo importantes", - "notifLevelNone": "Ninguna", "notifTipo": { "checkin_hoy": "Check-in del día", "checkout_hoy": "Check-out del día", diff --git a/app/src/lib/auth.ts b/app/src/lib/auth.ts index cec41b8..93523b8 100644 --- a/app/src/lib/auth.ts +++ b/app/src/lib/auth.ts @@ -7,7 +7,6 @@ export interface SessionUser { phone: string | null; language: 'auto' | 'es' | 'en'; role: string; - notification_level?: 'all' | 'important' | 'none'; display_name?: string; avatar?: string; is_demo?: boolean; @@ -92,13 +91,6 @@ export async function saveLanguage(language: 'auto' | 'es' | 'en'): Promise { - await api('/api/auth/profile', { - method: 'PUT', - body: JSON.stringify({ notificationLevel: level }), - }); -} - export function clearSession(): void { storeUser(null); } diff --git a/server/src/auth.js b/server/src/auth.js index 120e2f6..c3b60b2 100644 --- a/server/src/auth.js +++ b/server/src/auth.js @@ -109,7 +109,7 @@ export function currentUser(prodDb, demoDb, c) { const s = sessionFromCookie(prodDb, c) if (!s) return null const dataDb = s.is_demo ? demoDb : prodDb - const user = dataDb.prepare('SELECT id, username, email, phone, language, role, lookahead_days, notification_level, display_name, avatar, created_at FROM users WHERE id = ?').get(s.user_id) || null + const user = dataDb.prepare('SELECT id, username, email, phone, language, role, lookahead_days, display_name, avatar, created_at FROM users WHERE id = ?').get(s.user_id) || null if (!user) return null return { ...user, is_demo: Boolean(s.is_demo) } } @@ -152,7 +152,7 @@ export function handleDemoLogin(prodDb, c) { maxAge: SESSION_TTL_MS / 1000, path: '/', }) - return { id: 'demo-user', username: 'demo', email: null, phone: null, language: 'auto', role: 'demo', notification_level: 'all', is_demo: true } + return { id: 'demo-user', username: 'demo', email: null, phone: null, language: 'auto', role: 'demo', is_demo: true } } export async function handleLogin(db, c, { username, password, remember }) { @@ -204,22 +204,18 @@ export async function createUser(db, { username, password, phone, role }) { const id = crypto.randomUUID() db.prepare('INSERT INTO users (id, username, password_hash, phone, language, role, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)') .run(id, username, hash, phone || null, 'auto', role || 'user', Date.now()) - return db.prepare('SELECT id, username, email, phone, language, role, lookahead_days, notification_level, display_name, avatar, created_at FROM users WHERE id = ?').get(id) + return db.prepare('SELECT id, username, email, phone, language, role, lookahead_days, display_name, avatar, created_at FROM users WHERE id = ?').get(id) } export function updateLanguage(db, userId, language) { db.prepare('UPDATE users SET language = ? WHERE id = ?').run(language, userId) - return db.prepare('SELECT id, username, email, phone, language, role, lookahead_days, notification_level, created_at FROM users WHERE id = ?').get(userId) + return db.prepare('SELECT id, username, email, phone, language, role, lookahead_days, display_name, avatar, created_at FROM users WHERE id = ?').get(userId) } /** Días de aviso del panel por usuario (1-30; 0 = defecto global). */ export function updateLookaheadDays(db, userId, days) { db.prepare('UPDATE users SET lookahead_days = ? WHERE id = ?').run(days, userId) - return db.prepare('SELECT id, username, email, phone, language, role, lookahead_days, notification_level, created_at FROM users WHERE id = ?').get(userId) -} - -export function updateNotificationLevel(db, userId, level) { - db.prepare('UPDATE users SET notification_level = ? WHERE id = ?').run(level, userId) + return db.prepare('SELECT id, username, email, phone, language, role, lookahead_days, display_name, avatar, created_at FROM users WHERE id = ?').get(userId) } /** Reset de contraseña por un admin: re-hashea y destruye las sesiones del usuario. */ @@ -234,7 +230,7 @@ export async function setUserPassword(db, userId, password) { export function setUserRole(db, userId, role) { db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, userId) - return db.prepare('SELECT id, username, email, phone, language, role, lookahead_days, notification_level, created_at FROM users WHERE id = ?').get(userId) + return db.prepare('SELECT id, username, email, phone, language, role, lookahead_days, display_name, avatar, created_at FROM users WHERE id = ?').get(userId) } export function countAdmins(db) { diff --git a/server/src/db.js b/server/src/db.js index e8d3b9b..ecada4d 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -43,6 +43,9 @@ const MIGRATIONS = [ // el enlace de acceso. El hash sigue siendo el que valida en /api/t/:token. `ALTER TABLE people ADD COLUMN token_cipher TEXT; ALTER TABLE maintenance_tasks ADD COLUMN token_cipher TEXT`, + // 13: notificaciones POR TIPO (notification_preferences) sustituyen al nivel + // all/important/none. La columna se elimina: las prefs son la única fuente. + `ALTER TABLE users DROP COLUMN notification_level`, ] export function migrate(db) { diff --git a/server/src/index.js b/server/src/index.js index e8e47d6..35c064e 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -181,7 +181,6 @@ app.get('/api/auth/me', (c) => { const profileSchema = z.object({ language: z.enum(['auto', 'es', 'en']).optional(), lookaheadDays: z.coerce.number().int().min(1).max(30).optional(), - notificationLevel: z.enum(['all', 'important', 'none']).optional(), displayName: z.string().max(50).optional(), email: z.string().email().max(100).or(z.literal('')).optional(), }) @@ -193,7 +192,6 @@ app.put('/api/auth/profile', auth.requireAuth(prodDb, demoDb), async (c) => { let user = c.get('user') if (parsed.data.language) user = auth.updateLanguage(db, user.id, parsed.data.language) if (parsed.data.lookaheadDays) user = auth.updateLookaheadDays(db, user.id, parsed.data.lookaheadDays) - if (parsed.data.notificationLevel) auth.updateNotificationLevel(db, user.id, parsed.data.notificationLevel) if (parsed.data.displayName !== undefined) { db.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(parsed.data.displayName, user.id) user = { ...user, display_name: parsed.data.displayName } @@ -257,7 +255,7 @@ app.get('/api/audit', auth.requireAdmin(prodDb, demoDb), (c) => { /* Lista de usuarios (solo admin) */ app.get('/api/users', auth.requireAdmin(prodDb, demoDb), (c) => { - const users = prodDb.prepare('SELECT id, username, email, phone, language, role, lookahead_days, notification_level, display_name, avatar, created_at FROM users ORDER BY created_at').all() + const users = prodDb.prepare('SELECT id, username, email, phone, language, role, lookahead_days, display_name, avatar, created_at FROM users ORDER BY created_at').all() return c.json({ users }) }) diff --git a/server/src/push.js b/server/src/push.js index d65ce0a..7bdb02a 100644 --- a/server/src/push.js +++ b/server/src/push.js @@ -95,7 +95,6 @@ function stmts(db) { s = { subsPorUsuario: db.prepare('SELECT id, endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ?'), idioma: db.prepare('SELECT language FROM users WHERE id = ?'), - notifLevel: db.prepare('SELECT notification_level FROM users WHERE id = ?'), pref: db.prepare('SELECT enabled, min_severity FROM notification_preferences WHERE user_id = ? AND tipo = ?'), quiet: db.prepare('SELECT quiet_start, quiet_end, tz FROM notification_quiet_hours WHERE user_id = ?'), borrarPorEndpoint: db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?'), @@ -182,9 +181,6 @@ export async function notifyUsers(db, userIds, tipo, datos = {}, opciones = {}) const s = stmts(db) for (const userId of [...new Set(userIds)]) { - const level = s.notifLevel.get(userId)?.notification_level || 'all' - if (level === 'none') { res.omitidos++; continue } - if (level === 'important' && SEVERIDADES.indexOf(severity) < SEVERIDADES.indexOf('high')) { res.omitidos++; continue } const pref = s.pref.get(userId, tipo) if (pref) { if (!pref.enabled || SEVERIDADES.indexOf(severity) < SEVERIDADES.indexOf(pref.min_severity)) { From 2381bfa8e83c7667439ebda2109855fd4ab3efce Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 6 Aug 2026 23:53:44 +0200 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20toggles=20de=20notificaciones?= =?UTF-8?q?=20con=20Switch=20can=C3=B3nico=20y=20quitar=20Reducir=20animac?= =?UTF-8?q?iones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Los 7 toggles por tipo usan el componente Switch de shadcn (mismo patrón que limpieza automática/modo demo), no botones role=switch caseros. - Textos canónicos del patrón Helios/skill: 'Activar alertas' / 'Desactivar alertas' / 'Alertas activadas en este dispositivo' y se muestra estado.error cuando el subscribe falla (esperado en headless). - 'Reducir animaciones' ELIMINADO: era código muerto (las animaciones usan useReducedMotion() de framer-motion, preferencia del SO); no controlaba nada. Se quita el toggle, el estado reduceMotion del ThemeProvider, la clave REDUCE_MOTION_KEY, el inline de index.html y las claves i18n. Verificado: check canónico keynest-push.mjs (browser real, sin stubs) pasa en localhost (subscribe con error headless esperado) y en LAN (requiere-https). --- app/index.html | 3 - .../components/settings/settings-cards.tsx | 71 ++++++------------- app/src/i18n/locales/en/translation.json | 2 - app/src/i18n/locales/es/translation.json | 2 - app/src/index.css | 12 +--- app/src/main.tsx | 2 +- app/src/theme/ThemeProvider.tsx | 27 +------ 7 files changed, 24 insertions(+), 95 deletions(-) diff --git a/app/index.html b/app/index.html index 054a938..6687d38 100644 --- a/app/index.html +++ b/app/index.html @@ -37,9 +37,6 @@ if (localStorage.getItem('keynest-density') === 'compact') { document.documentElement.style.fontSize = '13.5px'; } - if (localStorage.getItem('keynest-reduce-motion') === '1') { - document.documentElement.classList.add('reduce-motion'); - } } catch (e) {} })(); diff --git a/app/src/components/settings/settings-cards.tsx b/app/src/components/settings/settings-cards.tsx index b31ae99..54928a0 100644 --- a/app/src/components/settings/settings-cards.tsx +++ b/app/src/components/settings/settings-cards.tsx @@ -1,6 +1,6 @@ // Tarjetas de Ajustes (webapp-shell adaptado a Keynest): // Apariencia (tema con previews pintados con las variables CSS reales, -// densidad, reduce-motion), Mi sesión (idioma, notificaciones por tipo, push, +// densidad), Mi sesión (idioma, notificaciones por tipo, push, // cambiar contraseña + cerrar sesión), y Acerca de (versión + repo + PWA + // bloque de sistema tipo NetPulse). import { useEffect, useState } from 'react'; @@ -11,6 +11,7 @@ import { Bell, Check, Download, Github, KeyRound, LogOut, Mail, Moon, MonitorSma import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; import { useTheme } from '@/theme/ThemeProvider'; import type { ThemeMode } from '@/theme/ThemeProvider'; import { api } from '@/lib/api'; @@ -92,7 +93,7 @@ const THEME_OPTIONS: { value: ThemeMode; labelKey: string; icon: typeof Moon }[] /* ---------- Tarjeta Apariencia ---------- */ export function AppearanceCard() { const { t: tr } = useTranslation(); - const { mode, setMode, density, setDensity, reduceMotion, setReduceMotion } = useTheme(); + const { mode, setMode, density, setDensity } = useTheme(); return ( @@ -177,31 +178,6 @@ export function AppearanceCard() { - {/* Reducir animaciones */} -
-
-

{tr('aj.reducirAnimaciones')}

-

- {tr('aj.reducirAnimacionesDesc')} -

-
- -
- {/* Días de aviso en el panel (preferencia por usuario) */}
@@ -614,15 +590,20 @@ export function SessionCard({ isDemo }: { isDemo: boolean }) { {soporte === 'requiere-https' ? (

{tr('aj.pushRequiereHttps')}

) : soporte === 'ok' ? ( -
-
-

{tr('aj.alertasPush')}

-

{estado.suscrito ? tr('aj.pushActivas') : tr('aj.pushInactivas')}

+
+
+
+

{tr('aj.notificaciones')}

+

{estado.suscrito ? tr('aj.notifActivadas') : tr('aj.pushInactivas')}

+
+ {estado.suscrito ? ( + + ) : ( + + )}
- {estado.suscrito ? ( - - ) : ( - + {estado.error && ( +

{estado.error}

)}
) : null} @@ -634,22 +615,10 @@ export function SessionCard({ isDemo }: { isDemo: boolean }) { {TIPOS_NOTIF.map((tipo) => (
{tr(`aj.notifTipo.${tipo}`)} - + cambiarPref(tipo, checked)} + />
))}
diff --git a/app/src/i18n/locales/en/translation.json b/app/src/i18n/locales/en/translation.json index 3844581..47efb22 100644 --- a/app/src/i18n/locales/en/translation.json +++ b/app/src/i18n/locales/en/translation.json @@ -696,8 +696,6 @@ "densidadDesc": "Compact fits more content on screen", "densidadComoda": "Comfortable", "densidadCompacta": "Compact", - "reducirAnimaciones": "Reduce animations", - "reducirAnimacionesDesc": "Minimizes transitions and motion effects", "miSesion": "My session", "salirDemo": "Exit demo mode", "acercaDe": "About", diff --git a/app/src/i18n/locales/es/translation.json b/app/src/i18n/locales/es/translation.json index 9906e9a..db236aa 100644 --- a/app/src/i18n/locales/es/translation.json +++ b/app/src/i18n/locales/es/translation.json @@ -696,8 +696,6 @@ "densidadDesc": "Compacta muestra más contenido en pantalla", "densidadComoda": "Cómoda", "densidadCompacta": "Compacta", - "reducirAnimaciones": "Reducir animaciones", - "reducirAnimacionesDesc": "Minimiza transiciones y efectos de movimiento", "miSesion": "Mi sesión", "salirDemo": "Salir del modo demo", "acercaDe": "Acerca de", diff --git a/app/src/index.css b/app/src/index.css index 76d2a47..6b6f16f 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -294,16 +294,8 @@ .animate-ping-soft { animation: ping-soft 1.6s cubic-bezier(0, 0, 0.2, 1) infinite; } } -/* Reducir animaciones: clase del switch de Apariencia + respeto del SO */ -html.reduce-motion *, -html.reduce-motion *::before, -html.reduce-motion *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; -} - +/* Reducir animaciones: solo preferencia del SO (framer-motion usa + useReducedMotion(); ya no existe el switch de Apariencia) */ @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/app/src/main.tsx b/app/src/main.tsx index df1b03d..e2a14ed 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -3,7 +3,7 @@ import './index.css'; import { applyBootPreferences } from '@/theme/ThemeProvider'; import App from './App.tsx'; -// Preferencias (tema/densidad/reduce-motion) antes del primer render. +// Preferencias (tema/densidad) antes del primer render. applyBootPreferences(); // Service worker (push): solo producción y solo en contextos seguros diff --git a/app/src/theme/ThemeProvider.tsx b/app/src/theme/ThemeProvider.tsx index fbd9406..75c379d 100644 --- a/app/src/theme/ThemeProvider.tsx +++ b/app/src/theme/ThemeProvider.tsx @@ -9,7 +9,6 @@ const SLUG = 'keynest'; const MODE_KEY = `${SLUG}-theme-mode`; const LEGACY_MODE_KEY = `${SLUG}-theme`; // clave antigua (bi-estado), se migra a MODE_KEY const DENSITY_KEY = `${SLUG}-density`; -const REDUCE_MOTION_KEY = `${SLUG}-reduce-motion`; const THEME_COLORS: Record = { light: '#F4F6FA', @@ -49,9 +48,6 @@ export function applyBootPreferences() { applyTheme(mode === 'system' ? resolveSystem() : mode); const density = localStorage.getItem(DENSITY_KEY); if (density === 'compact' || density === 'comfortable') applyDensity(density); - if (localStorage.getItem(REDUCE_MOTION_KEY) === '1') { - document.documentElement.classList.add('reduce-motion'); - } } catch { /* sin localStorage */ } @@ -65,8 +61,6 @@ interface ThemeContextValue { toggle: () => void; density: Density; setDensity: (d: Density) => void; - reduceMotion: boolean; - setReduceMotion: (v: boolean) => void; } const ThemeContext = createContext(null); @@ -81,13 +75,6 @@ export default function ThemeProvider({ children }: { children: ReactNode }) { return 'comfortable'; } }); - const [reduceMotion, setReduceMotionState] = useState(() => { - try { - return localStorage.getItem(REDUCE_MOTION_KEY) === '1'; - } catch { - return false; - } - }); const resolved: EffectiveTheme = mode === 'system' ? systemTheme : mode; @@ -127,16 +114,6 @@ export default function ThemeProvider({ children }: { children: ReactNode }) { } }, []); - const setReduceMotion = useCallback((next: boolean) => { - setReduceMotionState(next); - try { - localStorage.setItem(REDUCE_MOTION_KEY, next ? '1' : '0'); - document.documentElement.classList.toggle('reduce-motion', next); - } catch { - /* sin localStorage */ - } - }, []); - const toggle = useCallback(() => setMode(resolved === 'dark' ? 'light' : 'dark'), [resolved, setMode]); const value = useMemo( @@ -148,10 +125,8 @@ export default function ThemeProvider({ children }: { children: ReactNode }) { toggle, density, setDensity, - reduceMotion, - setReduceMotion, }), - [mode, setMode, resolved, toggle, density, setDensity, reduceMotion, setReduceMotion], + [mode, setMode, resolved, toggle, density, setDensity], ); return {children}; From ce7e6cbb886e6b2696f99266995801f91d2bb322 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 7 Aug 2026 00:02:19 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat(ajustes):=20tarjeta=20Acerca=20de=20ca?= =?UTF-8?q?n=C3=B3nica=20webapp-shell=20+=20InstallCard=20propia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aplica el patrón AboutCard de la skill webapp-shell (decisión 14, Deltos v1.9.2): - Fila 1: logo + nombre + descripción a la izquierda, tiles de enlaces BAJOS a la derecha (Código en GitHub, Cambios, Ko-fi, Privacidad). - Fila 2: versión · licencia · runtime en UNA línea sin recuadros, alineada con la descripción (md:pl-[54px]). - El instalador PWA sale de Acerca de a una InstallCard propia (oculta si el navegador no la soporta). - Bloque Sistema fusionado dentro de Acerca de (patrón EasyZFS): SystemInfoBlock recibe info por prop para no duplicar el fetch de /api/system/info. - Nuevas claves i18n aboutCode/aboutCambios/aboutKofi/aboutPrivacidad/aboutUptime. Verificado con keynest-about-check.mjs (browser real): 6/6 OK. --- .../components/settings/settings-cards.tsx | 114 +++++++++++++----- app/src/i18n/locales/en/translation.json | 5 + app/src/i18n/locales/es/translation.json | 5 + app/src/pages/Ajustes.tsx | 3 +- 4 files changed, 98 insertions(+), 29 deletions(-) diff --git a/app/src/components/settings/settings-cards.tsx b/app/src/components/settings/settings-cards.tsx index 54928a0..be3940a 100644 --- a/app/src/components/settings/settings-cards.tsx +++ b/app/src/components/settings/settings-cards.tsx @@ -7,7 +7,7 @@ import { useEffect, useState } from 'react'; import type { ReactNode } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import React from 'react'; -import { Bell, Check, Download, Github, KeyRound, LogOut, Mail, Moon, MonitorSmartphone, Pencil, Sun, User, X } from 'lucide-react'; +import { Bell, Check, Download, FileText, Github, Heart, KeyRound, LogOut, Mail, Moon, MonitorSmartphone, Pencil, ShieldCheck, Sun, User, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; @@ -657,12 +657,13 @@ interface SystemInfoData { demo: boolean; } -function SystemInfoBlock() { +function SystemInfoBlock({ info: propInfo }: { info?: SystemInfoData | null }) { const { t } = useTranslation(); - const [info, setInfo] = useState(null); + const [info, setInfo] = useState(propInfo ?? null); const [failed, setFailed] = useState(false); useEffect(() => { + if (propInfo) { setInfo(propInfo); return; } let disposed = false; void (async () => { try { @@ -677,7 +678,7 @@ function SystemInfoBlock() { } })(); return () => { disposed = true; }; - }, []); + }, [propInfo]); const server = info && !info.demo ? info : null; const rows: { label: string; value: string }[] = [ @@ -724,36 +725,95 @@ function SystemInfoBlock() { ); } -/* ---------- Tarjeta Acerca de: versión + repo + PWA + sistema ---------- */ +/* ---------- Tarjeta Acerca de: logo + enlaces + versión·licencia·runtime (canon webapp-shell) ---------- + Fila 1: logo + nombre + descripción a la izquierda, tiles de enlaces BAJOS a la derecha. + Fila 2: versión · licencia · runtime en UNA línea sin recuadros, alineada con la descripción. + El instalador PWA y Comprobar actualizaciones NO viven aquí (InstallCard propia / AdminBar). */ export function AboutCard() { const { t: tr } = useTranslation(); - const { state, install } = useInstallPrompt(); + const [info, setInfo] = useState(null); + + useEffect(() => { + let disposed = false; + void (async () => { + try { + const res = await fetch('/api/system/info'); + if (!res.ok || !(res.headers.get('content-type') ?? '').includes('application/json')) { + throw new Error(`HTTP ${res.status}`); + } + const json = (await res.json()) as SystemInfoData; + if (!disposed) setInfo(json); + } catch { + if (!disposed) setInfo(null); + } + })(); + return () => { disposed = true; }; + }, []); + + const server = info && !info.demo ? info : null; + const runtimeLine = `Node ${server?.nodeVersion || '—'} · React v${React.version} · ${tr('aj.aboutUptime')} ${server ? fmtUptime(server.uptimeS) : '—'}`; + const tiles = [ + { key: 'code', icon: Github, label: tr('aj.aboutCode'), href: REPO_URL }, + { key: 'changelog', icon: FileText, label: tr('aj.aboutCambios'), href: `${REPO_URL}/commits/main` }, + { key: 'kofi', icon: Heart, label: tr('aj.aboutKofi') }, + { key: 'privacy', icon: ShieldCheck, label: tr('aj.aboutPrivacidad') }, + ]; + const linkCls = 'flex items-center gap-2 rounded-lg border border-[var(--border)] px-2.5 py-1 text-xs font-medium transition-colors duration-150 hover:border-[#6366F1]/50 hover:text-[#6366F1]'; + const plainCls = 'flex items-center gap-2 rounded-lg border border-[var(--border)] px-2.5 py-1 text-xs font-medium'; return ( -
- Keynest -
-

Keynest

-

- v{APP_VERSION} · AGPL-3.0 -

+
+ {/* Fila 1: logo + nombre + descripción a la izquierda, enlaces a la derecha */} +
+
+ Keynest +
+

Keynest

+

+ {tr('aj.acercaDeDesc')} +

+
+
+
+ {tiles.map((item) => + item.href ? ( + + + ) : ( +
+
+ ), + )} +
+ {/* Fila 2: versión · licencia · runtime en UNA línea sin recuadros, + alineada con la descripción (tras el logo) en escritorio */} +

+ v{APP_VERSION} · AGPL-3.0 · {runtimeLine} +

+ + {/* Bloque Sistema fusionado dentro de Acerca de (patrón EasyZFS) */} +
-

- {tr('aj.acercaDeDesc')} -

- - - {tr('aj.codigoFuente')} - + + ); +} + +/* ---------- Tarjeta Instalar app (PWA): tarjeta propia, NO vive en Acerca de ---------- */ +export function InstallCard() { + const { t: tr } = useTranslation(); + const { state, install } = useInstallPrompt(); + + // 'hidden' = navegador sin soporte → NO renderizar nada (regla del usuario) + if (state === 'hidden') return null; + return ( + {state === 'installed' && (

)} - - ); } diff --git a/app/src/i18n/locales/en/translation.json b/app/src/i18n/locales/en/translation.json index 47efb22..ae9a5e5 100644 --- a/app/src/i18n/locales/en/translation.json +++ b/app/src/i18n/locales/en/translation.json @@ -701,6 +701,11 @@ "acercaDe": "About", "acercaDeDesc": "Vacation rental management: bookings, cleanings, smart locks and profitability. 100% private · 0% cloud.", "codigoFuente": "Source code", + "aboutCode": "Code on GitHub", + "aboutCambios": "Changelog", + "aboutKofi": "Support the project on Ko-fi", + "aboutPrivacidad": "100% private · 0% cloud", + "aboutUptime": "Active", "instalarApp": "Install app", "appInstalada": "App installed", "instalarIos": "To install: Share → Add to Home Screen", diff --git a/app/src/i18n/locales/es/translation.json b/app/src/i18n/locales/es/translation.json index db236aa..bb2ab28 100644 --- a/app/src/i18n/locales/es/translation.json +++ b/app/src/i18n/locales/es/translation.json @@ -701,6 +701,11 @@ "acercaDe": "Acerca de", "acercaDeDesc": "Gestión de inmuebles turísticos: reservas, limpiezas, cerraduras y rentabilidad. 100% privado · 0% nube.", "codigoFuente": "Código fuente", + "aboutCode": "Código en GitHub", + "aboutCambios": "Cambios", + "aboutKofi": "Apoya el proyecto en Ko-fi", + "aboutPrivacidad": "100% privado · 0% nube", + "aboutUptime": "Activo", "instalarApp": "Instalar aplicación", "appInstalada": "App instalada", "instalarIos": "Para instalar: Compartir → Añadir a pantalla de inicio", diff --git a/app/src/pages/Ajustes.tsx b/app/src/pages/Ajustes.tsx index 098d2a2..75968d3 100644 --- a/app/src/pages/Ajustes.tsx +++ b/app/src/pages/Ajustes.tsx @@ -61,7 +61,7 @@ import { cachedUser, demoStatus, setDemoMode } from '@/lib/auth'; import { api } from '@/lib/api'; import { copyText } from '@/lib/clipboard'; import { catIcon, CAT_ICONS } from '@/lib/cat-icons'; -import { AppearanceCard, AboutCard, Card, SessionCard } from '@/components/settings/settings-cards'; +import { AppearanceCard, AboutCard, Card, InstallCard, SessionCard } from '@/components/settings/settings-cards'; import UsersManager from '@/components/settings/UsersManager'; import ImportAirbnbCard from '@/components/settings/ImportAirbnbCard'; import BackupCard from '@/components/settings/BackupCard'; @@ -1456,6 +1456,7 @@ export default function Ajustes() {

)} +
)} From 91037b956720807f62fbf36ca6e6b6ea933e62ee Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 7 Aug 2026 07:31:44 +0200 Subject: [PATCH 4/4] feat: alerta de pago abonado 24h post check-in y reservas nuevas enriquecidas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nueva alerta 'transaccion' (issue #34): - Job diario (09:00) busca reservas cuyo check-in fue AYER con importe registrado (amount > 0, del CSV de Airbnb; el iCal no trae importes). - Avisa importe + inmueble + huésped, url /rentabilidad. Dedupe por kv (un aviso por reserva). Nuevo toggle en Mi perfil + i18n ES/EN. reserva_nueva enriquecida: - Añade tiempo (checkin → checkout), personas y importe cuando existen (cruza por confirmation_code/uid con la BD). Sin importe/personas no inventa campos: el payload queda igual que antes. - Columna 'guests' leída de forma defensiva (PRAGMA cacheado) por si la trae otra sesión; funciona igual sin ella. Closes #34 --- .../components/settings/settings-cards.tsx | 1 + app/src/i18n/locales/en/translation.json | 1 + app/src/i18n/locales/es/translation.json | 1 + server/src/alerts.js | 68 ++++++++++++++- server/src/index.js | 4 +- server/src/push.js | 19 +++- server/test/push.test.js | 87 ++++++++++++++++++- 7 files changed, 176 insertions(+), 5 deletions(-) diff --git a/app/src/components/settings/settings-cards.tsx b/app/src/components/settings/settings-cards.tsx index be3940a..3135752 100644 --- a/app/src/components/settings/settings-cards.tsx +++ b/app/src/components/settings/settings-cards.tsx @@ -282,6 +282,7 @@ const TIPOS_NOTIF = [ 'checkin_hoy', 'checkout_hoy', 'reserva_nueva', + 'transaccion', 'tedee_offline', 'tedee_ok', 'tedee_bateria', diff --git a/app/src/i18n/locales/en/translation.json b/app/src/i18n/locales/en/translation.json index ae9a5e5..bd58947 100644 --- a/app/src/i18n/locales/en/translation.json +++ b/app/src/i18n/locales/en/translation.json @@ -736,6 +736,7 @@ "checkin_hoy": "Today's check-ins", "checkout_hoy": "Today's check-outs", "reserva_nueva": "New booking imported", + "transaccion": "Payment received (24h after check-in)", "tedee_offline": "Lock offline", "tedee_ok": "Lock recovered", "tedee_bateria": "Low lock battery", diff --git a/app/src/i18n/locales/es/translation.json b/app/src/i18n/locales/es/translation.json index bb2ab28..18f1e7a 100644 --- a/app/src/i18n/locales/es/translation.json +++ b/app/src/i18n/locales/es/translation.json @@ -736,6 +736,7 @@ "checkin_hoy": "Check-in del día", "checkout_hoy": "Check-out del día", "reserva_nueva": "Reserva nueva importada", + "transaccion": "Pago abonado (24h tras el check-in)", "tedee_offline": "Cerradura offline", "tedee_ok": "Cerradura recuperada", "tedee_bateria": "Batería de cerradura baja", diff --git a/server/src/alerts.js b/server/src/alerts.js index d553d0b..fed8b2d 100644 --- a/server/src/alerts.js +++ b/server/src/alerts.js @@ -15,6 +15,9 @@ // - limpieza_pendiente: job diario (12:00 local): limpiezas 'pendiente' con // fecha ≤ hoy (el check-out ya pasó o es hoy). Dedupe por kv: un aviso por // limpieza y día como máximo. +// - transaccion: job diario: reservas cuyo check-in fue AYER (24h después, +// cuando Airbnb suele abonar) con importe registrado (amount > 0, del CSV). +// Informa importe + inmueble + huésped. Dedupe por kv: un aviso por reserva. // // notifyFn inyectable para tests (defecto: notifyAll de push.js). import { kvGet, kvSet } from './db.js' @@ -58,6 +61,37 @@ export function checkReservasHoy(db, notifyFn = notifyAll) { } // ── Reservas nuevas importadas (llamado desde el sync iCal) ────────────────── +// Enriquecido (issue #34): además de inmueble + resumen, se informa del tiempo +// (checkin → checkout), personas y importe CUANDO existan (vienen del CSV de +// Airbnb, no del iCal; el iCal solo trae fechas/summary). +const guestsDisponible = new WeakMap() +function tieneGuests(db) { + if (!guestsDisponible.has(db)) { + const cols = db.prepare('PRAGMA table_info(reservations)').all().map((c) => c.name) + guestsDisponible.set(db, cols.includes('guests')) + } + return guestsDisponible.get(db) +} + +function detalleReserva(db, item) { + const d = { tiempo: `${item.checkin} → ${item.checkout}` } + // El CSV cruza por confirmation_code; si no hay, se busca por uid. + const fila = item.confirmation_code + ? db.prepare('SELECT amount, guest_name FROM reservations WHERE confirmation_code = ?').get(item.confirmation_code) + : null + const r = fila || (item.uid ? db.prepare('SELECT amount, guest_name FROM reservations WHERE uid = ?').get(item.uid) : null) + if (!r) return d + if (tieneGuests(db)) { + const g = (item.confirmation_code + ? db.prepare('SELECT guests FROM reservations WHERE confirmation_code = ?').get(item.confirmation_code) + : item.uid ? db.prepare('SELECT guests FROM reservations WHERE uid = ?').get(item.uid) : null) + if (g?.guests) d.personas = g.guests + } + if (r.amount > 0) d.importe = r.amount + if (r.guest_name) d.huesped = r.guest_name + return d +} + export function notifyReservasNuevas(db, propiedad, items, notifyFn = notifyAll) { if (!items || items.length === 0) return if (items.length > MAX_AVISOS_INDIVIDUALES) { @@ -69,12 +103,44 @@ export function notifyReservasNuevas(db, propiedad, items, notifyFn = notifyAll) notifyFn( db, 'reserva_nueva', - { propiedad, resumen: i.summary, fecha: `${i.checkin} → ${i.checkout}` }, + { propiedad, resumen: i.summary, ...detalleReserva(db, i) }, { severity: 'normal', url: '/reservas' } ) } } +// ── Transacción abonada: 24h después del check-in con importe ──────────────── +// Airbnb suele abonar el pago ~24h tras el check-in; solo se avisa si la +// reserva tiene importe registrado (amount > 0, del CSV). Dedupe por kv: +// un aviso por reserva (nunca más, aunque el job se repita). +export function checkTransacciones(db, notifyFn = notifyAll) { + const ayer = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Europe/Madrid', + year: 'numeric', month: '2-digit', day: '2-digit', + }).format(Date.now() - 24 * 3600 * 1000) + const filas = db + .prepare( + `SELECT r.id, r.checkin, r.checkout, r.summary, r.amount, r.guest_name, p.name AS propiedad + FROM reservations r JOIN properties p ON p.id = r.property_id + WHERE r.checkin = ? AND r.amount > 0` + ) + .all(ayer) + let avisos = 0 + for (const f of filas) { + const key = `push_tx_${f.id}` + if (kvGet(db, key)) continue // ya avisada + notifyFn( + db, + 'transaccion', + { propiedad: f.propiedad, resumen: f.summary, importe: f.amount, huesped: f.guest_name || null }, + { severity: 'normal', url: '/rentabilidad' } + ) + kvSet(db, key, '1') + avisos++ + } + return avisos +} + // ── Tedee: offline (3 ticks) y batería baja ────────────────────────────────── export function createTedeeChecker({ db, notifyFn = notifyAll, locksFn = tedeeLocks }) { // Por cerradura (id): ticks offline seguidos + flancos ya alertados. diff --git a/server/src/index.js b/server/src/index.js index 35c064e..150215f 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -1249,11 +1249,13 @@ setInterval(() => { }, 3600 * 1000) /* Alertas push: Tedee cada 5 min (anti-rebote 3 ticks), reservas del día a - * las 09:00 y limpiezas pendientes a las 12:00 (hora local del CT). */ + * las 09:00, transacciones abonadas (24h post check-in) a las 09:00 y + * limpiezas pendientes a las 12:00 (hora local del CT). */ const tedeeChecker = alerts.createTedeeChecker({ db: prodDb }) setTimeout(() => tedeeChecker.check().catch((e) => console.error('[keynest] tedee check error:', e.message)), 10000) setInterval(() => tedeeChecker.check().catch((e) => console.error('[keynest] tedee check error:', e.message)), 5 * 60 * 1000) alerts.scheduleDaily(9, () => alerts.checkReservasHoy(prodDb), 'reservas-hoy') +alerts.scheduleDaily(9, () => alerts.checkTransacciones(prodDb), 'transacciones') alerts.scheduleDaily(12, () => alerts.checkLimpiezas(prodDb), 'limpiezas-pendientes') /* Backup diario a las 03:00 si está habilitado */ diff --git a/server/src/push.js b/server/src/push.js index 7bdb02a..7ffedab 100644 --- a/server/src/push.js +++ b/server/src/push.js @@ -53,23 +53,37 @@ const CATALOGO = { es: { checkin_hoy: { titulo: 'Check-in hoy', cuerpo: (d) => `${d.propiedad}: entra «${d.resumen}»` }, checkout_hoy: { titulo: 'Check-out hoy', cuerpo: (d) => `${d.propiedad}: sale «${d.resumen}»` }, - reserva_nueva: { titulo: 'Reserva nueva', cuerpo: (d) => `${d.propiedad}: «${d.resumen}» (${d.fecha})` }, + reserva_nueva: { + titulo: 'Reserva nueva', + cuerpo: (d) => { + const extra = [d.tiempo, d.personas ? `${d.personas} pers.` : '', d.importe ? `${d.importe} €` : ''].filter(Boolean).join(' · ') + return `${d.propiedad}: «${d.resumen}»${extra ? ` (${extra})` : ''}` + }, + }, reservas_nuevas: { titulo: 'Reservas nuevas', cuerpo: (d) => `${d.total} reservas nuevas importadas` }, tedee_offline: { titulo: 'Cerradura offline', cuerpo: (d) => `La cerradura «${d.nombre}» no responde` }, tedee_ok: { titulo: 'Cerradura recuperada', cuerpo: (d) => `La cerradura «${d.nombre}» vuelve a estar online` }, tedee_bateria: { titulo: 'Batería cerradura baja', cuerpo: (d) => `«${d.nombre}» al ${d.nivel}%` }, limpieza_pendiente: { titulo: 'Limpieza pendiente', cuerpo: (d) => `${d.propiedad}: limpieza del ${d.fecha} sin completar` }, + transaccion: { titulo: 'Pago abonado', cuerpo: (d) => `${d.propiedad}: ${d.importe} €${d.huesped ? ` · ${d.huesped}` : ''}` }, resumen: { titulo: 'Actividad en Keynest', cuerpo: (d) => `${d.total} avisos durante las horas de silencio` }, }, en: { checkin_hoy: { titulo: 'Check-in today', cuerpo: (d) => `${d.propiedad}: “${d.resumen}” arrives` }, checkout_hoy: { titulo: 'Check-out today', cuerpo: (d) => `${d.propiedad}: “${d.resumen}” leaves` }, - reserva_nueva: { titulo: 'New booking', cuerpo: (d) => `${d.propiedad}: “${d.resumen}” (${d.fecha})` }, + reserva_nueva: { + titulo: 'New booking', + cuerpo: (d) => { + const extra = [d.tiempo, d.personas ? `${d.personas} guests` : '', d.importe ? `${d.importe} €` : ''].filter(Boolean).join(' · ') + return `${d.propiedad}: “${d.resumen}”${extra ? ` (${extra})` : ''}` + }, + }, reservas_nuevas: { titulo: 'New bookings', cuerpo: (d) => `${d.total} new bookings imported` }, tedee_offline: { titulo: 'Lock offline', cuerpo: (d) => `Lock “${d.nombre}” is not responding` }, tedee_ok: { titulo: 'Lock recovered', cuerpo: (d) => `Lock “${d.nombre}” is back online` }, tedee_bateria: { titulo: 'Low lock battery', cuerpo: (d) => `“${d.nombre}” at ${d.nivel}%` }, limpieza_pendiente: { titulo: 'Cleaning pending', cuerpo: (d) => `${d.propiedad}: cleaning from ${d.fecha} not completed` }, + transaccion: { titulo: 'Payment received', cuerpo: (d) => `${d.propiedad}: ${d.importe} €${d.huesped ? ` · ${d.huesped}` : ''}` }, resumen: { titulo: 'Keynest activity', cuerpo: (d) => `${d.total} alerts during your quiet hours` }, }, } @@ -83,6 +97,7 @@ export const TIPOS_ALERTA = [ 'tedee_ok', 'tedee_bateria', 'limpieza_pendiente', + 'transaccion', ] const SEVERIDADES = ['normal', 'high', 'critical'] diff --git a/server/test/push.test.js b/server/test/push.test.js index 62b4498..86b4c0e 100644 --- a/server/test/push.test.js +++ b/server/test/push.test.js @@ -13,6 +13,7 @@ import { notifyReservasNuevas, createTedeeChecker, checkLimpiezas, + checkTransacciones, hoyLocal, } from '../src/alerts.js' @@ -211,7 +212,7 @@ describe('alertas: reservas nuevas', () => { ], notifyFn) expect(llamadas).toHaveLength(2) expect(llamadas[0].tipo).toBe('reserva_nueva') - expect(llamadas[0].datos.fecha).toBe('2026-08-10 → 2026-08-12') + expect(llamadas[0].datos.tiempo).toBe('2026-08-10 → 2026-08-12') llamadas.length = 0 notifyReservasNuevas(db, 'Carmen', Array.from({ length: 6 }, (_, i) => ({ summary: `R${i}`, checkin: '2026-09-01', checkout: '2026-09-03' })), notifyFn) @@ -225,6 +226,90 @@ describe('alertas: reservas nuevas', () => { notifyReservasNuevas(db, 'Carmen', [], notifyFn) expect(llamadas).toHaveLength(0) }) + + it('enriquece con tiempo + personas + importe cuando la reserva tiene CSV', () => { + // La reserva ya está en BD con amount/guest_name (importada del CSV antes + // o por un sync posterior); el item del iCal solo trae fechas/summary. + const p = insertProperty('Ruzafa') + const code = 'HMK3XYZ' + db.prepare( + `INSERT INTO reservations (id, property_id, uid, checkin, checkout, summary, confirmation_code, amount, guest_name, created_at) + VALUES (?, ?, ?, '2026-08-10', '2026-08-12', 'A', ?, 450, 'María', ?)` + ).run(crypto.randomUUID(), p, 'ical-uid-1', code, Date.now()) + const { llamadas, notifyFn } = captura() + notifyReservasNuevas(db, 'Ruzafa', [{ summary: 'A', checkin: '2026-08-10', checkout: '2026-08-12', confirmation_code: code }], notifyFn) + expect(llamadas[0].datos).toMatchObject({ + tiempo: '2026-08-10 → 2026-08-12', + importe: 450, + huesped: 'María', + }) + }) + + it('sin importe/personas en BD no inventa campos', () => { + const p = insertProperty('Ruzafa') + const code = 'HMK3NO' + db.prepare( + `INSERT INTO reservations (id, property_id, uid, checkin, checkout, summary, confirmation_code, amount, created_at) + VALUES (?, ?, ?, '2026-08-10', '2026-08-12', 'A', ?, 0, ?)` + ).run(crypto.randomUUID(), p, 'ical-uid-2', code, Date.now()) + const { llamadas, notifyFn } = captura() + notifyReservasNuevas(db, 'Ruzafa', [{ summary: 'A', checkin: '2026-08-10', checkout: '2026-08-12', confirmation_code: code }], notifyFn) + expect(llamadas[0].datos).toEqual({ + propiedad: 'Ruzafa', + resumen: 'A', + tiempo: '2026-08-10 → 2026-08-12', + }) + }) +}) + +describe('alertas: transacción abonada (24h post check-in)', () => { + function ayerLocal() { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'Europe/Madrid', year: 'numeric', month: '2-digit', day: '2-digit', + }).format(Date.now() - 24 * 3600 * 1000) + } + + it('avisa de reservas cuyo check-in fue ayer con importe, y no se repite', () => { + const p = insertProperty('Ruzafa') + const ayer = ayerLocal() + const mañana = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Europe/Madrid', year: 'numeric', month: '2-digit', day: '2-digit', + }).format(Date.now() + 24 * 3600 * 1000) + const id = crypto.randomUUID() + db.prepare( + `INSERT INTO reservations (id, property_id, uid, checkin, checkout, summary, confirmation_code, amount, guest_name, created_at) + VALUES (?, ?, ?, ?, ?, 'A', 'TX1', 450, 'María', ?)` + ).run(id, p, 'tx-uid-1', ayer, mañana, Date.now()) + const { llamadas, notifyFn } = captura() + const n1 = checkTransacciones(db, notifyFn) + expect(n1).toBe(1) + expect(llamadas[0].tipo).toBe('transaccion') + expect(llamadas[0].datos).toMatchObject({ propiedad: 'Ruzafa', importe: 450, huesped: 'María' }) + expect(llamadas[0].opciones.url).toBe('/rentabilidad') + + llamadas.length = 0 + const n2 = checkTransacciones(db, notifyFn) + expect(n2).toBe(0) // dedupe: no se vuelve a avisar + expect(llamadas).toHaveLength(0) + }) + + it('no avisa sin importe (amount=0) ni de otras fechas', () => { + const p = insertProperty('Ruzafa') + const ayer = ayerLocal() + const hoy = hoyLocal() + db.prepare( + `INSERT INTO reservations (id, property_id, uid, checkin, checkout, summary, amount, created_at) + VALUES (?, ?, ?, ?, ?, 'SinImporte', 0, ?)` + ).run(crypto.randomUUID(), p, 'tx-uid-2', ayer, ayer, Date.now()) + db.prepare( + `INSERT INTO reservations (id, property_id, uid, checkin, checkout, summary, amount, created_at) + VALUES (?, ?, ?, ?, ?, 'DeHoy', 900, ?)` + ).run(crypto.randomUUID(), p, 'tx-uid-3', hoy, hoy, Date.now()) + const { llamadas, notifyFn } = captura() + const n = checkTransacciones(db, notifyFn) + expect(n).toBe(0) + expect(llamadas).toHaveLength(0) + }) }) describe('alertas: Tedee', () => {