diff --git a/apps/web/src/lib/ui/data/TaskItem.svelte b/apps/web/src/lib/ui/data/TaskItem.svelte
index 536ba55..2f37666 100644
--- a/apps/web/src/lib/ui/data/TaskItem.svelte
+++ b/apps/web/src/lib/ui/data/TaskItem.svelte
@@ -1,15 +1,96 @@
@@ -26,18 +107,36 @@
{/if}
{/if}
- {#if assignees?.length}
-
+
+
+ {#if assignees?.length}
asignados: {assignees.join(', ')}
-
- {/if}
+ {/if}
+
+
+
+ {#if !isAssigned}
+
+ {:else}
+
+ {/if}
+
+ {#if !editing}
+
+ {:else}
+
+
+
+
+ {/if}
+
diff --git a/apps/web/src/routes/api/tasks/[id]/+server.ts b/apps/web/src/routes/api/tasks/[id]/+server.ts
new file mode 100644
index 0000000..cfc329b
--- /dev/null
+++ b/apps/web/src/routes/api/tasks/[id]/+server.ts
@@ -0,0 +1,112 @@
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+
+function isValidYmd(input: string): boolean {
+ const m = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(input || '');
+ if (!m) return false;
+ const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
+ if (!Number.isFinite(y) || !Number.isFinite(mo) || !Number.isFinite(d)) return false;
+ if (mo < 1 || mo > 12 || d < 1 || d > 31) return false;
+ const dt = new Date(`${String(y).padStart(4, '0')}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}T00:00:00Z`);
+ // Comprobar que el Date resultante coincide (evita 2025-02-31)
+ return dt.getUTCFullYear() === y && (dt.getUTCMonth() + 1) === mo && dt.getUTCDate() === d;
+}
+
+export const PATCH: 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 });
+ }
+
+ let payload: any = null;
+ try {
+ payload = await event.request.json();
+ } catch {
+ return new Response('Bad Request', { status: 400 });
+ }
+
+ const due_date_raw = payload?.due_date;
+ if (due_date_raw !== null && due_date_raw !== undefined && typeof due_date_raw !== 'string') {
+ return new Response('Bad Request', { status: 400 });
+ }
+ const due_date =
+ due_date_raw == null || String(due_date_raw).trim() === ''
+ ? null
+ : String(due_date_raw).trim();
+
+ if (due_date !== null && !isValidYmd(due_date)) {
+ return new Response(JSON.stringify({ error: 'invalid_due_date' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+
+ const db = await getDb();
+
+ // Cargar tarea y validar abierta
+ 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' }
+ });
+ }
+ if (Number(task.completed) !== 0 || task.completed_at) {
+ return new Response(JSON.stringify({ status: 'completed' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+
+ // Gating: grupo permitido + usuario miembro activo (si tiene group_id)
+ 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 });
+ }
+ }
+
+ // Aplicar actualización
+ db.prepare(`UPDATE tasks SET due_date = ? WHERE id = ?`).run(due_date, taskId);
+
+ const updated = db
+ .prepare(`SELECT id, description, due_date, display_code 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
+ }
+ };
+
+ 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/api/tasks/[id]/claim/+server.ts b/apps/web/src/routes/api/tasks/[id]/claim/+server.ts
new file mode 100644
index 0000000..06996e0
--- /dev/null
+++ b/apps/web/src/routes/api/tasks/[id]/claim/+server.ts
@@ -0,0 +1,99 @@
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+
+function toIsoSql(d: Date): string {
+ return d.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+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();
+
+ // Cargar tarea y validar abierta
+ 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' }
+ });
+ }
+ if (Number(task.completed) !== 0 || task.completed_at) {
+ return new Response(JSON.stringify({ status: 'completed' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+
+ // Gating: grupo permitido + usuario miembro activo (si tiene group_id)
+ 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 });
+ }
+ }
+
+ // Asegurar existencia del usuario (best-effort)
+ try {
+ db.transaction(() => {
+ db.prepare(
+ `INSERT INTO users (id, first_seen, last_seen)
+ VALUES (?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
+ ON CONFLICT(id) DO NOTHING`
+ ).run(userId);
+ db.prepare(
+ `UPDATE users SET last_seen = strftime('%Y-%m-%d %H:%M:%f','now') WHERE id = ?`
+ ).run(userId);
+ })();
+ } catch {}
+
+ // Reclamar (idempotente)
+ const res = db
+ .prepare(
+ `INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by)
+ VALUES (?, ?, ?)`
+ )
+ .run(taskId, userId, userId) as any;
+
+ const status = Number(res?.changes || 0) > 0 ? 'claimed' : 'already';
+
+ const body = {
+ status,
+ 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
+ }
+ };
+
+ 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/api/tasks/[id]/unassign/+server.ts b/apps/web/src/routes/api/tasks/[id]/unassign/+server.ts
new file mode 100644
index 0000000..e4a5db0
--- /dev/null
+++ b/apps/web/src/routes/api/tasks/[id]/unassign/+server.ts
@@ -0,0 +1,84 @@
+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();
+
+ // Cargar tarea y validar abierta
+ 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' }
+ });
+ }
+ if (Number(task.completed) !== 0 || task.completed_at) {
+ return new Response(JSON.stringify({ status: 'completed' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+
+ // Gating: grupo permitido + usuario miembro activo (si tiene group_id)
+ 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 });
+ }
+ }
+
+ // Eliminar asignación (idempotente)
+ const delRes = db
+ .prepare(`DELETE FROM task_assignments WHERE task_id = ? AND user_id = ?`)
+ .run(taskId, userId) as any;
+
+ const cntRow = db
+ .prepare(`SELECT COUNT(*) AS cnt FROM task_assignments WHERE task_id = ?`)
+ .get(taskId) as any;
+ const remaining = Number(cntRow?.cnt || 0);
+
+ const status = Number(delRes?.changes || 0) > 0 ? 'unassigned' : 'not_assigned';
+
+ const body = {
+ status,
+ now_unassigned: remaining === 0,
+ 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
+ }
+ };
+
+ 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.svelte b/apps/web/src/routes/app/+page.svelte
index 4d584d6..28b1365 100644
--- a/apps/web/src/routes/app/+page.svelte
+++ b/apps/web/src/routes/app/+page.svelte
@@ -44,7 +44,7 @@
{#each data.tasks as t}
-
+
{/each}