fix: evitar recargas y actualizar tareas con optimista y scroll estable

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
brobert 2 weeks ago
parent fc2fa00338
commit 8e0fa53050

@ -20,7 +20,9 @@
const res = await fetch(`/api/tasks/${taskId}/claim`, { method: 'POST' }); const res = await fetch(`/api/tasks/${taskId}/claim`, { method: 'POST' });
if (res.ok) { if (res.ok) {
success('Tarea reclamada'); 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 { } else {
const txt = await res.text(); const txt = await res.text();
toastError(txt || 'No se pudo reclamar'); toastError(txt || 'No se pudo reclamar');

@ -7,7 +7,8 @@
isTomorrow, isTomorrow,
} from "$lib/utils/date"; } from "$lib/utils/date";
import { success, error as toastError } from "$lib/stores/toasts"; 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 Popover from "$lib/ui/feedback/Popover.svelte";
import { normalizeDigits, buildWaMeUrl } from "$lib/utils/phone"; import { normalizeDigits, buildWaMeUrl } from "$lib/utils/phone";
import { colorForGroup } from "$lib/utils/groupColor"; import { colorForGroup } from "$lib/utils/groupColor";
@ -30,6 +31,8 @@
export let groupName: string | null = null; export let groupName: string | null = null;
export let groupId: 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 code = display_code ?? id;
const codeStr = String(code).padStart(4, "0"); const codeStr = String(code).padStart(4, "0");
$: isAssigned = $: isAssigned =
@ -72,8 +75,14 @@
try { try {
const res = await fetch(`/api/tasks/${id}/claim`, { method: "POST" }); const res = await fetch(`/api/tasks/${id}/claim`, { method: "POST" });
if (res.ok) { if (res.ok) {
// Actualizar estado local (añadirte si no estabas)
if (currentUserId) {
const set = new Set<string>(assignees || []);
set.add(String(currentUserId));
assignees = Array.from(set);
}
success("Tarea reclamada"); success("Tarea reclamada");
location.reload(); dispatch("changed", { id, action: "claim", patch: { assignees } });
} else { } else {
const txt = await res.text(); const txt = await res.text();
toastError(txt || "No se pudo reclamar"); toastError(txt || "No se pudo reclamar");
@ -92,12 +101,18 @@
const hadNoAssignees = assigneesCount === 0; const hadNoAssignees = assigneesCount === 0;
const res = await fetch(`/api/tasks/${id}/complete`, { method: "POST" }); const res = await fetch(`/api/tasks/${id}/complete`, { method: "POST" });
if (res.ok) { if (res.ok) {
success( const data = await res.json().catch(() => null);
hadNoAssignees const newCompletedAt: string | null = data?.task?.completed_at ? String(data.task.completed_at) : new Date().toISOString().replace('T', ' ').replace('Z', '');
? "Te has asignado y completado la tarea" // Si no tenía responsables, el backend te auto-asigna: reflejarlo localmente
: "Tarea completada", if (hadNoAssignees && currentUserId) {
); const set = new Set<string>(assignees || []);
location.reload(); 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 { } else {
const txt = await res.text(); const txt = await res.text();
toastError(txt || "No se pudo completar la tarea"); toastError(txt || "No se pudo completar la tarea");
@ -113,12 +128,13 @@
if (busy || !completed) return; if (busy || !completed) return;
busy = true; busy = true;
try { try {
const res = await fetch(`/api/tasks/${id}/uncomplete`, { const res = await fetch(`/api/tasks/${id}/uncomplete`, { method: "POST" });
method: "POST",
});
if (res.ok) { if (res.ok) {
await res.json().catch(() => null);
completed = false;
completed_at = null;
success("Tarea reabierta"); success("Tarea reabierta");
location.reload(); dispatch("changed", { id, action: "uncomplete", patch: { completed: false, completed_at: null } });
} else { } else {
const txt = await res.text(); const txt = await res.text();
toastError(txt || "No se pudo deshacer completar"); toastError(txt || "No se pudo deshacer completar");
@ -136,8 +152,12 @@
try { try {
const res = await fetch(`/api/tasks/${id}/unassign`, { method: "POST" }); const res = await fetch(`/api/tasks/${id}/unassign`, { method: "POST" });
if (res.ok) { if (res.ok) {
if (currentUserId) {
const after = (assignees || []).filter((a) => normalizeDigits(a) !== normalizeDigits(String(currentUserId)));
assignees = after;
}
success("Asignación eliminada"); success("Asignación eliminada");
location.reload(); dispatch("changed", { id, action: "unassign", patch: { assignees } });
} else { } else {
const txt = await res.text(); const txt = await res.text();
toastError(txt || "No se pudo soltar"); toastError(txt || "No se pudo soltar");
@ -154,8 +174,7 @@
busy = true; busy = true;
try { try {
const body = { const body = {
due_date: due_date: dateValue && dateValue.trim() !== "" ? dateValue.trim() : null,
dateValue && dateValue.trim() !== "" ? dateValue.trim() : null,
}; };
const res = await fetch(`/api/tasks/${id}`, { const res = await fetch(`/api/tasks/${id}`, {
method: "PATCH", method: "PATCH",
@ -163,8 +182,10 @@
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (res.ok) { if (res.ok) {
due_date = body.due_date;
success("Fecha actualizada"); success("Fecha actualizada");
location.reload(); dispatch("changed", { id, action: "update_due", patch: { due_date } });
editing = false;
} else { } else {
const txt = await res.text(); const txt = await res.text();
toastError(txt || "No se pudo actualizar la fecha"); toastError(txt || "No se pudo actualizar la fecha");
@ -236,6 +257,7 @@
if (res.ok) { if (res.ok) {
description = raw; description = raw;
success("Descripción actualizada"); success("Descripción actualizada");
dispatch("changed", { id, action: "update_desc", patch: { description } });
editingText = false; editingText = false;
} else { } else {
const txt = await res.text(); const txt = await res.text();
@ -256,7 +278,7 @@
} }
</script> </script>
<li class="task" class:completed> <li class="task" class:completed in:fade={{ duration: 180 }} out:fade={{ duration: 180 }}>
<div class="code">{codeStr}</div> <div class="code">{codeStr}</div>
<div <div
tabindex="0" tabindex="0"

@ -23,6 +23,11 @@
hasMore?: boolean | null; hasMore?: boolean | null;
}; };
// Estado local para permitir actualización sin recargar ni perder scroll
let openTasks: Task[] = [...data.openTasks];
let unassignedOpen: Task[] = [...data.unassignedOpen];
let recentTasks: (Task & { completed?: boolean; completed_at?: string | null })[] = [...data.recentTasks];
function buildQuery(params: { order?: 'due' | 'group'; page?: number }) { function buildQuery(params: { order?: 'due' | 'group'; page?: number }) {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
if (params.order) sp.set("order", params.order); if (params.order) sp.set("order", params.order);
@ -57,6 +62,104 @@
// Mantener el orden provisto por el servidor (ya ordenado alfabéticamente con "Personal" al final) // Mantener el orden provisto por el servidor (ya ordenado alfabéticamente con "Personal" al final)
return groups; return groups;
} }
function maintainScrollWhile(mutate: () => void) {
const y = window.scrollY;
mutate();
queueMicrotask(() => window.scrollTo({ top: y }));
}
function updateTaskInLists(detail: { id: number; action: string; patch: Partial<Task & { completed?: boolean; completed_at?: string | null }> }) {
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];
}
}
</script> </script>
<svelte:head> <svelte:head>
@ -79,13 +182,13 @@
</div> </div>
<h2 class="section-title">Mis tareas (abiertas)</h2> <h2 class="section-title">Mis tareas (abiertas)</h2>
{#if data.openTasks.length === 0} {#if openTasks.length === 0}
<p>No tienes tareas asignadas. Crea o reclama una para empezar.</p> <p>No tienes tareas asignadas. Crea o reclama una para empezar.</p>
{:else} {:else}
<Card> <Card>
<ul class="list"> <ul class="list">
{#each data.openTasks as t} {#each openTasks as t (t.id)}
<TaskItem {...t} currentUserId={data.userId} groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'} groupId={t.group_id} /> <TaskItem {...t} currentUserId={data.userId} groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'} groupId={t.group_id} on:changed={(e) => updateTaskInLists(e.detail)} />
{/each} {/each}
</ul> </ul>
</Card> </Card>
@ -99,16 +202,16 @@
{/if} {/if}
<h2 class="section-title">Sin responsable de mis grupos</h2> <h2 class="section-title">Sin responsable de mis grupos</h2>
{#if data.unassignedOpen.length === 0} {#if unassignedOpen.length === 0}
<p>No hay tareas sin responsable en tus grupos. Crea una nueva o invita a tus compañeros.</p> <p>No hay tareas sin responsable en tus grupos. Crea una nueva o invita a tus compañeros.</p>
{:else} {:else}
{#if data.order === 'group'} {#if data.order === 'group'}
{#each groupByGroup(data.unassignedOpen) as g} {#each groupByGroup(unassignedOpen) as g (g.id)}
<h3 class="group-subtitle">{g.name}</h3> <h3 class="group-subtitle">{g.name}</h3>
<Card> <Card>
<ul class="list"> <ul class="list">
{#each g.tasks as t} {#each g.tasks as t (t.id)}
<TaskItem {...t} currentUserId={data.userId} groupName={g.name} groupId={t.group_id} /> <TaskItem {...t} currentUserId={data.userId} groupName={g.name} groupId={t.group_id} on:changed={(e) => updateTaskInLists(e.detail)} />
{/each} {/each}
</ul> </ul>
</Card> </Card>
@ -116,8 +219,8 @@
{:else} {:else}
<Card> <Card>
<ul class="list"> <ul class="list">
{#each data.unassignedOpen as t} {#each unassignedOpen as t (t.id)}
<TaskItem {...t} currentUserId={data.userId} groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'} groupId={t.group_id} /> <TaskItem {...t} currentUserId={data.userId} groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'} groupId={t.group_id} on:changed={(e) => updateTaskInLists(e.detail)} />
{/each} {/each}
</ul> </ul>
</Card> </Card>
@ -125,12 +228,12 @@
{/if} {/if}
<h2 class="section-title">Completadas (últimas 24 h)</h2> <h2 class="section-title">Completadas (últimas 24 h)</h2>
{#if data.recentTasks.length === 0} {#if recentTasks.length === 0}
<p>No hay tareas completadas recientemente.</p> <p>No hay tareas completadas recientemente.</p>
{:else} {:else}
<Card> <Card>
<ul class="list"> <ul class="list">
{#each data.recentTasks as t} {#each recentTasks as t (t.id)}
<TaskItem <TaskItem
{...t} {...t}
currentUserId={data.userId} currentUserId={data.userId}
@ -138,6 +241,7 @@
completed={true} completed={true}
completed_at={t.completed_at ?? null} completed_at={t.completed_at ?? null}
groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'} groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'}
on:changed={(e) => updateTaskInLists(e.detail)}
/> />
{/each} {/each}
</ul> </ul>

@ -24,7 +24,10 @@
unassignedFirst?: boolean; unassignedFirst?: boolean;
}; };
const groups = data.groups || []; const groups = data.groups || [];
const itemsByGroup = data.itemsByGroup || {}; let itemsByGroup: Record<string, Task[]> = {};
for (const [gid, arr] of Object.entries(data.itemsByGroup || {})) {
itemsByGroup[gid] = Array.isArray(arr as any) ? ([...(arr as any)]) : [];
}
function buildQuery(params: { unassignedFirst?: boolean }) { function buildQuery(params: { unassignedFirst?: boolean }) {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
@ -92,6 +95,33 @@
collapsed = {}; 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<Task & { completed?: boolean; completed_at?: string | null }> }) {
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] };
}
}
</script> </script>
<svelte:head> <svelte:head>
@ -119,7 +149,7 @@
</label> </label>
</div> </div>
{#each groups as g} {#each groups as g (g.id)}
<details <details
class="group" class="group"
open={isOpen(g.id)} open={isOpen(g.id)}
@ -134,7 +164,7 @@
</summary> </summary>
<Card> <Card>
<ul class="list"> <ul class="list">
{#each itemsByGroup[g.id] || [] as t} {#each itemsByGroup[g.id] || [] as t (t.id)}
<TaskItem <TaskItem
id={t.id} id={t.id}
description={t.description} description={t.description}
@ -144,6 +174,7 @@
currentUserId={data.userId} currentUserId={data.userId}
groupName={g.name ?? g.id} groupName={g.name ?? g.id}
groupId={t.group_id ?? g.id} groupId={t.group_id ?? g.id}
on:changed={(e) => updateGroupTask(g.id, e.detail)}
/> />
{/each} {/each}
</ul> </ul>

Loading…
Cancel
Save