From 8cacc7dacf506caf7711ef7a7ee3a61d72ff704e Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 13 Jun 2026 20:46:09 +0200 Subject: [PATCH] =?UTF-8?q?fix(calendar):=20vista=20lista=20sin=20recurren?= =?UTF-8?q?tes=20repetidos=20+=20secciones=20pr=C3=B3ximos/pasados?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/views/CalendarView.tsx | 239 ++++++++++++++++++---------- 1 file changed, 154 insertions(+), 85 deletions(-) diff --git a/frontend/src/views/CalendarView.tsx b/frontend/src/views/CalendarView.tsx index b0fc063..ab361a0 100644 --- a/frontend/src/views/CalendarView.tsx +++ b/frontend/src/views/CalendarView.tsx @@ -107,11 +107,13 @@ export function CalendarView() { to: c.add(1, "day").format("YYYY-MM-DD"), }; } - // lista: TODOS los eventos del calendario (pasados y futuros). Rango muy - // amplio para que el backend expanda cualquier serie recurrente y no recorte. + // lista: histórico completo de eventos puntuales (desde 2000) + las series + // 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 { from: "2000-01-01", - to: "2100-01-01", + to: dayjs().add(13, "month").format("YYYY-MM-DD"), }; }, [cursor, view]); @@ -936,108 +938,175 @@ function AgendaView({ onNew: () => void; onEvent: (ev: CalendarEvent) => void; }) { - // Orden ascendente por instante de inicio + agrupado por día (clave YYYY-MM-DD). - const groups = useMemo(() => { - const sorted = [...events].sort((a, b) => { - const sa = eventStart(a, tz); - const sb = eventStart(b, tz); - const va = sa ? sa.valueOf() : Number.POSITIVE_INFINITY; - const vb = sb ? sb.valueOf() : Number.POSITIVE_INFINITY; - return va - vb; - }); + const startOfToday = useMemo(() => dayjs().tz(tz).startOf("day"), [tz]); + const todayKey = startOfToday.format("YYYY-MM-DD"); + + // 1) Deduplica las series recurrentes: una sola entrada por uid (su próxima + // ocurrencia desde hoy; si todas son pasadas, la más reciente). Así un + // cumpleaños anual aparece una vez, no una por año. Los puntuales se dejan. + const deduped = useMemo(() => { + const recurring = new Map(); + 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(); - for (const ev of sorted) { + for (const ev of list) { const s = eventStart(ev, tz); if (!s) continue; const k = s.format("YYYY-MM-DD"); - const list = map.get(k) ?? []; - list.push(ev); - map.set(k, list); + const arr = map.get(k) ?? []; + arr.push(ev); + map.set(k, arr); } 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 ( + + + {d.format("dddd")} · {d.format("DD/MM/YYYY")} + + + {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 ( + onEvent(ev)} + style={{ + cursor: "pointer", + borderRadius: 6, + padding: "6px 8px", + borderLeft: `3px solid ${eventColor(ev, calColor)}`, + background: "var(--mantine-color-default-hover)", + }} + > + + {timeLabel} + + + {ev.recurring && ( + + )} + + {ev.summary || "(sin título)"} + + + + ); + })} + + + ); + }; + + const isEmpty = upcomingGroups.length === 0 && pastGroups.length === 0; return ( - Todos los eventos - - {groups.length === 0 ? ( + {isEmpty ? ( No hay eventos en este calendario. ) : ( - - {groups.map(([dayKey, list]) => { - const d = dayjs(dayKey); - const isToday = dayKey === today; - return ( - - - {d.format("dddd")} · {d.format("DD/MM/YYYY")} - - - {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 ( - onEvent(ev)} - style={{ - cursor: "pointer", - borderRadius: 6, - padding: "6px 8px", - borderLeft: `3px solid ${eventColor(ev, calColor)}`, - background: "var(--mantine-color-default-hover)", - }} - > - - {timeLabel} - - - {ev.recurring && ( - - )} - - {ev.summary || "(sin título)"} - - - - ); - })} - - - ); - })} + + {/* Próximos */} + + + Próximos + + {upcomingGroups.length === 0 ? ( + + No hay eventos próximos. + + ) : ( + {upcomingGroups.map(renderGroup)} + )} + + + {/* Pasados — atenuados en gris claro */} + {pastGroups.length > 0 && ( + + + Pasados + + {pastGroups.map(renderGroup)} + + )} )}