feat(calendar,tables): vista Lista con todos los eventos + tabla de Eventos en Tablas

- CalendarView vista Lista: rango ampliado a todos los eventos (pasados y futuros),
  agrupados por dia con cabecera DD/MM/AAAA.
- TablesView: pestana Eventos nueva (fecha, hora, titulo, calendario, ubicacion,
  indicador de recurrencia), mismo patron de tabla ordenable/filtrable que el resto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 01:12:33 +02:00
parent fb3956e8eb
commit ef23c8aee1
2 changed files with 252 additions and 6 deletions
+6 -5
View File
@@ -107,10 +107,11 @@ export function CalendarView() {
to: c.add(1, "day").format("YYYY-MM-DD"),
};
}
// lista: ventana amplia desde hoy (~90 días) para una agenda útil.
// lista: TODOS los eventos del calendario (pasados y futuros). Rango muy
// amplio para que el backend expanda cualquier serie recurrente y no recorte.
return {
from: dayjs().format("YYYY-MM-DD"),
to: dayjs().add(90, "day").format("YYYY-MM-DD"),
from: "2000-01-01",
to: "2100-01-01",
};
}, [cursor, view]);
@@ -962,7 +963,7 @@ function AgendaView({
<ScrollArea h="100%" type="auto">
<Box p="md">
<Group justify="space-between" mb="sm" wrap="nowrap">
<Text fw={600}>Próximos eventos</Text>
<Text fw={600}>Todos los eventos</Text>
<Button
size="xs"
leftSection={<IconPlus size={14} />}
@@ -974,7 +975,7 @@ function AgendaView({
{groups.length === 0 ? (
<Text c="dimmed" size="sm">
No hay eventos en el rango.
No hay eventos en este calendario.
</Text>
) : (
<Stack gap="lg">
+246 -1
View File
@@ -13,16 +13,29 @@ import {
UnstyledButton,
} from "@mantine/core";
import {
IconCalendarEvent,
IconChevronDown,
IconChevronUp,
IconRepeat,
IconSearch,
IconSelector,
} from "@tabler/icons-react";
import { fetchGraph, fetchNodes, type NodeRow } from "../api";
import {
fetchCalendar,
fetchGraph,
fetchNodes,
type CalendarEvent,
type NodeRow,
} from "../api";
import { eventStart, eventEnd } from "../calendar";
import { formatFrontmatterValue } from "../format";
import { tipoStyle } from "../tipos";
import { useNodeCard } from "../NodeCardContext";
// Valor de pestaña sintético para la tabla de eventos del calendario. No es un
// `tipo` de nodo del vault — los eventos vienen de Xandikos via fetchCalendar.
const EVENTS_TAB = "__eventos__";
// Una pestaña por tipo de nodo real (no fantasma). Cada pestaña carga
// perezosamente sus filas de /api/nodes?tipo=<t> y las muestra en una tabla
// Mantine ordenable + filtrable. Las columnas se deducen de las claves de
@@ -110,6 +123,12 @@ export function TablesView() {
{tipoStyle(t).label}
</Tabs.Tab>
))}
<Tabs.Tab
value={EVENTS_TAB}
leftSection={<IconCalendarEvent size={14} />}
>
Eventos
</Tabs.Tab>
</Tabs.List>
{availableTipos.map((t) => (
@@ -121,6 +140,13 @@ export function TablesView() {
{active === t && <TypeTable tipo={t} />}
</Tabs.Panel>
))}
<Tabs.Panel
value={EVENTS_TAB}
style={{ flex: 1, minHeight: 0, display: "flex" }}
>
{active === EVENTS_TAB && <EventsTable />}
</Tabs.Panel>
</Tabs>
);
}
@@ -287,3 +313,222 @@ function SortIcon({ active, dir }: { active: boolean; dir: SortDir }) {
<IconChevronDown size={14} />
);
}
// --- Tabla de eventos del calendario ---------------------------------------
// Columnas de la tabla de eventos. `key` identifica la columna para ordenar;
// `label` es la cabecera humana. Las fechas/horas se muestran en formato europeo.
const EVENT_COLUMNS: { key: string; label: string }[] = [
{ key: "fecha", label: "Fecha" },
{ key: "hora", label: "Hora" },
{ key: "summary", label: "Título" },
{ key: "calendar", label: "Calendario" },
{ key: "location", label: "Ubicación" },
{ key: "recurring", label: "Recurrencia" },
];
// Etiqueta de calendario derivada del href del evento (Xandikos expone el slug
// del calendario como segmento de la URL). Cae al href entero si no hay slug.
function calendarLabel(ev: CalendarEvent): string {
const href = ev.href ?? "";
if (!href) return "";
const parts = href.split("/").filter(Boolean);
// El penúltimo segmento suele ser el slug del calendario (…/<cal>/<uid>.ics).
if (parts.length >= 2) return decodeURIComponent(parts[parts.length - 2]);
return decodeURIComponent(parts[parts.length - 1] ?? href);
}
// Lee todos los eventos del calendario (sin restricción de colección) en un rango
// muy amplio y los muestra como tabla ordenable + filtrable, mismo patrón que
// TypeTable. Por defecto ordena por fecha ascendente.
function EventsTable() {
// TZ del visor para posicionar el día/hora de cada evento (la del navegador).
const tz = useMemo(() => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "Europe/Madrid";
} catch {
return "Europe/Madrid";
}
}, []);
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const [sortCol, setSortCol] = useState<string>("fecha");
const [sortDir, setSortDir] = useState<SortDir>("asc");
useEffect(() => {
let alive = true;
setLoading(true);
// Sin cal → todos los calendarios; rango muy amplio → todos los eventos.
fetchCalendar("", "2000-01-01", "2100-01-01")
.then((d) => {
if (!alive) return;
if (d.status !== "ok") {
setError(d.error || "Xandikos no respondió");
return;
}
setEvents(d.events ?? []);
})
.catch((e) => alive && setError(String(e)))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, []);
const cellValue = (ev: CalendarEvent, col: string): string => {
const s = eventStart(ev, tz);
switch (col) {
case "fecha":
return ev.all_day
? `${s ? s.format("DD/MM/YYYY") : ""} · todo el día`
: s
? s.format("DD/MM/YYYY")
: "";
case "hora": {
if (ev.all_day) return "";
const e = eventEnd(ev, tz);
return `${s ? s.format("HH:mm") : "--:--"}${
e ? ` ${e.format("HH:mm")}` : ""
}`;
}
case "summary":
return ev.summary || "(sin título)";
case "calendar":
return calendarLabel(ev);
case "location":
return ev.location || "";
case "recurring":
return ev.recurring ? "sí" : "";
default:
return "";
}
};
// Clave de orden estable por fecha (instante real, no string formateado).
const sortKey = (ev: CalendarEvent, col: string): string | number => {
if (col === "fecha" || col === "hora") {
const s = eventStart(ev, tz);
return s ? s.valueOf() : Number.POSITIVE_INFINITY;
}
return cellValue(ev, col).toLowerCase();
};
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
let out = events;
if (q) {
out = events.filter((ev) =>
EVENT_COLUMNS.some((c) =>
cellValue(ev, c.key).toLowerCase().includes(q),
),
);
}
if (sortDir) {
out = [...out].sort((a, b) => {
const va = sortKey(a, sortCol);
const vb = sortKey(b, sortCol);
if (va < vb) return sortDir === "asc" ? -1 : 1;
if (va > vb) return sortDir === "asc" ? 1 : -1;
return 0;
});
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [events, filter, sortCol, sortDir, tz]);
function toggleSort(col: string) {
if (sortCol !== col) {
setSortCol(col);
setSortDir("asc");
} else {
setSortDir((d) => (d === "asc" ? "desc" : d === "desc" ? null : "asc"));
}
}
if (error) {
return (
<Alert color="orange" title="Calendario no disponible" m="md">
{error}
<Text size="sm" mt="xs" c="dimmed">
Los eventos vienen del servidor Xandikos. El resto de tablas funcionan
sin él.
</Text>
</Alert>
);
}
return (
<Box style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<Group p="md" pb="xs" justify="space-between">
<TextInput
placeholder="Filtrar eventos…"
leftSection={<IconSearch size={16} />}
value={filter}
onChange={(e) => setFilter(e.currentTarget.value)}
w={300}
/>
<Text size="sm" c="dimmed">
{filtered.length} de {events.length}
</Text>
</Group>
{loading ? (
<Center style={{ flex: 1 }}>
<Loader />
</Center>
) : (
<ScrollArea style={{ flex: 1 }} px="md">
<Table striped highlightOnHover stickyHeader withTableBorder>
<Table.Thead>
<Table.Tr>
{EVENT_COLUMNS.map((c) => (
<Table.Th key={c.key}>
<UnstyledButton onClick={() => toggleSort(c.key)}>
<Group gap={4} wrap="nowrap">
<Text fw={600} size="sm">
{c.label}
</Text>
<SortIcon active={sortCol === c.key} dir={sortDir} />
</Group>
</UnstyledButton>
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((ev, i) => (
<Table.Tr key={(ev.uid ?? "") + (ev.dtstart ?? "") + i}>
{EVENT_COLUMNS.map((c) => (
<Table.Td key={c.key}>
{c.key === "recurring" ? (
ev.recurring ? (
<IconRepeat size={15} />
) : null
) : (
<Text size="sm" lineClamp={2}>
{cellValue(ev, c.key)}
</Text>
)}
</Table.Td>
))}
</Table.Tr>
))}
{filtered.length === 0 && (
<Table.Tr>
<Table.Td colSpan={EVENT_COLUMNS.length}>
<Text c="dimmed" ta="center" py="md">
Sin eventos
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</ScrollArea>
)}
</Box>
);
}