feat: quitar soonDays y adaptar vistas a Fase 1 con orden y grupos

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

@ -18,6 +18,7 @@
export let currentUserId: string | null | undefined = null;
export let completed: boolean = false;
export let completed_at: string | null = null;
export let groupName: string | null = null;
const code = display_code ?? id;
const codeStr = String(code).padStart(4, "0");
@ -26,6 +27,7 @@
$: overdue = !!due_date && compareYmd(due_date, today) < 0;
$: imminent = !!due_date && (isToday(due_date) || isTomorrow(due_date));
$: dateDmy = due_date ? ymdToDmy(due_date) : "";
$: groupLabel = groupName != null ? groupName : "Personal";
let editing = false;
let dateValue: string = due_date ?? "";
@ -227,6 +229,7 @@
</div>
<div class="meta">
<span class="group-badge" title="Grupo">{groupLabel}</span>
{#if due_date}
<span
class="date-badge"
@ -380,10 +383,19 @@
align-items: start;
grid-row: 1/2;
grid-column: 2/3;
display: inline-flex;
gap: 6px;
align-items: center;
}
.muted {
color: var(--color-text-muted);
}
.group-badge {
padding: 2px 6px;
border-radius: 999px;
border: 1px solid var(--color-border);
font-size: 12px;
}
.date-badge {
padding: 4px 6px;
border-radius: 6px;

@ -8,6 +8,12 @@ export const load: PageServerLoad = async (event) => {
throw redirect(303, '/');
}
// Parámetros de orden y paginación
const orderParam = (event.url.searchParams.get('order') || 'due').trim().toLowerCase();
const order: 'due' | 'group' = orderParam === 'group' ? 'group' : 'due';
const pageStr = (event.url.searchParams.get('page') || '1').trim();
const page = Math.max(1, parseInt(pageStr, 10) || 1);
// Cargar "mis tareas" desde la API interna
let openTasks: Array<{
id: number;
@ -29,36 +35,98 @@ export const load: PageServerLoad = async (event) => {
}> = [];
let hasMore: boolean = false;
// Filtros desde la query (?q=&soonDays=)
const q = (event.url.searchParams.get('q') || '').trim();
const soonDaysStr = (event.url.searchParams.get('soonDays') || '').trim();
const pageStr = (event.url.searchParams.get('page') || '1').trim();
const page = Math.max(1, parseInt(pageStr, 10) || 1);
// Agregado: "Sin responsable de mis grupos"
let unassignedOpen: Array<{
id: number;
description: string;
due_date: string | null;
group_id: string | null;
display_code: number | null;
assignees: string[];
}> = [];
const groupNames: Record<string, string> = {};
try {
// Mis tareas abiertas (paginadas, orden por fecha en server)
let fetchUrl = '/api/me/tasks?limit=20';
if (q) fetchUrl += `&search=${encodeURIComponent(q)}`;
if (soonDaysStr) fetchUrl += `&soonDays=${encodeURIComponent(soonDaysStr)}`;
fetchUrl += `&page=${encodeURIComponent(String(page))}`;
const res = await event.fetch(fetchUrl);
const res = await event.fetch(fetchUrl, { headers: { 'cache-control': 'no-store' } });
if (res.ok) {
const json = await res.json();
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);
// Completadas en las últimas 24h (sin paginar por ahora)
const resRecent = await event.fetch('/api/me/tasks?limit=20&status=recent', {
headers: { 'cache-control': 'no-store' }
});
if (resRecent.ok) {
const jsonRecent = await resRecent.json();
recentTasks = Array.isArray(jsonRecent?.items) ? jsonRecent.items : [];
}
// Mis grupos (para nombres y para recolectar "sin responsable")
const resGroups = await event.fetch('/api/me/groups', {
headers: { 'cache-control': 'no-store' }
});
if (resGroups.ok) {
const jsonGroups = await resGroups.json();
const groups = Array.isArray(jsonGroups?.items) ? jsonGroups.items : [];
for (const g of groups) {
const gid = String(g.id);
const gname = g.name != null ? String(g.name) : null;
if (gname) groupNames[gid] = gname;
// Cargar solo "sin responsable" por grupo (sin límite)
try {
const r = await event.fetch(
`/api/groups/${encodeURIComponent(gid)}/tasks?onlyUnassigned=true&limit=0`,
{ headers: { 'cache-control': 'no-store' } }
);
if (r.ok) {
const j = await r.json();
const items: any[] = Array.isArray(j?.items) ? j.items : [];
for (const it of items) {
unassignedOpen.push({
id: Number(it.id),
description: String(it.description || ''),
due_date: it.due_date ? String(it.due_date) : null,
group_id: it.group_id ? String(it.group_id) : null,
display_code: it.display_code != null ? Number(it.display_code) : null,
assignees: Array.isArray(it.assignees) ? it.assignees.map(String) : []
});
}
}
} catch {
// ignorar fallos por grupo
}
}
}
// Orden base por fecha para el agregado (NULL al final)
unassignedOpen.sort((a, b) => {
const ad = a.due_date, bd = b.due_date;
if (ad == null && bd == null) return a.id - b.id;
if (ad == null) return 1;
if (bd == null) return -1;
if (ad < bd) return -1;
if (ad > bd) return 1;
return a.id - b.id;
});
} catch {
// Ignorar errores y dejar lista vacía
// Ignorar errores y dejar listas vacías
}
return { userId, openTasks, recentTasks, q, soonDays: soonDaysStr ? Number(soonDaysStr) : null, page, hasMore };
return {
userId,
openTasks,
recentTasks,
unassignedOpen,
groupNames,
order,
page,
hasMore
};
};

@ -1,38 +1,84 @@
<script lang="ts">
import Card from "$lib/ui/layout/Card.svelte";
import TextField from "$lib/ui/inputs/TextField.svelte";
import TaskItem from "$lib/ui/data/TaskItem.svelte";
import Pagination from "$lib/ui/layout/Pagination.svelte";
type Task = {
id: number;
description: string;
due_date: string | null;
group_id: string | null;
display_code: number | null;
assignees: string[];
};
export let data: {
userId: string;
openTasks: Array<{
id: number;
description: string;
due_date: string | null;
group_id: string | null;
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;
openTasks: Task[];
recentTasks: (Task & { completed?: boolean; completed_at?: string | null })[];
unassignedOpen: Task[];
groupNames: Record<string, string>;
order: 'due' | 'group';
page?: number | null;
hasMore?: boolean | null;
};
function buildQuery(params: { order?: 'due' | 'group'; page?: number }) {
const sp = new URLSearchParams();
if (params.order) sp.set("order", params.order);
if (params.page && params.page > 1) sp.set("page", String(params.page));
return sp.toString();
}
function sortByDue(items: Task[]): Task[] {
return [...items].sort((a, b) => {
const ad = a.due_date, bd = b.due_date;
if (ad == null && bd == null) return a.id - b.id;
if (ad == null) return 1;
if (bd == null) return -1;
if (ad < bd) return -1;
if (ad > bd) return 1;
return a.id - b.id;
});
}
function groupByGroup(items: Task[]): { id: string; name: string; tasks: Task[] }[] {
const map = new Map<string, Task[]>();
for (const it of items) {
const gid = it.group_id ? String(it.group_id) : "";
if (!map.has(gid)) map.set(gid, []);
map.get(gid)!.push(it);
}
const groups = Array.from(map.entries()).map(([gid, tasks]) => ({
id: gid,
name: gid ? (data.groupNames[gid] || gid) : "Personal",
tasks: sortByDue(tasks),
}));
// Ordenar grupos por nombre (Personal al final)
groups.sort((a, b) => {
if (!a.id && !b.id) return 0;
if (!a.id) return 1;
if (!b.id) return -1;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
});
return groups;
}
</script>
<p class="subtle">Sesión: <strong>{data.userId}</strong></p>
<div class="order-toggle">
<span>Orden:</span>
<a
class:active={data.order === 'due'}
href={`/app?${buildQuery({ order: 'due', page: data.page ?? 1 })}`}>Fecha</a
>
<a
class:active={data.order === 'group'}
href={`/app?${buildQuery({ order: 'group', page: data.page ?? 1 })}`}>Grupo</a
>
</div>
<h2 class="section-title">Mis tareas (abiertas)</h2>
{#if data.openTasks.length === 0}
<p>No tienes tareas abiertas.</p>
@ -40,7 +86,7 @@
<Card>
<ul class="list">
{#each data.openTasks as t}
<TaskItem {...t} currentUserId={data.userId} />
<TaskItem {...t} currentUserId={data.userId} groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'} />
{/each}
</ul>
</Card>
@ -48,15 +94,37 @@
{#if (data.page ?? 1) > 1 || data.hasMore}
<Pagination
prevHref={(data.page ?? 1) > 1
? `/app?${new URLSearchParams({ q: data.q ?? "", soonDays: data.soonDays != null ? String(data.soonDays) : "", page: String((data.page ?? 1) - 1) }).toString()}`
: null}
nextHref={data.hasMore
? `/app?${new URLSearchParams({ q: data.q ?? "", soonDays: data.soonDays != null ? String(data.soonDays) : "", page: String((data.page ?? 1) + 1) }).toString()}`
: null}
prevHref={(data.page ?? 1) > 1 ? `/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}
<h2 class="section-title">Sin responsable de mis grupos</h2>
{#if data.unassignedOpen.length === 0}
<p>No hay tareas sin responsable en tus grupos.</p>
{:else}
{#if data.order === 'group'}
{#each groupByGroup(data.unassignedOpen) as g}
<h3 class="group-subtitle">{g.name}</h3>
<Card>
<ul class="list">
{#each g.tasks as t}
<TaskItem {...t} currentUserId={data.userId} groupName={g.name} />
{/each}
</ul>
</Card>
{/each}
{:else}
<Card>
<ul class="list">
{#each sortByDue(data.unassignedOpen) as t}
<TaskItem {...t} currentUserId={data.userId} groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'} />
{/each}
</ul>
</Card>
{/if}
{/if}
<h2 class="section-title">Completadas (últimas 24 h)</h2>
{#if data.recentTasks.length === 0}
<p>No hay tareas completadas recientemente.</p>
@ -69,58 +137,47 @@
currentUserId={data.userId}
completed={true}
completed_at={t.completed_at ?? null}
groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'}
/>
{/each}
</ul>
</Card>
{/if}
<form method="GET" action="/app" class="filters">
<div class="grow">
<TextField name="q" placeholder="Buscar tareas..." value={data.q ?? ""} />
</div>
<select name="soonDays">
<option value="" selected={String(data.soonDays ?? "") === ""}
>Todas las fechas</option
>
<option value="3" selected={String(data.soonDays ?? "") === "3"}
>Próximos 3 días</option
>
<option value="7" selected={String(data.soonDays ?? "") === "7"}
>Próximos 7 días</option
>
<option value="14" selected={String(data.soonDays ?? "") === "14"}
>Próximos 14 días</option
>
</select>
<button type="submit">Filtrar</button>
</form>
<p class="footnote">
La cookie de sesión se renueva con cada visita (idle timeout).
</p>
<style>
.title {
margin-bottom: 0.5rem;
}
.subtle {
color: var(--color-text-muted);
margin: 0 0 1rem 0;
}
.filters {
margin: 0 0 1rem 0;
display: flex;
.order-toggle {
display: inline-flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.grow {
flex: 1 1 260px;
min-width: 200px;
.order-toggle a {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: 999px;
text-decoration: none;
color: var(--color-text);
}
.order-toggle a.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.section-title {
margin: 0.5rem 0;
}
.group-subtitle {
margin: 0.5rem 0 0.25rem 0;
font-size: 0.95rem;
}
.list {
margin: 0;
padding: 0;

@ -4,30 +4,31 @@ export const load: PageServerLoad = async (event) => {
const res = await event.fetch('/api/me/groups', { headers: { 'cache-control': 'no-store' } });
if (!res.ok) {
// El gate del layout debería impedir llegar aquí sin sesión; devolvemos vacío como salvaguarda.
return { groups: [], previews: {} };
return { groups: [], itemsByGroup: {}, unassignedFirst: false };
}
const data = await res.json();
const groups = Array.isArray(data?.items) ? data.items : [];
// Ordenar fichas por cantidad de tareas sin responsable (desc)
groups.sort((a: any, b: any) => Number(b?.counts?.unassigned || 0) - Number(a?.counts?.unassigned || 0));
// Prefetch de "sin responsable" por grupo (ligero)
const previews: Record<string, any[]> = {};
// Leer preferencia de orden para el listado del grupo
const unassignedFirst =
(event.url.searchParams.get('unassignedFirst') || '').trim().toLowerCase() === 'true';
// Recolectar TODAS las tareas abiertas por grupo (sin límite)
const itemsByGroup: Record<string, any[]> = {};
for (const g of groups) {
try {
const r = await event.fetch(
`/api/groups/${encodeURIComponent(g.id)}/tasks?unassignedFirst=true&onlyUnassigned=true`,
{ headers: { 'cache-control': 'no-store' } }
);
const url = `/api/groups/${encodeURIComponent(g.id)}/tasks?limit=0${
unassignedFirst ? '&unassignedFirst=true' : ''
}`;
const r = await event.fetch(url, { headers: { 'cache-control': 'no-store' } });
if (r.ok) {
const j = await r.json();
previews[String(g.id)] = Array.isArray(j?.items) ? j.items : [];
itemsByGroup[String(g.id)] = Array.isArray(j?.items) ? j.items : [];
}
} catch {
// ignorar errores de un grupo y continuar
}
}
return { groups, previews };
return { groups, itemsByGroup, unassignedFirst };
};

@ -1,21 +1,30 @@
<script lang="ts">
import GroupCard from '$lib/ui/data/GroupCard.svelte';
import TaskItem from '$lib/ui/data/TaskItem.svelte';
import Card from '$lib/ui/layout/Card.svelte';
type GroupItem = {
id: string;
name: string | null;
counts: { open: number; unassigned: number };
};
type TaskItem = {
type Task = {
id: number;
description: string;
due_date: string | null;
display_code: number | null;
group_id?: string | null;
assignees?: string[];
};
export let data: { groups: GroupItem[]; previews?: Record<string, TaskItem[]> };
export let data: { groups: GroupItem[]; itemsByGroup: Record<string, Task[]>; unassignedFirst?: boolean };
const groups = data.groups || [];
const previews = data.previews || {};
const itemsByGroup = data.itemsByGroup || {};
function buildQuery(params: { unassignedFirst?: boolean }) {
const sp = new URLSearchParams();
if (params.unassignedFirst) sp.set('unassignedFirst', 'true');
return sp.toString();
}
</script>
<svelte:head>
@ -27,18 +36,86 @@
<p>No perteneces a ningún grupo permitido.</p>
{:else}
<h1 class="title">Grupos</h1>
<div class="grid">
{#each groups as g}
<GroupCard id={g.id} name={g.name} counts={g.counts} previews={previews[g.id] || []} />
{/each}
<div class="toolbar">
<label class="toggle">
<input
type="checkbox"
checked={!!data.unassignedFirst}
on:change={(e) => {
const checked = (e.currentTarget as HTMLInputElement).checked;
const q = buildQuery({ unassignedFirst: checked });
location.href = q ? `/app/groups?${q}` : `/app/groups`;
}}
/>
Unassigned first
</label>
</div>
{#each groups as g}
<details open class="group">
<summary class="group-header">
<span class="name">{g.name ?? g.id}</span>
<span class="counts">
<span class="badge">abiertas: {g.counts.open}</span>
<span class="badge warn">sin responsable: {g.counts.unassigned}</span>
</span>
</summary>
<Card>
<ul class="list">
{#each (itemsByGroup[g.id] || []) as t}
<TaskItem
id={t.id}
description={t.description}
due_date={t.due_date}
display_code={t.display_code}
assignees={t.assignees || []}
currentUserId={null}
groupName={g.name ?? g.id}
/>
{/each}
</ul>
</Card>
</details>
{/each}
{/if}
<style>
.title { margin-bottom: .75rem; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-3);
.toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: .5rem;
}
.toggle { display: inline-flex; gap: 6px; align-items: center; }
.group {
margin: .5rem 0;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
}
.group-header {
display: flex;
justify-content: space-between;
padding: .5rem .75rem;
cursor: pointer;
list-style: none;
user-select: none;
}
.group-header .name { font-weight: 600; }
.counts { display: inline-flex; gap: .5rem; }
.badge {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: 999px;
font-size: 12px;
}
.badge.warn {
border-color: var(--color-warning);
}
.list {
margin: 0;
padding: .25rem .5rem .5rem .5rem;
list-style: none;
}
</style>

Loading…
Cancel
Save