Calendar panel: event dots in the mini calendar, bigger arrows on phones

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 13:40:26 +02:00
co-authored by Claude Opus 5.5
parent 53fd603e21
commit 84d2d24c7d
@@ -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 += `<button class="${cls}" data-mini="${iso(d)}">${d.getDate()}</button>`;
}
return `<div class="cal-mini">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<b>${MONTHS[m.getMonth()].slice(0, 3)} ${m.getFullYear()}</b>
<span><button class="cal-iconbtn" data-act="miniprev">‹</button><button class="cal-iconbtn" data-act="mini-today" title="Go to today"><svg width="11" height="11" viewBox="0 0 12 12"><circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg></button><button class="cal-iconbtn" data-act="mininext">›</button></span>
<span class="cal-mininav"><button class="cal-iconbtn" data-act="miniprev" title="Previous month"><svg width=".4em" height=".7em" viewBox="0 0 8 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 1L1 7l5 6"/></svg></button><button class="cal-iconbtn" data-act="mini-today" title="Go to today"><svg width="11" height="11" viewBox="0 0 12 12"><circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg></button><button class="cal-iconbtn" data-act="mininext" title="Next month"><svg width=".4em" height=".7em" viewBox="0 0 8 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 1l5 6-5 6"/></svg></button></span>
</div>
<div class="cal-mini-grid">${["Mo","Tu","We","Th","Fr","Sa","Su"].map((h) => `<div class="h">${h}</div>`).join("")}${cells}</div>
</div>`;
@@ -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);