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"),
|
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 {
|
return {
|
||||||
from: dayjs().format("YYYY-MM-DD"),
|
from: "2000-01-01",
|
||||||
to: dayjs().add(90, "day").format("YYYY-MM-DD"),
|
to: "2100-01-01",
|
||||||
};
|
};
|
||||||
}, [cursor, view]);
|
}, [cursor, view]);
|
||||||
|
|
||||||
@@ -962,7 +963,7 @@ function AgendaView({
|
|||||||
<ScrollArea h="100%" type="auto">
|
<ScrollArea h="100%" type="auto">
|
||||||
<Box p="md">
|
<Box p="md">
|
||||||
<Group justify="space-between" mb="sm" wrap="nowrap">
|
<Group justify="space-between" mb="sm" wrap="nowrap">
|
||||||
<Text fw={600}>Próximos eventos</Text>
|
<Text fw={600}>Todos los eventos</Text>
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
leftSection={<IconPlus size={14} />}
|
leftSection={<IconPlus size={14} />}
|
||||||
@@ -974,7 +975,7 @@ function AgendaView({
|
|||||||
|
|
||||||
{groups.length === 0 ? (
|
{groups.length === 0 ? (
|
||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
No hay eventos en el rango.
|
No hay eventos en este calendario.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
|||||||
@@ -13,16 +13,29 @@ import {
|
|||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
IconCalendarEvent,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronUp,
|
IconChevronUp,
|
||||||
|
IconRepeat,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconSelector,
|
IconSelector,
|
||||||
} from "@tabler/icons-react";
|
} 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 { formatFrontmatterValue } from "../format";
|
||||||
import { tipoStyle } from "../tipos";
|
import { tipoStyle } from "../tipos";
|
||||||
import { useNodeCard } from "../NodeCardContext";
|
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
|
// 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
|
// 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
|
// Mantine ordenable + filtrable. Las columnas se deducen de las claves de
|
||||||
@@ -110,6 +123,12 @@ export function TablesView() {
|
|||||||
{tipoStyle(t).label}
|
{tipoStyle(t).label}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
))}
|
))}
|
||||||
|
<Tabs.Tab
|
||||||
|
value={EVENTS_TAB}
|
||||||
|
leftSection={<IconCalendarEvent size={14} />}
|
||||||
|
>
|
||||||
|
Eventos
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
{availableTipos.map((t) => (
|
{availableTipos.map((t) => (
|
||||||
@@ -121,6 +140,13 @@ export function TablesView() {
|
|||||||
{active === t && <TypeTable tipo={t} />}
|
{active === t && <TypeTable tipo={t} />}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<Tabs.Panel
|
||||||
|
value={EVENTS_TAB}
|
||||||
|
style={{ flex: 1, minHeight: 0, display: "flex" }}
|
||||||
|
>
|
||||||
|
{active === EVENTS_TAB && <EventsTable />}
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -287,3 +313,222 @@ function SortIcon({ active, dir }: { active: boolean; dir: SortDir }) {
|
|||||||
<IconChevronDown size={14} />
|
<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