From 098e135b11e5dd148124e61d1a3502becffca6e1 Mon Sep 17 00:00:00 2001 From: brobert Date: Sat, 20 Sep 2025 20:08:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20modo=20weekdays=20en=20re?= =?UTF-8?q?cordatorios=20con=20hora=20configurable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- src/db/migrations/index.ts | 44 ++++++++++++++++++ src/services/command.ts | 46 ++++++++++++++++--- src/services/reminders.ts | 7 ++- .../services/command.reminders-config.test.ts | 14 +++++- tests/unit/services/reminders.test.ts | 33 ++++++++++++- 5 files changed, 133 insertions(+), 11 deletions(-) diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 658c47d..6724232 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -198,5 +198,49 @@ export const migrations: Migration[] = [ ON user_aliases (user_id); `); } + }, + { + version: 7, + name: 'user-preferences-weekdays', + checksum: 'v7-user-preferences-weekdays-2025-09-20', + up: (db: Database) => { + // Re-crear tabla user_preferences para ampliar CHECK con 'weekdays' + try { db.exec(`PRAGMA foreign_keys = OFF;`); } catch {} + + db.exec(` + CREATE TABLE IF NOT EXISTS user_preferences_new ( + user_id TEXT PRIMARY KEY, + reminder_freq TEXT NOT NULL CHECK (reminder_freq IN ('off','daily','weekly','weekdays')), + reminder_time TEXT NOT NULL DEFAULT '08:30', + last_reminded_on TEXT NULL, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + `); + + // Copiar datos existentes si la tabla anterior existe + try { + db.exec(` + INSERT INTO user_preferences_new (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at) + SELECT user_id, + CASE WHEN reminder_freq IN ('off','daily','weekly') THEN reminder_freq ELSE 'off' END, + COALESCE(reminder_time, '08:30'), + last_reminded_on, + COALESCE(updated_at, strftime('%Y-%m-%d %H:%M:%f', 'now')) + FROM user_preferences; + `); + db.exec(`DROP TABLE user_preferences;`); + } catch { + // nada que copiar + } + + db.exec(`ALTER TABLE user_preferences_new RENAME TO user_preferences;`); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_user_prefs_freq_time + ON user_preferences (reminder_freq, reminder_time); + `); + + try { db.exec(`PRAGMA foreign_keys = ON;`); } catch {} + } } ]; diff --git a/src/services/command.ts b/src/services/command.ts index 843a941..d1b369b 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -171,7 +171,7 @@ export class CommandService { ' · Tomar: tomar, claim', ' · Soltar: soltar, unassign', '- Preferencias:', - ' · `/t configurar daily|weekly|off` (hora por defecto 08:30; semanal: lunes 08:30)', + ' · `/t configurar daily|l-v|weekly|off [HH:MM]` (por defecto 08:30; semanal: lunes; l-v: lunes a viernes)', '- Notas:', ' · En grupos, el bot responde por DM (no publica en el grupo).', ' · Si creas en grupo y no mencionas a nadie → “sin responsable”; en DM → se asigna a quien la cree.', @@ -190,7 +190,7 @@ export class CommandService { '- Ver mis tareas: `/t ver mis` (por DM)', '- Ver todos: `/t ver todos`', '- Completar: `/t x 123`', - '- Configurar recordatorios: `/t configurar daily|weekly|off`' + '- Configurar recordatorios: `/t configurar daily|l-v|weekly|off [HH:MM]`' ].join('\n'); return [{ recipient: context.sender, @@ -670,10 +670,14 @@ export class CommandService { if (action === 'configurar') { const optRaw = (tokens[2] || '').toLowerCase(); - const map: Record = { + const map: Record = { 'daily': 'daily', 'diario': 'daily', 'diaria': 'daily', + 'l-v': 'weekdays', + 'lv': 'weekdays', + 'laborables': 'weekdays', + 'weekdays': 'weekdays', 'semanal': 'weekly', 'weekly': 'weekly', 'off': 'off', @@ -681,10 +685,26 @@ export class CommandService { 'ninguno': 'off' }; const freq = map[optRaw]; + + // Hora opcional HH:MM + const timeRaw = tokens[3] || ''; + let timeNorm: string | null = null; + if (timeRaw) { + const m = /^(\d{1,2}):([0-5]\d)$/.exec(timeRaw); + if (!m) { + return [{ + recipient: context.sender, + message: 'Uso: `/t configurar daily|l-v|weekly|off [HH:MM]`' + }]; + } + const hh = Math.max(0, Math.min(23, parseInt(m[1], 10))); + timeNorm = `${String(hh).padStart(2, '0')}:${m[2]}`; + } + if (!freq) { return [{ recipient: context.sender, - message: 'Uso: `/t configurar daily|weekly|off`' + message: 'Uso: `/t configurar daily|l-v|weekly|off [HH:MM]`' }]; } const ensured = ensureUserExists(context.sender, this.dbInstance); @@ -693,12 +713,24 @@ export class CommandService { } this.dbInstance.prepare(` INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at) - VALUES (?, ?, COALESCE((SELECT reminder_time FROM user_preferences WHERE user_id = ?), '08:30'), NULL, strftime('%Y-%m-%d %H:%M:%f', 'now')) + VALUES (?, ?, COALESCE(?, COALESCE((SELECT reminder_time FROM user_preferences WHERE user_id = ?), '08:30')), NULL, strftime('%Y-%m-%d %H:%M:%f', 'now')) ON CONFLICT(user_id) DO UPDATE SET reminder_freq = excluded.reminder_freq, + reminder_time = CASE WHEN ? IS NOT NULL THEN excluded.reminder_time ELSE reminder_time END, updated_at = excluded.updated_at - `).run(ensured, freq, ensured); - const label = freq === 'daily' ? 'diario' : freq === 'weekly' ? 'semanal (lunes 08:30)' : 'apagado'; + `).run(ensured, freq, timeNorm, ensured, timeNorm); + + let label: string; + if (freq === 'daily') { + label = timeNorm ? `diario (${timeNorm})` : 'diario'; + } else if (freq === 'weekdays') { + label = timeNorm ? `laborables (lunes a viernes ${timeNorm})` : 'laborables (lunes a viernes)'; + } else if (freq === 'weekly') { + label = timeNorm ? `semanal (lunes ${timeNorm})` : 'semanal (lunes 08:30)'; + } else { + label = 'apagado'; + } + return [{ recipient: context.sender, message: `✅ Recordatorios: ${label}` diff --git a/src/services/reminders.ts b/src/services/reminders.ts index 409e6fd..e4217b2 100644 --- a/src/services/reminders.ts +++ b/src/services/reminders.ts @@ -9,7 +9,7 @@ import { codeId, formatDDMM, bold, italic } from '../utils/formatting'; type UserPreference = { user_id: string; - reminder_freq: 'daily' | 'weekly' | 'off'; + reminder_freq: 'daily' | 'weekly' | 'weekdays' | 'off'; reminder_time: string; // 'HH:MM' last_reminded_on: string | null; // 'YYYY-MM-DD' }; @@ -85,7 +85,7 @@ export class RemindersService { const rows = this.dbInstance.prepare(` SELECT user_id, reminder_freq, reminder_time, last_reminded_on FROM user_preferences - WHERE reminder_freq IN ('daily', 'weekly') + WHERE reminder_freq IN ('daily', 'weekly', 'weekdays') `).all() as UserPreference[]; for (const pref of rows) { @@ -95,6 +95,9 @@ export class RemindersService { // Verificar hora alcanzada if (!pref.reminder_time || nowHM < pref.reminder_time) continue; + // Laborables: solo de lunes a viernes + if (pref.reminder_freq === 'weekdays' && (weekday === 'Sat' || weekday === 'Sun')) continue; + // Semanal: solo lunes (Mon) if (pref.reminder_freq === 'weekly' && weekday !== 'Mon') continue; diff --git a/tests/unit/services/command.reminders-config.test.ts b/tests/unit/services/command.reminders-config.test.ts index 62f25af..3c8915f 100644 --- a/tests/unit/services/command.reminders-config.test.ts +++ b/tests/unit/services/command.reminders-config.test.ts @@ -73,7 +73,7 @@ describe('CommandService - configurar recordatorios', () => { it('configurar con opción inválida devuelve uso correcto y no escribe en DB', async () => { const res = await runCmd('/t configurar foo'); expect(res).toHaveLength(1); - expect(res[0].message).toContain('Uso: `/t configurar daily|weekly|off`'); + expect(res[0].message).toContain('Uso: `/t configurar daily|l-v|weekly|off [HH:MM]`'); const pref = getPref(); expect(pref).toBeNull(); @@ -88,4 +88,16 @@ describe('CommandService - configurar recordatorios', () => { pref = getPref(); expect(pref!.freq).toBe('off'); }); + + it('configurar l-v con hora guarda weekdays y respeta hora', async () => { + const res = await runCmd('/t configurar l-v 8:00'); + expect(res).toHaveLength(1); + expect(res[0].recipient).toBe(SENDER); + expect(res[0].message).toContain('laborables'); + + const pref = getPref(); + expect(pref).not.toBeNull(); + expect(pref!.freq).toBe('weekdays'); + expect(pref!.time).toBe('08:00'); + }); }); diff --git a/tests/unit/services/reminders.test.ts b/tests/unit/services/reminders.test.ts index 76e889c..35ba108 100644 --- a/tests/unit/services/reminders.test.ts +++ b/tests/unit/services/reminders.test.ts @@ -40,7 +40,7 @@ describe('RemindersService', () => { `); }); - function insertPref(freq: 'daily' | 'weekly' | 'off', time: string = '08:30', last: string | null = null) { + function insertPref(freq: 'daily' | 'weekly' | 'weekdays' | 'off', time: string = '08:30', last: string | null = null) { memdb.prepare(` INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at) VALUES (?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', 'now')) @@ -164,4 +164,35 @@ describe('RemindersService', () => { const msg: string = String(row?.message || ''); expect(msg.includes('… y 2 más')).toBe(true); }); + + it('weekdays: envía en martes a la hora configurada', async () => { + insertPref('weekdays', '08:00', null); + // Martes 2025-09-09 08:05 Europe/Madrid ≈ 06:05Z + const now = new Date('2025-09-09T06:05:00.000Z'); + + // Crear 1 tarea asignada al usuario + TaskService.createTask( + { description: 'Tarea LV', due_date: '2025-09-10', group_id: null, created_by: USER }, + [{ user_id: USER, assigned_by: USER }] + ); + + await RemindersService.runOnce(now); + expect(countQueued()).toBe(1); + expect(getLastReminded()).toBe('2025-09-09'); + }); + + it('weekdays: no envía en sábado', async () => { + insertPref('weekdays', '08:00', null); + // Sábado 2025-09-13 08:05 Europe/Madrid ≈ 06:05Z + const now = new Date('2025-09-13T06:05:00.000Z'); + + TaskService.createTask( + { description: 'Tarea LV2', due_date: '2025-09-14', group_id: null, created_by: USER }, + [{ user_id: USER, assigned_by: USER }] + ); + + await RemindersService.runOnce(now); + expect(countQueued()).toBe(0); + expect(getLastReminded()).toBeNull(); + }); });