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:
@@ -289,6 +289,17 @@ const CSS = `
|
|||||||
.cal-mini-grid button:hover{background:rgb(var(--app-hover))}
|
.cal-mini-grid button:hover{background:rgb(var(--app-hover))}
|
||||||
.cal-mini-grid button.out{color:rgb(var(--app-faint))}
|
.cal-mini-grid button.out{color:rgb(var(--app-faint))}
|
||||||
.cal-mini-grid button.today{background:#3b82f6;color:#fff}
|
.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}
|
.cal-mini-grid button.sel{background:rgb(var(--app-active));font-weight:600}
|
||||||
/* month */
|
/* month */
|
||||||
.cal-month{display:grid;grid-template-columns:repeat(7,1fr);grid-template-rows:auto;grid-auto-rows:minmax(0,1fr);height:100%}
|
.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
|
panelMini: "month", // embed mini calendar: month | week
|
||||||
panelView: "agenda", // embed agenda: day | 3days | agenda
|
panelView: "agenda", // embed agenda: day | 3days | agenda
|
||||||
scrollToAnchor: false, // embed: bring the anchor day to the top of the agenda once
|
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: "",
|
me: "",
|
||||||
quota: "",
|
quota: "",
|
||||||
jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/)
|
jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/)
|
||||||
@@ -500,8 +513,10 @@ export default function mount(el, ctx, props) {
|
|||||||
|
|
||||||
function rangeFor() {
|
function rangeFor() {
|
||||||
if (state.embed) {
|
if (state.embed) {
|
||||||
const gs = startOfMonth(state.anchor);
|
// The anchor month's six mini-calendar weeks: covers the agenda (that
|
||||||
return [gs, addDays(gs, 42)]; // covers the panel's ~30-day agenda
|
// 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") {
|
if (state.view === "month") {
|
||||||
const gs = startOfWeek(startOfMonth(state.anchor));
|
const gs = startOfWeek(startOfMonth(state.anchor));
|
||||||
@@ -519,6 +534,7 @@ export default function mount(el, ctx, props) {
|
|||||||
const [s, e] = rangeFor();
|
const [s, e] = rangeFor();
|
||||||
const res = await api.pluginFetch(`/events?start=${encodeURIComponent(iso(s))}&end=${encodeURIComponent(iso(e))}`);
|
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.events = (res.events || []).map((ev) => ({ ...ev, _s: new Date(ev.start), _e: new Date(ev.end) }));
|
||||||
|
state.loadedRange = [s, e];
|
||||||
}
|
}
|
||||||
function visibleEvents() {
|
function visibleEvents() {
|
||||||
return state.events.filter((ev) => !state.hidden.has(ev.calendar_id));
|
return state.events.filter((ev) => !state.hidden.has(ev.calendar_id));
|
||||||
@@ -529,6 +545,50 @@ export default function mount(el, ctx, props) {
|
|||||||
render();
|
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"); }
|
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
|
// 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,
|
// 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/
|
// Compact single-column layout for Mail's right panel. Reuses miniMonth/agenda/
|
||||||
// searchPanelHtml + the form/popover/confirm overlays exactly as the full app.
|
// searchPanelHtml + the form/popover/confirm overlays exactly as the full app.
|
||||||
function renderEmbed() {
|
function renderEmbed() {
|
||||||
|
if (!miniCovered() && state.miniBusy.key !== iso(miniRange()[0])) void loadMiniBusy();
|
||||||
// Re-rendering replaces the agenda: keep where it was scrolled to (else
|
// 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).
|
// opening an event's details lower down jumped the list back to the top).
|
||||||
const prevBody = el.querySelector(".cal-embed-body");
|
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 week = state.embed && state.panelMini === "week";
|
||||||
const gs = week ? startOfWeek(state.anchor) : startOfWeek(startOfMonth(m));
|
const gs = week ? startOfWeek(state.anchor) : startOfWeek(startOfMonth(m));
|
||||||
const cellCount = week ? 7 : 42;
|
const cellCount = week ? 7 : 42;
|
||||||
|
const busySet = !state.embed
|
||||||
|
? new Set()
|
||||||
|
: busyDays(miniCovered() ? state.events : (state.miniBusy.events || []));
|
||||||
let cells = "";
|
let cells = "";
|
||||||
for (let i = 0; i < cellCount; i++) {
|
for (let i = 0; i < cellCount; i++) {
|
||||||
const d = addDays(gs, i);
|
const d = addDays(gs, i);
|
||||||
const today = sameDay(d, new Date());
|
const today = sameDay(d, new Date());
|
||||||
// Embed: the agenda has no other sign of which day was picked.
|
// Embed: the agenda has no other sign of which day was picked.
|
||||||
const sel = state.embed && !today && sameDay(d, state.anchor);
|
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>`;
|
cells += `<button class="${cls}" data-mini="${iso(d)}">${d.getDate()}</button>`;
|
||||||
}
|
}
|
||||||
return `<div class="cal-mini">
|
return `<div class="cal-mini">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
||||||
<b>${MONTHS[m.getMonth()].slice(0, 3)} ${m.getFullYear()}</b>
|
<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>
|
||||||
<div class="cal-mini-grid">${["Mo","Tu","We","Th","Fr","Sa","Su"].map((h) => `<div class="h">${h}</div>`).join("")}${cells}</div>
|
<div class="cal-mini-grid">${["Mo","Tu","We","Th","Fr","Sa","Su"].map((h) => `<div class="h">${h}</div>`).join("")}${cells}</div>
|
||||||
</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.
|
// Not while a form is open: re-rendering would throw away what is being typed.
|
||||||
const onChanged = async () => {
|
const onChanged = async () => {
|
||||||
await loadEvents();
|
await loadEvents();
|
||||||
|
if (state.embed) state.miniBusy.key = ""; // another month's dots: fetched again
|
||||||
if (!state.composing && !state.confirm) render();
|
if (!state.composing && !state.confirm) render();
|
||||||
};
|
};
|
||||||
window.addEventListener("webmail:calendar-changed", onChanged);
|
window.addEventListener("webmail:calendar-changed", onChanged);
|
||||||
|
|||||||
Reference in New Issue
Block a user