mejora los copis

webui
brobert 1 week ago
parent b4f2f9be92
commit d27a4aa201

@ -1,294 +1,335 @@
<script lang="ts"> <script lang="ts">
import Card from "$lib/ui/layout/Card.svelte"; import Card from "$lib/ui/layout/Card.svelte";
import TaskItem from "$lib/ui/data/TaskItem.svelte"; import TaskItem from "$lib/ui/data/TaskItem.svelte";
import Pagination from "$lib/ui/layout/Pagination.svelte"; import Pagination from "$lib/ui/layout/Pagination.svelte";
type Task = { type Task = {
id: number; id: number;
description: string; description: string;
due_date: string | null; due_date: string | null;
group_id: string | null; group_id: string | null;
display_code: number | null; display_code: number | null;
assignees: string[]; assignees: string[];
}; };
export let data: { export let data: {
userId: string; userId: string;
openTasks: Task[]; openTasks: Task[];
recentTasks: (Task & { completed?: boolean; completed_at?: string | null })[]; recentTasks: (Task & {
unassignedOpen: Task[]; completed?: boolean;
groupNames: Record<string, string>; completed_at?: string | null;
order: 'due' | 'group'; })[];
page?: number | null; unassignedOpen: Task[];
hasMore?: boolean | null; groupNames: Record<string, string>;
}; order: "due" | "group";
page?: number | null;
hasMore?: boolean | null;
};
// Estado local para permitir actualización sin recargar ni perder scroll // Estado local para permitir actualización sin recargar ni perder scroll
let openTasks: Task[] = [...data.openTasks]; let openTasks: Task[] = [...data.openTasks];
let unassignedOpen: Task[] = [...data.unassignedOpen]; let unassignedOpen: Task[] = [...data.unassignedOpen];
let recentTasks: (Task & { completed?: boolean; completed_at?: string | null })[] = [...data.recentTasks]; 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);
if (params.page && params.page > 1) sp.set("page", String(params.page)); if (params.page && params.page > 1) sp.set("page", String(params.page));
return sp.toString(); return sp.toString();
} }
function sortByDue(items: Task[]): Task[] { function sortByDue(items: Task[]): Task[] {
return [...items].sort((a, b) => { return [...items].sort((a, b) => {
const ad = a.due_date, bd = b.due_date; const ad = a.due_date,
if (ad == null && bd == null) return a.id - b.id; bd = b.due_date;
if (ad == null) return 1; if (ad == null && bd == null) return a.id - b.id;
if (bd == null) return -1; if (ad == null) return 1;
if (ad < bd) return -1; if (bd == null) return -1;
if (ad > bd) return 1; if (ad < bd) return -1;
return a.id - b.id; if (ad > bd) return 1;
}); return a.id - b.id;
} });
}
function groupByGroup(items: Task[]): { id: string; name: string; tasks: Task[] }[] { function groupByGroup(
const map = new Map<string, Task[]>(); items: Task[],
for (const it of items) { ): { id: string; name: string; tasks: Task[] }[] {
const gid = it.group_id ? String(it.group_id) : ""; const map = new Map<string, Task[]>();
if (!map.has(gid)) map.set(gid, []); for (const it of items) {
map.get(gid)!.push(it); const gid = it.group_id ? String(it.group_id) : "";
} if (!map.has(gid)) map.set(gid, []);
const groups = Array.from(map.entries()).map(([gid, tasks]) => ({ map.get(gid)!.push(it);
id: gid, }
name: gid ? (data.groupNames[gid] || gid) : "Personal", const groups = Array.from(map.entries()).map(([gid, tasks]) => ({
tasks id: gid,
})); name: gid ? data.groupNames[gid] || gid : "Personal",
// Mantener el orden provisto por el servidor (ya ordenado alfabéticamente con "Personal" al final) tasks,
return groups; }));
} // Mantener el orden provisto por el servidor (ya ordenado alfabéticamente con "Personal" al final)
return groups;
}
function maintainScrollWhile(mutate: () => void) { function maintainScrollWhile(mutate: () => void) {
const y = window.scrollY; const y = window.scrollY;
mutate(); mutate();
queueMicrotask(() => window.scrollTo({ top: y })); queueMicrotask(() => window.scrollTo({ top: y }));
} }
function updateTaskInLists(detail: { id: number; action: string; patch: Partial<Task & { completed?: boolean; completed_at?: string | null }> }) { function updateTaskInLists(detail: {
const { id, action, patch } = detail; id: number;
action: string;
patch: Partial<
Task & { completed?: boolean; completed_at?: string | null }
>;
}) {
const { id, action, patch } = detail;
const patchIn = (arr: Task[]) => { const patchIn = (arr: Task[]) => {
const idx = arr.findIndex((t) => t.id === id); const idx = arr.findIndex((t) => t.id === id);
if (idx >= 0) { if (idx >= 0) {
arr[idx] = { ...arr[idx], ...patch }; arr[idx] = { ...arr[idx], ...patch };
return true; return true;
} }
return false; return false;
}; };
if (action === 'complete') { if (action === "complete") {
maintainScrollWhile(() => { maintainScrollWhile(() => {
let moved = false; let moved = false;
let idx = unassignedOpen.findIndex((t) => t.id === id); let idx = unassignedOpen.findIndex((t) => t.id === id);
if (idx >= 0) { if (idx >= 0) {
const [it] = unassignedOpen.splice(idx, 1); const [it] = unassignedOpen.splice(idx, 1);
const completedItem: any = { ...it, ...patch, completed: true }; const completedItem: any = { ...it, ...patch, completed: true };
recentTasks = [completedItem, ...recentTasks]; recentTasks = [completedItem, ...recentTasks];
moved = true; moved = true;
} }
idx = openTasks.findIndex((t) => t.id === id); idx = openTasks.findIndex((t) => t.id === id);
if (idx >= 0) { if (idx >= 0) {
const [it] = openTasks.splice(idx, 1); const [it] = openTasks.splice(idx, 1);
const completedItem: any = { ...it, ...patch, completed: true }; const completedItem: any = { ...it, ...patch, completed: true };
recentTasks = [completedItem, ...recentTasks]; recentTasks = [completedItem, ...recentTasks];
moved = true; moved = true;
} }
if (!moved) { if (!moved) {
patchIn(recentTasks as any); patchIn(recentTasks as any);
} }
// Forzar reactividad en listas mutadas // Forzar reactividad en listas mutadas
openTasks = [...openTasks]; openTasks = [...openTasks];
unassignedOpen = [...unassignedOpen]; unassignedOpen = [...unassignedOpen];
recentTasks = [...recentTasks]; recentTasks = [...recentTasks];
}); });
} else if (action === 'uncomplete') { } else if (action === "uncomplete") {
maintainScrollWhile(() => { maintainScrollWhile(() => {
const idx = recentTasks.findIndex((t) => t.id === id); const idx = recentTasks.findIndex((t) => t.id === id);
if (idx >= 0) { if (idx >= 0) {
const [it] = recentTasks.splice(idx, 1); const [it] = recentTasks.splice(idx, 1);
const reopened: any = { ...it, ...patch, completed: false }; const reopened: any = { ...it, ...patch, completed: false };
openTasks = [reopened, ...openTasks]; openTasks = [reopened, ...openTasks];
} else { } else {
patchIn(openTasks); patchIn(openTasks);
} }
openTasks = [...openTasks]; openTasks = [...openTasks];
recentTasks = [...recentTasks]; recentTasks = [...recentTasks];
}); });
} else if (action === 'claim') { } else if (action === "claim") {
maintainScrollWhile(() => { maintainScrollWhile(() => {
const idx = unassignedOpen.findIndex((t) => t.id === id); const idx = unassignedOpen.findIndex((t) => t.id === id);
if (idx >= 0) { if (idx >= 0) {
const [it] = unassignedOpen.splice(idx, 1); const [it] = unassignedOpen.splice(idx, 1);
const claimed = { ...it, ...patch }; const claimed = { ...it, ...patch };
if (!openTasks.some((x) => x.id === id)) { if (!openTasks.some((x) => x.id === id)) {
openTasks = [claimed, ...openTasks]; openTasks = [claimed, ...openTasks];
} else { } else {
patchIn(openTasks); patchIn(openTasks);
} }
} else { } else {
patchIn(openTasks); patchIn(openTasks);
} }
openTasks = [...openTasks]; openTasks = [...openTasks];
unassignedOpen = [...unassignedOpen]; unassignedOpen = [...unassignedOpen];
}); });
} else if (action === 'unassign') { } else if (action === "unassign") {
maintainScrollWhile(() => { maintainScrollWhile(() => {
if (!patchIn(openTasks)) patchIn(unassignedOpen); if (!patchIn(openTasks)) patchIn(unassignedOpen);
// Si quedó sin responsables, mover a "sin responsable" // Si quedó sin responsables, mover a "sin responsable"
const idx = openTasks.findIndex((t) => t.id === id); const idx = openTasks.findIndex((t) => t.id === id);
if (idx >= 0 && (openTasks[idx].assignees || []).length === 0) { if (idx >= 0 && (openTasks[idx].assignees || []).length === 0) {
const [it] = openTasks.splice(idx, 1); const [it] = openTasks.splice(idx, 1);
unassignedOpen = [it, ...unassignedOpen]; unassignedOpen = [it, ...unassignedOpen];
} }
openTasks = [...openTasks]; openTasks = [...openTasks];
unassignedOpen = [...unassignedOpen]; unassignedOpen = [...unassignedOpen];
}); });
} else { } else {
// update_due, update_desc u otros parches ligeros // update_due, update_desc u otros parches ligeros
if (!patchIn(openTasks)) { if (!patchIn(openTasks)) {
if (!patchIn(unassignedOpen)) { if (!patchIn(unassignedOpen)) {
patchIn(recentTasks as any); patchIn(recentTasks as any);
} }
} }
openTasks = [...openTasks]; openTasks = [...openTasks];
unassignedOpen = [...unassignedOpen]; unassignedOpen = [...unassignedOpen];
recentTasks = [...recentTasks]; recentTasks = [...recentTasks];
} }
} }
</script> </script>
<svelte:head> <svelte:head>
<title>Tareas</title> <title>Tareas</title>
<meta name="robots" content="noindex,nofollow" /> <meta name="robots" content="noindex,nofollow" />
</svelte:head> </svelte:head>
<p class="subtle">Sesión: <strong>{data.userId}</strong></p> <p class="subtle">Sesión: <strong>{data.userId}</strong></p>
<div class="order-toggle"> <div class="order-toggle">
<span>Orden:</span> <span>Orden:</span>
<a <a
class:active={data.order === 'due'} class:active={data.order === "due"}
href={`/app?${buildQuery({ order: 'due', page: data.page ?? 1 })}`}>Fecha</a href={`/app?${buildQuery({ order: "due", page: data.page ?? 1 })}`}>Fecha</a
> >
<a <a
class:active={data.order === 'group'} class:active={data.order === "group"}
href={`/app?${buildQuery({ order: 'group', page: data.page ?? 1 })}`}>Grupo</a href={`/app?${buildQuery({ order: "group", page: data.page ?? 1 })}`}
> >Grupo</a
>
</div> </div>
<h2 class="section-title">Mis tareas (abiertas)</h2> <h2 class="section-title">Mis tareas</h2>
{#if 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 openTasks as t (t.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)} /> <TaskItem
{/each} {...t}
</ul> currentUserId={data.userId}
</Card> 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>
{/if} {/if}
{#if (data.page ?? 1) > 1 || data.hasMore} {#if (data.page ?? 1) > 1 || data.hasMore}
<Pagination <Pagination
prevHref={(data.page ?? 1) > 1 ? `/app?${buildQuery({ order: data.order, page: (data.page ?? 1) - 1 })}` : null} prevHref={(data.page ?? 1) > 1
nextHref={data.hasMore ? `/app?${buildQuery({ order: data.order, page: (data.page ?? 1) + 1 })}` : null} ? `/app?${buildQuery({ order: data.order, page: (data.page ?? 1) - 1 })}`
/> : null}
nextHref={data.hasMore
? `/app?${buildQuery({ order: data.order, page: (data.page ?? 1) + 1 })}`
: null}
/>
{/if} {/if}
<h2 class="section-title">Sin responsable de mis grupos</h2> <h2 class="section-title">Sin responsable de mis grupos</h2>
{#if 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
alguien.
</p>
{:else if data.order === "group"}
{#each groupByGroup(unassignedOpen) as g (g.id)}
<h3 class="group-subtitle">{g.name}</h3>
<Card>
<ul class="list">
{#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>
{/each}
{:else} {:else}
{#if data.order === 'group'} <Card>
{#each groupByGroup(unassignedOpen) as g (g.id)} <ul class="list">
<h3 class="group-subtitle">{g.name}</h3> {#each unassignedOpen as t (t.id)}
<Card> <TaskItem
<ul class="list"> {...t}
{#each g.tasks as t (t.id)} currentUserId={data.userId}
<TaskItem {...t} currentUserId={data.userId} groupName={g.name} groupId={t.group_id} on:changed={(e) => updateTaskInLists(e.detail)} /> groupName={t.group_id
{/each} ? data.groupNames[t.group_id] || t.group_id
</ul> : "Personal"}
</Card> groupId={t.group_id}
{/each} on:changed={(e) => updateTaskInLists(e.detail)}
{:else} />
<Card> {/each}
<ul class="list"> </ul>
{#each unassignedOpen as t (t.id)} </Card>
<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>
{/if}
{/if} {/if}
<h2 class="section-title">Completadas (últimas 24 h)</h2> <h2 class="section-title">Completadas (últimas 24 h)</h2>
{#if 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 recentTasks as t (t.id)} {#each recentTasks as t (t.id)}
<TaskItem <TaskItem
{...t} {...t}
currentUserId={data.userId} currentUserId={data.userId}
groupId={t.group_id} groupId={t.group_id}
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
on:changed={(e) => updateTaskInLists(e.detail)} ? data.groupNames[t.group_id] || t.group_id
/> : "Personal"}
{/each} on:changed={(e) => updateTaskInLists(e.detail)}
</ul> />
</Card> {/each}
</ul>
</Card>
{/if} {/if}
<p class="footnote">
La cookie de sesión se renueva con cada visita (idle timeout).
</p>
<style> <style>
.subtle { .subtle {
color: var(--color-text-muted); color: var(--color-text-muted);
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
} }
.order-toggle { .order-toggle {
display: inline-flex; display: inline-flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.order-toggle a { .order-toggle a {
padding: 2px 8px; padding: 2px 8px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 999px; border-radius: 999px;
text-decoration: none; text-decoration: none;
color: var(--color-text); color: var(--color-text);
} }
.order-toggle a.active { .order-toggle a.active {
background: var(--color-primary); background: var(--color-primary);
border-color: var(--color-primary); border-color: var(--color-primary);
color: #fff; color: #fff;
} }
.section-title { .section-title {
margin: 0.5rem 0; margin: 0.5rem 0;
} }
.group-subtitle { .group-subtitle {
margin: 0.5rem 0 0.25rem 0; margin: 0.5rem 0 0.25rem 0;
font-size: 0.95rem; font-size: 0.95rem;
} }
.list { .list {
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
} }
.footnote { .footnote {
margin-top: 0.75rem; margin-top: 0.75rem;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
</style> </style>

@ -227,7 +227,7 @@ export class CommandService {
' · `/t configurar diario|l-v|semanal|off [HH:MM]` (por defecto _08:30_; semanal: _lunes_; l-v: lunes a viernes)', ' · `/t configurar diario|l-v|semanal|off [HH:MM]` (por defecto _08:30_; semanal: _lunes_; l-v: lunes a viernes)',
'Notas:', 'Notas:',
' · En grupos, el bot responde por DM (no publica en el grupo).', ' · En grupos, el bot responde por DM (no publica en el grupo).',
' · Si creas una tarea en un grupo y no mencionas a nadie → “sin responsable”; en DM → se asigna a quien la cree.', ' · Si creas una tarea en un grupo y no mencionas a nadie → “sin responsable”; en DM → se asigna a quien la crea.',
' · Fechas dd/MM con ⚠️ si está vencida.', ' · Fechas dd/MM con ⚠️ si está vencida.',
' · Mostramos los IDs de las tareas con 4 dígitos, pero puedes escribirlos sin ceros (p. ej., 26).', ' · Mostramos los IDs de las tareas con 4 dígitos, pero puedes escribirlos sin ceros (p. ej., 26).',
].join('\n'); ].join('\n');
@ -462,7 +462,7 @@ export class CommandService {
if (enforce && fresh && !GroupSyncService.isUserActiveInGroup(context.sender, context.groupId)) { if (enforce && fresh && !GroupSyncService.isUserActiveInGroup(context.sender, context.groupId)) {
return [{ return [{
recipient: context.sender, recipient: context.sender,
message: 'No puedes ver las tareas de este grupo porque no apareces como miembro activo. Pide acceso a un admin si crees que es un error.' message: 'No puedes ver las tareas de este grupo. Pide que te añadan si crees que es un error.'
}]; }];
} }
@ -604,7 +604,7 @@ export class CommandService {
if (task && task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) { if (task && task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
return [{ return [{
recipient: context.sender, recipient: context.sender,
message: 'No puedes completar esta tarea porque no apareces como miembro activo del grupo.' message: 'No puedes completar esta tarea porque no eres de este grupo.'
}]; }];
} }
@ -733,7 +733,7 @@ export class CommandService {
if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) { if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
return [{ return [{
recipient: context.sender, recipient: context.sender,
message: 'No puedes tomar esta tarea: no apareces como miembro activo del grupo. Pide acceso a un admin si crees que es un error.' message: 'No puedes tomar esta tarea porque no eres de este grupo.'
}]; }];
} }
@ -859,7 +859,7 @@ export class CommandService {
if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) { if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
return [{ return [{
recipient: context.sender, recipient: context.sender,
message: '⚠️ No puedes soltar esta tarea porque no apareces como miembro activo del grupo.' message: '⚠️ No puedes soltar esta tarea porque no eres de este grupo.'
}]; }];
} }

@ -11,7 +11,7 @@ export function getQuickHelp(baseUrl?: string): string {
parts.push( parts.push(
bullets([ bullets([
`${code('/t n ...')} crear (acepta fecha y menciones)`, `${code('/t n ...')} crear (acepta fecha y menciones)`,
`${code('/t ver')} en grupo · ${code('/t ver mis')} por DM · ${code('/t ver todos')}`, `${code('/t ver mis')} por DM · ${code('/t ver todos')}`,
`${code('/t x 26')} completar (máx. 10) · ${code('/t tomar 12')} · ${code('/t soltar 26')}`, `${code('/t x 26')} completar (máx. 10) · ${code('/t tomar 12')} · ${code('/t soltar 26')}`,
`${code('/t configurar diario|l-v|semanal|off [HH:MM]')}`, `${code('/t configurar diario|l-v|semanal|off [HH:MM]')}`,
`${code('/t web')}`, `${code('/t web')}`,
@ -33,7 +33,7 @@ export function getFullHelp(baseUrl?: string): string {
out.push( out.push(
bullets([ bullets([
`${code('/t n Descripción [YYYY-MM-DD|YY-MM-DD|hoy|mañana] [@menciones...]')}`, `${code('/t n Descripción [YYYY-MM-DD|YY-MM-DD|hoy|mañana] [@menciones...]')}`,
'En DM: sin menciones → asignada al creador.', 'En DM: sin menciones → asignada a quien la crea.',
'En grupo: sin menciones → queda “sin responsable”.', 'En grupo: sin menciones → queda “sin responsable”.',
'Fechas: usa la última válida encontrada; no acepta pasadas.', 'Fechas: usa la última válida encontrada; no acepta pasadas.',
]) ])
@ -48,7 +48,7 @@ export function getFullHelp(baseUrl?: string): string {
`${code('/t ver mis')} tus pendientes (por DM).`, `${code('/t ver mis')} tus pendientes (por DM).`,
`${code('/t ver todos')} tus pendientes + “sin responsable”.`, `${code('/t ver todos')} tus pendientes + “sin responsable”.`,
'En grupo: “sin responsable” solo del grupo actual.', 'En grupo: “sin responsable” solo del grupo actual.',
'En DM: “sin responsable” de grupos donde eres miembro activo (si la snapshot es fresca).', 'En DM: “sin responsable” de tus grupos.',
`${code('/t ver sin')} solo “sin responsable” del grupo actual (desde grupo).`, `${code('/t ver sin')} solo “sin responsable” del grupo actual (desde grupo).`,
'Máx. 10 elementos por sección; se añade “… y N más” si hay más.', 'Máx. 10 elementos por sección; se añade “… y N más” si hay más.',
'Fechas en DD/MM y ⚠️ si están vencidas.', 'Fechas en DD/MM y ⚠️ si están vencidas.',

Loading…
Cancel
Save