Files
dashcaddy/status/js/calendar-widget.js
T
Hermes cf2457b494 [pending-judge] feat(DC-168): calendar widget UX + theming — "No upcoming appointments" state, month grid with highlighted appointment days, theme-correct styling
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).
2026-10-11 23:40:46 -07:00

333 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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);
})();