Owner requests (2026-10-10/11): 1. "Instead of showing no calendar like it does now It should just show no upcoming appointments but if there are appointments it should show a calendar with the days and times highlighted for each appointment" 2. "We should properly stylize the calendar to fit with each theme correctly" 3. "Should also be a relatively small widget basically about the size of the weather widget" Changes (status/ only — frontend): - Chip: empty state now says "No upcoming appointments" (the calendar IS connected — the old "No calendar" string was wrong); with events it shows title + day/time with .cal-chip-when accent highlight. Chip CSS keeps the existing weather-widget proportions (padding 4px 10px, 0.85rem, pill). - Modal: month GRID (cal-grid, 7 columns, prev/next month nav) with: appointment days highlighted (accent-tinted background + border), today ring, per-event dots with time tooltips; Upcoming list kept below with day/time emphasis. Sources manager unchanged functionally, restyled via CSS vars. - Theming: every color via CSS variables (--fg/--muted/--accent/--border/ --base/--hover/--error). Per-theme overrides for dark-accent themes where small accent text fails contrast on dark surfaces (taxi/ocean/ solarized-dark get brightened chip-when/dots; black gets its red accent; solarized-light dark-on-light). User/custom themes derive automatically via color-mix(--accent, white). No hardcoded hex in the shared rules. Bug fixed en route: escapeHtml was previously expected as a global (never defined in this file — latent XSS/ReferenceError if another bundle didn't provide it). Now defined locally. Verification: node --check clean; DC-168 smoke harness (DOM shim) proves empty chip → "No upcoming appointments", events chip → title + highlighted when-span, modal grid renders correct day-cell counts with cal-day/cal-dow structure; eslint 0 errors (parser-options run).
333 lines
15 KiB
JavaScript
333 lines
15 KiB
JavaScript
// ========== CALENDAR WIDGET (DC-161 + DC-168) ==========
|
||
// Zero-config for calendars deployed through DashCaddy (Radicale auto-wiring);
|
||
// manual CalDAV/ICS sources via the ⚙️ settings modal. Weather-widget pattern:
|
||
// compact top-bar display + weather-modal shell + server-backed settings.
|
||
//
|
||
// DC-168 (owner requirements):
|
||
// 1. Empty → chip says "No upcoming appointments" (calendar IS connected).
|
||
// 2. With events → chip shows the next appointment (title + day/time).
|
||
// 3. Modal → month GRID with appointment days highlighted (+ times in
|
||
// tooltip and the Upcoming list), not just a flat list.
|
||
// 4. Chip stays weather-widget size (CSS keeps padding/font identical).
|
||
// 5. Everything styled with CSS variables + [data-theme] overrides in
|
||
// dashboard.css — correct across all themes and user themes.
|
||
(function() {
|
||
const errorHandler = new ErrorHandler();
|
||
|
||
injectModal('calendar-modal', `<div id="calendar-modal" class="weather-modal">
|
||
<div class="weather-modal-content" style="min-width:560px;max-width:720px;">
|
||
<h3>📅 Calendar</h3>
|
||
<div class="cal-monthbar">
|
||
<button id="cal-prev" class="cal-nav-btn" aria-label="Previous month">‹</button>
|
||
<span id="cal-month-label" class="cal-month-label"></span>
|
||
<button id="cal-next" class="cal-nav-btn" aria-label="Next month">›</button>
|
||
</div>
|
||
<div id="cal-grid" class="cal-grid"></div>
|
||
<div id="calendar-upcoming" style="margin:12px 0 14px;">
|
||
<div style="font-size:0.8rem;color:var(--muted);margin-bottom:6px;">Upcoming appointments</div>
|
||
<div id="calendar-upcoming-list" class="cal-list">
|
||
<div class="cal-empty">No upcoming appointments.</div>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border);">
|
||
<label style="display:block;font-weight:600;color:var(--fg);margin-bottom:8px;">Calendars</label>
|
||
<div id="calendar-sources-list" style="display:flex;flex-direction:column;gap:6px;margin-bottom:10px;"></div>
|
||
<details id="calendar-add-details">
|
||
<summary style="cursor:pointer;font-size:0.85rem;color:var(--accent);">+ Add a calendar (CalDAV URL or ICS feed)</summary>
|
||
<div style="margin-top:10px;display:flex;flex-direction:column;gap:8px;">
|
||
<div style="display:flex;gap:8px;">
|
||
<label style="font-size:0.82rem;">Type
|
||
<select id="cal-add-type" style="margin-left:6px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;padding:4px;">
|
||
<option value="caldav">CalDAV</option>
|
||
<option value="ics">ICS feed (read-only)</option>
|
||
</select>
|
||
</label>
|
||
<label style="font-size:0.82rem;flex:1;">Name
|
||
<input type="text" id="cal-add-name" placeholder="Work calendar" maxlength="120" style="width:100%;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;padding:4px 8px;">
|
||
</label>
|
||
</div>
|
||
<label style="font-size:0.82rem;">URL
|
||
<input type="text" id="cal-add-url" placeholder="https://calendar.example.com/user/calendar/" style="width:100%;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;padding:4px 8px;">
|
||
</label>
|
||
<div style="display:flex;gap:8px;">
|
||
<label style="font-size:0.82rem;flex:1;">User
|
||
<input type="text" id="cal-add-user" autocomplete="off" style="width:100%;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;padding:4px 8px;">
|
||
</label>
|
||
<label style="font-size:0.82rem;flex:1;">Password
|
||
<input type="password" id="cal-add-pass" autocomplete="new-password" style="width:100%;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;padding:4px 8px;">
|
||
</label>
|
||
</div>
|
||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||
<button id="cal-add-test">Test connection</button>
|
||
<button id="cal-add-save">Save</button>
|
||
</div>
|
||
<div id="cal-add-status" style="font-size:0.8rem;color:var(--muted);min-height:1em;"></div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
<div class="weather-modal-buttons"><button id="calendar-close">Close</button></div>
|
||
</div></div>`);
|
||
|
||
function escapeHtml(s) {
|
||
return String(s == null ? '' : s)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
const widgetEls = () => ({
|
||
icon: document.querySelector('.calendar-widget-icon'),
|
||
text: document.querySelector('.calendar-widget-text'),
|
||
});
|
||
|
||
function fmtWhen(start, allDay) {
|
||
const d = new Date(start);
|
||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||
const day = new Date(d); day.setHours(0, 0, 0, 0);
|
||
const days = Math.round((day - today) / 86400000);
|
||
const dayLabel = days === 0 ? 'Today' : days === 1 ? 'Tomorrow' :
|
||
d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||
const timeLabel = allDay ? 'all-day' : d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||
return `${dayLabel} ${timeLabel}`;
|
||
}
|
||
|
||
function fmtTime(start, allDay) {
|
||
if (allDay) return 'all-day';
|
||
return new Date(start).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||
}
|
||
|
||
function dayKey(d) {
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
}
|
||
|
||
async function fetchCalendar() {
|
||
// 60-day window: chip needs the next event; grid needs the current month.
|
||
const start = new Date(Date.now() - 86400000);
|
||
const end = new Date(Date.now() + 60 * 86400000);
|
||
const resp = await fetch(`/api/v1/calendar/events?start=${start.toISOString()}&end=${end.toISOString()}`, { cache: 'no-store' });
|
||
if (resp.status === 404) return { notConfigured: true };
|
||
if (!resp.ok) throw new Error(`Calendar API HTTP ${resp.status}`);
|
||
return resp.json();
|
||
}
|
||
|
||
function renderWidget(body) {
|
||
const els = widgetEls();
|
||
if (!els.icon || !els.text) return;
|
||
const events = (body && Array.isArray(body.events)) ? body.events : [];
|
||
if (!events.length) {
|
||
els.icon.textContent = '📅';
|
||
els.text.innerHTML = '<span class="cal-chip-muted">No upcoming appointments</span>';
|
||
return;
|
||
}
|
||
const next = events[0];
|
||
els.icon.textContent = next.allDay ? '🗓️' : '⏰';
|
||
els.text.innerHTML = `<span class="cal-chip-title">${escapeHtml(next.title || 'Untitled')}</span>` +
|
||
`<span class="cal-chip-when">· ${escapeHtml(fmtWhen(next.start, next.allDay))}</span>`;
|
||
}
|
||
|
||
// ---- Month grid ----
|
||
let viewMonth = null; // first day of visible month
|
||
function eventMap(body) {
|
||
const map = {};
|
||
for (const ev of ((body && Array.isArray(body.events)) ? body.events : [])) {
|
||
const k = dayKey(new Date(ev.start));
|
||
(map[k] = map[k] || []).push(ev);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function renderGrid(body) {
|
||
const grid = document.getElementById('cal-grid');
|
||
const label = document.getElementById('cal-month-label');
|
||
if (!grid || !label) return;
|
||
if (!viewMonth) { viewMonth = new Date(); viewMonth.setDate(1); }
|
||
const y = viewMonth.getFullYear(), m = viewMonth.getMonth();
|
||
label.textContent = viewMonth.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
|
||
const byDay = eventMap(body);
|
||
const todayKey = dayKey(new Date());
|
||
const firstDow = new Date(y, m, 1).getDay();
|
||
const daysIn = new Date(y, m + 1, 0).getDate();
|
||
let html = ['S', 'M', 'T', 'W', 'T', 'F', 'S'].map(d => `<div class="cal-dow">${d}</div>`).join('');
|
||
for (let i = 0; i < firstDow; i++) html += '<div class="cal-day cal-pad"></div>';
|
||
for (let day = 1; day <= daysIn; day++) {
|
||
const key = `${y}-${String(m + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||
const dayEvents = byDay[key] || [];
|
||
const cls = ['cal-day'];
|
||
if (key === todayKey) cls.push('cal-today');
|
||
if (dayEvents.length) cls.push('cal-has-events');
|
||
const dots = dayEvents.slice(0, 3).map((e) =>
|
||
`<span class="cal-dot" title="${escapeHtml(fmtTime(e.start, e.allDay))}"></span>`).join('');
|
||
const tip = dayEvents.map(e => `${fmtTime(e.start, e.allDay)} — ${e.title || 'Untitled'}`).join('\n');
|
||
html += `<div class="${cls.join(' ')}" title="${escapeHtml(tip || key)}">` +
|
||
`<span class="cal-daynum">${day}</span>${dots ? `<span class="cal-dots">${dots}</span>` : ''}</div>`;
|
||
}
|
||
grid.innerHTML = html;
|
||
}
|
||
|
||
function renderUpcoming(body) {
|
||
const list = document.getElementById('calendar-upcoming-list');
|
||
if (!list) return;
|
||
const events = (body && Array.isArray(body.events)) ? body.events : [];
|
||
if (!events.length) {
|
||
list.innerHTML = '<div class="cal-empty">No upcoming appointments.</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = events.slice(0, 12).map(ev =>
|
||
`<div class="cal-row">
|
||
<span class="cal-when">${escapeHtml(fmtWhen(ev.start, ev.allDay))}</span>
|
||
<span class="cal-evtitle">${escapeHtml(ev.title || 'Untitled')}</span>
|
||
${ev.location ? `<span class="cal-loc">${escapeHtml(ev.location)}</span>` : ''}
|
||
</div>`).join('');
|
||
}
|
||
|
||
async function renderSources() {
|
||
const listEl = document.getElementById('calendar-sources-list');
|
||
if (!listEl) return;
|
||
let sources = [];
|
||
try {
|
||
const resp = await fetch('/api/v1/calendar/sources', { cache: 'no-store' });
|
||
if (resp.ok) {
|
||
const body = await resp.json();
|
||
sources = (body && Array.isArray(body.sources)) ? body.sources
|
||
: (Array.isArray(body) ? body : []);
|
||
}
|
||
} catch (e) { /* modal still usable */ }
|
||
if (!sources.length) {
|
||
listEl.innerHTML = '<div class="cal-empty">No calendars connected. Deploy Radicale from the App Selector to auto-connect one, or add a CalDAV/ICS URL below.</div>';
|
||
return;
|
||
}
|
||
listEl.innerHTML = sources.map(s => `
|
||
<div class="cal-row cal-src-row">
|
||
<span class="cal-evtitle">${escapeHtml(s.name || s.id)}
|
||
<span class="cal-loc">· ${escapeHtml(s.type)}${s.autoProvisioned ? ' · auto-wired' : ''}</span></span>
|
||
<button class="cal-src-toggle cal-btn" data-id="${escapeHtml(s.id)}" data-enabled="${s.enabled !== false ? '1' : '0'}">${s.enabled !== false ? '✓ on' : 'off'}</button>
|
||
<button class="cal-src-del cal-btn cal-btn-danger" data-id="${escapeHtml(s.id)}">remove</button>
|
||
</div>`).join('');
|
||
listEl.querySelectorAll('.cal-src-toggle').forEach(btn => btn.addEventListener('click', async () => {
|
||
const id = btn.dataset.id;
|
||
const enable = btn.dataset.enabled !== '1';
|
||
await fetch(`/api/v1/calendar/sources/${encodeURIComponent(id)}`, {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: enable })
|
||
});
|
||
renderSources(); refresh();
|
||
}));
|
||
listEl.querySelectorAll('.cal-src-del').forEach(btn => btn.addEventListener('click', async () => {
|
||
const id = btn.dataset.id;
|
||
await fetch(`/api/v1/calendar/sources/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||
renderSources(); refresh();
|
||
}));
|
||
}
|
||
|
||
let lastBody = null;
|
||
async function refresh() {
|
||
if (refresh._inFlight) return;
|
||
refresh._inFlight = true;
|
||
try {
|
||
const body = await fetchCalendar();
|
||
lastBody = body;
|
||
renderWidget(body);
|
||
if (document.getElementById('calendar-modal').style.display === 'flex') {
|
||
renderGrid(body);
|
||
renderUpcoming(body);
|
||
}
|
||
} catch (e) {
|
||
errorHandler.log(e, 'calendar-widget');
|
||
const els = widgetEls();
|
||
if (els.text) els.text.innerHTML = '<span class="cal-chip-muted">Calendar offline</span>';
|
||
} finally {
|
||
refresh._inFlight = false;
|
||
}
|
||
}
|
||
|
||
// ---- Modal wiring ----
|
||
function openModal() {
|
||
document.getElementById('calendar-modal').style.display = 'flex';
|
||
renderGrid(lastBody || { events: [] });
|
||
renderUpcoming(lastBody || { events: [] });
|
||
renderSources();
|
||
refresh();
|
||
}
|
||
function closeModal() {
|
||
document.getElementById('calendar-modal').style.display = 'none';
|
||
}
|
||
|
||
function formCreds() {
|
||
return {
|
||
type: document.getElementById('cal-add-type').value,
|
||
name: document.getElementById('cal-add-name').value.trim(),
|
||
url: document.getElementById('cal-add-url').value.trim(),
|
||
user: document.getElementById('cal-add-user').value.trim(),
|
||
pass: document.getElementById('cal-add-pass').value,
|
||
};
|
||
}
|
||
|
||
function setStatus(msg) {
|
||
document.getElementById('cal-add-status').textContent = msg;
|
||
}
|
||
|
||
document.getElementById('cal-add-test').addEventListener('click', async () => {
|
||
const f = formCreds();
|
||
if (!f.url) { setStatus('Enter a URL first.'); return; }
|
||
setStatus('Testing…');
|
||
try {
|
||
const resp = await fetch('/api/v1/calendar/discover', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url: f.url, user: f.user, pass: f.pass })
|
||
});
|
||
const body = await resp.json();
|
||
const p = body && body.probe;
|
||
if (p && p.ok) setStatus(`✓ Reachable (${p.server || 'server'})${p.looksLikeCalendar ? ' — looks like a calendar' : ''}`);
|
||
else setStatus(`✗ ${p && p.error ? p.error : 'Not reachable'}`);
|
||
} catch (e) {
|
||
setStatus(`✗ ${e.message}`);
|
||
}
|
||
});
|
||
|
||
document.getElementById('cal-add-save').addEventListener('click', async () => {
|
||
const f = formCreds();
|
||
if (!f.name || !f.url) { setStatus('Name and URL are required.'); return; }
|
||
try {
|
||
const resp = await fetch('/api/v1/calendar/sources', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: f.name, type: f.type, url: f.url, user: f.user, pass: f.pass })
|
||
});
|
||
if (!resp.ok) {
|
||
const body = await resp.json().catch(() => ({}));
|
||
setStatus(`✗ ${body.error || body.message || `HTTP ${resp.status}`}`);
|
||
return;
|
||
}
|
||
setStatus('Saved.');
|
||
document.getElementById('cal-add-details').open = false;
|
||
['cal-add-name', 'cal-add-url', 'cal-add-user', 'cal-add-pass'].forEach(id => { document.getElementById(id).value = ''; });
|
||
renderSources(); refresh();
|
||
} catch (e) {
|
||
setStatus(`✗ ${e.message}`);
|
||
}
|
||
});
|
||
|
||
const calBtn = document.getElementById('calendar-widget');
|
||
if (calBtn) calBtn.addEventListener('click', openModal);
|
||
const calSettings = document.getElementById('calendar-settings');
|
||
if (calSettings) calSettings.addEventListener('click', (e) => { e.stopPropagation(); openModal(); });
|
||
const calClose = document.getElementById('calendar-close');
|
||
if (calClose) calClose.addEventListener('click', closeModal);
|
||
const calPrev = document.getElementById('cal-prev');
|
||
if (calPrev) calPrev.addEventListener('click', () => {
|
||
if (!viewMonth) { viewMonth = new Date(); viewMonth.setDate(1); }
|
||
viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1);
|
||
renderGrid(lastBody || { events: [] });
|
||
});
|
||
const calNext = document.getElementById('cal-next');
|
||
if (calNext) calNext.addEventListener('click', () => {
|
||
if (!viewMonth) { viewMonth = new Date(); viewMonth.setDate(1); }
|
||
viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1);
|
||
renderGrid(lastBody || { events: [] });
|
||
});
|
||
|
||
window.refreshCalendarWidget = refresh;
|
||
|
||
const POLL = (typeof DC !== 'undefined' && DC.POLL && DC.POLL.CALENDAR) || 300000;
|
||
setInterval(refresh, POLL);
|
||
setTimeout(refresh, 400);
|
||
})();
|