From 84d2d24c7d77c53aa0bd5541e242a7eaf39943ad Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Sat, 10 Oct 2026 13:40:26 +0200 Subject: [PATCH] Calendar panel: event dots in the mini calendar, bigger arrows on phones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The side panel's mini calendar marks each day with events with a small dot under the number (white in dark mode, the text colour in light; white on today's blue circle), following the calendars filter. The dots come from the agenda's own events — the panel now loads its month's six mini-calendar weeks once for both; a mini calendar paged to another month fetches that month once. The month arrows are drawn icons, centred with the today circle (the text glyphs sat lower); on phones they are 50% larger with 5px more between the three buttons. Co-Authored-By: Claude Opus 5.5 --- .../webmail_plugin_calendar/ui/page.js | 74 ++++++++++++++++++- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js index 0c516ef..1ea3594 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js @@ -289,6 +289,17 @@ const CSS = ` .cal-mini-grid button:hover{background:rgb(var(--app-hover))} .cal-mini-grid button.out{color:rgb(var(--app-faint))} .cal-mini-grid button.today{background:#3b82f6;color:#fff} +.cal-mini-grid button{position:relative} +.cal-mininav{display:inline-flex;align-items:center} +.cal-mininav .cal-iconbtn{align-items:center;justify-content:center;line-height:1} +.cal-mininav svg{display:block} +/* Phones (the app's mobile layout): bigger arrows, more room to tap between. */ +@media (max-width:767px),(pointer:coarse) and (max-height:500px){ +.cal-mininav{gap:5px} +.cal-mininav [data-act=miniprev],.cal-mininav [data-act=mininext]{font-size:1.5em} +} +.cal-mini-grid button.busy::after{content:"";position:absolute;left:50%;bottom:1px;width:4px;height:4px;margin-left:-2px;border-radius:50%;background:rgb(var(--app-fg))} +.cal-mini-grid button.today.busy::after{background:#fff} .cal-mini-grid button.sel{background:rgb(var(--app-active));font-weight:600} /* month */ .cal-month{display:grid;grid-template-columns:repeat(7,1fr);grid-template-rows:auto;grid-auto-rows:minmax(0,1fr);height:100%} @@ -487,6 +498,8 @@ export default function mount(el, ctx, props) { panelMini: "month", // embed mini calendar: month | week panelView: "agenda", // embed agenda: day | 3days | agenda scrollToAnchor: false, // embed: bring the anchor day to the top of the agenda once + loadedRange: null, // [start, end) of state.events + miniBusy: { key: "", events: [] }, // embed: another month's events, for the mini calendar's dots me: "", quota: "", jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/) @@ -500,8 +513,10 @@ export default function mount(el, ctx, props) { function rangeFor() { if (state.embed) { - const gs = startOfMonth(state.anchor); - return [gs, addDays(gs, 42)]; // covers the panel's ~30-day agenda + // The anchor month's six mini-calendar weeks: covers the agenda (that + // month, from its 1st) and the mini calendar's dots in one request. + const gs = startOfWeek(startOfMonth(state.anchor)); + return [gs, addDays(gs, 42)]; } if (state.view === "month") { const gs = startOfWeek(startOfMonth(state.anchor)); @@ -519,6 +534,7 @@ export default function mount(el, ctx, props) { const [s, e] = rangeFor(); const res = await api.pluginFetch(`/events?start=${encodeURIComponent(iso(s))}&end=${encodeURIComponent(iso(e))}`); state.events = (res.events || []).map((ev) => ({ ...ev, _s: new Date(ev.start), _e: new Date(ev.end) })); + state.loadedRange = [s, e]; } function visibleEvents() { return state.events.filter((ev) => !state.hidden.has(ev.calendar_id)); @@ -529,6 +545,50 @@ export default function mount(el, ctx, props) { render(); } + // Panel: which days of the mini calendar have events (a dot under the number). + function miniRange() { + if (state.panelMini === "week") { const s = startOfWeek(state.anchor); return [s, addDays(s, 7)]; } + const s = startOfWeek(startOfMonth(state.mini)); + return [s, addDays(s, 42)]; + } + const dayKey = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; + function busyDays(events) { + const days = new Set(); + for (const ev of events) { + if (state.hidden.has(ev.calendar_id)) continue; + if (ev.all_day) { // date-anchored: its own dates, end exclusive + let d = new Date(`${ev.start.slice(0, 10)}T00:00:00`); + const last = new Date(`${ev.end.slice(0, 10)}T00:00:00`); + do { days.add(dayKey(d)); d = addDays(d, 1); } while (d < last); + } else { + let d = startOfDay(new Date(ev.start)); + const end = new Date(ev.end); + do { days.add(dayKey(d)); d = addDays(d, 1); } while (d < end); + } + } + return days; + } + // The dots come from the agenda's events while the mini calendar shows their + // month; paged to another month, that month's are fetched once. + function miniCovered() { + const [s, e] = miniRange(); + const r = state.loadedRange; + return Boolean(r && s >= r[0] && e <= r[1]); + } + async function loadMiniBusy() { + const [s, e] = miniRange(); + const key = iso(s); + if (state.miniBusy.key === key) return; + state.miniBusy.key = key; + let res; + try { + res = await api.pluginFetch(`/events?start=${encodeURIComponent(iso(s))}&end=${encodeURIComponent(iso(e))}`); + } catch { return; } + if (state.miniBusy.key !== key) return; // paged on meanwhile + state.miniBusy.events = res.events || []; + render(); + } + function pad(n) { return String(n).padStart(2, "0"); } // Wire every custom date field in the form: an editable text box showing the date in // the preferred order (typing is parsed in that order), backed by a hidden ISO input, @@ -1098,6 +1158,7 @@ export default function mount(el, ctx, props) { // Compact single-column layout for Mail's right panel. Reuses miniMonth/agenda/ // searchPanelHtml + the form/popover/confirm overlays exactly as the full app. function renderEmbed() { + if (!miniCovered() && state.miniBusy.key !== iso(miniRange()[0])) void loadMiniBusy(); // Re-rendering replaces the agenda: keep where it was scrolled to (else // opening an event's details lower down jumped the list back to the top). const prevBody = el.querySelector(".cal-embed-body"); @@ -1260,19 +1321,23 @@ export default function mount(el, ctx, props) { const week = state.embed && state.panelMini === "week"; const gs = week ? startOfWeek(state.anchor) : startOfWeek(startOfMonth(m)); const cellCount = week ? 7 : 42; + const busySet = !state.embed + ? new Set() + : busyDays(miniCovered() ? state.events : (state.miniBusy.events || [])); let cells = ""; for (let i = 0; i < cellCount; i++) { const d = addDays(gs, i); const today = sameDay(d, new Date()); // Embed: the agenda has no other sign of which day was picked. const sel = state.embed && !today && sameDay(d, state.anchor); - const cls = [!week && d.getMonth() !== m.getMonth() ? "out" : "", today ? "today" : "", sel ? "sel" : ""].join(" "); + const busy = state.embed && busySet.has(dayKey(d)); + const cls = [!week && d.getMonth() !== m.getMonth() ? "out" : "", today ? "today" : "", sel ? "sel" : "", busy ? "busy" : ""].join(" "); cells += ``; } return `
${MONTHS[m.getMonth()].slice(0, 3)} ${m.getFullYear()} - +
${["Mo","Tu","We","Th","Fr","Sa","Su"].map((h) => `
${h}
`).join("")}${cells}
`; @@ -1924,6 +1989,7 @@ export default function mount(el, ctx, props) { // Not while a form is open: re-rendering would throw away what is being typed. const onChanged = async () => { await loadEvents(); + if (state.embed) state.miniBusy.key = ""; // another month's dots: fetched again if (!state.composing && !state.confirm) render(); }; window.addEventListener("webmail:calendar-changed", onChanged);