feat: rediseño de TaskItem, añade completar y lista 24h

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
brobert 2 weeks ago
parent 2446427b5f
commit 06c4a0619d

@ -1,6 +1,6 @@
<script lang="ts">
import Badge from '$lib/ui/atoms/Badge.svelte';
import { dueStatus } from '$lib/utils/date';
import { compareYmd, todayYmdUTC, ymdToDmy, isToday, isTomorrow } from '$lib/utils/date';
import { success, error as toastError } from '$lib/stores/toasts';
import { tick } from 'svelte';
@ -10,10 +10,16 @@
export let display_code: number | null = null;
export let assignees: string[] = [];
export let currentUserId: string | null | undefined = null;
export let completed: boolean = false;
export let completed_at: string | null = null;
const code = display_code ?? id;
$: status = dueStatus(due_date, 3);
const codeStr = String(code).padStart(4, '0');
$: isAssigned = currentUserId ? assignees.includes(currentUserId) : false;
$: today = todayYmdUTC();
$: overdue = !!due_date && compareYmd(due_date, today) < 0;
$: imminent = !!due_date && (isToday(due_date) || isTomorrow(due_date));
$: dateDmy = due_date ? ymdToDmy(due_date) : '';
let editing = false;
let dateValue: string = due_date ?? '';
@ -42,6 +48,25 @@
}
}
async function doComplete() {
if (busy || completed) return;
busy = true;
try {
const res = await fetch(`/api/tasks/${id}/complete`, { method: 'POST' });
if (res.ok) {
success('Tarea completada');
location.reload();
} else {
const txt = await res.text();
toastError(txt || 'No se pudo completar la tarea');
}
} catch {
toastError('Error de red');
} finally {
busy = false;
}
}
async function doUnassign() {
if (busy) return;
busy = true;
@ -165,13 +190,14 @@
}
</script>
<li class="task">
<li class="task" class:completed={completed}>
<div class="left">
<span class="code">#{code}</span>
<span class="code">#{codeStr}</span>
<span
class="desc"
class:editing={editingText}
contenteditable={editingText}
class:completed={completed}
contenteditable={editingText && !completed}
role="textbox"
aria-label="Descripción de la tarea"
spellcheck="true"
@ -182,44 +208,48 @@
else if (e.key === 'Enter') { e.preventDefault(); }
}}
>{description}</span>
{#if due_date}
{#if status === 'overdue'}
<Badge tone="danger">vence: {due_date}</Badge>
{:else if status === 'soon'}
<Badge tone="warning">vence: {due_date}</Badge>
{:else}
<Badge>vence: {due_date}</Badge>
{/if}
{/if}
</div>
<div class="meta">
{#if due_date}
<span class="date-badge" class:overdue={overdue} class:soon={imminent} title={overdue ? 'Vencida' : (imminent ? 'Próxima' : 'Fecha')}>
📅 {dateDmy}{#if overdue}{/if}
</span>
{/if}
{#if assignees?.length}
<small class="muted">asignados: {assignees.join(', ')}</small>
<span class="assignees">
{#each assignees as a}
<span class="assignee" title={a}>👤</span>
{/each}
</span>
{/if}
</div>
<div class="actions">
{#if !isAssigned}
<button class="btn" on:click|preventDefault={doClaim} disabled={busy}>Reclamar</button>
{:else}
<button class="btn" on:click|preventDefault={doUnassign} disabled={busy}>Soltar</button>
{/if}
{#if !completed}
{#if !isAssigned}
<button class="icon-btn" aria-label="Reclamar" on:click|preventDefault={doClaim} disabled={busy}>Reclamar</button>
{:else}
<button class="icon-btn" aria-label="Soltar" title="Soltar" on:click|preventDefault={doUnassign} disabled={busy}>🫳</button>
{/if}
{#if !editingText}
<button class="btn secondary" on:click|preventDefault={toggleEditText} disabled={busy}>Editar texto</button>
{:else}
<button class="btn primary" on:click|preventDefault={saveText} disabled={busy}>Guardar</button>
<button class="btn ghost" on:click|preventDefault={cancelText} disabled={busy}>Cancelar</button>
{/if}
<button class="icon-btn" aria-label="Completar" title="Completar" on:click|preventDefault={doComplete} disabled={busy}></button>
{#if !editing}
<button class="btn secondary" on:click|preventDefault={toggleEdit} disabled={busy}>Editar fecha</button>
{:else}
<input class="date" type="date" bind:value={dateValue} />
<button class="btn primary" on:click|preventDefault={saveDate} disabled={busy}>Guardar</button>
<button class="btn danger" on:click|preventDefault={clearDate} disabled={busy}>Quitar</button>
<button class="btn ghost" on:click|preventDefault={toggleEdit} disabled={busy}>Cancelar</button>
{#if !editingText}
<button class="icon-btn" aria-label="Editar texto" title="Editar texto" on:click|preventDefault={toggleEditText} disabled={busy}>✍️</button>
{:else}
<button class="btn primary" on:click|preventDefault={saveText} disabled={busy}>Guardar</button>
<button class="btn ghost" on:click|preventDefault={cancelText} disabled={busy}>Cancelar</button>
{/if}
{#if !editing}
<button class="icon-btn" aria-label="Editar fecha" title="Editar fecha" on:click|preventDefault={toggleEdit} disabled={busy}>🗓️</button>
{:else}
<input class="date" type="date" bind:value={dateValue} />
<button class="btn primary" on:click|preventDefault={saveDate} disabled={busy}>Guardar</button>
<button class="btn danger" on:click|preventDefault={clearDate} disabled={busy}>Quitar</button>
<button class="btn ghost" on:click|preventDefault={toggleEdit} disabled={busy}>Cancelar</button>
{/if}
{/if}
</div>
</li>
@ -227,7 +257,7 @@
<style>
.task {
display: grid;
grid-template-columns: 1fr auto auto;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--space-2);
padding: 8px 0;
@ -244,11 +274,20 @@
.code {
font-weight: 600;
color: var(--color-text-muted);
font-family: monospace;
letter-spacing: 0.5px;
}
.desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.desc.editing { outline: 2px solid var(--color-primary); outline-offset: 2px; background: var(--color-surface); padding: 2px 4px; border-radius: 4px; white-space: normal; text-overflow: clip; }
.meta { justify-self: end; }
.desc.completed { text-decoration: line-through; }
.meta { justify-self: end; display: inline-flex; align-items: center; gap: 8px; }
.muted { color: var(--color-text-muted); }
.date-badge { padding: 2px 6px; border-radius: 6px; border: 1px solid transparent; font-size: 12px; }
.date-badge.overdue { border-color: var(--color-danger); }
.date-badge.soon { border-color: var(--color-warning); }
.assignees { display: inline-flex; gap: 4px; }
.assignee { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; }
.task.completed { opacity: .7; }
.actions {
display: inline-flex;
@ -271,5 +310,6 @@
.btn.secondary { }
.btn.ghost { background: transparent; }
.btn.danger { background: var(--color-danger); color: #fff; border-color: transparent; }
.icon-btn { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: 6px; background: var(--color-surface); font-size: 16px; line-height: 1; }
.date { padding: 4px 6px; font-size: 14px; }
</style>

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

@ -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<number, string[]>();
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 = ?`,

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

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

@ -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 @@
</form>
<h2 class="section-title">Mis tareas (abiertas)</h2>
{#if data.tasks.length === 0}
{#if data.openTasks.length === 0}
<p>No tienes tareas abiertas.</p>
{:else}
<Card>
<ul class="list">
{#each data.tasks as t}
{#each data.openTasks as t}
<TaskItem {...t} currentUserId={data.userId} />
{/each}
</ul>
@ -61,6 +71,19 @@
/>
{/if}
<h2 class="section-title">Completadas (últimas 24 h)</h2>
{#if data.recentTasks.length === 0}
<p>No hay tareas completadas recientemente.</p>
{:else}
<Card>
<ul class="list">
{#each data.recentTasks as t}
<TaskItem {...t} currentUserId={data.userId} completed={true} completed_at={t.completed_at ?? null} />
{/each}
</ul>
</Card>
{/if}
<p class="footnote">La cookie de sesión se renueva con cada visita (idle timeout).</p>
<style>

@ -25,9 +25,13 @@ export class TaskService {
const pickNextDisplayCode = (): number => {
const rows = this.dbInstance
.prepare(`
SELECT display_code
FROM tasks
WHERE COALESCE(completed, 0) = 0 AND display_code IS NOT NULL
SELECT display_code
FROM tasks
WHERE display_code IS NOT NULL
AND (
COALESCE(completed, 0) = 0
OR (completed_at IS NOT NULL AND completed_at >= strftime('%Y-%m-%d %H:%M:%f','now','-24 hours'))
)
ORDER BY display_code ASC
`)
.all() as Array<{ display_code: number }>;

@ -197,4 +197,34 @@ describe('Web API - GET /api/me/tasks', () => {
});
expect(resLong.status).toBe(400);
});
it('permite completar una tarea asignada y aparece en recent', async () => {
// Buscar una tarea abierta por texto
const q = encodeURIComponent('alpha');
const listRes = await fetch(`${server!.baseUrl}/api/me/tasks?limit=10&search=${q}`, {
headers: { Cookie: `sid=${sid}` }
});
expect(listRes.status).toBe(200);
const list = await listRes.json();
expect(list.items.length).toBe(1);
const taskId = list.items[0].id;
// Completar
const resComplete = await fetch(`${server!.baseUrl}/api/tasks/${taskId}/complete`, {
method: 'POST',
headers: { Cookie: `sid=${sid}` }
});
expect(resComplete.status).toBe(200);
const done = await resComplete.json();
expect(done.status === 'updated' || done.status === 'already').toBe(true);
// Ver en recent
const recentRes = await fetch(`${server!.baseUrl}/api/me/tasks?status=recent&limit=10`, {
headers: { Cookie: `sid=${sid}` }
});
expect(recentRes.status).toBe(200);
const recent = await recentRes.json();
const ids = recent.items.map((it: any) => it.id);
expect(ids.includes(taskId)).toBe(true);
});
});

Loading…
Cancel
Save