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}
- {#each data.tasks as t}
+ {#each data.openTasks as t}
{/each}
@@ -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}
+