From 8e0fa53050badcf196a591833392996f5514dae4 Mon Sep 17 00:00:00 2001 From: brobert Date: Sat, 18 Oct 2025 22:52:30 +0200 Subject: [PATCH] fix: evitar recargas y actualizar tareas con optimista y scroll estable Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/lib/ui/data/GroupCard.svelte | 4 +- apps/web/src/lib/ui/data/TaskItem.svelte | 56 ++++++--- apps/web/src/routes/app/+page.svelte | 126 ++++++++++++++++++-- apps/web/src/routes/app/groups/+page.svelte | 37 +++++- 4 files changed, 191 insertions(+), 32 deletions(-) diff --git a/apps/web/src/lib/ui/data/GroupCard.svelte b/apps/web/src/lib/ui/data/GroupCard.svelte index 9c190dc..40b203c 100644 --- a/apps/web/src/lib/ui/data/GroupCard.svelte +++ b/apps/web/src/lib/ui/data/GroupCard.svelte @@ -20,7 +20,9 @@ const res = await fetch(`/api/tasks/${taskId}/claim`, { method: 'POST' }); if (res.ok) { success('Tarea reclamada'); - location.reload(); + // Actualizar estado local sin recargar + previews = previews.filter((t) => t.id !== taskId); + counts = { ...counts, unassigned: Math.max(0, (counts?.unassigned ?? 0) - 1) }; } else { const txt = await res.text(); toastError(txt || 'No se pudo reclamar'); diff --git a/apps/web/src/lib/ui/data/TaskItem.svelte b/apps/web/src/lib/ui/data/TaskItem.svelte index 9a42548..7576f1a 100644 --- a/apps/web/src/lib/ui/data/TaskItem.svelte +++ b/apps/web/src/lib/ui/data/TaskItem.svelte @@ -7,7 +7,8 @@ isTomorrow, } from "$lib/utils/date"; import { success, error as toastError } from "$lib/stores/toasts"; - import { tick, onDestroy } from "svelte"; + import { tick, onDestroy, createEventDispatcher } from "svelte"; + import { fade } from "svelte/transition"; import Popover from "$lib/ui/feedback/Popover.svelte"; import { normalizeDigits, buildWaMeUrl } from "$lib/utils/phone"; import { colorForGroup } from "$lib/utils/groupColor"; @@ -30,6 +31,8 @@ export let groupName: string | null = null; export let groupId: string | null = null; + const dispatch = createEventDispatcher<{ changed: { id: number; action: string; patch: any } }>(); + const code = display_code ?? id; const codeStr = String(code).padStart(4, "0"); $: isAssigned = @@ -72,8 +75,14 @@ try { const res = await fetch(`/api/tasks/${id}/claim`, { method: "POST" }); if (res.ok) { + // Actualizar estado local (añadirte si no estabas) + if (currentUserId) { + const set = new Set(assignees || []); + set.add(String(currentUserId)); + assignees = Array.from(set); + } success("Tarea reclamada"); - location.reload(); + dispatch("changed", { id, action: "claim", patch: { assignees } }); } else { const txt = await res.text(); toastError(txt || "No se pudo reclamar"); @@ -92,12 +101,18 @@ const hadNoAssignees = assigneesCount === 0; const res = await fetch(`/api/tasks/${id}/complete`, { method: "POST" }); if (res.ok) { - success( - hadNoAssignees - ? "Te has asignado y completado la tarea" - : "Tarea completada", - ); - location.reload(); + const data = await res.json().catch(() => null); + const newCompletedAt: string | null = data?.task?.completed_at ? String(data.task.completed_at) : new Date().toISOString().replace('T', ' ').replace('Z', ''); + // Si no tenía responsables, el backend te auto-asigna: reflejarlo localmente + if (hadNoAssignees && currentUserId) { + const set = new Set(assignees || []); + set.add(String(currentUserId)); + assignees = Array.from(set); + } + completed = true; + completed_at = newCompletedAt; + success(hadNoAssignees ? "Te has asignado y completado la tarea" : "Tarea completada"); + dispatch("changed", { id, action: "complete", patch: { completed: true, completed_at, assignees } }); } else { const txt = await res.text(); toastError(txt || "No se pudo completar la tarea"); @@ -113,12 +128,13 @@ if (busy || !completed) return; busy = true; try { - const res = await fetch(`/api/tasks/${id}/uncomplete`, { - method: "POST", - }); + const res = await fetch(`/api/tasks/${id}/uncomplete`, { method: "POST" }); if (res.ok) { + await res.json().catch(() => null); + completed = false; + completed_at = null; success("Tarea reabierta"); - location.reload(); + dispatch("changed", { id, action: "uncomplete", patch: { completed: false, completed_at: null } }); } else { const txt = await res.text(); toastError(txt || "No se pudo deshacer completar"); @@ -136,8 +152,12 @@ try { const res = await fetch(`/api/tasks/${id}/unassign`, { method: "POST" }); if (res.ok) { + if (currentUserId) { + const after = (assignees || []).filter((a) => normalizeDigits(a) !== normalizeDigits(String(currentUserId))); + assignees = after; + } success("Asignación eliminada"); - location.reload(); + dispatch("changed", { id, action: "unassign", patch: { assignees } }); } else { const txt = await res.text(); toastError(txt || "No se pudo soltar"); @@ -154,8 +174,7 @@ busy = true; try { const body = { - due_date: - dateValue && dateValue.trim() !== "" ? dateValue.trim() : null, + due_date: dateValue && dateValue.trim() !== "" ? dateValue.trim() : null, }; const res = await fetch(`/api/tasks/${id}`, { method: "PATCH", @@ -163,8 +182,10 @@ body: JSON.stringify(body), }); if (res.ok) { + due_date = body.due_date; success("Fecha actualizada"); - location.reload(); + dispatch("changed", { id, action: "update_due", patch: { due_date } }); + editing = false; } else { const txt = await res.text(); toastError(txt || "No se pudo actualizar la fecha"); @@ -236,6 +257,7 @@ if (res.ok) { description = raw; success("Descripción actualizada"); + dispatch("changed", { id, action: "update_desc", patch: { description } }); editingText = false; } else { const txt = await res.text(); @@ -256,7 +278,7 @@ } -
  • +
  • {codeStr}
    void) { + const y = window.scrollY; + mutate(); + queueMicrotask(() => window.scrollTo({ top: y })); + } + + function updateTaskInLists(detail: { id: number; action: string; patch: Partial }) { + const { id, action, patch } = detail; + + const patchIn = (arr: Task[]) => { + const idx = arr.findIndex((t) => t.id === id); + if (idx >= 0) { + arr[idx] = { ...arr[idx], ...patch }; + return true; + } + return false; + }; + + if (action === 'complete') { + maintainScrollWhile(() => { + let moved = false; + let idx = unassignedOpen.findIndex((t) => t.id === id); + if (idx >= 0) { + const [it] = unassignedOpen.splice(idx, 1); + const completedItem: any = { ...it, ...patch, completed: true }; + recentTasks = [completedItem, ...recentTasks]; + moved = true; + } + idx = openTasks.findIndex((t) => t.id === id); + if (idx >= 0) { + const [it] = openTasks.splice(idx, 1); + const completedItem: any = { ...it, ...patch, completed: true }; + recentTasks = [completedItem, ...recentTasks]; + moved = true; + } + if (!moved) { + patchIn(recentTasks as any); + } + // Forzar reactividad en listas mutadas + openTasks = [...openTasks]; + unassignedOpen = [...unassignedOpen]; + recentTasks = [...recentTasks]; + }); + } else if (action === 'uncomplete') { + maintainScrollWhile(() => { + const idx = recentTasks.findIndex((t) => t.id === id); + if (idx >= 0) { + const [it] = recentTasks.splice(idx, 1); + const reopened: any = { ...it, ...patch, completed: false }; + openTasks = [reopened, ...openTasks]; + } else { + patchIn(openTasks); + } + openTasks = [...openTasks]; + recentTasks = [...recentTasks]; + }); + } else if (action === 'claim') { + maintainScrollWhile(() => { + const idx = unassignedOpen.findIndex((t) => t.id === id); + if (idx >= 0) { + const [it] = unassignedOpen.splice(idx, 1); + const claimed = { ...it, ...patch }; + if (!openTasks.some((x) => x.id === id)) { + openTasks = [claimed, ...openTasks]; + } else { + patchIn(openTasks); + } + } else { + patchIn(openTasks); + } + openTasks = [...openTasks]; + unassignedOpen = [...unassignedOpen]; + }); + } else if (action === 'unassign') { + maintainScrollWhile(() => { + if (!patchIn(openTasks)) patchIn(unassignedOpen); + // Si quedó sin responsables, mover a "sin responsable" + const idx = openTasks.findIndex((t) => t.id === id); + if (idx >= 0 && (openTasks[idx].assignees || []).length === 0) { + const [it] = openTasks.splice(idx, 1); + unassignedOpen = [it, ...unassignedOpen]; + } + openTasks = [...openTasks]; + unassignedOpen = [...unassignedOpen]; + }); + } else { + // update_due, update_desc u otros parches ligeros + if (!patchIn(openTasks)) { + if (!patchIn(unassignedOpen)) { + patchIn(recentTasks as any); + } + } + openTasks = [...openTasks]; + unassignedOpen = [...unassignedOpen]; + recentTasks = [...recentTasks]; + } + } @@ -79,13 +182,13 @@

    Mis tareas (abiertas)

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

    No tienes tareas asignadas. Crea o reclama una para empezar.

    {:else}
      - {#each data.openTasks as t} - + {#each openTasks as t (t.id)} + updateTaskInLists(e.detail)} /> {/each}
    @@ -99,16 +202,16 @@ {/if}

    Sin responsable de mis grupos

    -{#if data.unassignedOpen.length === 0} +{#if unassignedOpen.length === 0}

    No hay tareas sin responsable en tus grupos. Crea una nueva o invita a tus compañeros.

    {:else} {#if data.order === 'group'} - {#each groupByGroup(data.unassignedOpen) as g} + {#each groupByGroup(unassignedOpen) as g (g.id)}

    {g.name}

      - {#each g.tasks as t} - + {#each g.tasks as t (t.id)} + updateTaskInLists(e.detail)} /> {/each}
    @@ -116,8 +219,8 @@ {:else}
      - {#each data.unassignedOpen as t} - + {#each unassignedOpen as t (t.id)} + updateTaskInLists(e.detail)} /> {/each}
    @@ -125,12 +228,12 @@ {/if}

    Completadas (últimas 24 h)

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

    No hay tareas completadas recientemente.

    {:else}
      - {#each data.recentTasks as t} + {#each recentTasks as t (t.id)} updateTaskInLists(e.detail)} /> {/each}
    diff --git a/apps/web/src/routes/app/groups/+page.svelte b/apps/web/src/routes/app/groups/+page.svelte index 4a34126..c516dbf 100644 --- a/apps/web/src/routes/app/groups/+page.svelte +++ b/apps/web/src/routes/app/groups/+page.svelte @@ -24,7 +24,10 @@ unassignedFirst?: boolean; }; const groups = data.groups || []; - const itemsByGroup = data.itemsByGroup || {}; + let itemsByGroup: Record = {}; + for (const [gid, arr] of Object.entries(data.itemsByGroup || {})) { + itemsByGroup[gid] = Array.isArray(arr as any) ? ([...(arr as any)]) : []; + } function buildQuery(params: { unassignedFirst?: boolean }) { const sp = new URLSearchParams(); @@ -92,6 +95,33 @@ collapsed = {}; } }); + + function maintainScrollWhile(mutate: () => void) { + const y = window.scrollY; + mutate(); + queueMicrotask(() => window.scrollTo({ top: y })); + } + + function updateGroupTask(groupId: string, detail: { id: number; action: string; patch: Partial }) { + const { id, action, patch } = detail; + const arr = itemsByGroup[groupId] || []; + const idx = arr.findIndex((t) => t.id === id); + + if (action === 'complete') { + if (idx >= 0) { + maintainScrollWhile(() => { + arr.splice(idx, 1); + itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] }; + }); + } + return; + } + + if (idx >= 0) { + arr[idx] = { ...arr[idx], ...patch }; + itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] }; + } + } @@ -119,7 +149,7 @@ - {#each groups as g} + {#each groups as g (g.id)}
      - {#each itemsByGroup[g.id] || [] as t} + {#each itemsByGroup[g.id] || [] as t (t.id)} updateGroupTask(g.id, e.detail)} /> {/each}