[pending-judge] feat(DC-161): calendar widget + Radicale auto-wiring
- Top-bar calendar widget (status/js/calendar-widget.js): next-appointment
display, settings modal with CalDAV/ICS source management and a
Test-connection probe. Follows the weather-widget pattern.
- Backend routes/calendar.js: sources CRUD (secrets in credential manager,
never in responses), aggregated events across sources with per-source
error isolation, Radicale provision endpoint, CalDAV discover probe.
- src/calendar/ics-parser.js: dependency-free RFC 5545 parser with bounded
RRULE expansion (DAILY/WEEKLY-BYDAY/MONTHLY/YEARLY, COUNT/UNTIL/INTERVAL,
EXDATE consumes COUNT, RECURRENCE-ID overrides), 500-event cap.
- src/calendar/caldav-client.js: CalDAV REPORT time-range queries over
node:https (no new deps), HTTPS-enforced off localhost, 5MiB response cap.
- src/calendar/radicale-provisioner.js: zero-config auto-wiring - generates
a 24-char password, writes Radicale htpasswd config (pure-Node APR1 hash
verified byte-for-byte against openssl passwd -apr1 vectors), stores
credentials, registers the source. Deploy hook in routes/apps/deploy.js
wires any Radicale deployed via the App Selector automatically.
- Radicale app template (tomsquest/docker-radicale) in the catalog.
- Tests: 32 new (parser unit, provisioner unit incl. openssl vectors,
route tests). Full suite 3552 passed; only pre-existing pdf-parse
billing/invoice failures remain (proven red at base 448f535).
- OpenAPI: 5 calendar paths documented. CHANGELOG entry added.
This commit is contained in:
18 files changed
+1845
No files matched your search
@@ -81,6 +81,9 @@ const bundles = {
|
||||
JS('deploys.js'),
|
||||
JS('weather.js'),
|
||||
JS('clock.js'),
|
||||
// DC-161: calendar widget — top-bar display + sources modal. Reads
|
||||
// /api/v1/calendar/*; auto-shows Radicale calendars deployed via DashCaddy.
|
||||
JS('calendar-widget.js'),
|
||||
JS('card-badges.js'),
|
||||
JS('theme-builder.js'),
|
||||
JS('license.js'),
|
||||
|
||||
@@ -4200,3 +4200,50 @@ button:focus-visible {
|
||||
font-size: clamp(1rem, 6vw, 1.4rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Calendar Widget (DC-161) — mirrors weather-widget placement ===== */
|
||||
.calendar-widget-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.calendar-widget {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fg);
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.calendar-widget:hover,
|
||||
.calendar-widget:focus-visible {
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.calendar-widget-icon {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.calendar-widget-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
/* Top-row position variants — calendar sits after the clock widget */
|
||||
.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; }
|
||||
@@ -132,6 +132,15 @@
|
||||
<button id="clock-settings" class="clock-settings-btn" aria-label="Clock settings">⚙️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar widget (DC-161) — auto-wires to DashCaddy-deployed Radicale -->
|
||||
<div class="calendar-widget-container">
|
||||
<div id="calendar-widget" class="calendar-widget" title="Calendar — click for details" role="button" tabindex="0" aria-label="Calendar widget">
|
||||
<div class="calendar-widget-icon">📅</div>
|
||||
<span class="calendar-widget-text">Calendar</span>
|
||||
<button id="calendar-settings" class="weather-settings-btn-bottom" aria-label="Calendar settings">⚙️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- License status + Reload Caddy Button - top right corner -->
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// ========== CALENDAR WIDGET (DC-161) ==========
|
||||
// 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.
|
||||
(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 id="calendar-upcoming" style="margin:0 0 14px;">
|
||||
<div style="font-size:0.8rem;color:var(--muted);margin-bottom:6px;">Next 14 days</div>
|
||||
<div id="calendar-upcoming-list" style="display:flex;flex-direction:column;gap:4px;max-height:180px;overflow-y:auto;">
|
||||
<div style="color:var(--muted);font-size:0.85rem;">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>`);
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
async function fetchCalendar() {
|
||||
const start = new Date(Date.now() - 86400000);
|
||||
const end = new Date(Date.now() + 14 * 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.textContent = 'No calendar';
|
||||
return;
|
||||
}
|
||||
const next = events[0];
|
||||
els.icon.textContent = next.allDay ? '🗓️' : '⏰';
|
||||
els.text.innerHTML = `${escapeHtml(next.title || 'Untitled')}
|
||||
<span style="color:var(--muted);font-size:0.78em;">· ${escapeHtml(fmtWhen(next.start, next.allDay))}</span>`;
|
||||
}
|
||||
|
||||
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 style="color:var(--muted);font-size:0.85rem;">No upcoming appointments.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = events.slice(0, 12).map(ev =>
|
||||
`<div style="display:flex;gap:8px;align-items:baseline;">
|
||||
<span style="font-size:0.78rem;color:var(--muted);white-space:nowrap;">${escapeHtml(fmtWhen(ev.start, ev.allDay))}</span>
|
||||
<span style="font-size:0.85rem;">${escapeHtml(ev.title || 'Untitled')}</span>
|
||||
${ev.location ? `<span style="font-size:0.75rem;color:var(--muted);">${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 style="font-size:0.82rem;color:var(--muted);">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 style="display:flex;gap:8px;align-items:center;border:1px solid var(--border);border-radius:6px;padding:6px 10px;">
|
||||
<span style="font-size:0.85rem;flex:1;">${escapeHtml(s.name || s.id)}
|
||||
<span style="color:var(--muted);font-size:0.72rem;">· ${escapeHtml(s.type)}${s.autoProvisioned ? ' · auto-wired' : ''}</span></span>
|
||||
<button class="cal-src-toggle" data-id="${escapeHtml(s.id)}" data-enabled="${s.enabled !== false ? '1' : '0'}"
|
||||
style="font-size:0.75rem;padding:2px 8px;">${s.enabled !== false ? '✓ on' : 'off'}</button>
|
||||
<button class="cal-src-del" data-id="${escapeHtml(s.id)}" style="font-size:0.75rem;padding:2px 8px;color:var(--err-fg);">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();
|
||||
}));
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (refresh._inFlight) return;
|
||||
refresh._inFlight = true;
|
||||
try {
|
||||
const body = await fetchCalendar();
|
||||
renderWidget(body);
|
||||
if (document.getElementById('calendar-modal').style.display === 'flex') {
|
||||
renderUpcoming(body);
|
||||
}
|
||||
} catch (e) {
|
||||
errorHandler.log(e, 'calendar-widget');
|
||||
const els = widgetEls();
|
||||
if (els.text) els.text.textContent = 'Calendar offline';
|
||||
} finally {
|
||||
refresh._inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Settings modal wiring ----
|
||||
function openModal() {
|
||||
document.getElementById('calendar-modal').style.display = 'flex';
|
||||
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);
|
||||
|
||||
window.refreshCalendarWidget = refresh;
|
||||
|
||||
const POLL = (typeof DC !== 'undefined' && DC.POLL && DC.POLL.CALENDAR) || 300000;
|
||||
setInterval(refresh, POLL);
|
||||
setTimeout(refresh, 400);
|
||||
})();
|
||||
@@ -24,6 +24,7 @@ const DC = {
|
||||
LOGS: 3000, // 3s — log viewer updates
|
||||
STATS: (typeof window !== 'undefined' && window.__DC_STATS_OVERRIDE) || 5000, // 5s default — resource monitor refresh (overridable via Settings → Health)
|
||||
WEATHER: 600000, // 10m — weather widget refresh
|
||||
CALENDAR: 300000, // 5m — calendar widget refresh (DC-161)
|
||||
HEALTH: 1000, // 1s — card health badge refresh
|
||||
DEPLOY_SSL: 5000, // 5s — SSL cert check during deploy
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user