diff --git a/apps/web/src/lib/ui/data/TaskItem.svelte b/apps/web/src/lib/ui/data/TaskItem.svelte index 39f4b43..3b381dc 100644 --- a/apps/web/src/lib/ui/data/TaskItem.svelte +++ b/apps/web/src/lib/ui/data/TaskItem.svelte @@ -1,6 +1,6 @@ -
  • +
  • - #{code} + #{codeStr} {description} - {#if due_date} - {#if status === 'overdue'} - vence: {due_date} - {:else if status === 'soon'} - vence: {due_date} - {:else} - vence: {due_date} - {/if} - {/if}
    + {#if due_date} + + 📅 {dateDmy}{#if overdue} ⚠{/if} + + {/if} {#if assignees?.length} - asignados: {assignees.join(', ')} + + {#each assignees as a} + 👤 + {/each} + {/if}
    - {#if !isAssigned} - - {:else} - - {/if} + {#if !completed} + {#if !isAssigned} + + {:else} + + {/if} - {#if !editingText} - - {:else} - - - {/if} + - {#if !editing} - - {:else} - - - - + {#if !editingText} + + {:else} + + + {/if} + + {#if !editing} + + {:else} + + + + + {/if} {/if}
  • @@ -227,7 +257,7 @@ diff --git a/apps/web/src/lib/utils/date.ts b/apps/web/src/lib/utils/date.ts index 3e8e375..141b25e 100644 --- a/apps/web/src/lib/utils/date.ts +++ b/apps/web/src/lib/utils/date.ts @@ -29,3 +29,17 @@ export function dueStatus(ymd: string | null, soonDays: number = 3): 'none' | 'o if (compareYmd(ymd, soonCut) <= 0) return 'soon'; return 'none'; } + +export function ymdToDmy(ymd: string): string { + const m = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(ymd || ''); + if (!m) return ymd; + return `${m[3]}/${m[2]}/${m[1]}`; +} + +export function isToday(ymd: string): boolean { + return ymd === todayYmdUTC(); +} + +export function isTomorrow(ymd: string): boolean { + return ymd === addDaysYmd(todayYmdUTC(), 1); +} diff --git a/apps/web/src/routes/api/me/tasks/+server.ts b/apps/web/src/routes/api/me/tasks/+server.ts index f2e7c38..0624a3d 100644 --- a/apps/web/src/routes/api/me/tasks/+server.ts +++ b/apps/web/src/routes/api/me/tasks/+server.ts @@ -27,15 +27,104 @@ export const GET: RequestHandler = async (event) => { dueCutoff = d.toISOString().slice(0, 10); } - // Por ahora solo "open" - if (status !== 'open') { - return new Response('Bad Request', { status: 400 }); + // Acepta "open" (por defecto) o "recent" (completadas <24h) + if (status !== 'open' && status !== 'recent') { + return new Response('Bad Request', { status: 400 }); } const offset = (page - 1) * limit; const db = await getDb(); + if (status === 'recent') { + // Construir filtros para tareas completadas en <24h asignadas al usuario. + const whereParts = [ + `a.user_id = ?`, + `(COALESCE(t.completed, 0) = 1 OR t.completed_at IS NOT NULL)`, + `t.completed_at IS NOT NULL AND t.completed_at >= strftime('%Y-%m-%d %H:%M:%f','now','-24 hours')`, + `(t.group_id IS NULL OR (EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed') AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1)))` + ]; + const params: any[] = [userId, userId]; + + if (search) { + whereParts.push(`t.description LIKE ? ESCAPE '\\'`); + params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`); + } + + // Total + const totalRow = db + .prepare( + `SELECT COUNT(*) AS cnt + FROM tasks t + INNER JOIN task_assignments a ON a.task_id = t.id + WHERE ${whereParts.join(' AND ')}` + ) + .get(...params) as any; + const total = Number(totalRow?.cnt || 0); + + // Items (order by completed_at DESC) + const itemsRows = db + .prepare( + `SELECT t.id, t.description, t.due_date, t.group_id, t.display_code, COALESCE(t.completed,0) as completed, t.completed_at + FROM tasks t + INNER JOIN task_assignments a ON a.task_id = t.id + WHERE ${whereParts.join(' AND ')} + ORDER BY t.completed_at DESC, t.id DESC + LIMIT ? OFFSET ?` + ) + .all(...params, limit, offset) as any[]; + + const items = itemsRows.map((r) => ({ + id: Number(r.id), + description: String(r.description || ''), + due_date: r.due_date ? String(r.due_date) : null, + group_id: r.group_id ? String(r.group_id) : null, + display_code: r.display_code != null ? Number(r.display_code) : null, + completed: Number(r.completed || 0) === 1, + completed_at: r.completed_at ? String(r.completed_at) : null, + assignees: [] as string[] + })); + + // Cargar asignados + if (items.length > 0) { + const ids = items.map((it) => it.id); + const placeholders = ids.map(() => '?').join(','); + const assignRows = db + .prepare( + `SELECT task_id, user_id + FROM task_assignments + WHERE task_id IN (${placeholders}) + ORDER BY assigned_at ASC` + ) + .all(...ids) as any[]; + + const map = new Map(); + for (const row of assignRows) { + const tid = Number(row.task_id); + const uid = String(row.user_id); + if (!map.has(tid)) map.set(tid, []); + map.get(tid)!.push(uid); + } + for (const it of items) { + it.assignees = map.get(it.id) || []; + } + } + + const body = { + items, + page, + limit, + total, + hasMore: offset + items.length < total + }; + + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); + } + + // OPEN (comportamiento existente) // Construir filtros dinámicos (con gating por grupo permitido y membresía activa) const whereParts = [ `a.user_id = ?`, diff --git a/apps/web/src/routes/api/tasks/[id]/complete/+server.ts b/apps/web/src/routes/api/tasks/[id]/complete/+server.ts new file mode 100644 index 0000000..9947d8e --- /dev/null +++ b/apps/web/src/routes/api/tasks/[id]/complete/+server.ts @@ -0,0 +1,102 @@ +import type { RequestHandler } from './$types'; +import { getDb } from '$lib/server/db'; + +export const POST: RequestHandler = async (event) => { + const userId = event.locals.userId ?? null; + if (!userId) { + return new Response('Unauthorized', { status: 401 }); + } + + const idStr = event.params.id || ''; + const taskId = parseInt(idStr, 10); + if (!Number.isFinite(taskId) || taskId <= 0) { + return new Response('Bad Request', { status: 400 }); + } + + const db = await getDb(); + + const task = db.prepare(` + SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code + FROM tasks + WHERE id = ? + `).get(taskId) as any; + + if (!task) { + return new Response(JSON.stringify({ status: 'not_found' }), { + status: 404, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); + } + + // Gating: + // - Si tiene group_id: grupo allowed y miembro activo + // - Si NO tiene group_id: debe estar asignada al usuario + const groupId: string | null = task.group_id ? String(task.group_id) : null; + if (groupId) { + const allowed = db + .prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`) + .get(groupId); + const active = db + .prepare(`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`) + .get(groupId, userId); + if (!allowed || !active) { + return new Response('Forbidden', { status: 403 }); + } + } else { + const isAssigned = db + .prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`) + .get(taskId, userId); + if (!isAssigned) { + return new Response('Forbidden', { status: 403 }); + } + } + + if (Number(task.completed) !== 0 || task.completed_at) { + const body = { + status: 'already', + task: { + id: Number(task.id), + description: String(task.description || ''), + due_date: task.due_date ? String(task.due_date) : null, + display_code: task.display_code != null ? Number(task.display_code) : null, + completed: 1, + completed_at: task.completed_at ? String(task.completed_at) : null + } + }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); + } + + db.prepare(` + UPDATE tasks + SET completed = 1, + completed_at = strftime('%Y-%m-%d %H:%M:%f', 'now'), + completed_by = ? + WHERE id = ? + `).run(userId, taskId); + + const updated = db.prepare(` + SELECT id, description, due_date, display_code, COALESCE(completed, 0) AS completed, completed_at + FROM tasks + WHERE id = ? + `).get(taskId) as any; + + const body = { + status: 'updated', + task: { + id: Number(updated.id), + description: String(updated.description || ''), + due_date: updated.due_date ? String(updated.due_date) : null, + display_code: updated.display_code != null ? Number(updated.display_code) : null, + completed: Number(updated.completed || 0), + completed_at: updated.completed_at ? String(updated.completed_at) : null + } + }; + + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); +}; diff --git a/apps/web/src/routes/app/+page.server.ts b/apps/web/src/routes/app/+page.server.ts index 9320ade..ddd5f44 100644 --- a/apps/web/src/routes/app/+page.server.ts +++ b/apps/web/src/routes/app/+page.server.ts @@ -9,7 +9,7 @@ export const load: PageServerLoad = async (event) => { } // Cargar "mis tareas" desde la API interna - let tasks: Array<{ + let openTasks: Array<{ id: number; description: string; due_date: string | null; @@ -17,6 +17,16 @@ export const load: PageServerLoad = async (event) => { display_code: number | null; assignees: string[]; }> = []; + let recentTasks: Array<{ + id: number; + description: string; + due_date: string | null; + group_id: string | null; + display_code: number | null; + assignees: string[]; + completed?: boolean; + completed_at?: string | null; + }> = []; let hasMore: boolean = false; // Filtros desde la query (?q=&soonDays=) @@ -34,12 +44,21 @@ export const load: PageServerLoad = async (event) => { const res = await event.fetch(fetchUrl); if (res.ok) { const json = await res.json(); - tasks = Array.isArray(json?.items) ? json.items : []; + openTasks = Array.isArray(json?.items) ? json.items : []; hasMore = Boolean(json?.hasMore); } + + // Cargar completadas en las últimas 24h (sin paginar por ahora) + let recentUrl = '/api/me/tasks?limit=20&status=recent'; + if (q) recentUrl += `&search=${encodeURIComponent(q)}`; + const resRecent = await event.fetch(recentUrl); + if (resRecent.ok) { + const jsonRecent = await resRecent.json(); + recentTasks = Array.isArray(jsonRecent?.items) ? jsonRecent.items : []; + } } catch { // Ignorar errores y dejar lista vacía } - return { userId, tasks, q, soonDays: soonDaysStr ? Number(soonDaysStr) : null, page, hasMore }; + return { userId, openTasks, recentTasks, q, soonDays: soonDaysStr ? Number(soonDaysStr) : null, page, hasMore }; }; diff --git a/apps/web/src/routes/app/+page.svelte b/apps/web/src/routes/app/+page.svelte index 28b1365..c31aa22 100644 --- a/apps/web/src/routes/app/+page.svelte +++ b/apps/web/src/routes/app/+page.svelte @@ -6,7 +6,7 @@ export let data: { userId: string; - tasks: Array<{ + openTasks: Array<{ id: number; description: string; due_date: string | null; @@ -14,6 +14,16 @@ display_code: number | null; assignees: string[]; }>; + recentTasks: Array<{ + id: number; + description: string; + due_date: string | null; + group_id: string | null; + display_code: number | null; + assignees: string[]; + completed?: boolean; + completed_at?: string | null; + }>; q?: string | null; soonDays?: number | null; page?: number | null; @@ -38,12 +48,12 @@

    Mis tareas (abiertas)

    -{#if data.tasks.length === 0} +{#if data.openTasks.length === 0}

    No tienes tareas abiertas.

    {:else} @@ -61,6 +71,19 @@ /> {/if} +

    Completadas (últimas 24 h)

    +{#if data.recentTasks.length === 0} +

    No hay tareas completadas recientemente.

    +{:else} + +
      + {#each data.recentTasks as t} + + {/each} +
    +
    +{/if} +

    La cookie de sesión se renueva con cada visita (idle timeout).