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

@ -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<string>(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<string>(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 @@
}
</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
tabindex="0"

@ -23,6 +23,11 @@
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 }) {
const sp = new URLSearchParams();
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)
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>
<svelte:head>
@ -79,13 +182,13 @@
</div>
<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>
{:else}
<Card>
<ul class="list">
{#each data.openTasks as t}
<TaskItem {...t} currentUserId={data.userId} groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'} groupId={t.group_id} />
{#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} on:changed={(e) => updateTaskInLists(e.detail)} />
{/each}
</ul>
</Card>
@ -99,16 +202,16 @@
{/if}
<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>
{:else}
{#if data.order === 'group'}
{#each groupByGroup(data.unassignedOpen) as g}
{#each groupByGroup(unassignedOpen) as g (g.id)}
<h3 class="group-subtitle">{g.name}</h3>
<Card>
<ul class="list">
{#each g.tasks as t}
<TaskItem {...t} currentUserId={data.userId} groupName={g.name} groupId={t.group_id} />
{#each g.tasks as t (t.id)}
<TaskItem {...t} currentUserId={data.userId} groupName={g.name} groupId={t.group_id} on:changed={(e) => updateTaskInLists(e.detail)} />
{/each}
</ul>
</Card>
@ -116,8 +219,8 @@
{:else}
<Card>
<ul class="list">
{#each data.unassignedOpen as t}
<TaskItem {...t} currentUserId={data.userId} groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'} groupId={t.group_id} />
{#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} on:changed={(e) => updateTaskInLists(e.detail)} />
{/each}
</ul>
</Card>
@ -125,12 +228,12 @@
{/if}
<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>
{:else}
<Card>
<ul class="list">
{#each data.recentTasks as t}
{#each recentTasks as t (t.id)}
<TaskItem
{...t}
currentUserId={data.userId}
@ -138,6 +241,7 @@
completed={true}
completed_at={t.completed_at ?? null}
groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'}
on:changed={(e) => updateTaskInLists(e.detail)}
/>
{/each}
</ul>

@ -24,7 +24,10 @@
unassignedFirst?: boolean;
};
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 }) {
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<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>
<svelte:head>
@ -119,7 +149,7 @@
</label>
</div>
{#each groups as g}
{#each groups as g (g.id)}
<details
class="group"
open={isOpen(g.id)}
@ -134,7 +164,7 @@
</summary>
<Card>
<ul class="list">
{#each itemsByGroup[g.id] || [] as t}
{#each itemsByGroup[g.id] || [] as t (t.id)}
<TaskItem
id={t.id}
description={t.description}
@ -144,6 +174,7 @@
currentUserId={data.userId}
groupName={g.name ?? g.id}
groupId={t.group_id ?? g.id}
on:changed={(e) => updateGroupTask(g.id, e.detail)}
/>
{/each}
</ul>

Loading…
Cancel
Save