Files
webmail-client/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js
T
lukasandClaude Opus 4.8 0f686bbd5c Calendar/Contacts/Mail: pill resize handles, day hover, locale-safe date picker
- Panel resize handles: replace the tall bar with a small centered pill/capsule
  (elevated fill + border, accent on hover) across Mail, Calendar and Contacts.
- Calendar month/week/3-day views: brighten a day block on hover.
- Calendar month grid: shade the current month's days theme-aware — a subtle dark
  overlay in light mode (they were only distinguished in dark mode before), white
  overlay in dark mode.
- Calendar event form: replace the native <input type=date> (whose order follows the
  browser locale) with a custom field driven by the 24h/12h preference — an editable
  text box (typed dd/mm/yyyy for 24h) backed by a hidden ISO input, plus a popup
  month picker. Applies to the start date and the recurrence "Ends on date".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
2026-08-04 19:23:44 +02:00

1746 lines
102 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// calendar · app.page bundle (SPEC §A.6). A full-bleed calendar app: product
// switcher, left panel (new event, mini-month, calendars), main view
// (month/week/day/3-days) and a toggleable right agenda panel. Framework-agnostic
// vanilla DOM; theme-aware via rgb(var(--app-*)); events come from the plugin API.
const HOUR_PX = 48;
const DAYNAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
// ---- date helpers (browser-local rendering) --------------------------------
const startOfDay = (d) => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x; };
const addDays = (d, n) => { const x = new Date(d); x.setDate(x.getDate() + n); return x; };
const startOfWeek = (d) => addDays(startOfDay(d), -((new Date(d).getDay() + 6) % 7)); // Monday
const startOfMonth = (d) => new Date(d.getFullYear(), d.getMonth(), 1);
const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
const iso = (d) => d.toISOString();
const esc = (s) => String(s == null ? "" : s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
// 12h/24h time preference (from core user settings `time_format`; 24h is default).
let HOUR12 = false;
// Date order follows the time preference: 24h → en-GB (day/month/year), 12h → en-US.
const dateLocale = () => (HOUR12 ? "en-US" : "en-GB");
// Format a native date input's ISO value (yyyy-mm-dd) for display in the preferred
// order — the native <input type=date> text is fixed to the browser locale, so the
// form overlays this label instead.
const fmtDateInput = (iso) => {
const [y, m, d] = String(iso || "").split("-").map(Number);
return y && m && d ? new Date(y, m - 1, d).toLocaleDateString(dateLocale()) : "";
};
const pad2 = (n) => String(n).padStart(2, "0");
// Icons copied from Mail (frontend/icons/*.svg) so the two apps match exactly.
const HIDE_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m4 4l16 16m-3.5-3.244C15.147 17.485 13.618 18 12 18c-3.53 0-6.634-2.452-8.413-4.221c-.47-.467-.705-.7-.854-1.159c-.107-.327-.107-.913 0-1.24c.15-.459.385-.693.855-1.16c.897-.892 2.13-1.956 3.584-2.793M19.5 14.634c.333-.293.638-.582.912-.854l.003-.003c.468-.466.703-.7.852-1.156c.107-.327.107-.914 0-1.241c-.15-.458-.384-.692-.854-1.159C18.633 8.452 15.531 6 12 6q-.507 0-1 .064m2.323 7.436a2 2 0 0 1-2.762-2.889"/></svg>`;
const SHOW_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3.587 13.779c1.78 1.769 4.883 4.22 8.413 4.22s6.634-2.451 8.413-4.22c.47-.467.705-.7.854-1.159c.107-.327.107-.913 0-1.24c-.15-.458-.385-.692-.854-1.159C18.633 8.452 15.531 6 12 6c-3.53 0-6.634 2.452-8.413 4.221c-.47.467-.705.7-.854 1.159c-.107.327-.107.913 0 1.24c.15.458.384.692.854 1.159"/><path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0-4 0"/></svg>`;
const SETTINGS_ICON = `<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 7.5h-7.628a2.251 2.251 0 0 0-4.244 0H5V9h2.128a2.25 2.25 0 0 0 4.244 0H19zm0 7.5h-2.128a2.251 2.251 0 0 0-4.244 0H5v1.5h7.628a2.251 2.251 0 0 0 4.244 0H19z"/></svg>`;
const LOGOUT_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m16 17l5-5l-5-5m5 5H9m0 9H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/></svg>`;
const COMPOSE_ICON = `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="m14.515.456l.965.965a1.555 1.555 0 0 1 0 2.2L9.745 9.355a1.55 1.55 0 0 1-.672.396l-2.89.826a.67.67 0 0 1-.828-.474a.66.66 0 0 1 .004-.35l.825-2.89c.073-.254.209-.486.396-.673L12.315.456c.144-.145.316-.259.505-.337a1.54 1.54 0 0 1 1.19 0c.189.078.361.192.505.337m-3.322 3.008l-3.67 3.669a.2.2 0 0 0-.057.096L6.97 8.965l1.736-.496a.2.2 0 0 0 .096-.056l3.67-3.67Zm2.065-2.066L12.135 2.52l1.28 1.28l1.122-1.122a.22.22 0 0 0 .065-.157a.22.22 0 0 0-.065-.157l-.965-.966a.22.22 0 0 0-.157-.065a.23.23 0 0 0-.157.065"/><path d="M0 14.25V2.75A1.75 1.75 0 0 1 1.75 1H7a.75.75 0 0 1 0 1.5H1.75a.25.25 0 0 0-.25.25v11.5a.25.25 0 0 0 .25.25h11.5a.25.25 0 0 0 .25-.25V8.5a.75.75 0 0 1 1.5 0v5.75c0 .464-.184.909-.513 1.237A1.75 1.75 0 0 1 13.25 16H1.75A1.75 1.75 0 0 1 0 14.25"/></svg>`;
const themeGlyph = () => (document.documentElement.classList.contains("dark") ? "☀" : "☾");
const hhmm = (d) => d.toLocaleTimeString([], { hour: HOUR12 ? "numeric" : "2-digit", minute: "2-digit", hour12: HOUR12 });
// A clock label for "HH"/"MM" honouring the 12h/24h preference (form time dropdown).
const fmtHM = (h, m) => (HOUR12 ? `${h % 12 || 12}:${pad2(m)} ${h < 12 ? "AM" : "PM"}` : `${pad2(h)}:${pad2(m)}`);
// <option>s at 15-min steps (native <input type=time> can't be forced to 24h).
function timeOptionsHtml(selected) {
const vals = [];
for (let h = 0; h < 24; h++) for (let m = 0; m < 60; m += 15) vals.push(`${pad2(h)}:${pad2(m)}`);
if (selected && !vals.includes(selected)) vals.push(selected); // keep off-grid seed value
vals.sort();
return vals.map((v) => {
const [h, m] = v.split(":").map(Number);
return `<option value="${v}"${v === selected ? " selected" : ""}>${fmtHM(h, m)}</option>`;
}).join("");
}
// A random Jitsi Meet room slug for generated video-call links.
const jitsiRoom = () => (Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 8));
const jitsiLink = (server) => `${(server || "https://meet.jit.si/").replace(/\/?$/, "/")}${jitsiRoom()}`;
// Assign overlapping timed events to side-by-side columns (like Google Calendar):
// split into clusters of mutually-overlapping events, greedily pack each into the
// first free column, and return a Map ev -> { col, total } for width/left maths.
function layoutDay(evs) {
const layout = new Map();
const sorted = [...evs].sort((a, b) => a._s - b._s || a._e - b._e);
let cluster = [];
let clusterEnd = -Infinity;
const flush = () => {
if (!cluster.length) return;
const colEnds = [];
cluster.forEach((ev) => {
let ci = colEnds.findIndex((end) => end <= ev._s.getTime());
if (ci === -1) { ci = colEnds.length; colEnds.push(0); }
colEnds[ci] = ev._e.getTime();
layout.set(ev, { col: ci });
});
cluster.forEach((ev) => { layout.get(ev).total = colEnds.length; });
cluster = [];
clusterEnd = -Infinity;
};
sorted.forEach((ev) => {
if (cluster.length && ev._s.getTime() >= clusterEnd) flush();
cluster.push(ev);
clusterEnd = Math.max(clusterEnd, ev._e.getTime());
});
flush();
return layout;
}
// Parse an RRULE string back into the repeat-form fields (FREQ/INTERVAL/COUNT/UNTIL).
function parseRrule(rrule) {
const out = { interval: 1 };
String(rrule).split(";").forEach((part) => {
const [k, v] = part.split("=");
if (k === "FREQ") out.freq = v;
else if (k === "INTERVAL") out.interval = Number(v) || 1;
else if (k === "COUNT") out.count = Number(v);
else if (k === "UNTIL" && v && v.length >= 8) out.until = `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}`;
});
return out;
}
// RSVP status → a small badge on the guest's initials circle.
const normStat = (p) => {
const v = String(p || "needs-action").toLowerCase();
return ["accepted", "declined", "tentative"].includes(v) ? v : "needs-action";
};
function statusBadge(status) {
const check = `<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>`;
const cross = `<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>`;
const clock = `<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>`;
const glyph = status === "accepted" ? check : status === "declined" ? cross : status === "tentative" ? "?" : clock;
const title = status === "accepted" ? "Going" : status === "declined" ? "Declined" : status === "tentative" ? "Maybe" : "Awaiting response";
return `<span class="cal-badge ${status}" title="${title}">${glyph}</span>`;
}
// Guest list for the event popover: organizer first (badged + always confirmed),
// then attendees with their RSVP status, de-duplicating the organizer.
function guestListHtml(ev) {
const org = ev.organizer && ev.organizer.email ? ev.organizer : null;
const rows = [];
if (org) rows.push({ email: org.email, name: org.name || "", role: "Organizer", status: "accepted" });
(ev.attendees || []).forEach((a) => {
if (!a || !a.email) return;
if (org && a.email.toLowerCase() === org.email.toLowerCase()) return;
rows.push({ email: a.email, name: a.name || "", role: "", status: normStat(a.partstat) });
});
if (!rows.length) return "";
const count = rows.length === 1 ? "1 guest" : `${rows.length} guests`;
return `<hr/><div class="cal-guestlist"><div class="cap">${count}</div>${rows.map((g) => {
const display = g.name || g.email;
return `<div class="g"><span class="av">${esc((display[0] || "?").toUpperCase())}${statusBadge(g.status)}</span>
<span class="gm"><span class="gn">${esc(display)}${g.role ? ` <span class="gr">${g.role}</span>` : ""}</span>${g.name ? `<span class="ge">${esc(g.email)}</span>` : ""}</span></div>`;
}).join("")}</div>`;
}
const videoLinkHtml = (link) => `<div class="cal-videolink">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>
<a href="${esc(link)}" target="_blank" rel="noopener noreferrer">${esc(link)}</a>
<button class="cal-iconbtn" type="button" data-act="copy-jitsi" title="Copy link"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
</div>`;
const COLORS = [
["Blue", "#3b82f6"], ["Green", "#22c55e"], ["Red", "#ef4444"], ["Orange", "#f97316"],
["Purple", "#a855f7"], ["Teal", "#14b8a6"], ["Pink", "#ec4899"], ["Gray", "#6b7280"],
];
function tzList() {
try {
return Intl.supportedValuesOf("timeZone");
} catch {
return ["UTC", "Europe/London", "Europe/Warsaw", "Europe/Berlin", "America/New_York",
"America/Chicago", "America/Los_Angeles", "Asia/Tokyo", "Asia/Shanghai", "Australia/Sydney"];
}
}
// The zone's short code/abbreviation (e.g. "CEST", "GMT+2") for the timegrid label.
function tzAbbr(tz) {
if (!tz || tz === "floating") return "";
try {
const parts = new Intl.DateTimeFormat("en", { timeZone: tz, timeZoneName: "short" }).formatToParts(new Date());
const p = parts.find((x) => x.type === "timeZoneName");
return p ? p.value : tz;
} catch {
return tz;
}
}
function tzOffset(tz) {
try {
const parts = new Intl.DateTimeFormat("en", { timeZone: tz, timeZoneName: "shortOffset" }).formatToParts(new Date());
const p = parts.find((x) => x.type === "timeZoneName");
return p ? p.value : "GMT";
} catch {
return "GMT";
}
}
// Long descriptive label + offset, e.g. "Central European Summer Time (GMT+2)".
function tzLongLabel(tz) {
if (!tz || tz === "floating") return "Floating time";
let name = tz;
try {
const parts = new Intl.DateTimeFormat("en", { timeZone: tz, timeZoneName: "long" }).formatToParts(new Date());
const p = parts.find((x) => x.type === "timeZoneName");
if (p) name = p.value;
} catch { /* keep tz */ }
return `${name} (${tzOffset(tz)})`;
}
const tzCity = (tz) => tz.split("/").pop().replace(/_/g, " ");
// Full searchable list: current (checked) + Floating + all IANA cities (sorted by
// offset then name) + fixed GMT offsets + UTC.
function tzEntries(current) {
const zones = tzList();
const withOffset = zones.map((z) => ({ tz: z, city: tzCity(z), abbr: tzAbbr(z), off: tzOffset(z) }));
const offNum = (o) => { const m = /GMT([+-]\d+)(?::(\d+))?/.exec(o); return m ? Number(m[1]) * 60 + (m[1][0] === "-" ? -1 : 1) * Number(m[2] || 0) : 0; };
withOffset.sort((a, b) => offNum(a.off) - offNum(b.off) || a.city.localeCompare(b.city));
const gmt = [];
for (let n = -12; n <= 14; n++) if (n !== 0) gmt.push({ tz: `Etc/GMT${n > 0 ? "-" : "+"}${Math.abs(n)}`, city: `GMT${n > 0 ? "+" : "-"}${Math.abs(n)}`, abbr: `GMT${n > 0 ? "+" : "-"}${Math.abs(n)}`, off: "" });
const out = [{ tz: current, city: tzCity(current), abbr: tzAbbr(current), off: "", cur: true }, { tz: "floating", city: "Floating time", abbr: "", off: "" }];
return out.concat(withOffset, gmt, [{ tz: "UTC", city: "UTC", abbr: "UTC", off: "" }]);
}
const CSS = `
.cal-app{display:flex;height:100vh;position:relative;background:rgb(var(--app-bg));color:rgb(var(--app-fg));font-family:"Lato",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:14px}
.cal-maincol{flex:1;min-width:0;display:flex;flex-direction:column}
.cal-mainarea{flex:1;min-height:0;display:flex}
.cal-topbar{display:flex;align-items:center;gap:10px;height:48px;padding:0 12px;flex:0 0 auto}
/* Left panel styling lives in the shared sidebar shell (.wm-side-*, plugin-ui.css),
which @applies Mail's exact Tailwind utilities + pins the 16px/Lato base context. */
.cal-secrow{display:flex;align-items:center;justify-content:space-between}
.cal-secadd{opacity:0;border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;font-size:15px;line-height:1;padding:2px 4px;border-radius:4px}
.cal-secrow:hover .cal-secadd{opacity:1}
.cal-secadd:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
/* collapsed icon-only rail (matches Mail's 60px rail) */
.cal-rail{width:60px;align-items:center}
.cal-railtop{display:flex;flex-direction:column;align-items:center;gap:4px;padding:12px 0 6px}
.cal-railprimary{padding:2px 0 8px}
.cal-railnav{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;align-items:center;gap:4px;padding:2px 0}
.cal-railfoot{width:100%;display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 0;border-top:1px solid rgb(var(--app-border))}
.cal-railbtn{display:flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:9px;color:rgb(var(--app-muted));cursor:pointer;background:none;border:0}
.cal-railbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
.cal-railnew{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:11px;background:rgb(var(--app-accent));color:rgb(var(--app-accent-fg));cursor:pointer;border:0}
.cal-railnew:hover{opacity:.9}
.cal-resizer{flex:0 0 auto;width:5px;margin:0 -2px;cursor:col-resize;position:relative;z-index:10}
.cal-resizer::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:transparent}
.cal-resizer:hover::after{background:#3b82f6}
.cal-resizer::before{content:"";position:absolute;top:50%;left:50%;width:5px;height:28px;transform:translate(-50%,-50%);border-radius:999px;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));box-sizing:border-box;z-index:1}
.cal-resizer:hover::before{background:#3b82f6;border-color:#3b82f6}
.cal-main{flex:1;min-width:0;display:flex;flex-direction:column}
.cal-right{width:320px;flex:0 0 auto;border-left:1px solid rgb(var(--app-border));overflow:auto}
/* search results panel */
.cal-srch{font-size:13px;padding-bottom:24px}
.cal-srch-hd{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 14px 6px;color:rgb(var(--app-muted))}
.cal-srch-more{padding:4px 14px 14px;border-bottom:1px solid rgb(var(--app-border))}
.cal-srch-more .cal-btn{background:rgb(var(--app-surface))}
.cal-srch-month{padding:16px 14px 8px;font-size:19px;font-weight:600}
.cal-srch-month span{color:rgb(var(--app-muted));font-weight:400}
.cal-srch-day{display:flex;gap:12px;padding:8px 14px}
.cal-srch-dt{width:40px;flex:0 0 auto;text-align:left;position:relative}
.cal-srch-dt .dow{display:block;font-size:11px;color:rgb(var(--app-faint));letter-spacing:.03em}
.cal-srch-dt .dnum{display:block;font-size:18px;font-weight:500}
.cal-srch-dt.today .dnum,.cal-srch-dt.today .dow{color:#3b82f6}
.cal-srch-evs{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px;justify-content:center}
.cal-srch-ev{background:rgb(var(--app-hover));border-left:3px solid #3b82f6;border-radius:5px;padding:6px 9px;cursor:pointer}
.cal-srch-ev:hover{background:rgb(var(--app-active))}
.cal-srch-ev .t{display:flex;justify-content:space-between;gap:8px}
.cal-srch-ev .t b{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cal-srch-ev .t span{color:rgb(var(--app-muted));flex:0 0 auto}
.cal-srch-ev .loc{color:rgb(var(--app-muted));overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cal-srch-none{color:rgb(var(--app-faint));padding:6px 0}
.cal-srch-today{color:#3b82f6;font-size:11px;font-weight:600;letter-spacing:.04em;text-align:right;padding:0 14px}
.cal-mainhead{display:flex;align-items:center;gap:14px;padding:10px 16px;flex:0 0 auto}
/* embed (Mail right panel) compact layout */
.cal-embed{flex-direction:column;height:100%}
.cal-panelbar{display:flex;align-items:center;gap:2px;padding:6px 10px 2px;flex:0 0 auto}
.cal-panelsearch{padding:4px 10px}
.cal-embed-body{flex:1;min-height:0;overflow:auto}
.cal-embed-mini{padding:6px 14px 0}
.cal-agenda-today{background:none;border:0;color:rgb(var(--app-accent));font-weight:600;font-size:13px;cursor:pointer}
.cal-panelfoot{flex:0 0 auto;border-top:1px solid rgb(var(--app-border));padding:10px;text-align:center}
.cal-panelfoot button{display:inline-flex;align-items:center;gap:6px;background:none;border:0;color:rgb(var(--app-muted));cursor:pointer;font:inherit;font-size:13px}
.cal-panelfoot button:hover{color:rgb(var(--app-fg))}
.cal-panelcal{position:relative}
.cal-panelmenu{position:relative}
.cal-panelcalbtn{display:flex;align-items:center;gap:6px;background:none;border:0;outline:none;color:rgb(var(--app-fg));cursor:pointer;font:inherit;font-size:13px;padding:6px 4px}
.cal-panelcalbtn:hover{color:rgb(var(--app-muted))}
.cal-ddsec{padding:5px 10px 2px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:rgb(var(--app-faint))}
.cal-ddsep{border-top:1px solid rgb(var(--app-border));margin:4px 0}
.cal-embed .cal-searchbox{margin:0}
.cal-embed .cal-form{right:8px;width:auto;top:52px;max-height:calc(100% - 64px)}
.cal-embed .cal-pop{position:fixed}
.cal-view{flex:1;min-height:0;overflow:auto;position:relative}
.cal-btn{border:0;border-radius:6px;padding:6px 10px;cursor:pointer;font:inherit;background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
.cal-btn-primary{background:#3b82f6;color:#fff;flex:1}
.cal-iconbtn{border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;padding:6px;border-radius:4px;display:inline-flex;outline:none}
.cal-iconbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
.cal-searchbox{flex:1;max-width:520px;margin:0 auto;display:flex;align-items:center;gap:8px;padding:0 10px;border-radius:8px;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface))}
.cal-searchbox .ic{color:rgb(var(--app-faint));flex:0 0 auto}
.cal-search{flex:1;min-width:0;padding:7px 0;border:0;background:none;color:rgb(var(--app-fg));outline:none;font:inherit}
.cal-seg{display:inline-flex;background:rgb(var(--app-surface));border-radius:9px;padding:3px;gap:2px}
.cal-seg button{border:0;background:none;color:rgb(var(--app-muted));padding:5px 26px;border-radius:7px;cursor:pointer;font:inherit;font-size:13px}
.cal-seg button.on{background:rgb(var(--app-elevated));color:rgb(var(--app-fg));box-shadow:0 1px 3px rgba(0,0,0,.35)}
.cal-title{font-size:18px;font-weight:600}
.cal-title span{color:rgb(var(--app-muted));font-weight:400}
.cal-mini{padding:6px 4px 12px}
.cal-mini-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;font-size:12px;text-align:center}
.cal-mini-grid .h{color:rgb(var(--app-faint));padding:3px 0}
.cal-mini-grid button{width:26px;height:26px;margin:1px auto;display:flex;align-items:center;justify-content:center;border:0;background:none;color:rgb(var(--app-fg));border-radius:50%;cursor:pointer;font:inherit;font-size:14px}
.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}
/* 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 .dow{border-bottom:1px solid rgb(var(--app-border));border-right:1px solid rgb(var(--app-border));padding:4px 8px;font-size:13px;font-weight:600;text-transform:uppercase;color:rgb(var(--app-faint))}
.cal-cell{border-bottom:1px solid rgb(var(--app-border));border-right:1px solid rgb(var(--app-border));padding:4px;min-height:0;overflow:hidden;cursor:pointer}
.cal-cell .n{display:flex;align-items:center;gap:4px;font-size:15px;color:rgb(var(--app-fg));font-weight:500}
.cal-cell .num{display:inline-flex;align-items:center;justify-content:center;min-width:25px;height:25px;border-radius:50%;box-sizing:border-box}
.cal-cell .mtag{color:rgb(var(--app-muted));font-weight:400;font-size:14px}
.cal-cell.out .n{color:rgb(var(--app-faint))}
.cal-cell.out .mtag{color:rgb(var(--app-faint))}
.cal-cell:not(.out){background:rgba(0,0,0,.02)}
.dark .cal-cell:not(.out){background:rgba(255,255,255,.03)}
.cal-cell.today{background:rgba(59,130,246,.06)}
.cal-cell:hover{background:rgba(0,0,0,.055)}
.dark .cal-cell:hover{background:rgba(255,255,255,.06)}
.cal-cell.today:hover{background:rgba(59,130,246,.11)}
.cal-cell.today .num{background:#3b82f6;color:#fff}
.cal-ev{font-size:14px;padding:1px 4px;border-radius:4px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;border-left:3px solid #3b82f6;background:rgb(var(--app-hover))}
.cal-ev.srch-hl,.cal-tev.srch-hl{outline:2px solid #3b82f6;outline-offset:-1px;background:rgba(59,130,246,.28)}
/* timegrid */
.cal-tg{display:grid;grid-template-columns:56px 1fr}
.cal-tg-head{position:sticky;top:0;background:rgb(var(--app-bg));z-index:5;display:grid;grid-template-columns:56px 1fr;border-bottom:1px solid rgb(var(--app-border))}
.cal-tg-days{display:grid}
.cal-tg-days .d{padding:6px 8px;border-left:1px solid rgb(var(--app-border));font-size:14px;color:rgb(var(--app-muted))}
.cal-tg-days .d b{font-size:18px;color:rgb(var(--app-fg));display:block;font-weight:600}
.cal-tg-days .d.today{color:#3b82f6;border-bottom:2px solid #3b82f6;margin-bottom:-1px}
.cal-tg-days .d.today .num{background:#3b82f6;color:#fff;border-radius:50%;width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;font-size:16px}
.cal-tz{font-size:11px;color:rgb(var(--app-faint));display:flex;align-items:flex-end;justify-content:center;padding-bottom:4px;text-transform:uppercase}
.cal-hours{color:rgb(var(--app-faint));font-size:11px}
.cal-hours div{height:${HOUR_PX}px;text-align:right;padding-right:6px;transform:translateY(-6px)}
.cal-cols{display:grid;position:relative}
.cal-col{border-left:1px solid rgb(var(--app-border));position:relative}
.cal-col.today{background:rgba(59,130,246,.06)}
.cal-col:hover{background:rgba(0,0,0,.045)}
.dark .cal-col:hover{background:rgba(255,255,255,.05)}
.cal-col.today:hover{background:rgba(59,130,246,.11)}
.cal-col .line{height:${HOUR_PX}px;border-top:1px solid rgb(var(--app-border))}
.cal-tev{position:absolute;left:2px;width:calc(100% - 4px);border-radius:5px;border:1px solid rgb(var(--app-elevated));border-left:3px solid #3b82f6;background:rgb(var(--app-hover));padding:2px 5px;font-size:14px;overflow:hidden;cursor:pointer;box-sizing:border-box}
.cal-now{position:absolute;left:0;right:0;height:0;border-top:2px solid #ef4444;z-index:4}
/* right agenda */
.cal-agenda .day{display:flex;gap:14px;padding:10px 14px;border-bottom:1px solid rgb(var(--app-border))}
.cal-agenda .day .dt{width:44px;flex:0 0 auto;font-size:12px;color:rgb(var(--app-muted))}
.cal-agenda .day .dt b{display:block;font-size:18px;color:rgb(var(--app-fg))}
.cal-agenda .none{color:rgb(var(--app-faint));font-size:13px}
/* new-event form (floating box) */
/* Content-sized box: grows in height as sections are added, scrolls only past the
viewport cap; the Save/Cancel footer sticks to the bottom while scrolling. */
.cal-form{position:absolute;left:10px;top:58px;width:364px;max-height:calc(100vh - 74px);background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:12px;box-shadow:0 14px 44px rgba(0,0,0,.5);z-index:40;overflow:auto}
.cal-form-body{padding:16px}
.cal-form-foot{position:sticky;bottom:0;background:rgb(var(--app-elevated));border-top:1px solid rgb(var(--app-border));padding:12px 16px;display:flex;gap:8px}
.cal-acpop{position:absolute;left:0;right:0;top:100%;margin-top:4px;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:8px;padding:4px;z-index:55;max-height:220px;overflow:auto;box-shadow:0 8px 24px rgba(0,0,0,.4)}
.cal-acpop button{display:flex;flex-direction:column;align-items:flex-start;gap:1px;width:100%;border:0;background:none;color:rgb(var(--app-fg));padding:6px 8px;border-radius:6px;cursor:pointer;font:inherit;text-align:left}
.cal-acpop button:hover{background:rgb(var(--app-hover))}
.cal-acpop button span{color:rgb(var(--app-muted));font-size:12px}
.cal-guestlist .cap{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:rgb(var(--app-faint));margin-bottom:8px}
.cal-guestlist{max-height:180px;overflow:auto}
.cal-guestlist .g{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.cal-guestlist .av{position:relative;width:26px;height:26px;border-radius:50%;background:rgb(var(--app-hover));display:inline-flex;align-items:center;justify-content:center;font-size:12px;flex:0 0 auto}
.cal-badge{position:absolute;right:-3px;bottom:-3px;width:14px;height:14px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;border:2px solid rgb(var(--app-elevated));color:#fff;font-size:9px;font-weight:700;box-sizing:content-box}
.cal-badge svg{width:9px;height:9px;display:block}
.cal-badge.accepted{background:#22c55e}
.cal-badge.declined{background:#ef4444}
.cal-badge.tentative{background:#6b7280}
.cal-badge.needs-action{background:#9ca3af}
.cal-guestlist .gm{display:flex;flex-direction:column;min-width:0}
.cal-guestlist .gn{font-size:13px}
.cal-guestlist .gr{color:rgb(var(--app-faint));font-size:11px}
.cal-guestlist .ge{font-size:11px;color:rgb(var(--app-faint));overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cal-videolink{display:flex;align-items:center;gap:8px;font-size:13px;word-break:break-all}
.cal-videolink a{color:#3b82f6;text-decoration:none}
.cal-videolink a:hover{text-decoration:underline}
.cal-videolink .cal-iconbtn{flex:0 0 auto}
.cal-iconbtn.copied{color:#22c55e}
.cal-form h4{margin:14px 0 5px;font-size:13px;font-weight:600}
.cal-swatches{display:flex;gap:7px;flex-wrap:wrap;align-items:center;height:34px}
.cal-sw{width:20px;height:20px;border-radius:50%;border:2px solid transparent;cursor:pointer;padding:0}
.cal-sw.sel{border-color:rgb(var(--app-fg))}
.cal-form input[type=text],.cal-form input[type=email],.cal-form textarea,.cal-form select,.cal-form input[type=date],.cal-form input[type=time],.cal-form input[type=number]{width:100%;padding:7px 9px;border:1px solid rgb(var(--app-border));border-radius:7px;background:rgb(var(--app-elevated));color:rgb(var(--app-fg));font:inherit;box-sizing:border-box}
.cal-form .row{display:flex;gap:8px;align-items:center}
/* Custom date field: a formatted read-only text box + popup month picker. Native
<input type=date> renders its text in the browser locale's order, which we can't
control — this drives the display order entirely from the 24h/12h preference. */
.cal-datewrap{position:relative;display:block;width:100%;min-width:0}
.cal-datewrap input[data-datedisplay]{cursor:text}
.cal-datepop{position:absolute;left:0;top:100%;margin-top:4px;z-index:60;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:10px;box-shadow:0 12px 32px rgba(0,0,0,.4);padding:8px;width:236px}
.cal-datepop-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
.cal-datepop-head span{font-size:13px;font-weight:600;color:rgb(var(--app-fg))}
.cal-datepop-head button{border:0;background:none;color:rgb(var(--app-fg));cursor:pointer;width:26px;height:26px;border-radius:6px;font-size:17px;line-height:1}
.cal-datepop-head button:hover{background:rgb(var(--app-hover))}
.cal-datepop-dow,.cal-datepop-grid{display:grid;grid-template-columns:repeat(7,1fr)}
.cal-datepop-dow span{text-align:center;font-size:11px;color:rgb(var(--app-faint));padding:2px 0}
.cal-datepop-grid button{border:0;background:none;color:rgb(var(--app-fg));cursor:pointer;height:28px;border-radius:50%;font:inherit;font-size:13px}
.cal-datepop-grid button:hover{background:rgb(var(--app-hover))}
.cal-datepop-grid button.today{outline:1px solid rgb(var(--app-border))}
.cal-datepop-grid button.sel{background:#3b82f6;color:#fff}
.cal-form .muted{color:rgb(var(--app-muted));font-size:13px}
.cal-formbtns{display:flex;gap:8px;margin-top:16px}
.cal-guest{display:inline-flex;align-items:center;gap:4px;background:rgb(var(--app-hover));border-radius:10px;padding:2px 8px;font-size:12px;margin:2px 4px 2px 0}
.cal-guest button{border:0;background:none;color:inherit;cursor:pointer}
.cal-tog{width:34px;height:20px;border-radius:10px;background:rgb(var(--app-hover));border:0;cursor:pointer;position:relative;flex:0 0 auto}
.cal-tog.on{background:#3b82f6}
.cal-tog::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;transition:left .1s}
.cal-tog.on::after{left:16px}
.cal-dd{position:relative}
.cal-dd>button{display:flex;align-items:center;gap:6px;padding:7px 9px;border:1px solid rgb(var(--app-border));border-radius:7px;background:rgb(var(--app-elevated));color:rgb(var(--app-fg));cursor:pointer;font:inherit;justify-content:space-between}
.cal-dot{width:14px;height:14px;border-radius:50%;display:inline-block;flex:0 0 auto}
.cal-ddpop{position:absolute;left:0;top:100%;margin-top:4px;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:8px;padding:4px;z-index:50;min-width:150px;max-height:280px;overflow:auto;box-shadow:0 8px 24px rgba(0,0,0,.4)}
.cal-ddpop button{display:flex;align-items:center;gap:8px;width:100%;border:0;background:none;color:rgb(var(--app-fg));padding:6px 8px;border-radius:6px;cursor:pointer;font:inherit;text-align:left}
.cal-ddpop button:hover{background:rgb(var(--app-hover))}
.cal-tzfield{position:relative}
.cal-tzfield>button{display:flex;align-items:center;justify-content:space-between;gap:6px;width:100%;padding:7px 9px;border:1px solid rgb(var(--app-border));border-radius:7px;background:rgb(var(--app-elevated));color:rgb(var(--app-fg));cursor:pointer;font:inherit;box-sizing:border-box}
.cal-tzfield .off{color:rgb(var(--app-faint))}
.cal-tzpop{position:absolute;left:0;right:0;top:100%;margin-top:4px;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:8px;z-index:50;box-shadow:0 10px 30px rgba(0,0,0,.5);overflow:hidden}
.cal-tzpop .search{margin:8px;width:calc(100% - 16px);box-sizing:border-box}
.cal-tzlist{max-height:280px;overflow:auto;padding-bottom:4px}
.cal-tzlist button{display:flex;justify-content:space-between;align-items:center;gap:10px;width:100%;border:0;background:none;color:rgb(var(--app-fg));padding:7px 12px;cursor:pointer;font:inherit;text-align:left}
.cal-tzlist button:hover{background:rgb(var(--app-hover))}
.cal-tzlist .abbr{color:rgb(var(--app-faint));font-size:12px;flex:0 0 auto}
.cal-pop{position:absolute;width:290px;max-height:calc(100vh - 16px);display:flex;flex-direction:column;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.55);z-index:60;overflow:hidden}
.cal-pop .body{padding:14px 16px;overflow:auto}
.cal-pop .cap{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:rgb(var(--app-faint))}
.cal-pop .ttl{font-size:17px;font-weight:600;margin:2px 0}
.cal-pop .loc{color:rgb(var(--app-muted))}
.cal-pop hr{border:0;border-top:1px solid rgb(var(--app-border));margin:12px 0}
.cal-pop .foot{flex:0 0 auto;border-top:1px solid rgb(var(--app-border));padding:10px 16px;display:flex;justify-content:space-between}
.cal-modal-ov{position:absolute;inset:0;z-index:70;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center}
.cal-modal{width:380px;max-width:calc(100vw - 32px);background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.6);overflow:hidden}
.cal-modal .body{padding:20px 22px}
.cal-modal h3{margin:0 0 8px;font-size:17px;font-weight:600}
.cal-modal p{margin:0;color:rgb(var(--app-muted));font-size:14px}
.cal-modal .opts{margin-top:14px;display:flex;flex-direction:column;gap:8px}
.cal-modal .opts button{display:block;width:100%;text-align:left;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface));color:rgb(var(--app-fg));padding:10px 12px;border-radius:8px;cursor:pointer;font:inherit}
.cal-modal .opts button:hover{background:rgb(var(--app-hover));border-color:#3b82f6}
.cal-modal .foot{border-top:1px solid rgb(var(--app-border));padding:12px 16px;display:flex;justify-content:flex-end;gap:8px}
`;
export default function mount(el, ctx, props) {
// Always (re)write the stylesheet so a newer bundle version's CSS wins — the
// singleton element would otherwise keep a stale copy from an earlier mount,
// leaving new classes (e.g. .cal-panelcalbtn) unstyled = default button frame.
let calStyle = document.getElementById("cal-styles");
if (!calStyle) {
calStyle = document.createElement("style");
calStyle.id = "cal-styles";
document.head.appendChild(calStyle);
}
calStyle.textContent = CSS;
// Embed mode: the SAME app runs compactly inside Mail's right panel — reusing the
// agenda, mini-month, event popover, event form and search results verbatim, so
// any change here propagates to both places.
const embed = !!(props && props.embed === "panel");
const newEventGuests = (props && props.newEventGuests) || null;
const state = {
embed,
view: "month",
anchor: startOfDay(new Date()),
mini: startOfMonth(new Date()),
calendars: [],
hidden: new Set(),
events: [],
rightOpen: false,
menuOpen: false,
leftW: Number(localStorage.getItem("cal.leftW")) || 240,
rightW: Number(localStorage.getItem("cal.rightW")) || 320,
leftCollapsed: localStorage.getItem("cal.leftCollapsed") === "1",
composing: null, // null | { date:"YYYY-MM-DD", time:"HH:MM", editId }
eventPopover: null, // null | { ev, rect }
confirm: null, // null | { ev } — delete confirmation modal
search: null, // null | { q, from:Date, to:Date, results:[], loading:bool }
searchOpen: false, // embed: search box revealed
panelCalOpen: false, // embed: "All calendars" dropdown open
panelMenuOpen: false, // embed: "..." view-options menu open
panelMini: "month", // embed mini calendar: month | week
panelView: "agenda", // embed agenda: day | 3days | agenda
me: "",
quota: "",
jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/)
defaultCalendar: "", // preferred calendar for new events (from settings)
defaultRemTimed: [], // default reminder minutes for timed events
defaultRemAllday: [], // default reminder minutes for all-day events
apps: [{ label: "Mail", to: "/mail" }, { label: "Calendar", to: null }],
};
const api = ctx.api;
function rangeFor() {
if (state.embed) {
const gs = startOfMonth(state.anchor);
return [gs, addDays(gs, 42)]; // covers the panel's ~30-day agenda
}
if (state.view === "month") {
const gs = startOfWeek(startOfMonth(state.anchor));
return [gs, addDays(gs, 42)];
}
const span = state.view === "day" ? 1 : state.view === "3days" ? 3 : 7;
const s = state.view === "week" ? startOfWeek(state.anchor) : startOfDay(state.anchor);
return [s, addDays(s, span)];
}
async function loadCalendars() {
state.calendars = await api.pluginFetch("/calendars");
}
async function loadEvents() {
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) }));
}
function visibleEvents() {
return state.events.filter((ev) => !state.hidden.has(ev.calendar_id));
}
async function reload() {
await loadEvents();
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,
// plus a popup month picker.
function initDatePickers() {
el.querySelectorAll("[data-datepick]").forEach(setupDatePicker);
}
// Parse a hand-typed date honouring the preferred order (24h → d/m/y, 12h → m/d/y).
function parseTypedDate(str) {
const parts = String(str).split(/[^0-9]+/).filter(Boolean).map(Number);
if (parts.length < 3) return null;
let d, m, y;
if (HOUR12) [m, d, y] = parts; else [d, m, y] = parts;
if (y < 100) y += 2000;
if (!(y && m >= 1 && m <= 12 && d >= 1 && d <= 31)) return null;
const dt = new Date(y, m - 1, d);
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null;
return `${y}-${pad2(m)}-${pad2(d)}`;
}
function setupDatePicker(wrap) {
if (wrap.dataset.wired) return; // idempotent across re-renders
wrap.dataset.wired = "1";
const hidden = wrap.querySelector("input[type=hidden]");
const display = wrap.querySelector("[data-datedisplay]");
display.setAttribute("placeholder", HOUR12 ? "mm/dd/yyyy" : "dd/mm/yyyy");
const parse = (iso) => {
const [y, m, d] = String(iso || "").split("-").map(Number);
return y && m && d ? { y, m, d } : null;
};
const refresh = () => { display.value = fmtDateInput(hidden.value); };
// Accept the typed text into the hidden ISO value, then normalise the display.
const commit = () => {
const isoTyped = parseTypedDate(display.value);
if (isoTyped) hidden.value = isoTyped;
refresh();
};
let pop = null;
let viewY = 0;
let viewM = 0; // 1-12
const onDoc = (e) => { if (pop && !wrap.contains(e.target)) { commit(); close(); } };
function close() {
if (pop) { pop.remove(); pop = null; }
document.removeEventListener("mousedown", onDoc);
}
function open() {
if (pop) return;
const cur = parseTypedDate(display.value) ? parse(parseTypedDate(display.value))
: parse(hidden.value) || (() => {
const n = new Date();
return { y: n.getFullYear(), m: n.getMonth() + 1, d: n.getDate() };
})();
viewY = cur.y; viewM = cur.m;
pop = document.createElement("div");
pop.className = "cal-datepop";
// Keep focus in the text box so clicking the picker never blurs mid-type.
pop.addEventListener("mousedown", (e) => e.preventDefault());
wrap.appendChild(pop);
renderPop();
setTimeout(() => document.addEventListener("mousedown", onDoc), 0);
}
function renderPop() {
const sel = parse(hidden.value);
const today = new Date();
const startDow = (new Date(viewY, viewM - 1, 1).getDay() + 6) % 7; // Monday-first
const daysIn = new Date(viewY, viewM, 0).getDate();
let cells = "";
for (let i = 0; i < startDow; i++) cells += "<span></span>";
for (let d = 1; d <= daysIn; d++) {
const isSel = sel && sel.y === viewY && sel.m === viewM && sel.d === d;
const isToday = today.getFullYear() === viewY && today.getMonth() + 1 === viewM && today.getDate() === d;
cells += `<button type="button" data-day="${d}" class="${isSel ? "sel" : ""}${isToday ? " today" : ""}">${d}</button>`;
}
pop.innerHTML = `
<div class="cal-datepop-head">
<button type="button" data-nav="-1">‹</button>
<span>${MONTHS[viewM - 1]} ${viewY}</span>
<button type="button" data-nav="1">›</button>
</div>
<div class="cal-datepop-dow">${DAYNAMES.map((d) => `<span>${d[0]}</span>`).join("")}</div>
<div class="cal-datepop-grid">${cells}</div>`;
pop.querySelectorAll("[data-nav]").forEach((b) => b.addEventListener("click", () => {
viewM += Number(b.getAttribute("data-nav"));
if (viewM < 1) { viewM = 12; viewY--; }
if (viewM > 12) { viewM = 1; viewY++; }
renderPop();
}));
pop.querySelectorAll("[data-day]").forEach((b) => b.addEventListener("click", () => {
hidden.value = `${viewY}-${pad2(viewM)}-${pad2(Number(b.getAttribute("data-day")))}`;
refresh();
close();
display.blur();
}));
}
display.addEventListener("focus", open);
// Live-sync the picker's shown month as a valid date is typed.
display.addEventListener("input", () => {
const isoTyped = parseTypedDate(display.value);
if (isoTyped) { hidden.value = isoTyped; const p = parse(isoTyped); viewY = p.y; viewM = p.m; if (pop) renderPop(); }
});
display.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); commit(); close(); display.blur(); }
else if (e.key === "Escape") { refresh(); close(); display.blur(); }
});
display.addEventListener("blur", () => { if (!pop) commit(); });
refresh();
}
function openForm(when, editEv) {
const d = when || new Date(); // default to the current time
state.eventPopover = null;
state.composing = {
date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`,
time: `${pad(d.getHours())}:${pad(d.getMinutes())}`,
tz: editEv ? editEv.tz : null,
conference: editEv ? (editEv.conference_url || "") : "",
editId: editEv ? editEv.id : null,
recurring: editEv ? !!editEv.recurring : false,
recurrenceStart: editEv ? editEv.start : null, // the clicked occurrence's start
};
render();
if (editEv) prefillForm(editEv);
else applyDefaultReminders(state.defaultRemTimed); // new events start with the user's defaults
initDatePickers();
const first = el.querySelector("[data-f=summary]");
if (first) first.focus();
}
// Populate the reminders section with a list of minutes-before values.
function applyDefaultReminders(mins) {
const box = el.querySelector("[data-reminders]");
if (!box || !mins || !mins.length) return;
mins.forEach((m) => {
const wrap = document.createElement("div");
wrap.innerHTML = reminderRow();
const row = wrap.firstElementChild;
const sel = row.querySelector("[data-rem]");
if (![...sel.options].some((o) => o.value === String(m))) {
const o = document.createElement("option");
o.value = String(m);
o.textContent = m === 0 ? "At time of event" : `${m} minutes before`;
sel.appendChild(o);
}
sel.value = String(m);
row.querySelector("[data-act=rm-reminder]").addEventListener("click", () => row.remove());
box.appendChild(row);
});
}
function prefillForm(ev) {
const q = (s) => el.querySelector(s);
q("[data-f=summary]").value = ev.summary || "";
q("[data-f=location]").value = ev.location || "";
if (q(`[data-f=calendar] option[value="${ev.calendar_id}"]`)) q("[data-f=calendar]").value = ev.calendar_id;
q("[data-f=showas]").value = ev.show_as || "busy";
q("[data-f=notes]").value = ev.description || "";
if (ev.all_day) {
q("[data-f=allday]").checked = true;
q("[data-f=time]").disabled = true;
q("[data-f=duration]").disabled = true;
}
const durMin = Math.round((ev._e - ev._s) / 60000);
if (q(`[data-f=duration] option[value="${durMin}"]`)) q("[data-f=duration]").value = String(durMin);
if (ev.color) {
const dd = q(".cal-dd");
dd.setAttribute("data-color", ev.color);
dd.querySelector(".cal-dot").style.background = ev.color;
}
// Reminders — rebuild a row per stored minute value.
if (ev.reminders && ev.reminders.length) {
const box = q("[data-reminders]");
box.innerHTML = "";
ev.reminders.forEach((m) => {
const wrap = document.createElement("div");
wrap.innerHTML = reminderRow();
const row = wrap.firstElementChild;
const sel = row.querySelector("[data-rem]");
if (![...sel.options].some((o) => o.value === String(m))) {
const o = document.createElement("option");
o.value = String(m);
o.textContent = `${m} minutes before`;
sel.appendChild(o);
}
sel.value = String(m);
row.querySelector("[data-act=rm-reminder]").addEventListener("click", () => row.remove());
box.appendChild(row);
});
}
// Repeat — restore the RRULE into the toggle + fields.
if (ev.rrule) {
const r = parseRrule(ev.rrule);
q("[data-act=repeat-toggle]").classList.add("on");
q("[data-repeat]").style.display = "";
if (r.freq) q("[data-f=freq]").value = r.freq;
q("[data-f=interval]").value = String(r.interval || 1);
if (r.count) {
q("[data-f=endmode]").value = "count";
q("[data-f=count]").value = String(r.count);
q("[data-f=count]").style.display = "";
} else if (r.until) {
q("[data-f=endmode]").value = "until";
q("[data-f=until]").value = r.until;
q("[data-untilwrap]").style.display = "";
}
}
// Guests — expand the Who section and restore chips (no auto video-call).
if (ev.attendees && ev.attendees.length) {
const addChip = expandGuests();
ev.attendees.forEach((a) => addChip(a.email, a.name || "", false));
}
}
async function saveForm() {
const q = (sel) => el.querySelector(sel);
const calId = q("[data-f=calendar]").value;
if (!calId) return;
const allday = q("[data-f=allday]").checked;
const date = q("[data-f=date]").value;
const time = allday ? "00:00" : q("[data-f=time]").value;
const start = new Date(`${date}T${time}:00`);
const durMin = allday ? 1440 : Number(q("[data-f=duration]").value);
const end = new Date(start.getTime() + durMin * 60000);
let rrule = null;
if (q("[data-act=repeat-toggle]").classList.contains("on")) {
const freq = q("[data-f=freq]").value;
const interval = Number(q("[data-f=interval]").value) || 1;
let s = `FREQ=${freq}`;
if (interval > 1) s += `;INTERVAL=${interval}`;
if (freq === "WEEKLY") s += `;BYDAY=${["MO", "TU", "WE", "TH", "FR", "SA", "SU"][(start.getDay() + 6) % 7]}`;
const mode = q("[data-f=endmode]").value;
if (mode === "count") s += `;COUNT=${Number(q("[data-f=count]").value) || 1}`;
else if (mode === "until" && q("[data-f=until]").value) s += `;UNTIL=${q("[data-f=until]").value.replace(/-/g, "")}T000000Z`;
rrule = s;
}
const reminders = [...el.querySelectorAll("[data-rem]")].map((x) => Number(x.value));
const attendees = [...el.querySelectorAll("[data-guest]")].map((x) => ({ email: x.getAttribute("data-guest"), name: x.getAttribute("data-name") || "" }));
let tz = el.querySelector(".cal-tzfield").getAttribute("data-tz");
if (tz === "floating") tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const color = el.querySelector(".cal-dd").getAttribute("data-color");
const summary = q("[data-f=summary]").value.trim();
const location = q("[data-f=location]").value.trim();
const description = q("[data-f=notes]").value;
const showAs = q("[data-f=showas]").value;
const conferenceUrl = (state.composing && state.composing.conference) || "";
const editId = state.composing && state.composing.editId;
const organizer = state.me ? { email: state.me } : null;
const fields = {
summary, location, description, dtstart: start.toISOString(), dtend: end.toISOString(),
all_day: allday, tz, rrule, reminders, attendees, color, show_as: showAs, conference_url: conferenceUrl,
};
if (editId && state.composing.recurring) {
// Recurring edit → "this and following": occurrences before the edited one
// keep the original series; this one onward becomes a new series with the edits.
await api.pluginFetch(`/events/${editId}/edit-following`, {
method: "POST",
body: JSON.stringify({
recurrence_start: state.composing.recurrenceStart,
event: { calendar_id: calId, ...fields, organizer },
}),
});
} else if (editId) {
await api.pluginFetch(`/events/${editId}`, { method: "PATCH", body: JSON.stringify(fields) });
} else {
await api.pluginFetch("/events", {
method: "POST",
body: JSON.stringify({ calendar_id: calId, ...fields, organizer }),
});
}
state.composing = null;
await reload();
}
function openEvent(ev, target) {
state.eventPopover = { ev, rect: target.getBoundingClientRect() };
render();
}
async function doDelete(scope) {
if (!state.confirm) return;
const ev = state.confirm.ev;
state.confirm = null;
const qs = scope === "all" ? "" : `?scope=${scope}&occurrence=${encodeURIComponent(ev.start)}`;
await api.pluginFetch(`/events/${ev.id}${qs}`, { method: "DELETE" });
await reload();
}
// ---- search ----------------------------------------------------------------
const SEARCH_WINDOW_DAYS = 540; // ± window; "further back/forward" extends by this
let searchTimer = null;
async function fetchSearch() {
const s = state.search;
if (!s || !s.q.trim()) return;
s.loading = true;
try {
const res = await api.pluginFetch(
`/events?start=${encodeURIComponent(iso(s.from))}&end=${encodeURIComponent(iso(s.to))}&q=${encodeURIComponent(s.q.trim())}`);
s.results = (res.events || []).map((ev) => ({ ...ev, _s: new Date(ev.start), _e: new Date(ev.end) }));
} catch { s.results = []; }
s.loading = false;
renderSearchPanel();
}
function renderSearchPanel() {
const panel = el.querySelector(".cal-right");
if (!panel) { render(); return; } // panel not mounted yet → full render
panel.innerHTML = searchPanelHtml();
wireSearchPanel();
}
function closeSearch() {
if (searchTimer) clearTimeout(searchTimer);
state.search = null;
render();
}
// Toggle a highlight on the calendar block(s) matching a hovered search result,
// only if that occurrence is drawn in the currently displayed view.
function highlightCalendarEvent(id, start, on) {
el.querySelectorAll(`.cal-view [data-ev="${id}"][data-start="${start}"]`)
.forEach((n) => n.classList.toggle("srch-hl", on));
}
async function openSearchResult(ev) {
const gs = startOfWeek(startOfMonth(state.anchor));
const visibleNow = state.view === "month" && ev._s >= gs && ev._s < addDays(gs, 42);
if (!visibleNow) {
state.view = "month";
state.anchor = startOfMonth(ev._s);
await reload();
}
const block = el.querySelector(`.cal-view [data-ev="${ev.id}"][data-start="${ev.start}"]`);
if (block) { openEvent(ev, block); return; }
// Not drawn (e.g. its calendar is hidden) — still show details near the view.
const view = el.querySelector(".cal-view");
const r = view ? view.getBoundingClientRect() : { top: 100, left: 300 };
state.eventPopover = { ev, rect: { top: r.top + 24, left: r.left + 40, right: r.left + 90, bottom: r.top + 44 } };
render();
}
function srchEventHtml(ev) {
return `<div class="cal-srch-ev" data-srch-ev="${ev.id}" data-srch-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">
<div class="t"><b>${esc(ev.summary || "(no title)")}</b><span>${ev.all_day ? "All day" : hhmm(ev._s)}</span></div>
${ev.location ? `<div class="loc">${esc(ev.location)}</div>` : ""}
</div>`;
}
function searchPanelHtml() {
const s = state.search;
const fromStr = s.from.toLocaleDateString(dateLocale());
const toStr = s.to.toLocaleDateString(dateLocale());
if (s.loading && !s.results.length) {
return `<div class="cal-srch"><div class="cal-srch-hd"><span>Searching…</span><button class="cal-iconbtn" data-act="search-clear" title="Close search">✕</button></div></div>`;
}
// Group results by day; always include today's row (marked TODAY), like the mockup.
const byDay = new Map();
s.results.forEach((ev) => {
const key = iso(startOfDay(ev._s));
if (!byDay.has(key)) byDay.set(key, { date: startOfDay(ev._s), events: [] });
byDay.get(key).events.push(ev);
});
const todayKey = iso(startOfDay(new Date()));
if (!byDay.has(todayKey)) byDay.set(todayKey, { date: startOfDay(new Date()), events: [] });
const days = [...byDay.values()].sort((a, b) => a.date - b.date);
let rows = "";
let curMonth = null;
days.forEach((day) => {
const mk = day.date.getFullYear() * 12 + day.date.getMonth();
if (mk !== curMonth) {
curMonth = mk;
rows += `<div class="cal-srch-month">${MONTHS[day.date.getMonth()]} <span>${day.date.getFullYear()}</span></div>`;
}
const isToday = sameDay(day.date, new Date());
const evs = day.events.slice().sort((a, b) => a._s - b._s);
rows += `${isToday ? `<div class="cal-srch-today">TODAY</div>` : ""}
<div class="cal-srch-day">
<div class="cal-srch-dt${isToday ? " today" : ""}"><span class="dow">${DAYNAMES[(day.date.getDay() + 6) % 7].toUpperCase()}</span><span class="dnum">${day.date.getDate()}</span></div>
<div class="cal-srch-evs">${evs.length ? evs.map(srchEventHtml).join("") : `<div class="cal-srch-none">No events</div>`}</div>
</div>`;
});
return `<div class="cal-srch">
<div class="cal-srch-hd"><span>Showing results from ${esc(fromStr)}</span><button class="cal-iconbtn" data-act="search-clear" title="Close search">✕</button></div>
<div class="cal-srch-more"><button class="cal-btn" data-act="search-back">Search further back</button></div>
${rows}
<div class="cal-srch-hd" style="border:0"><span>Showing results up to ${esc(toStr)}</span></div>
<div class="cal-srch-more" style="border:0"><button class="cal-btn" data-act="search-fwd">Search further forward</button></div>
</div>`;
}
function wireSearchPanel() {
const panel = el.querySelector(".cal-right");
if (!panel || !state.search) return;
panel.querySelectorAll("[data-act=search-clear]").forEach((b) => b.addEventListener("click", closeSearch));
panel.querySelector("[data-act=search-back]")?.addEventListener("click", () => {
state.search.from = addDays(state.search.from, -SEARCH_WINDOW_DAYS); fetchSearch();
});
panel.querySelector("[data-act=search-fwd]")?.addEventListener("click", () => {
state.search.to = addDays(state.search.to, SEARCH_WINDOW_DAYS); fetchSearch();
});
panel.querySelectorAll("[data-srch-ev]").forEach((node) => {
const id = node.getAttribute("data-srch-ev");
const start = node.getAttribute("data-srch-start");
node.addEventListener("mouseenter", () => highlightCalendarEvent(id, start, true));
node.addEventListener("mouseleave", () => highlightCalendarEvent(id, start, false));
node.addEventListener("click", () => {
const ev = (state.search.results || []).find((x) => x.id === id && x.start === start);
if (ev) openSearchResult(ev);
});
});
}
// Replace the Video-call button/link in place (no full re-render → keeps form input).
function fillVideo(link) {
if (!state.composing) return;
state.composing.conference = link;
const box = el.querySelector("[data-video]");
if (!box) return;
box.innerHTML = videoLinkHtml(link);
const copyBtn = box.querySelector("[data-act=copy-jitsi]");
if (copyBtn) copyBtn.addEventListener("click", () => copyLink(link, copyBtn));
}
function copyLink(link, btn) {
try { navigator.clipboard.writeText(link); } catch { /* ignore */ }
btn.classList.add("copied");
setTimeout(() => btn.classList.remove("copied"), 1200);
}
// Expand the collapsed "Who" section into the organizer + guest-input + chips UI,
// wire the invite button / autocomplete, and return an addGuestChip(email, name,
// autoVideo) helper (shared by the Add-guest button and edit prefill).
function expandGuests() {
const who = el.querySelector("[data-who]");
who.innerHTML = whoExpandedHtml();
const guests = who.querySelector("[data-guests]");
const inp = who.querySelector("[data-f=guest]");
const acpop = who.querySelector("[data-guest-ac]");
function addGuestChip(email, name, autoVideo = true) {
email = (email || "").trim();
if (!email) return;
const dup = [...guests.querySelectorAll("[data-guest]")].some(
(c) => c.getAttribute("data-guest").toLowerCase() === email.toLowerCase());
if (!dup) {
const label = name && name !== email ? `${name} <${email}>` : email;
const span = document.createElement("span");
span.className = "cal-guest";
span.setAttribute("data-guest", email);
span.setAttribute("data-name", name || "");
span.innerHTML = `${esc(label)} <button>×</button>`;
span.querySelector("button").addEventListener("click", () => span.remove());
guests.appendChild(span);
// Adding a NEW guest auto-provisions a video call if none exists yet
// (skipped when restoring an existing event's guests during edit).
if (autoVideo && !state.composing.conference) fillVideo(jitsiLink(state.jitsi));
}
inp.value = "";
acpop.style.display = "none";
}
who.querySelector("[data-act=invite]").addEventListener("click", () => { addGuestChip(inp.value); inp.focus(); });
inp.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); addGuestChip(inp.value); }
else if (e.key === "Escape") acpop.style.display = "none";
});
inp.addEventListener("blur", () => setTimeout(() => { acpop.style.display = "none"; }, 150));
// Recipient autocomplete (same source as the mail composer's To field).
let acTimer;
inp.addEventListener("input", () => {
const query = inp.value.trim();
clearTimeout(acTimer);
if (!query) { acpop.style.display = "none"; return; }
acTimer = setTimeout(async () => {
let results = [];
try { results = await api.fetch(`/recipients/suggest?q=${encodeURIComponent(query)}`); } catch { /* ignore */ }
if (!results || !results.length) { acpop.style.display = "none"; return; }
acpop.innerHTML = results.map((r) =>
`<button type="button" data-ac-email="${esc(r.email)}" data-ac-name="${esc(r.name || "")}"><b>${esc(r.name || r.email)}</b>${r.name ? `<span>${esc(r.email)}</span>` : ""}</button>`).join("");
acpop.style.display = "";
acpop.querySelectorAll("[data-ac-email]").forEach((b) => b.addEventListener("mousedown", (e) => {
e.preventDefault();
addGuestChip(b.getAttribute("data-ac-email"), b.getAttribute("data-ac-name"));
inp.focus();
}));
}, 160);
});
return addGuestChip;
}
function go(delta) {
const a = state.anchor;
if (state.view === "month") state.anchor = new Date(a.getFullYear(), a.getMonth() + delta, 1);
else state.anchor = addDays(a, delta * (state.view === "day" ? 1 : state.view === "3days" ? 3 : 7));
state.mini = startOfMonth(state.anchor);
reload();
}
// ---- render ----------------------------------------------------------------
function render() {
if (state.embed) { renderEmbed(); return; }
el.innerHTML = `<div class="cal-app">
${state.leftCollapsed ? railPanel() : `<div class="wm-side cal-left" style="width:${state.leftW}px;flex:0 0 auto">${leftPanel()}</div><div class="cal-resizer" data-resizer></div>`}
<div class="cal-maincol">
${topbar()}
<div class="cal-mainarea">
<div class="cal-main">${mainHead()}<div class="cal-view">${viewBody()}</div></div>
${(() => {
const inner = state.search ? searchPanelHtml() : (state.rightOpen ? agenda() : null);
return inner === null ? "" : `<div class="cal-resizer" data-rresizer></div><div class="cal-right" style="width:${state.rightW}px">${inner}</div>`;
})()}
</div>
</div>
${state.composing ? formHtml() : ""}
${state.eventPopover ? eventPopoverHtml() : ""}
${state.confirm ? confirmHtml() : ""}
</div>`;
wire();
if (state.view !== "month") scrollTimegrid();
if (state.eventPopover) positionPopover();
if (state.search && !state.eventPopover && !state.composing && !state.confirm) focusSearch();
}
// 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() {
el.innerHTML = `<div class="cal-app cal-embed">
${panelBar()}
${state.search ? "" : `<div class="cal-embed-mini">${miniMonth()}</div>`}
<div class="cal-embed-body">
${state.search ? searchPanelHtml() : agenda()}
</div>
${panelFoot()}
${state.composing ? formHtml() : ""}
${state.eventPopover ? eventPopoverHtml() : ""}
${state.confirm ? confirmHtml() : ""}
</div>`;
wire();
if (state.eventPopover) positionPopover();
if (state.search && !state.eventPopover && !state.composing && !state.confirm) focusSearch();
}
function panelBar() {
const cur = state.hidden.size && state.calendars.length - state.hidden.size === 1
? state.calendars.find((c) => !state.hidden.has(c.id))
: null;
// Inline resets guarantee no default-button frame/background/focus-ring even if
// an older injected stylesheet is missing these classes.
const plain = "border:0;background:none;outline:none;box-shadow:none";
return `<div class="cal-panelbar">
<button class="cal-iconbtn" style="${plain}" data-act="new" title="New event"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></button>
<button class="cal-iconbtn${state.searchOpen ? " on" : ""}" style="${plain}" data-act="panel-search" title="Search"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg></button>
<div class="cal-dd cal-panelmenu">
<button class="cal-iconbtn" style="${plain}" data-act="panel-menu" title="Options"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg></button>
${state.panelMenuOpen ? `<div class="cal-ddpop" style="left:0;min-width:170px">
<div class="cal-ddsec">Mini calendar</div>
<button data-panelmini="week">${state.panelMini === "week" ? "✓ " : "  "}Week</button>
<button data-panelmini="month">${state.panelMini === "month" ? "✓ " : "  "}Month</button>
<div class="cal-ddsep"></div>
<div class="cal-ddsec">View</div>
<button data-panelview="day">${state.panelView === "day" ? "✓ " : "  "}Day</button>
<button data-panelview="3days">${state.panelView === "3days" ? "✓ " : "  "}3 days</button>
<button data-panelview="agenda">${state.panelView === "agenda" ? "✓ " : "  "}Agenda</button>
</div>` : ""}
</div>
<div style="flex:1"></div>
<div class="cal-dd cal-panelcal">
<button class="cal-panelcalbtn" style="display:flex;align-items:center;gap:6px;${plain};color:rgb(var(--app-fg));cursor:pointer;font:inherit;font-size:13px;padding:6px 4px" data-act="panelcal"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>${cur ? esc(cur.name) : "All calendars"} <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 6l4 4 4-4"/></svg></button>
${state.panelCalOpen ? `<div class="cal-ddpop" style="right:0;left:auto">
<button data-panelcal="all">${!cur ? "✓ " : ""}All calendars</button>
${state.calendars.map((c) => `<button data-panelcal="${c.id}">${cur && cur.id === c.id ? "✓ " : ""}<span style="display:inline-block;width:9px;height:9px;border-radius:2px;background:${esc(c.color)};margin-right:4px"></span>${esc(c.name)}</button>`).join("")}
</div>` : ""}
</div>
</div>
${state.searchOpen || state.search ? `<div class="cal-panelsearch"><div class="cal-searchbox"><svg class="ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg><input class="cal-search" placeholder="Search calendar" value="${esc(state.search ? state.search.q : "")}"/>${state.search ? `<button class="cal-iconbtn" data-act="search-clear" title="Clear">✕</button>` : ""}</div></div>` : ""}`;
}
function panelFoot() {
return `<div class="cal-panelfoot"><button data-act="open-newtab">Open calendar in new tab <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h6v6M20 4l-9 9M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4"/></svg></button></div>`;
}
function focusSearch() {
const si = el.querySelector(".cal-search");
if (si && document.activeElement !== si) {
si.focus();
const v = si.value;
si.setSelectionRange(v.length, v.length);
}
}
// Clamp the details popover fully inside the viewport (measure actual size after
// paint) so a long guest list never pushes it off-screen / makes the page scroll.
function positionPopover() {
const pop = el.querySelector(".cal-pop");
if (!pop) return;
const r = state.eventPopover.rect;
const w = pop.offsetWidth;
const h = pop.offsetHeight;
let left = r.right + 8;
if (left + w > window.innerWidth - 8) left = Math.max(8, r.left - w - 8);
if (left + w > window.innerWidth - 8) left = window.innerWidth - w - 8;
const top = Math.max(8, Math.min(r.top, window.innerHeight - h - 8));
pop.style.left = `${Math.max(8, left)}px`;
pop.style.top = `${top}px`;
}
function topbar() {
return `<div class="cal-topbar">
<div class="cal-searchbox">
<svg class="ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input class="cal-search" placeholder="Search calendar" value="${esc(state.search ? state.search.q : "")}"/>
${state.search ? `<button class="cal-iconbtn" data-act="search-clear" title="Clear search">✕</button>` : ""}
</div>
</div>`;
}
// Collapsed sidebar: a narrow icon-only rail (like Mail) that keeps the expand
// toggle, so it can always be re-opened.
function railPanel() {
const TODAY_IC = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/><circle cx="12" cy="15.5" r="2.3" fill="currentColor" stroke="none"/></svg>`;
return `<div class="wm-side cal-rail" style="flex:0 0 auto">
<div class="cal-railtop">
<button class="cal-railbtn" data-act="left-toggle" title="Expand sidebar">${SHOW_ICON}</button>
<button class="cal-railbtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
</div>
<div class="cal-railprimary"><button class="cal-railnew" data-act="new" title="New event">${COMPOSE_ICON}</button></div>
<div class="cal-railnav">
<button class="cal-railbtn" data-act="today" title="Today">${TODAY_IC}</button>
</div>
<div class="cal-railfoot">
<button class="cal-railbtn" data-act="settings" title="Settings">${SETTINGS_ICON}</button>
<button class="cal-railbtn" data-act="signout" title="Sign out">${LOGOUT_ICON}</button>
</div>
</div>`;
}
// Left panel = the shared sidebar-shell template (.wm-side-*, see plugin-ui.css):
// switcher + collapse/theme, primary button, an app-specific scrollable body
// (mini-month + calendars here), and the shared foot. Identical to Mail.
function leftPanel() {
return `
<div class="wm-side-head">
<div class="wm-side-switch">
<button data-act="menu">Calendar <svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg></button>
${state.menuOpen ? `<div class="wm-side-menu">${state.apps.map((a, i) => `<button data-app="${i}">${esc(a.label)}</button>`).join("")}</div>` : ""}
</div>
<div class="wm-side-headicons">
<button class="wm-side-iconbtn" data-act="left-toggle" title="Collapse sidebar">${HIDE_ICON}</button>
<button class="wm-side-iconbtn wm-side-themebtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
</div>
</div>
<div class="wm-side-primary">
<button data-act="new">${COMPOSE_ICON}New event</button>
</div>
<div class="wm-side-body">
${miniMonth()}
<div class="wm-side-section cal-secrow"><span>Calendars</span><button class="cal-secadd" data-act="add-calendar" title="Create calendar">+</button></div>
<div class="wm-side-nav">${state.calendars.map((c) => `
<label class="wm-side-item"><input type="checkbox" data-cal="${c.id}" ${state.hidden.has(c.id) ? "" : "checked"}/>
<span style="width:10px;height:10px;border-radius:2px;background:${esc(c.color)};display:inline-block"></span>${esc(c.name)}</label>`).join("")}</div>
</div>
<div class="wm-side-foot">
<button data-act="settings">${SETTINGS_ICON}Settings</button>
<button data-act="signout">${LOGOUT_ICON}Sign out</button>
${state.quota ? `<div class="wm-side-quota">${esc(state.quota)}</div>` : ""}
</div>`;
}
function miniMonth() {
const m = state.mini;
// Embed "Mini calendar" option: Week shows just the anchor's week row.
const week = state.embed && state.panelMini === "week";
const gs = week ? startOfWeek(state.anchor) : startOfWeek(startOfMonth(m));
const cellCount = week ? 7 : 42;
let cells = "";
for (let i = 0; i < cellCount; i++) {
const d = addDays(gs, i);
const cls = [!week && d.getMonth() !== m.getMonth() ? "out" : "", sameDay(d, new Date()) ? "today" : ""].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>
</div>
<div class="cal-mini-grid">${["Mo","Tu","We","Th","Fr","Sa","Su"].map((h) => `<div class="h">${h}</div>`).join("")}${cells}</div>
</div>`;
}
function mainHead() {
const title = `<span class="cal-title">${MONTHS[state.anchor.getMonth()]} <span>${state.anchor.getFullYear()}</span></span>`;
const seg = ["day:Day", "3days:3 days", "week:Week", "month:Month"].map((v) => {
const [key, label] = v.split(":");
return `<button class="${state.view === key ? "on" : ""}" data-view="${key}">${label}</button>`;
}).join("");
return `<div class="cal-mainhead">
${title}
<div style="flex:1"></div>
<div class="cal-seg">${seg}</div>
<div style="flex:1"></div>
<button class="cal-iconbtn" data-act="prev">←</button>
<button class="cal-btn" data-act="today">Today</button>
<button class="cal-iconbtn" data-act="next">→</button>
${(state.rightOpen || state.search) ? "" : `<button class="cal-iconbtn" data-act="panel" title="Open side panel">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/></svg>
</button>`}
</div>`;
}
function viewBody() {
return state.view === "month" ? monthView() : timegridView();
}
function formHtml() {
const c = state.composing;
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const defCal = c.editId ? null : state.defaultCalendar; // new events honor the default calendar
const calOpts = state.calendars.filter((x) => !x.read_only).map((x) => `<option value="${x.id}"${x.id === defCal ? " selected" : ""}>${esc(x.name)}</option>`).join("");
const chev = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 6l4 4 4-4"/></svg>`;
const colorItems = COLORS.map((cc) => `<button type="button" data-color="${cc[1]}"><span class="cal-dot" style="background:${cc[1]}"></span>${cc[0]}</button>`).join("");
const tz0 = c.tz || browserTz;
const formLeft = state.embed ? 8 : (state.leftCollapsed ? 12 : state.leftW + 12); // clear the full-height sidebar
return `<div class="cal-form" style="left:${formLeft}px">
<div class="cal-form-body">
<h4 style="margin-top:0">What</h4><input type="text" data-f="summary"/>
<h4>Where</h4><input type="text" data-f="location"/>
<div class="row" style="align-items:flex-start;gap:12px">
<div style="flex:1"><h4>Calendar</h4><select data-f="calendar">${calOpts}</select></div>
<div><h4>Color</h4>
<div class="cal-dd" data-color="${COLORS[0][1]}">
<button type="button" data-act="color-open"><span class="cal-dot" style="background:${COLORS[0][1]}"></span>${chev}</button>
<div class="cal-ddpop" style="display:none">${colorItems}</div>
</div>
</div>
<div><h4>Show as</h4><select data-f="showas"><option value="busy">Busy</option><option value="free">Free</option></select></div>
</div>
<h4>When</h4>
<label class="row" style="margin-bottom:6px"><input type="checkbox" data-f="allday" style="width:auto"/> All day</label>
<div class="row"><span class="cal-datewrap" data-datepick><input type="text" data-datedisplay autocomplete="off"/><input type="hidden" data-f="date" value="${c.date}"/></span><select data-f="time">${timeOptionsHtml(c.time)}</select></div>
<div style="margin-top:8px"><select data-f="duration">
<option value="15">15 minutes</option><option value="30">30 minutes</option>
<option value="60" selected>1 hour</option><option value="90">1.5 hours</option><option value="120">2 hours</option>
</select></div>
<div style="margin-top:8px" class="cal-tzfield" data-tz="${tz0}">
<button type="button" data-act="tz-open"><span data-tz-label>${esc(tzLongLabel(tz0))}</span> ${chev}</button>
<div class="cal-tzpop" style="display:none">
<input class="search" data-tz-search placeholder="Type to search by city…"/>
<div class="cal-tzlist"></div>
</div>
</div>
<h4>Repeat <button class="cal-tog" data-act="repeat-toggle" style="float:right"></button></h4>
<div data-repeat style="display:none">
<div class="row">
<span class="muted">every</span>
<input type="number" data-f="interval" value="1" min="1" style="width:60px"/>
<select data-f="freq"><option value="DAILY">day</option><option value="WEEKLY" selected>week</option><option value="MONTHLY">month</option><option value="YEARLY">year</option></select>
</div>
<div class="row" style="margin-top:8px">
<select data-f="endmode"><option value="forever">Repeats forever</option><option value="count">Ends after N</option><option value="until">Ends on date</option></select>
<input type="number" data-f="count" value="10" min="1" style="width:64px;display:none"/>
<span class="cal-datewrap" data-datepick data-untilwrap style="display:none;width:150px"><input type="text" data-datedisplay autocomplete="off"/><input type="hidden" data-f="until"/></span>
</div>
</div>
<h4>Who</h4>
<div class="muted" style="margin-bottom:6px">Guests will be emailed an invitation to the event.</div>
<div data-who>
<button class="cal-btn" data-act="add-guest">+ Add guest</button>
</div>
<h4>Video call</h4>
<div data-video>${c.conference
? videoLinkHtml(c.conference)
: `<button class="cal-btn" data-act="add-jitsi" style="display:inline-flex;align-items:center;gap:6px"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="flex:0 0 auto"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>Add a video call in Jitsi Meet</button>`}</div>
<h4>Reminders</h4>
<div data-reminders></div>
<button class="cal-btn" data-act="add-reminder" style="margin-top:6px">+ Add reminder</button>
<h4>Description</h4><textarea data-f="notes" rows="3"></textarea>
</div>
<div class="cal-form-foot">
<button class="cal-btn cal-btn-primary" style="flex:none;width:auto" data-act="save">Save</button>
<button class="cal-btn" data-act="cancel">Cancel</button>
</div>
</div>`;
}
function eventPopoverHtml() {
const { ev, rect } = state.eventPopover;
const cal = state.calendars.find((c) => c.id === ev.calendar_id);
const dateStr = ev._s.toLocaleDateString(dateLocale(), { weekday: "long", year: "numeric", month: "long", day: "numeric" });
const durMin = Math.round((ev._e - ev._s) / 60000);
const durLabel = durMin % 60 === 0 ? `${durMin / 60} hour${durMin / 60 > 1 ? "s" : ""}` : `${durMin} min`;
const when = ev.all_day ? "All day" : `${hhmm(ev._s)} – ${hhmm(ev._e)} ${tzAbbr(ev.tz)} (${durLabel})`;
const left = Math.min(rect.right + 8, window.innerWidth - 302);
const top = Math.min(Math.max(rect.top, 8), window.innerHeight - 230);
return `<div class="cal-popoverlay" style="position:fixed;inset:0;z-index:59" data-act="pop-close"></div>
<div class="cal-pop" style="left:${left}px;top:${top}px">
<div class="body">
<div class="cap">${esc(cal ? cal.name : "")}</div>
<div class="ttl">${esc(ev.summary || "(no title)")}</div>
${ev.location ? `<div class="loc">${esc(ev.location)}</div>` : ""}
<hr/>
<div><b>${esc(dateStr)}</b><br/>${esc(when)}</div>
${ev.conference_url ? `<div class="cal-videolink" style="margin-top:12px"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg><a href="${esc(ev.conference_url)}" target="_blank" rel="noopener noreferrer">Join video call</a></div>` : ""}
${guestListHtml(ev)}
</div>
<div class="foot">
<button class="cal-btn" data-act="ev-edit">Edit</button>
<button class="cal-btn" data-act="ev-delete" style="color:#ef4444">Delete</button>
</div>
</div>`;
}
function confirmHtml() {
const ev = state.confirm.ev;
const title = esc(ev.summary || "(no title)");
if (ev.recurring) {
return `<div class="cal-modal-ov" data-act="confirm-cancel"><div class="cal-modal" data-stop>
<div class="body">
<h3>Delete recurring event</h3>
<p>“${title}” is a repeating event. What would you like to delete?</p>
<div class="opts">
<button data-act="del-this">This event only</button>
<button data-act="del-following">This and all following events</button>
</div>
</div>
<div class="foot"><button class="cal-btn" data-act="confirm-cancel">Cancel</button></div>
</div></div>`;
}
return `<div class="cal-modal-ov" data-act="confirm-cancel"><div class="cal-modal" data-stop>
<div class="body"><h3>Delete event</h3><p>Delete “${title}”? This can’t be undone.</p></div>
<div class="foot">
<button class="cal-btn" data-act="confirm-cancel">Cancel</button>
<button class="cal-btn" style="background:#ef4444;color:#fff" data-act="del-all">Delete</button>
</div>
</div></div>`;
}
function whoExpandedHtml() {
return `
<div class="row" style="justify-content:space-between;align-items:flex-start">
<div><div>${esc(state.me || "You")}</div><div class="muted">${esc(state.me || "")}</div></div>
<div class="muted" style="font-weight:600">Organizer</div>
</div>
<div class="row" style="margin-top:8px;align-items:stretch">
<div style="position:relative;flex:1">
<input type="email" data-f="guest" placeholder="email@example.com" autocomplete="off"/>
<div class="cal-acpop" data-guest-ac style="display:none"></div>
</div>
<button class="cal-btn" data-act="invite">Invite</button>
</div>
<div data-guests style="margin-top:6px"></div>`;
}
function reminderRow() {
return `<div class="row" data-reminder style="margin-bottom:6px">
<select data-rem><option value="5">5 minutes before</option><option value="10">10 minutes before</option><option value="15">15 minutes before</option><option value="30">30 minutes before</option><option value="60" selected>1 hour before</option><option value="1440">1 day before</option></select>
<button class="cal-iconbtn" data-act="rm-reminder">✕</button>
</div>`;
}
function renderTzList(field, filter) {
const cur = field.getAttribute("data-tz");
const list = field.querySelector(".cal-tzlist");
const entries = tzEntries(cur).filter(
(e) => !filter || e.city.toLowerCase().includes(filter) || (e.abbr && e.abbr.toLowerCase().includes(filter)),
);
list.innerHTML = entries.slice(0, 300).map(
(e) => `<button data-tzpick="${e.tz}">${e.cur ? "✓ " : ""}${esc(e.city)}<span class="abbr">${esc(e.abbr || "")}</span></button>`,
).join("");
list.querySelectorAll("[data-tzpick]").forEach((b) => b.addEventListener("click", () => {
const tz = b.getAttribute("data-tzpick");
field.setAttribute("data-tz", tz);
field.querySelector("[data-tz-label]").textContent = tzLongLabel(tz);
field.querySelector(".cal-tzpop").style.display = "none";
}));
}
function monthView() {
const gs = startOfWeek(startOfMonth(state.anchor));
const evs = visibleEvents();
let html = DAYNAMES.map((d) => `<div class="dow">${d}</div>`).join("");
for (let i = 0; i < 42; i++) {
const d = addDays(gs, i);
const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s);
const cls = [d.getMonth() !== state.anchor.getMonth() ? "out" : "", sameDay(d, new Date()) ? "today" : ""].join(" ");
const mtag = d.getDate() === 1 ? `<span class="mtag">${MONTHS[d.getMonth()].slice(0, 3)} ${d.getFullYear()}</span>` : "";
html += `<div class="cal-cell ${cls}" data-day="${iso(d)}">
<div class="n"><span class="num">${d.getDate()}</span>${mtag}</div>
${dayEvs.slice(0, 4).map((ev) => `<div class="cal-ev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}</div>`).join("")}
${dayEvs.length > 4 ? `<div class="cal-ev" style="border:0;background:none;color:rgb(var(--app-muted))">+${dayEvs.length - 4} more</div>` : ""}
</div>`;
}
return `<div class="cal-month">${html}</div>`;
}
function timegridView() {
const span = state.view === "day" ? 1 : state.view === "3days" ? 3 : 7;
const s0 = state.view === "week" ? startOfWeek(state.anchor) : startOfDay(state.anchor);
const days = Array.from({ length: span }, (_, i) => addDays(s0, i));
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "Local";
const evs = visibleEvents();
const hours = Array.from({ length: 24 }, (_, h) => `<div>${h === 0 ? "" : HOUR12 ? (h % 12 || 12) + (h < 12 ? " AM" : " PM") : pad(h) + ":00"}</div>`).join("");
const headDays = days.map((d) => {
const today = sameDay(d, new Date());
const num = `${d.getDate()}${d.getDate() === 1 ? " " + MONTHS[d.getMonth()].slice(0, 3) : ""}`;
return `<div class="d${today ? " today" : ""}">${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}<b>${today ? `<span class="num">${d.getDate()}</span>` : num}</b></div>`;
}).join("");
const cols = days.map((d) => {
const today = sameDay(d, new Date());
const lines = Array.from({ length: 24 }, () => `<div class="line"></div>`).join("");
const dayEvs = evs.filter((ev) => !ev.all_day && sameDay(ev._s, d));
const lay = layoutDay(dayEvs);
const blocks = dayEvs.map((ev) => {
const top = (ev._s.getHours() + ev._s.getMinutes() / 60) * HOUR_PX;
const h = Math.max(18, ((ev._e - ev._s) / 3600000) * HOUR_PX);
const pos = lay.get(ev) || { col: 0, total: 1 };
const w = 100 / pos.total;
return `<div class="cal-tev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="top:${top}px;height:${h}px;left:calc(${pos.col * w}% + 1px);width:calc(${w}% - 3px);border-color:${esc(ev.color)}"><b>${esc(ev.summary || "(no title)")}</b><div style="color:rgb(var(--app-muted))">${hhmm(ev._s)}</div></div>`;
}).join("");
const now = today ? `<div class="cal-now" style="top:${(new Date().getHours() + new Date().getMinutes() / 60) * HOUR_PX}px"></div>` : "";
return `<div class="cal-col${today ? " today" : ""}" data-col="${iso(d)}">${lines}${blocks}${now}</div>`;
}).join("");
return `<div class="cal-tg-head"><div class="cal-tz">${esc(tzAbbr(tz))}</div><div class="cal-tg-days" style="grid-template-columns:repeat(${span},1fr)">${headDays}</div></div>
<div class="cal-tg"><div class="cal-hours">${hours}</div><div class="cal-cols" style="grid-template-columns:repeat(${span},1fr)">${cols}</div></div>`;
}
function agenda() {
// Embed "View" option: Agenda = the month; Day/3 days = a short run from the anchor.
const short = state.embed && state.panelView !== "agenda";
const count = short ? (state.panelView === "day" ? 1 : 3) : 30;
const start = short ? startOfDay(state.anchor) : startOfMonth(state.anchor);
const evs = visibleEvents();
const rightAction = state.embed
? `<button class="cal-agenda-today" data-act="mini-today">TODAY</button>`
: `<button class="cal-iconbtn" data-act="panel">✕</button>`;
let html = `<div class="cal-mainhead"><span class="cal-title">${MONTHS[start.getMonth()]} <span>${start.getFullYear()}</span></span><div style="flex:1"></div>${rightAction}</div><div class="cal-agenda">`;
for (let i = 0; i < count; i++) {
const d = addDays(start, i);
if (!short && d.getMonth() !== start.getMonth()) break;
const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s);
html += `<div class="day"><div class="dt">${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}<b>${d.getDate()}</b></div><div style="flex:1">${
dayEvs.length ? dayEvs.map((ev) => `<div class="cal-ev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}</div>`).join("") : `<span class="none">No events</span>`
}</div></div>`;
}
return html + "</div>";
}
// ---- events ----------------------------------------------------------------
function wire() {
const on = (sel, ev, fn) => el.querySelectorAll(sel).forEach((n) => n.addEventListener(ev, fn));
const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn);
act("menu", () => { state.menuOpen = !state.menuOpen; render(); });
// --- embed (Mail right panel) toolbar ---
act("panel-search", () => { state.searchOpen = !state.searchOpen; if (!state.searchOpen && state.search) closeSearch(); else render(); });
act("panelcal", (e) => { e.stopPropagation(); state.panelCalOpen = !state.panelCalOpen; render(); });
on("[data-panelcal]", "click", (e) => {
e.stopPropagation();
const id = e.currentTarget.getAttribute("data-panelcal");
if (id === "all") state.hidden.clear();
else state.hidden = new Set(state.calendars.map((c) => c.id).filter((x) => x !== id));
state.panelCalOpen = false; render();
});
act("open-newtab", () => window.open("/p/calendar", "_blank", "noopener"));
act("panel-menu", (e) => { e.stopPropagation(); state.panelMenuOpen = !state.panelMenuOpen; render(); });
on("[data-panelmini]", "click", (e) => { e.stopPropagation(); state.panelMini = e.currentTarget.getAttribute("data-panelmini"); state.panelMenuOpen = false; render(); });
on("[data-panelview]", "click", (e) => { e.stopPropagation(); state.panelView = e.currentTarget.getAttribute("data-panelview"); state.panelMenuOpen = false; render(); });
if (state.panelCalOpen || state.panelMenuOpen) {
setTimeout(() => { const close = () => { state.panelCalOpen = false; state.panelMenuOpen = false; document.removeEventListener("click", close); render(); }; document.addEventListener("click", close); }, 0);
}
// --- search ---
on(".cal-search", "input", (e) => {
const q = e.currentTarget.value;
if (searchTimer) clearTimeout(searchTimer);
if (!q.trim()) { closeSearch(); return; }
if (!state.search) {
const today = startOfDay(new Date());
state.search = { q, from: addDays(today, -SEARCH_WINDOW_DAYS), to: addDays(today, SEARCH_WINDOW_DAYS), results: [], loading: true };
render(); // mounts the results panel (input re-focused in render)
} else {
state.search.q = q;
}
searchTimer = setTimeout(fetchSearch, 250);
});
on(".cal-search", "keydown", (e) => { if (e.key === "Escape") closeSearch(); });
// Topbar clear (the ✕ in the search box); the panel's own ✕ is wired in wireSearchPanel.
el.querySelectorAll(".cal-searchbox [data-act=search-clear]").forEach((b) => b.addEventListener("click", closeSearch));
if (state.search) wireSearchPanel();
on("[data-app]", "click", (e) => {
const a = state.apps[Number(e.currentTarget.getAttribute("data-app"))];
state.menuOpen = false;
if (a.to) ctx.navigate(a.to); else render();
});
// Pass ?from=/p/calendar so Settings' back button returns here, not to Mail.
act("settings", () => ctx.navigate("/settings?from=%2Fp%2Fcalendar"));
act("add-calendar", () => ctx.navigate("/settings/calendars?from=%2Fp%2Fcalendar&create=1"));
act("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); });
act("new", () => openForm(null));
act("today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); });
act("prev", () => go(-1));
act("next", () => go(1));
act("panel", () => { state.rightOpen = !state.rightOpen; render(); });
act("miniprev", () => { state.mini = new Date(state.mini.getFullYear(), state.mini.getMonth() - 1, 1); render(); });
act("mininext", () => { state.mini = new Date(state.mini.getFullYear(), state.mini.getMonth() + 1, 1); render(); });
act("mini-today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); });
// --- left panel: collapse, theme, resize (like Mail) ---
act("left-toggle", () => {
state.leftCollapsed = !state.leftCollapsed;
try { localStorage.setItem("cal.leftCollapsed", state.leftCollapsed ? "1" : "0"); } catch { /* ignore */ }
render();
});
act("theme", () => {
const dark = document.documentElement.classList.toggle("dark");
try { localStorage.setItem("theme", dark ? "dark" : "light"); } catch { /* ignore */ }
render();
});
const rz = el.querySelector("[data-resizer]");
if (rz) rz.addEventListener("mousedown", (e) => {
e.preventDefault();
const left = el.querySelector(".cal-left");
const startX = e.clientX;
const startW = left.offsetWidth;
const onMove = (ev) => {
const w = Math.max(190, Math.min(440, startW + ev.clientX - startX));
left.style.width = `${w}px`;
state.leftW = w;
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
try { localStorage.setItem("cal.leftW", String(state.leftW)); } catch { /* ignore */ }
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
});
// Right panel (agenda / search results) — drag its left edge to resize.
const rrz = el.querySelector("[data-rresizer]");
if (rrz) rrz.addEventListener("mousedown", (e) => {
e.preventDefault();
const right = el.querySelector(".cal-right");
const startX = e.clientX;
const startW = right.offsetWidth;
const onMove = (ev) => {
const w = Math.max(240, Math.min(640, startW - (ev.clientX - startX)));
right.style.width = `${w}px`;
state.rightW = w;
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
try { localStorage.setItem("cal.rightW", String(state.rightW)); } catch { /* ignore */ }
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
});
on("[data-view]", "click", (e) => { state.view = e.currentTarget.getAttribute("data-view"); reload(); });
on("[data-mini]", "click", (e) => { state.anchor = startOfDay(new Date(e.currentTarget.getAttribute("data-mini"))); if (state.view === "month") state.view = "day"; reload(); });
on("[data-cal]", "change", (e) => {
const id = e.currentTarget.getAttribute("data-cal");
if (e.currentTarget.checked) state.hidden.delete(id); else state.hidden.add(id);
render();
});
on("[data-ev]", "click", (e) => {
e.stopPropagation();
const id = e.currentTarget.getAttribute("data-ev");
const start = e.currentTarget.getAttribute("data-start");
// Recurring events share one base id across occurrences — match the clicked
// occurrence by its start too, so the popover shows that instance's date.
const ev = state.events.find((x) => x.id === id && (!start || x.start === start))
|| state.events.find((x) => x.id === id);
if (ev) openEvent(ev, e.currentTarget);
});
on("[data-day]", "click", (e) => {
const d = new Date(e.currentTarget.getAttribute("data-day"));
const now = new Date();
d.setHours(now.getHours(), now.getMinutes(), 0, 0); // default to the current time
openForm(d);
});
// --- event popover ---
act("pop-close", () => { state.eventPopover = null; render(); });
act("ev-delete", () => {
const ev = state.eventPopover.ev;
state.eventPopover = null;
state.confirm = { ev };
render();
});
act("ev-edit", () => { const ev = state.eventPopover.ev; openForm(ev._s, ev); });
// --- delete confirmation modal ---
on("[data-stop]", "click", (e) => e.stopPropagation());
act("confirm-cancel", () => { state.confirm = null; render(); });
act("del-all", () => doDelete("all"));
act("del-this", () => doDelete("this"));
act("del-following", () => doDelete("following"));
on("[data-col]", "click", (e) => {
const rect = e.currentTarget.getBoundingClientRect();
const hour = Math.max(0, Math.min(23, Math.floor((e.clientY - rect.top) / HOUR_PX)));
const d = new Date(e.currentTarget.getAttribute("data-col")); d.setHours(hour, 0, 0, 0);
openForm(d);
});
// --- new-event form ---
act("repeat-toggle", (e) => {
e.currentTarget.classList.toggle("on");
const box = el.querySelector("[data-repeat]");
if (box) box.style.display = e.currentTarget.classList.contains("on") ? "" : "none";
});
on("[data-f=endmode]", "change", (e) => {
const v = e.currentTarget.value;
el.querySelector("[data-f=count]").style.display = v === "count" ? "" : "none";
el.querySelector("[data-untilwrap]").style.display = v === "until" ? "" : "none";
});
on("[data-f=allday]", "change", (e) => {
const off = e.currentTarget.checked;
el.querySelector("[data-f=time]").disabled = off;
el.querySelector("[data-f=duration]").disabled = off;
});
// color dropdown
act("color-open", (e) => {
const pop = e.currentTarget.nextElementSibling;
pop.style.display = pop.style.display === "none" ? "" : "none";
});
on(".cal-ddpop [data-color]", "click", (e) => {
const color = e.currentTarget.getAttribute("data-color");
const dd = e.currentTarget.closest(".cal-dd");
dd.setAttribute("data-color", color);
dd.querySelector(".cal-dot").style.background = color;
dd.querySelector(".cal-ddpop").style.display = "none";
});
// timezone combobox
act("tz-open", (e) => {
const field = e.currentTarget.closest(".cal-tzfield");
const pop = field.querySelector(".cal-tzpop");
const open = pop.style.display === "none";
pop.style.display = open ? "" : "none";
if (open) {
renderTzList(field, "");
field.querySelector("[data-tz-search]").focus();
}
});
on("[data-tz-search]", "input", (e) => {
renderTzList(e.currentTarget.closest(".cal-tzfield"), e.currentTarget.value.toLowerCase());
});
act("add-guest", () => {
expandGuests();
const inp = el.querySelector("[data-f=guest]");
if (inp) inp.focus();
});
act("add-jitsi", () => fillVideo(jitsiLink(state.jitsi)));
// Copy handler for a link rendered on initial form paint (edit mode).
act("copy-jitsi", (e) => copyLink(state.composing.conference, e.currentTarget));
act("add-reminder", () => {
const box = el.querySelector("[data-reminders]");
const wrap = document.createElement("div");
wrap.innerHTML = reminderRow();
const row = wrap.firstElementChild;
row.querySelector("[data-act=rm-reminder]").addEventListener("click", () => row.remove());
box.appendChild(row);
});
act("save", () => saveForm());
act("cancel", () => { state.composing = null; render(); });
}
function scrollTimegrid() {
const view = el.querySelector(".cal-view");
if (view) view.scrollTop = 7 * HOUR_PX; // start around 7 AM like the mockups
}
async function loadChrome() {
try {
// Product switcher apps: Mail + every plugin page from the manifest.
const manifest = await api.fetch("/plugins/ui-manifest");
const pages = (manifest.plugins || []).flatMap((p) => p.pages.map((pg) => ({ label: pg.nav.label, to: pg.route === "/calendar" ? null : "/p" + pg.route })));
state.apps = [{ label: "Mail", to: "/mail" }, ...pages];
} catch { /* keep defaults */ }
try {
const accounts = await api.fetch("/accounts");
if (accounts && accounts[0]) state.me = accounts[0].email;
} catch { /* ignore */ }
try {
const s = await api.fetch("/settings");
HOUR12 = s && s.time_format === "12"; // 24h is the default
state.jitsi = (s && s.jitsi_server) || "";
state.defaultCalendar = (s && s.default_calendar) || "";
// Reminders are stored as {method, minutes}; event reminders only use minutes.
const remMins = (arr) =>
(arr || []).map((x) => (typeof x === "number" ? x : (x && x.minutes) || 0));
state.defaultRemTimed = remMins(s && s.default_reminders_timed);
state.defaultRemAllday = remMins(s && s.default_reminders_allday);
} catch { /* keep defaults (24h, public Jitsi) */ }
try {
const q = await api.fetch("/settings/quota");
if (q && q.quota_bytes) {
const pct = (q.used_bytes / q.quota_bytes) * 100;
const gb = q.quota_bytes / 1024 ** 3;
state.quota = `${pct >= 10 ? Math.round(pct) : Math.round(pct * 10) / 10}% of ${Number.isInteger(gb) ? gb : gb.toFixed(1)} GB`;
}
} catch { /* ignore */ }
}
(async () => {
await loadChrome();
await loadCalendars();
await loadEvents();
render();
// Embed mode ("Invite all to new event" from Mail's People tab): open a new-event
// form with the given guests, using the SAME form as the calendar app.
if (state.embed && newEventGuests && newEventGuests.length) {
openForm(null);
const addChip = expandGuests();
if (addChip) newEventGuests.map((g) => String(g).trim()).filter(Boolean).forEach((g) => addChip(g, "", false));
return;
}
// Deep link (Contacts "Invite"): /p/calendar?guest=<email> opens a new-event
// form with that guest already added, then strips the param so refresh is clean.
try {
const guest = new URLSearchParams(window.location.search).get("guest");
if (guest) {
window.history.replaceState({}, "", window.location.pathname);
openForm(null);
const addChip = expandGuests();
if (addChip) guest.split(",").map((g) => g.trim()).filter(Boolean).forEach((g) => addChip(g, "", false));
}
} catch { /* ignore */ }
})();
return () => {
el.innerHTML = "";
};
}