diff --git a/src/services/commands/handlers/ver.ts b/src/services/commands/handlers/ver.ts new file mode 100644 index 0000000..dc0b378 --- /dev/null +++ b/src/services/commands/handlers/ver.ts @@ -0,0 +1,179 @@ +import { TaskService } from '../../tasks/service'; +import { GroupSyncService } from '../../group-sync'; +import { ContactsService } from '../../contacts'; +import { ICONS } from '../../../utils/icons'; +import { codeId, formatDDMM, bold, italic } from '../../../utils/formatting'; +import { SCOPE_ALIASES, todayYMD } from '../shared'; + +type Ctx = { + sender: string; + groupId: string; + message: string; +}; + +type Msg = { + recipient: string; + message: string; + mentions?: string[]; +}; + +export async function handleVer(context: Ctx): Promise { + const trimmed = (context.message || '').trim(); + const tokens = trimmed.split(/\s+/); + const rawAction = (tokens[1] || '').toLowerCase(); + + const scopeRaw = (tokens[2] || '').toLowerCase(); + const scope = scopeRaw + ? (SCOPE_ALIASES[scopeRaw] || scopeRaw) + : ((rawAction === 'mias' || rawAction === 'mías') ? 'mis' : ((rawAction === 'todas' || rawAction === 'todos') ? 'todos' : 'todos')); + + const LIMIT = 10; + const today = todayYMD(); + + if (scope === 'todos') { + const sections: string[] = []; + + // Encabezado fijo para la sección de tareas del usuario + sections.push(bold('Tus tareas')); + + // Tus tareas (mis) + const myItems = TaskService.listUserPending(context.sender, LIMIT); + if (myItems.length > 0) { + // Agrupar por grupo como en "ver mis" + const byGroup = new Map(); + for (const t of myItems) { + const key = t.group_id || '(sin grupo)'; + const arr = byGroup.get(key) || []; + arr.push(t); + byGroup.set(key, arr); + } + + for (const [groupId, arr] of byGroup.entries()) { + const groupName = + (groupId && GroupSyncService.activeGroupsCache.get(groupId)) || + (groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo'); + + sections.push(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) + ); + const owner = + (t.assignees?.length || 0) === 0 + ? `${ICONS.unassigned} sin responsable` + : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; + const isOverdue = t.due_date ? t.due_date < today : false; + const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; + return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`; + })); + sections.push(...rendered); + sections.push(''); + } + + // Quitar línea en blanco final si procede + if (sections.length > 0 && sections[sections.length - 1] === '') { + sections.pop(); + } + + const totalMy = TaskService.countUserPending(context.sender); + if (totalMy > myItems.length) { + sections.push(`… y ${totalMy - myItems.length} más`); + } + } else { + sections.push(italic('_No tienes tareas pendientes._')); + } + + // En DM: usar membresía real (snapshot fresca) para incluir "sin responsable" por grupo + const memberGroups = GroupSyncService.getFreshMemberGroupsForUser(context.sender); + if (memberGroups.length > 0) { + const perGroup = TaskService.listUnassignedByGroups(memberGroups, LIMIT); + for (const gid of perGroup.keys()) { + const unassigned = perGroup.get(gid)!; + const groupName = + (gid && GroupSyncService.activeGroupsCache.get(gid)) || + gid; + + if (unassigned.length > 0) { + if (sections.length && sections[sections.length - 1] !== '') sections.push(''); + sections.push(`${groupName} — Sin responsable`); + const renderedUnassigned = unassigned.map((t) => { + const isOverdue = t.due_date ? t.due_date < today : false; + const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; + return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${ICONS.unassigned}`; + }); + sections.push(...renderedUnassigned); + + const totalUnassigned = TaskService.countGroupUnassigned(gid); + if (totalUnassigned > unassigned.length) { + sections.push(`… y ${totalUnassigned - unassigned.length} más`); + } + } + } + } else { + // Si no hay snapshot fresca de membresía, nota instructiva + sections.push('ℹ️ Para ver tareas sin responsable, escribe por privado `/t todas` o usa `/t web`.'); + } + + return [{ + recipient: context.sender, + message: sections.join('\n') + }]; + } + + // Ver mis + const items = TaskService.listUserPending(context.sender, LIMIT); + if (items.length === 0) { + return [{ + recipient: context.sender, + message: italic('No tienes tareas pendientes.') + }]; + } + + const total = TaskService.countUserPending(context.sender); + + // Agrupar por grupo + const byGroup = new Map(); + for (const t of items) { + const key = t.group_id || '(sin grupo)'; + const arr = byGroup.get(key) || []; + arr.push(t); + byGroup.set(key, arr); + } + + const sections: string[] = [bold('Tus tareas')]; + for (const [groupId, arr] of byGroup.entries()) { + const groupName = + (groupId && GroupSyncService.activeGroupsCache.get(groupId)) || + (groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo'); + + sections.push(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) + ); + const owner = + (t.assignees?.length || 0) === 0 + ? `${ICONS.unassigned}` + : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; + const isOverdue = t.due_date ? t.due_date < today : false; + const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; + return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`; + })); + sections.push(...rendered); + sections.push(''); + } + + // Quitar línea en blanco final si procede + if (sections.length > 0 && sections[sections.length - 1] === '') { + sections.pop(); + } + + if (total > items.length) { + sections.push(`… y ${total - items.length} más`); + } + + return [{ + recipient: context.sender, + message: sections.join('\n') + }]; +} diff --git a/src/services/commands/index.ts b/src/services/commands/index.ts index c700e65..7afe6c7 100644 --- a/src/services/commands/index.ts +++ b/src/services/commands/index.ts @@ -7,7 +7,10 @@ import type { Database } from 'bun:sqlite'; import { ACTION_ALIASES } from './shared'; import { handleConfigurar } from './handlers/configurar'; import { handleWeb } from './handlers/web'; +import { handleVer } from './handlers/ver'; import { ResponseQueue } from '../response-queue'; +import { isGroupId } from '../../utils/whatsapp'; +import { Metrics } from '../metrics'; export type RoutedMessage = { recipient: string; @@ -35,6 +38,30 @@ export async function route(context: RouteContext, deps?: { db: Database }): Pro const database = deps?.db; if (!database) return null; + if (action === 'ver') { + // Métricas de alias (mias/todas) como en el código actual + try { + if (rawAction === 'mias' || rawAction === 'mías') { + Metrics.inc('commands_alias_used_total', 1, { action: 'mias' }); + } else if (rawAction === 'todas' || rawAction === 'todos') { + Metrics.inc('commands_alias_used_total', 1, { action: 'todas' }); + } + } catch {} + + try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} + + // En grupo: transición a DM + if (isGroupId(context.groupId)) { + try { Metrics.inc('ver_dm_transition_total'); } catch {} + return [{ + recipient: context.sender, + message: 'No respondo en grupos. Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web' + }]; + } + + return await handleVer(context as any); + } + if (action === 'configurar') { try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} return handleConfigurar(context as any, { db: database });