From 05952efbf3769e9bc99f9de7c4283f9fb347d175 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 8 Sep 2025 15:46:51 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20formatting.ts=20para=20ID?= =?UTF-8?q?s=204=20d=C3=ADgitos=20y=20fechas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- src/services/command.ts | 153 +++++++++++------- src/services/reminders.ts | 13 +- src/utils/formatting.ts | 31 ++++ .../services/command.claim-unassign.test.ts | 4 +- 4 files changed, 135 insertions(+), 66 deletions(-) create mode 100644 src/utils/formatting.ts diff --git a/src/services/command.ts b/src/services/command.ts index d8ce24c..9dd3d85 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -5,6 +5,7 @@ import { TaskService } from '../tasks/service'; import { GroupSyncService } from './group-sync'; import { ContactsService } from './contacts'; import { ICONS } from '../utils/icons'; +import { padTaskId, codeId, formatDDMM, bold, italic } from '../utils/formatting'; type CommandContext = { sender: string; // normalized user id (digits only), but accept raw too @@ -141,16 +142,7 @@ export class CommandService { }; const action = ACTION_ALIASES[rawAction] || rawAction; - // Helper para fechas dd/MM - const formatDDMM = (ymd?: string | null): string | null => { - if (!ymd) return null; - const parts = String(ymd).split('-'); - if (parts.length >= 3) { - const [Y, M, D] = parts; - if (D && M) return `${D}/${M}`; - } - return String(ymd); - }; + // Usar formatDDMM desde utils/formatting // TZ y "hoy" en TZ para marcar vencidas en listados const TZ = process.env.TZ && process.env.TZ.trim() ? process.env.TZ : 'Europe/Madrid'; @@ -167,14 +159,36 @@ export class CommandService { const todayYMD = ymdInTZ(new Date()); if (!action || action === 'ayuda') { + const isAdvanced = (tokens[2] || '').toLowerCase() === 'avanzada'; + if (isAdvanced) { + const adv = [ + 'Ayuda avanzada:', + '- Comandos y alias:', + ' · Crear: n, nueva, crear, +', + ' · Ver: ver, mostrar, listar, ls (scopes: grupo | mis | todos | sin)', + ' · Completar: x, hecho, completar, done', + ' · Tomar: tomar, claim', + ' · Soltar: soltar, unassign', + '- Preferencias:', + ' · /t configurar daily|weekly|off (hora por defecto 08:30; semanal: lunes 08:30)', + '- Notas:', + ' · En grupos, el bot responde por DM al autor (no publica en el grupo).', + ' · Si creas en grupo y no mencionas a nadie → “sin responsable”; en DM → se asigna al creador.', + ' · Fechas dd/MM con ⚠️ si está vencida.', + ' · Mostramos los IDs con 4 dígitos, pero puedes escribirlos sin ceros (p. ej., 26).', + ].join('\n'); + return [{ + recipient: context.sender, + message: adv + }]; + } const help = [ 'Guía rápida:', - '- Crear: /t n Descripción mañana @Ana', - '- Ver grupo: /t ver grupo', - '- Ver mis: /t ver mis', - '- Ver todos: /t ver todos', - '- Completar: /t x 123', - '- Configurar recordatorios: /t configurar daily|weekly|off' + '- Crear: /t n Descripción @600123456', + '- Ver grupo: /t ver grupo | tus tareas: /t ver mis', + '- Completar: /t x 26 | Tomar: /t tomar 26 | Soltar: /t soltar 26', + '- Recordatorios: /t configurar daily|weekly|off', + '- Más: /t ayuda avanzada' ].join('\n'); return [{ recipient: context.sender, @@ -224,17 +238,17 @@ export class CommandService { const rendered = items.map((t) => { const isOverdue = t.due_date ? t.due_date < todayYMD : false; const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; - return `- ${t.id}) _${t.description || '(sin descripción)'}_${datePart} — ${ICONS.unassigned} sin responsable`; + return `- ${codeId(t.id)} ${t.description || '(sin descripción)'}${datePart} — ${ICONS.unassigned} sin responsable`; }); const total = TaskService.countGroupUnassigned(context.groupId); if (total > items.length) { - rendered.push(`… y ${total - items.length} más`); + rendered.push(italic(`… y ${total - items.length} más`)); } return [{ recipient: context.sender, - message: [`${groupName} — Sin responsable`, ...rendered].join('\n') + message: [bold(`${groupName} — Sin responsable`), ...rendered].join('\n') }]; } @@ -243,7 +257,7 @@ export class CommandService { const sections: string[] = []; // Encabezado fijo para la sección de tareas del usuario - sections.push('Tus tareas'); + sections.push(bold('Tus tareas')); // Tus tareas (mis) const myItems = TaskService.listUserPending(context.sender, LIMIT); @@ -262,7 +276,7 @@ export class CommandService { (groupId && GroupSyncService.activeGroupsCache.get(groupId)) || (groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo'); - sections.push(groupName); + sections.push(bold(groupName)); const rendered = await Promise.all(arr.map(async (t) => { const names = await Promise.all( (t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid) @@ -273,14 +287,14 @@ export class CommandService { : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; const isOverdue = t.due_date ? t.due_date < todayYMD : false; const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; - return `- ${t.id}) _${t.description || '(sin descripción)'}_${datePart} — ${owner}`; + return `- ${codeId(t.id)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`; })); sections.push(...rendered); } const totalMy = TaskService.countUserPending(context.sender); if (totalMy > myItems.length) { - sections.push(`… y ${totalMy - myItems.length} más`); + sections.push(italic(`… y ${totalMy - myItems.length} más`)); } } else { sections.push('No tienes tareas pendientes.'); @@ -294,17 +308,17 @@ export class CommandService { const groupName = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId; const unassigned = TaskService.listGroupUnassigned(context.groupId, LIMIT); if (unassigned.length > 0) { - sections.push(`${groupName} — Sin responsable`); + sections.push(bold(`${groupName} — Sin responsable`)); const renderedUnassigned = unassigned.map((t) => { const isOverdue = t.due_date ? t.due_date < todayYMD : false; const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; - return `- ${t.id}) _${t.description || '(sin descripción)'}_${datePart} — ${ICONS.unassigned} sin responsable`; + return `- ${codeId(t.id)} ${t.description || '(sin descripción)'}${datePart} — ${ICONS.unassigned} sin responsable`; }); sections.push(...renderedUnassigned); const totalUnassigned = TaskService.countGroupUnassigned(context.groupId); if (totalUnassigned > unassigned.length) { - sections.push(`… y ${totalUnassigned - unassigned.length} más`); + sections.push(italic(`… y ${totalUnassigned - unassigned.length} más`)); } } else { sections.push(`${groupName} — Sin responsable\n(no hay tareas sin responsable)`); @@ -322,17 +336,17 @@ export class CommandService { gid; if (unassigned.length > 0) { - sections.push(`${groupName} — Sin responsable`); + sections.push(bold(`${groupName} — Sin responsable`)); const renderedUnassigned = unassigned.map((t) => { const isOverdue = t.due_date ? t.due_date < todayYMD : false; const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; - return `- ${t.id}) _${t.description || '(sin descripción)'}_${datePart} — ${ICONS.unassigned} sin responsable`; + return `- ${codeId(t.id)} ${t.description || '(sin descripción)'}${datePart} — ${ICONS.unassigned} sin responsable`; }); sections.push(...renderedUnassigned); const totalUnassigned = TaskService.countGroupUnassigned(gid); if (totalUnassigned > unassigned.length) { - sections.push(`… y ${totalUnassigned - unassigned.length} más`); + sections.push(italic(`… y ${totalUnassigned - unassigned.length} más`)); } } } @@ -392,7 +406,7 @@ export class CommandService { : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; const isOverdue = t.due_date ? t.due_date < todayYMD : false; const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; - return `- ${t.id}) _${t.description || '(sin descripción)'}_${datePart} — ${owner}`; + return `- ${codeId(t.id)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`; })); const total = TaskService.countGroupPending(context.groupId); @@ -402,7 +416,7 @@ export class CommandService { return [{ recipient: context.sender, - message: [groupName, ...rendered].join('\n') + message: [bold(groupName), ...rendered].join('\n') }]; } @@ -432,7 +446,7 @@ export class CommandService { (groupId && GroupSyncService.activeGroupsCache.get(groupId)) || (groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo'); - sections.push(groupName); + sections.push(bold(groupName)); const rendered = await Promise.all(arr.map(async (t) => { const names = await Promise.all( (t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid) @@ -443,13 +457,13 @@ export class CommandService { : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; const isOverdue = t.due_date ? t.due_date < todayYMD : false; const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; - return `- ${t.id}) _${t.description || '(sin descripción)'}_${datePart} — ${owner}`; + return `- ${codeId(t.id)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`; })); sections.push(...rendered); } if (total > items.length) { - sections.push(`… y ${total - items.length} más`); + sections.push(italic(`… y ${total - items.length} más`)); } return [{ @@ -465,7 +479,7 @@ export class CommandService { if (!id || Number.isNaN(id)) { return [{ recipient: context.sender, - message: 'Uso: /t x ' + message: 'ℹ️ Uso: /t x 26' }]; } @@ -473,7 +487,7 @@ export class CommandService { if (!task) { return [{ recipient: context.sender, - message: `⚠️ Tarea ${id} no encontrada.` + message: `⚠️ Tarea ${codeId(id)} no encontrada.` }]; } const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true'; @@ -489,21 +503,27 @@ export class CommandService { if (res.status === 'not_found') { return [{ recipient: context.sender, - message: `⚠️ Tarea ${id} no encontrada.` + message: `⚠️ Tarea ${codeId(id)} no encontrada.` }]; } if (res.status === 'already') { - const due = res.task?.due_date ? ` — 📅 ${formatDDMM(res.task?.due_date)}` : ''; + const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; return [{ recipient: context.sender, - message: `ℹ️ ${id} ya estaba completada — _${res.task?.description || '(sin descripción)'}_${due}` + message: `ℹ️ ${codeId(id)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}` }]; } - const due = res.task?.due_date ? ` — 📅 ${formatDDMM(res.task?.due_date)}` : ''; + const dueLine = res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; + const lines = [ + `${ICONS.complete} ${codeId(id)}`, + `${res.task?.description || '(sin descripción)'}`, + dueLine, + italic(`Gracias, ${who}.`) + ].filter(Boolean); return [{ recipient: context.sender, - message: `${ICONS.complete} ${id} completada — _${res.task?.description || '(sin descripción)'}_${due}\nGracias, ${who}.` + message: lines.join('\n') }]; } @@ -514,7 +534,7 @@ export class CommandService { if (!id || Number.isNaN(id)) { return [{ recipient: context.sender, - message: 'Uso: /t tomar ' + message: 'ℹ️ Uso: /t tomar 26' }]; } @@ -522,7 +542,7 @@ export class CommandService { if (!task) { return [{ recipient: context.sender, - message: `⚠️ Tarea ${id} no encontrada.` + message: `⚠️ Tarea ${codeId(id)} no encontrada.` }]; } const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true'; @@ -534,7 +554,7 @@ export class CommandService { } const res = TaskService.claimTask(id, context.sender); - const due = res.task?.due_date ? ` — 📅 ${formatDDMM(res.task?.due_date)}` : ''; + const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; if (res.status === 'not_found') { return [{ @@ -545,19 +565,24 @@ export class CommandService { if (res.status === 'completed') { return [{ recipient: context.sender, - message: `ℹ️ ${id} ya estaba completada — _${res.task?.description || '(sin descripción)'}_${due}` + message: `ℹ️ ${codeId(id)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}` }]; } if (res.status === 'already') { return [{ recipient: context.sender, - message: `ℹ️ ${id} ya la tenías — _${res.task?.description || '(sin descripción)'}_${due}` + message: `ℹ️ ${codeId(id)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}` }]; } + const lines = [ + `${ICONS.take} ${codeId(id)}`, + `${res.task?.description || '(sin descripción)'}`, + res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '' + ].filter(Boolean); return [{ recipient: context.sender, - message: `${ICONS.take} Has tomado ${id} — _${res.task?.description || '(sin descripción)'}_${due}` + message: lines.join('\n') }]; } @@ -568,7 +593,7 @@ export class CommandService { if (!id || Number.isNaN(id)) { return [{ recipient: context.sender, - message: 'Uso: /t soltar ' + message: 'ℹ️ Uso: /t soltar 26' }]; } @@ -588,7 +613,7 @@ export class CommandService { } const res = TaskService.unassignTask(id, context.sender); - const due = res.task?.due_date ? ` — 📅 ${formatDDMM(res.task?.due_date)}` : ''; + const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; if (res.status === 'not_found') { return [{ @@ -599,26 +624,37 @@ export class CommandService { if (res.status === 'completed') { return [{ recipient: context.sender, - message: `ℹ️ ${id} ya estaba completada — _${res.task?.description || '(sin descripción)'}_${due}` + message: `ℹ️ ${codeId(id)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}` }]; } if (res.status === 'not_assigned') { return [{ recipient: context.sender, - message: `ℹ️ ${id} no la tenías asignada — _${res.task?.description || '(sin descripción)'}_${due}` + message: `ℹ️ ${codeId(id)} no la tenías asignada — ${res.task?.description || '(sin descripción)'}${due}` }]; } if (res.now_unassigned) { + const lines = [ + `${ICONS.unassigned} ${codeId(id)}`, + `${res.task?.description || '(sin descripción)'}`, + res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '', + italic('Queda sin responsable.') + ].filter(Boolean); return [{ recipient: context.sender, - message: `${ICONS.unassigned} ${id} queda sin responsable — _${res.task?.description || '(sin descripción)'}_${due}` + message: lines.join('\n') }]; } + const lines = [ + `${ICONS.unassign} ${codeId(id)}`, + `${res.task?.description || '(sin descripción)'}`, + res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '' + ].filter(Boolean); return [{ recipient: context.sender, - message: `${ICONS.unassign} Has soltado ${id} — _${res.task?.description || '(sin descripción)'}_${due}` + message: lines.join('\n') }]; } @@ -753,8 +789,8 @@ export class CommandService { const responses: CommandResponse[] = []; // 1) Ack al creador con formato compacto - const ackHeader = `${ICONS.create} ${taskId} _${description || '(sin descripción)'}_`; - const ackLines: string[] = [ackHeader]; + const ackHeader = `${ICONS.create} ${codeId(taskId)}`; + const ackLines: string[] = [ackHeader, `${description || '(sin descripción)'}`]; const dueFmt = formatDDMM(dueDate); if (dueFmt) ackLines.push(`${ICONS.date} ${dueFmt}`); if (assignmentUserIds.length === 0) { @@ -775,10 +811,11 @@ export class CommandService { responses.push({ recipient: uid, message: [ - `${ICONS.assignNotice} Tarea ${taskId}${formatDDMM(dueDate) ? ` — ${ICONS.date} ${formatDDMM(dueDate)}` : ''}`, - `_${description || '(sin descripción)'}_`, + `${ICONS.assignNotice} ${codeId(taskId)}`, + `${description || '(sin descripción)'}`, + formatDDMM(dueDate) ? `${ICONS.date} ${formatDDMM(dueDate)}` : null, groupName ? `Grupo: ${groupName}` : null, - `Completar: /t x ${taskId}` + italic(`Acciones: Completar → /t x ${taskId} · Soltar → /t soltar ${taskId}`) ].filter(Boolean).join('\n'), mentions: [creatorJid] }); diff --git a/src/services/reminders.ts b/src/services/reminders.ts index ad86370..409e6fd 100644 --- a/src/services/reminders.ts +++ b/src/services/reminders.ts @@ -5,6 +5,7 @@ import { ResponseQueue } from './response-queue'; import { ContactsService } from './contacts'; import { GroupSyncService } from './group-sync'; import { ICONS } from '../utils/icons'; +import { codeId, formatDDMM, bold, italic } from '../utils/formatting'; type UserPreference = { user_id: string; @@ -132,7 +133,7 @@ export class RemindersService { (groupId && GroupSyncService.activeGroupsCache.get(groupId)) || (groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo'); - sections.push(groupName); + sections.push(bold(groupName)); const rendered = await Promise.all(arr.map(async (t) => { const names = await Promise.all( (t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid) @@ -143,13 +144,13 @@ export class RemindersService { : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; const isOverdue = t.due_date ? t.due_date < todayYMD : false; const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; - return `- ${t.id}) _${t.description || '(sin descripción)'}_${datePart} — ${owner}`; + return `- ${codeId(t.id)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`; })); sections.push(...rendered); } if (total > items.length) { - sections.push(`… y ${total - items.length} más`); + sections.push(italic(`… y ${total - items.length} más`)); } // (Etapa 3) Sección opcional de "sin responsable" filtrada por membresía activa + snapshot fresca. @@ -162,17 +163,17 @@ export class RemindersService { const groupName = (gid && GroupSyncService.activeGroupsCache.get(gid)) || gid; - sections.push(`${groupName} — Sin responsable`); + sections.push(bold(`${groupName} — Sin responsable`)); const renderedUnassigned = unassigned.map((t) => { const isOverdue = t.due_date ? t.due_date < todayYMD : false; const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; - return `- ${t.id}) _${t.description || '(sin descripción)'}_${datePart} — ${ICONS.unassigned} sin responsable`; + return `- ${codeId(t.id)} ${t.description || '(sin descripción)'}${datePart} — ${ICONS.unassigned} sin responsable`; }); sections.push(...renderedUnassigned); const totalUnassigned = TaskService.countGroupUnassigned(gid); if (totalUnassigned > unassigned.length) { - sections.push(`… y ${totalUnassigned - unassigned.length} más`); + sections.push(italic(`… y ${totalUnassigned - unassigned.length} más`)); } } } diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts new file mode 100644 index 0000000..aa313f0 --- /dev/null +++ b/src/utils/formatting.ts @@ -0,0 +1,31 @@ +/** + * Utilidades de formato de mensajes (IDs, fechas y estilos WhatsApp). + */ + +export function padTaskId(id: number, width: number = 4): string { + const s = String(Math.max(0, Math.floor(id))); + if (s.length >= width) return s; + return '0'.repeat(width - s.length) + s; +} + +export function codeId(id: number): string { + return '`' + padTaskId(id) + '`'; +} + +export function formatDDMM(ymd?: string | null): string | null { + if (!ymd) return null; + const parts = String(ymd).split('-'); + if (parts.length >= 3) { + const [Y, M, D] = parts; + if (D && M) return `${D}/${M}`; + } + return String(ymd); +} + +export function bold(s: string): string { + return `*${s}*`; +} + +export function italic(s: string): string { + return `_${s}_`; +} diff --git a/tests/unit/services/command.claim-unassign.test.ts b/tests/unit/services/command.claim-unassign.test.ts index c95eb8e..833c019 100644 --- a/tests/unit/services/command.claim-unassign.test.ts +++ b/tests/unit/services/command.claim-unassign.test.ts @@ -47,7 +47,7 @@ describe('CommandService - /t tomar y /t soltar', () => { const res = await CommandService.handle(ctx('111', '/t tomar')); expect(res).toHaveLength(1); expect(res[0].recipient).toBe('111'); - expect(res[0].message).toContain('Uso: /t tomar '); + expect(res[0].message).toContain('Uso: /t tomar 26'); }); it('tomar: not_found', async () => { @@ -78,7 +78,7 @@ describe('CommandService - /t tomar y /t soltar', () => { it('soltar: uso inválido (sin id)', async () => { const res = await CommandService.handle(ctx('111', '/t soltar')); - expect(res[0].message).toContain('Uso: /t soltar '); + expect(res[0].message).toContain('Uso: /t soltar 26'); }); it('soltar: not_found', async () => {