feat: añadir página /app/preferences para gestionar recordatorios
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>webui
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,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…
Reference in New Issue