diff --git a/apps/web/src/routes/app/preferences/+page.server.ts b/apps/web/src/routes/app/preferences/+page.server.ts new file mode 100644 index 0000000..ec148ee --- /dev/null +++ b/apps/web/src/routes/app/preferences/+page.server.ts @@ -0,0 +1,108 @@ +import type { PageServerLoad } from './$types'; +import { getDb } from '$lib/server/db'; +import { redirect } from '@sveltejs/kit'; + +function ymdInTZ(d: Date, tz: string): string { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).formatToParts(d); + const get = (t: string) => parts.find((p) => p.type === t)?.value || ''; + return `${get('year')}-${get('month')}-${get('day')}`; +} + +function hmInTZ(d: Date, tz: string): string { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: tz, + hour: '2-digit', + minute: '2-digit', + hour12: false + }).formatToParts(d); + const get = (t: string) => parts.find((p) => p.type === t)?.value || ''; + return `${get('hour')}:${get('minute')}`; +} + +function weekdayShortInTZ(d: Date, tz: string): string { + return new Intl.DateTimeFormat('en-GB', { timeZone: tz, weekday: 'short' }).format(d); +} + +function normalizeTime(input: string): string | null { + const m = /^\s*(\d{1,2}):(\d{1,2})\s*$/.exec(input || ''); + if (!m) return null; + const h = Number(m[1]); + const min = Number(m[2]); + if (!Number.isFinite(h) || !Number.isFinite(min)) return null; + if (h < 0 || h > 23 || min < 0 || min > 59) return null; + const hh = String(h).padStart(2, '0'); + const mm = String(min).padStart(2, '0'); + return `${hh}:${mm}`; +} + +function computeNextReminder( + freq: 'off' | 'daily' | 'weekly' | 'weekdays', + time: string | null, + now: Date, + tz: string +): string | null { + if (freq === 'off' || !time) return null; + + const nowHM = hmInTZ(now, tz); + const [nowH, nowM] = String(nowHM).split(':'); + const [cfgH, cfgM] = String(time).split(':'); + const nowMin = (parseInt(nowH || '0', 10) || 0) * 60 + (parseInt(nowM || '0', 10) || 0); + const cfgMin = (parseInt(cfgH || '0', 10) || 0) * 60 + (parseInt(cfgM || '0', 10) || 0); + + const allowDay = (w: string) => { + if (freq === 'daily') return true; + if (freq === 'weekly') return w === 'Mon'; + // weekdays + return w !== 'Sat' && w !== 'Sun'; + }; + + for (let offset = 0; offset < 14; offset++) { + const cand = new Date(now.getTime() + offset * 24 * 60 * 60 * 1000); + const wd = weekdayShortInTZ(cand, tz); + if (!allowDay(wd)) continue; + + if (offset === 0 && nowMin >= cfgMin) { + // hoy ya pasó la hora → buscar siguiente día permitido + continue; + } + const ymd = ymdInTZ(cand, tz); + return `${ymd} ${normalizeTime(time)}`; + } + return null; +} + +export const load: PageServerLoad = async ({ locals }) => { + const userId = locals.userId ?? null; + if (!userId) { + throw redirect(302, '/login'); + } + + const db = await getDb(); + const row = db + .prepare( + `SELECT reminder_freq AS freq, reminder_time AS time + FROM user_preferences + WHERE user_id = ? + LIMIT 1` + ) + .get(userId) as any; + + const pref = + row && row.freq + ? { freq: String(row.freq) as 'off' | 'daily' | 'weekly' | 'weekdays', time: row.time ? String(row.time) : null } + : { freq: 'off' as const, time: '08:30' as string }; + + const tz = (process.env.TZ && process.env.TZ.trim()) || 'Europe/Madrid'; + const next = computeNextReminder(pref.freq, pref.time, new Date(), tz); + + return { + pref, + tz, + next + }; +}; diff --git a/apps/web/src/routes/app/preferences/+page.svelte b/apps/web/src/routes/app/preferences/+page.svelte new file mode 100644 index 0000000..c871259 --- /dev/null +++ b/apps/web/src/routes/app/preferences/+page.svelte @@ -0,0 +1,161 @@ + + +
+

Preferencias de recordatorios

+ +
+
+ + +

+ - Diario: cada día a la hora indicada. - Laborables: solo lunes a viernes. - Semanal: los lunes. +

+
+ +
+ + +

Zona horaria: {data.tz}

+
+ + {#if errorMsg} +
{errorMsg}
+ {/if} + {#if successMsg} +
{successMsg}
+ {/if} + + +
+ +
+

Próximo recordatorio

+ +
+
diff --git a/tests/web/app.preferences.page.test.ts b/tests/web/app.preferences.page.test.ts new file mode 100644 index 0000000..a6aee4f --- /dev/null +++ b/tests/web/app.preferences.page.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { startWebServer } from './helpers/server'; +import { initializeDatabase, ensureUserExists } from '../../src/db'; + +async function sha256Hex(input: string): Promise { + const enc = new TextEncoder().encode(input); + const buf = await crypto.subtle.digest('SHA-256', enc); + const bytes = new Uint8Array(buf); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function toIsoSql(d = new Date()): string { + return d.toISOString().replace('T', ' ').replace('Z', ''); +} + +describe('Web UI - /app/preferences', () => { + const userId = '34600123456'; + let dbPath: string; + let server: Awaited> | null = null; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'webtest-')); + dbPath = join(tmpDir, 'tasks.db'); + + // Inicializar DB en archivo (como en prod) + const db = new Database(dbPath); + initializeDatabase(db); + ensureUserExists(userId, db); + + // Crear sesión válida + const sid = 'sid-test-pref-ui'; + const hash = await sha256Hex(sid); + const now = new Date(); + const nowIso = toIsoSql(now); + const expIso = toIsoSql(new Date(now.getTime() + 60 * 60 * 1000)); // +1h + + db.prepare(` + INSERT INTO web_sessions (session_hash, user_id, created_at, last_seen_at, expires_at) + VALUES (?, ?, ?, ?, ?) + `).run(hash, userId, nowIso, nowIso, expIso); + db.close(); + + // Arrancar web apuntando a este DB + server = await startWebServer({ + port: 19110, + env: { DB_PATH: dbPath, TZ: 'UTC' } + }); + }); + + afterAll(async () => { + try { await server?.stop(); } catch {} + try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} + }); + + it('renderiza el formulario con valores por defecto y muestra próximo recordatorio', async () => { + const sid = 'sid-test-pref-ui'; + const res = await fetch(`${server!.baseUrl}/app/preferences`, { + headers: { Cookie: `sid=${sid}` } + }); + expect(res.status).toBe(200); + const html = await res.text(); + + expect(html).toContain('Preferencias de recordatorios'); + // select de frecuencia y opción 'off' presente + expect(html).toContain(''); + // input type="time" con valor por defecto + expect(html).toContain('type="time"'); + expect(html).toContain('08:30'); + // bloque de "Próximo recordatorio" + expect(html).toContain('Próximo recordatorio'); + }); +});