feat: añadir formatting.ts para IDs 4 dígitos y fechas

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
pull/1/head
borja 2 months ago
parent 81be46c69c
commit 05952efbf3

@ -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 <id>'
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 <id>'
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 <id>'
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]
});

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

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

@ -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 <id>');
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 <id>');
expect(res[0].message).toContain('Uso: /t soltar 26');
});
it('soltar: not_found', async () => {

Loading…
Cancel
Save