feat: añadir página /app/preferences para gestionar recordatorios

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
borja 2 weeks ago
parent 94ad9119f4
commit 1744f317b8

@ -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
};
};

@ -0,0 +1,161 @@
<script lang="ts">
export let data: {
pref: { freq: 'off' | 'daily' | 'weekly' | 'weekdays'; time: string | null };
tz: string;
next: string | null;
};
let freq: 'off' | 'daily' | 'weekly' | 'weekdays' = data.pref.freq;
let time: string = data.pref.time ?? '08:30';
let saving = false;
let errorMsg: string | null = null;
let successMsg: string | null = null;
let serverNext: string | null = data.next ?? null;
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 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 computeNext(freq: 'off' | 'daily' | 'weekly' | 'weekdays', timeStr: string | null, tz: string): string | null {
if (freq === 'off' || !timeStr) return null;
const now = new Date();
const nowHM = hmInTZ(now, tz);
const [nowH, nowM] = String(nowHM).split(':');
const [cfgH, cfgM] = String(timeStr).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';
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) continue;
return `${ymdInTZ(cand, tz)} ${normalizeTime(timeStr)}`;
}
return null;
}
$: clientNext = computeNext(freq, time, data.tz);
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
errorMsg = null;
successMsg = null;
saving = true;
try {
const body: any = { freq };
if (freq !== 'off') {
body.time = time && time.trim() ? time : '08:30';
} else if (time && time.trim()) {
// Permitimos conservar/actualizar hora estando en off
body.time = time.trim();
}
const res = await fetch('/api/me/preferences', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
if (res.ok) {
const json = await res.json();
freq = json.freq;
time = json.time;
serverNext = computeNext(freq, time, data.tz);
successMsg = 'Preferencias guardadas.';
} else {
const txt = await res.text();
errorMsg = txt || 'No se pudieron guardar las preferencias.';
}
} catch (e: any) {
errorMsg = e?.message || 'Error de red al guardar.';
} finally {
saving = false;
}
}
</script>
<section style="max-width: 720px; margin: 2rem auto; padding: 0 1rem;">
<h1 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 1rem;">Preferencias de recordatorios</h1>
<form on:submit|preventDefault={onSubmit} style="display: grid; gap: 1rem;">
<div>
<label for="freq" style="display:block; font-weight:600; margin-bottom: 0.25rem;">Frecuencia</label>
<select id="freq" name="freq" bind:value={freq}>
<option value="off">Apagado</option>
<option value="daily">Diario</option>
<option value="weekdays">Laborables (LV)</option>
<option value="weekly">Semanal (lunes)</option>
</select>
<p style="font-size: 0.9rem; color: #555; margin-top: 0.25rem;">
- Diario: cada día a la hora indicada. - Laborables: solo lunes a viernes. - Semanal: los lunes.
</p>
</div>
<div>
<label for="time" style="display:block; font-weight:600; margin-bottom: 0.25rem;">Hora (HH:MM)</label>
<input id="time" name="time" type="time" step="60" bind:value={time} disabled={freq === 'off'} />
<p style="font-size: 0.9rem; color: #555; margin-top: 0.25rem;">Zona horaria: {data.tz}</p>
</div>
{#if errorMsg}
<div style="color: #b00020;">{errorMsg}</div>
{/if}
{#if successMsg}
<div style="color: #007e33;">{successMsg}</div>
{/if}
<button type="submit" disabled={saving} style="padding: 0.5rem 1rem;">
{saving ? 'Guardando…' : 'Guardar'}
</button>
</form>
<div style="margin-top: 2rem;">
<h2 style="font-size: 1.2rem; font-weight: 600;">Próximo recordatorio</h2>
<ul>
<li>Servidor: {serverNext ?? '—'}</li>
<li>Calculado ahora: {clientNext ?? '—'}</li>
</ul>
</div>
</section>

@ -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<string> {
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<ReturnType<typeof startWebServer>> | 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('<option value="off">Apagado</option>');
// 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');
});
});
Loading…
Cancel
Save