From cf2457b49413a49130c2bf5d6272f7ece9a97619 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sun, 11 Oct 2026 23:40:46 -0700 Subject: [PATCH] =?UTF-8?q?[pending-judge]=20feat(DC-168):=20calendar=20wi?= =?UTF-8?q?dget=20UX=20+=20theming=20=E2=80=94=20"No=20upcoming=20appointm?= =?UTF-8?q?ents"=20state,=20month=20grid=20with=20highlighted=20appointmen?= =?UTF-8?q?t=20days,=20theme-correct=20styling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- status/css/dashboard.css | 110 +++++++++++++++++++++++++++++ status/js/calendar-widget.js | 132 +++++++++++++++++++++++++++++------ 2 files changed, 219 insertions(+), 23 deletions(-) diff --git a/status/css/dashboard.css b/status/css/dashboard.css index 379ca5d..788ac11 100644 --- a/status/css/dashboard.css +++ b/status/css/dashboard.css @@ -4247,3 +4247,113 @@ button:focus-visible { .top-row[data-logo-pos="left"] .calendar-widget-container { order: 3; } .top-row[data-logo-pos="center"] .calendar-widget-container { order: 3; } .top-row[data-logo-pos="right"] .calendar-widget-container { order: 3; margin-top: -80px; } + +/* ========== CALENDAR WIDGET — DC-168 (month grid + themed chip) ========== */ +/* Chip: weather-widget sized (same padding/font as before); all colors via + CSS variables so every theme + user theme renders correctly. */ + +/* Chip text states */ +.calendar-widget .cal-chip-muted { color: var(--muted); } +.calendar-widget .cal-chip-title { color: var(--fg); font-weight: 600; } +.calendar-widget .cal-chip-when { + color: var(--cal-chip-when, var(--accent)); + font-size: 0.78em; + font-weight: 600; + margin-left: 4px; +} + +/* Month navigation bar */ +.cal-monthbar { + display: flex; align-items: center; justify-content: center; gap: 10px; + margin-bottom: 8px; +} +.cal-month-label { font-weight: 600; color: var(--fg); min-width: 130px; text-align: center; } +.cal-nav-btn { + background: var(--base); color: var(--fg); + border: 1px solid var(--border); border-radius: 6px; + padding: 2px 10px; cursor: pointer; font-size: 0.95rem; line-height: 1.3; +} +.cal-nav-btn:hover { background: var(--hover); border-color: var(--accent); } + +/* Month grid */ +.cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 3px; + margin-bottom: 4px; +} +.cal-dow { + text-align: center; font-size: 0.68rem; font-weight: 700; + color: var(--muted); text-transform: uppercase; padding: 2px 0; +} +.cal-day { + position: relative; + min-height: 34px; + border: 1px solid var(--border); + border-radius: 6px; + padding: 2px 4px; + font-size: 0.72rem; + color: var(--fg); + background: transparent; + display: flex; flex-direction: column; align-items: flex-start; justify-content: space-between; +} +.cal-day.cal-pad { border-color: transparent; } +.cal-day.cal-today { + border-color: var(--accent); + box-shadow: inset 0 0 0 1px var(--accent); +} +.cal-day.cal-has-events { + background: color-mix(in srgb, var(--accent) 16%, transparent); + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); + font-weight: 600; +} +.cal-day.cal-has-events.cal-today { + background: color-mix(in srgb, var(--accent) 26%, transparent); +} +.cal-dots { display: flex; gap: 2px; } +.cal-dot { + width: 5px; height: 5px; border-radius: 50%; + background: var(--cal-dot, var(--accent-strong)); +} + +/* Upcoming list rows */ +.cal-list { display: flex; flex-direction: column; gap: 4px; max-height: 180px; overflow-y: auto; } +.cal-row { display: flex; gap: 8px; align-items: baseline; } +.cal-when { font-size: 0.78rem; color: var(--cal-when, var(--accent)); white-space: nowrap; font-weight: 600; } +.cal-evtitle { font-size: 0.85rem; color: var(--fg); } +.cal-loc { font-size: 0.75rem; color: var(--muted); } +.cal-empty { color: var(--muted); font-size: 0.85rem; } + +/* Sources rows */ +.cal-src-row { + border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; + align-items: center; +} +.cal-btn { + font-size: 0.75rem; padding: 2px 8px; + background: var(--base); color: var(--fg); + border: 1px solid var(--border); border-radius: 4px; cursor: pointer; +} +.cal-btn:hover { background: var(--hover); border-color: var(--accent); } +.cal-btn-danger { color: var(--error, var(--bad-fg)); } + + +/* ========== DC-168: per-theme calendar chip contrast ========== */ +/* Small text (0.78em) must stay readable: on dark themes with dark accents, + brighten the chip-when/when colors; light themes keep the accent. */ +:root.taxi .calendar-widget .cal-chip-when, +:root.taxi .cal-when { --cal-chip-when: #e8b26a; --cal-when: #e8b26a; --cal-dot: #e8b26a; } +:root.ocean .calendar-widget .cal-chip-when, +:root.ocean .cal-when { --cal-chip-when: #7fb8e8; --cal-when: #7fb8e8; --cal-dot: #7fb8e8; } +:root.solarized-dark .calendar-widget .cal-chip-when, +:root.solarized-dark .cal-when { --cal-chip-when: #6fb3e0; --cal-when: #6fb3e0; --cal-dot: #6fb3e0; } +:root.solarized-light .cal-when { --cal-when: #1f6bab; --cal-dot: #1f6bab; } +:root.black .calendar-widget .cal-chip-when, +:root.black .cal-when { --cal-chip-when: #ff6b76; --cal-dot: #ff6b76; } + +/* User/custom themes: derive from --accent automatically ( lighten on dark). */ +:root.mytheme .calendar-widget .cal-chip-when, +:root.mytheme .cal-when { + --cal-chip-when: color-mix(in srgb, var(--accent) 70%, white); + --cal-when: color-mix(in srgb, var(--accent) 70%, white); +} diff --git a/status/js/calendar-widget.js b/status/js/calendar-widget.js index b5f7e74..966daa8 100644 --- a/status/js/calendar-widget.js +++ b/status/js/calendar-widget.js @@ -1,17 +1,32 @@ -// ========== CALENDAR WIDGET (DC-161) ========== +// ========== 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', `

📅 Calendar

-
-
Next 14 days
-
-
No upcoming appointments.
+
+ + + +
+
+
+
Upcoming appointments
+
+
No upcoming appointments.
@@ -53,6 +68,12 @@
`); + function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + const widgetEls = () => ({ icon: document.querySelector('.calendar-widget-icon'), text: document.querySelector('.calendar-widget-text'), @@ -69,9 +90,19 @@ 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() + 14 * 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}`); @@ -84,13 +115,52 @@ const events = (body && Array.isArray(body.events)) ? body.events : []; if (!events.length) { els.icon.textContent = '📅'; - els.text.textContent = 'No calendar'; + els.text.innerHTML = 'No upcoming appointments'; return; } const next = events[0]; els.icon.textContent = next.allDay ? '🗓️' : '⏰'; - els.text.innerHTML = `${escapeHtml(next.title || 'Untitled')} - · ${escapeHtml(fmtWhen(next.start, next.allDay))}`; + els.text.innerHTML = `${escapeHtml(next.title || 'Untitled')}` + + `· ${escapeHtml(fmtWhen(next.start, next.allDay))}`; + } + + // ---- 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 => `
${d}
`).join(''); + for (let i = 0; i < firstDow; i++) html += '
'; + 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) => + ``).join(''); + const tip = dayEvents.map(e => `${fmtTime(e.start, e.allDay)} — ${e.title || 'Untitled'}`).join('\n'); + html += `
` + + `${day}${dots ? `${dots}` : ''}
`; + } + grid.innerHTML = html; } function renderUpcoming(body) { @@ -98,14 +168,14 @@ if (!list) return; const events = (body && Array.isArray(body.events)) ? body.events : []; if (!events.length) { - list.innerHTML = '
No upcoming appointments.
'; + list.innerHTML = '
No upcoming appointments.
'; return; } list.innerHTML = events.slice(0, 12).map(ev => - `
- ${escapeHtml(fmtWhen(ev.start, ev.allDay))} - ${escapeHtml(ev.title || 'Untitled')} - ${ev.location ? `${escapeHtml(ev.location)}` : ''} + `
+ ${escapeHtml(fmtWhen(ev.start, ev.allDay))} + ${escapeHtml(ev.title || 'Untitled')} + ${ev.location ? `${escapeHtml(ev.location)}` : ''}
`).join(''); } @@ -122,16 +192,15 @@ } } catch (e) { /* modal still usable */ } if (!sources.length) { - listEl.innerHTML = '
No calendars connected. Deploy Radicale from the App Selector to auto-connect one, or add a CalDAV/ICS URL below.
'; + listEl.innerHTML = '
No calendars connected. Deploy Radicale from the App Selector to auto-connect one, or add a CalDAV/ICS URL below.
'; return; } listEl.innerHTML = sources.map(s => ` -
- ${escapeHtml(s.name || s.id)} - · ${escapeHtml(s.type)}${s.autoProvisioned ? ' · auto-wired' : ''} - - +
+ ${escapeHtml(s.name || s.id)} + · ${escapeHtml(s.type)}${s.autoProvisioned ? ' · auto-wired' : ''} + +
`).join(''); listEl.querySelectorAll('.cal-src-toggle').forEach(btn => btn.addEventListener('click', async () => { const id = btn.dataset.id; @@ -149,27 +218,32 @@ })); } + 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.textContent = 'Calendar offline'; + if (els.text) els.text.innerHTML = 'Calendar offline'; } finally { refresh._inFlight = false; } } - // ---- Settings modal wiring ---- + // ---- Modal wiring ---- function openModal() { document.getElementById('calendar-modal').style.display = 'flex'; + renderGrid(lastBody || { events: [] }); + renderUpcoming(lastBody || { events: [] }); renderSources(); refresh(); } @@ -237,6 +311,18 @@ 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;