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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user