fix(calendar): vista lista sin recurrentes repetidos + secciones próximos/pasados

La vista de lista pedía el rango 2000-2100, así que el backend expandía cada
cumpleaños anual ~75 veces (hasta 2099). Ahora: (1) el rango llega solo a +13
meses, suficiente para la próxima ocurrencia de cada serie; (2) AgendaView
deduplica las series recurrentes a una sola entrada (su próxima ocurrencia); y
(3) separa la agenda en 'Próximos' (>= hoy, ascendente) arriba y 'Pasados'
(< hoy, descendente) debajo, estos atenuados (opacity 0.5) en gris claro.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 20:46:09 +02:00
parent f5d15a9f7b
commit 8cacc7dacf
+154 -85
View File
@@ -107,11 +107,13 @@ export function CalendarView() {
to: c.add(1, "day").format("YYYY-MM-DD"), to: c.add(1, "day").format("YYYY-MM-DD"),
}; };
} }
// lista: TODOS los eventos del calendario (pasados y futuros). Rango muy // lista: histórico completo de eventos puntuales (desde 2000) + las series
// amplio para que el backend expanda cualquier serie recurrente y no recorte. // recurrentes expandidas solo hasta ~13 meses vista. Ese tope evita que un
// cumpleaños anual se expanda hasta 2099; AgendaView deduplica además cada
// serie a una sola entrada (su próxima ocurrencia).
return { return {
from: "2000-01-01", from: "2000-01-01",
to: "2100-01-01", to: dayjs().add(13, "month").format("YYYY-MM-DD"),
}; };
}, [cursor, view]); }, [cursor, view]);
@@ -936,108 +938,175 @@ function AgendaView({
onNew: () => void; onNew: () => void;
onEvent: (ev: CalendarEvent) => void; onEvent: (ev: CalendarEvent) => void;
}) { }) {
// Orden ascendente por instante de inicio + agrupado por día (clave YYYY-MM-DD). const startOfToday = useMemo(() => dayjs().tz(tz).startOf("day"), [tz]);
const groups = useMemo(() => { const todayKey = startOfToday.format("YYYY-MM-DD");
const sorted = [...events].sort((a, b) => {
const sa = eventStart(a, tz); // 1) Deduplica las series recurrentes: una sola entrada por uid (su próxima
const sb = eventStart(b, tz); // ocurrencia desde hoy; si todas son pasadas, la más reciente). Así un
const va = sa ? sa.valueOf() : Number.POSITIVE_INFINITY; // cumpleaños anual aparece una vez, no una por año. Los puntuales se dejan.
const vb = sb ? sb.valueOf() : Number.POSITIVE_INFINITY; const deduped = useMemo(() => {
return va - vb; const recurring = new Map<string, CalendarEvent[]>();
}); const singles: CalendarEvent[] = [];
for (const ev of events) {
if (ev.recurring && ev.uid) {
const l = recurring.get(ev.uid) ?? [];
l.push(ev);
recurring.set(ev.uid, l);
} else {
singles.push(ev);
}
}
const out = [...singles];
for (const occ of recurring.values()) {
const sorted = occ
.slice()
.sort(
(a, b) =>
(eventStart(a, tz)?.valueOf() ?? 0) -
(eventStart(b, tz)?.valueOf() ?? 0),
);
const next = sorted.find((e) => {
const s = eventStart(e, tz);
return s && !s.isBefore(startOfToday);
});
out.push(next ?? sorted[sorted.length - 1]);
}
return out;
}, [events, tz, startOfToday]);
// 2) Separa en próximos (>= hoy, ascendente) y pasados (< hoy, descendente),
// cada uno agrupado por día.
const groupByDay = (list: CalendarEvent[]) => {
const map = new Map<string, CalendarEvent[]>(); const map = new Map<string, CalendarEvent[]>();
for (const ev of sorted) { for (const ev of list) {
const s = eventStart(ev, tz); const s = eventStart(ev, tz);
if (!s) continue; if (!s) continue;
const k = s.format("YYYY-MM-DD"); const k = s.format("YYYY-MM-DD");
const list = map.get(k) ?? []; const arr = map.get(k) ?? [];
list.push(ev); arr.push(ev);
map.set(k, list); map.set(k, arr);
} }
return Array.from(map.entries()); return Array.from(map.entries());
}, [events, tz]); };
const today = dayjs().tz(tz).format("YYYY-MM-DD"); const { upcomingGroups, pastGroups } = useMemo(() => {
const upcoming: CalendarEvent[] = [];
const past: CalendarEvent[] = [];
for (const ev of deduped) {
const s = eventStart(ev, tz);
if (s && s.isBefore(startOfToday)) past.push(ev);
else upcoming.push(ev);
}
upcoming.sort(
(a, b) =>
(eventStart(a, tz)?.valueOf() ?? Infinity) -
(eventStart(b, tz)?.valueOf() ?? Infinity),
);
past.sort(
(a, b) =>
(eventStart(b, tz)?.valueOf() ?? 0) -
(eventStart(a, tz)?.valueOf() ?? 0),
);
return { upcomingGroups: groupByDay(upcoming), pastGroups: groupByDay(past) };
}, [deduped, tz, startOfToday]);
const renderGroup = ([dayKey, list]: [string, CalendarEvent[]]) => {
const d = dayjs(dayKey);
const isToday = dayKey === todayKey;
return (
<Box key={dayKey}>
<Text
size="sm"
fw={600}
c={isToday ? "brand" : undefined}
mb={6}
style={{ textTransform: "capitalize" }}
>
{d.format("dddd")} · {d.format("DD/MM/YYYY")}
</Text>
<Stack gap={4}>
{list.map((ev, i) => {
const s = eventStart(ev, tz);
const e = eventEnd(ev, tz);
const timeLabel = ev.all_day
? "Todo el día"
: `${s?.format("HH:mm") ?? "--:--"}${
e ? ` ${e.format("HH:mm")}` : ""
}`;
return (
<Group
key={(ev.uid ?? "") + i}
gap="sm"
wrap="nowrap"
onClick={() => onEvent(ev)}
style={{
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
borderLeft: `3px solid ${eventColor(ev, calColor)}`,
background: "var(--mantine-color-default-hover)",
}}
>
<Text size="xs" c="dimmed" style={{ width: 96, flexShrink: 0 }}>
{timeLabel}
</Text>
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
{ev.recurring && (
<IconRepeat size={13} style={{ flexShrink: 0 }} />
)}
<Text size="sm" truncate>
{ev.summary || "(sin título)"}
</Text>
</Group>
</Group>
);
})}
</Stack>
</Box>
);
};
const isEmpty = upcomingGroups.length === 0 && pastGroups.length === 0;
return ( return (
<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}>Todos los eventos</Text> <Text fw={600}>Agenda</Text>
<Button <Button size="xs" leftSection={<IconPlus size={14} />} onClick={onNew}>
size="xs"
leftSection={<IconPlus size={14} />}
onClick={onNew}
>
Nuevo evento Nuevo evento
</Button> </Button>
</Group> </Group>
{groups.length === 0 ? ( {isEmpty ? (
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
No hay eventos en este calendario. No hay eventos en este calendario.
</Text> </Text>
) : ( ) : (
<Stack gap="lg"> <Stack gap="xl">
{groups.map(([dayKey, list]) => { {/* Próximos */}
const d = dayjs(dayKey); <Box>
const isToday = dayKey === today; <Text size="xs" tt="uppercase" fw={700} c="dimmed" mb="sm">
return ( Próximos
<Box key={dayKey}> </Text>
<Text {upcomingGroups.length === 0 ? (
size="sm" <Text c="dimmed" size="sm">
fw={600} No hay eventos próximos.
c={isToday ? "brand" : undefined} </Text>
mb={6} ) : (
style={{ textTransform: "capitalize" }} <Stack gap="lg">{upcomingGroups.map(renderGroup)}</Stack>
> )}
{d.format("dddd")} · {d.format("DD/MM/YYYY")} </Box>
</Text>
<Stack gap={4}> {/* Pasados — atenuados en gris claro */}
{list.map((ev, i) => { {pastGroups.length > 0 && (
const s = eventStart(ev, tz); <Box style={{ opacity: 0.5 }}>
const e = eventEnd(ev, tz); <Text size="xs" tt="uppercase" fw={700} c="dimmed" mb="sm">
const timeLabel = ev.all_day Pasados
? "Todo el día" </Text>
: `${s?.format("HH:mm") ?? "--:--"}${ <Stack gap="lg">{pastGroups.map(renderGroup)}</Stack>
e ? ` ${e.format("HH:mm")}` : "" </Box>
}`; )}
return (
<Group
key={(ev.uid ?? "") + i}
gap="sm"
wrap="nowrap"
onClick={() => onEvent(ev)}
style={{
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
borderLeft: `3px solid ${eventColor(ev, calColor)}`,
background: "var(--mantine-color-default-hover)",
}}
>
<Text
size="xs"
c="dimmed"
style={{ width: 96, flexShrink: 0 }}
>
{timeLabel}
</Text>
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
{ev.recurring && (
<IconRepeat size={13} style={{ flexShrink: 0 }} />
)}
<Text size="sm" truncate>
{ev.summary || "(sin título)"}
</Text>
</Group>
</Group>
);
})}
</Stack>
</Box>
);
})}
</Stack> </Stack>
)} )}
</Box> </Box>