Files
espresso_frame/server/app/static/widget_dialog_calendar.js
T
tfaour a33a3a71e4
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 1m57s
Build and push server image / deploy (push) Successful in 52s
Widget system Phase 4b: per-widget gear-icon config dialogs
Replaces the Photos/Calendar/Whiteboard tabs with a single Layout page
(now the frame's landing route) where each widget gets a gear icon
opening a dialog scoped to that specific widget's own settings. This
was the missing piece for genuinely independent same-type widgets --
"the Calendar tab" never made sense once a frame could hold more than
one calendar widget with different settings.

Data layer: FrameCalendar re-keyed from frame_id to widget_id, so each
calendar widget has its own independent included-calendars set. The
rekey runs as an unconditional post-startup step (like the existing
widget backfill), not a numbered migration -- it depends on calendar
widgets already existing, which themselves come from that same
backfill step, not from schema migration. Registering it as a numbered
migration would have run it first during a real upgrade, silently
dropping every row; caught by a new test that exercises the raw-SQL
upgrade path instead of the fresh-install create_all() shortcut every
other migration test takes.

API layer: every endpoint that used to assume "the frame's widget of
this type" (photo queue/thumbnail/preview, calendar select/color/
tasks/weather, whiteboard source/browse/preview) moved into
api_widgets.py under /api/frames/{id}/widgets/{widget_id}/..., with a
new require_widget_view/control dependency pair mirroring the existing
frame-level ones. Device status (battery/last-seen/firmware) got its
own frame-level /status endpoint, split out of the old photo-specific
/queue it used to piggyback on -- fixes the status bar going silently
blank on any frame without a photo widget.

UI layer: each widget type's existing settings markup/JS was ported
into a dialog partial + an explicit init/close function pair (the
content is now fetched and injected on demand, not loaded at page load
time). window.FRAME_API is repointed to the open dialog's widget-scoped
API base for its duration and restored on close; a separate
window.FRAME_BASE_API stays stable for the always-present header/
status-bar scripts.

Caught during manual browser testing: the consolidated config-save
endpoint initially expected a JSON body while the copied-over dialog JS
posts form-urlencoded data (the old convention) -- fixed to match, with
new HTTP-level test coverage that would have caught it immediately.
2026-07-24 14:31:24 -04:00

291 lines
12 KiB
JavaScript

// Calendar widget dialog: view/week-start settings, per-user opt-in,
// weather, tasks, and the rendered preview. Not a page-load script --
// frame_layout.js fetches this widget's dialog HTML fragment, injects
// it into the shared <dialog>, points window.FRAME_API at this specific
// widget (/api/frames/{id}/widgets/{widget_id}), then calls
// initCalendarDialog(). Checkboxes are always sent explicitly as
// "true"/"false".
// Week-view-only settings (days/layout/start-offset) only matter when
// View is actually "Week"; "Week starts on" also matters for Month, so
// it gets its own, slightly looser condition. The start-offset row is
// further gated on the day count -- it's meaningless at the default 7
// days, where "Week starts on" governs instead (see
// calendar_render.py's _build_week).
function updateCalendarFieldVisibility() {
const view = document.getElementById('calendar_view').value;
const days = Number(document.getElementById('calendar_week_days').value);
const isWeek = view === 'week';
document.getElementById('calendar-week-start-row').style.display =
(view === 'week' || view === 'month') ? '' : 'none';
document.getElementById('calendar-week-days-row').style.display = isWeek ? '' : 'none';
document.getElementById('calendar-week-layout-row').style.display = isWeek ? '' : 'none';
document.getElementById('calendar-week-offset-row').style.display = (isWeek && days !== 7) ? '' : 'none';
}
function addWeatherCityRow(label) {
const list = document.getElementById('weather-city-list');
const empty = document.getElementById('weather-city-empty');
if (empty) empty.remove();
const li = document.createElement('li');
li.className = 'checkbox-row';
li.style.cssText = 'justify-content: space-between; margin-top: 6px;';
const span = document.createElement('span');
span.textContent = label;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn-inline secondary weather-city-remove';
btn.dataset.label = label;
btn.textContent = 'Remove';
btn.addEventListener('click', removeWeatherCity);
li.appendChild(span);
li.appendChild(btn);
list.appendChild(li);
}
async function removeWeatherCity(e) {
const label = e.target.dataset.label;
try {
const resp = await fetch(`${window.FRAME_API}/weather-cities/remove`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label }),
});
if (!resp.ok) throw new Error(await apiError(resp));
e.target.closest('li').remove();
const list = document.getElementById('weather-city-list');
if (!list.querySelector('li')) {
list.innerHTML = '<li class="sub" id="weather-city-empty">No cities added yet.</li>';
}
showStatus(true, `${label} removed.`);
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
}
// Rewrites #tasks-current-source in place instead of telling the user
// to reload -- the API always assigns a successful "set" to the caller
// (see api_widget_tasks_source), so after either action we already know
// exactly what the new state is without asking the server again.
function renderTasksCurrentSource(label) {
const container = document.getElementById('tasks-current-source');
container.innerHTML = '';
if (!label) return; // matches the template's no-tasks_source branch: nothing rendered
const p = document.createElement('p');
p.className = 'sub';
p.style.marginTop = '10px';
p.append('Currently using your ');
const labelEl = document.createElement('strong');
labelEl.textContent = label;
p.append(labelEl, ' list. ');
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'btn-inline secondary';
clearBtn.id = 'tasks-source-clear';
clearBtn.textContent = 'Clear';
clearBtn.addEventListener('click', clearTasksSource);
p.append(clearBtn);
container.append(p);
}
async function clearTasksSource() {
try {
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: null }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Task list cleared.');
renderTasksCurrentSource(null);
document.querySelectorAll('.tasks-source-choice').forEach((r) => { r.checked = false; });
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
}
function loadCalendarPreview() {
document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`;
}
function initCalendarDialog() {
document.getElementById('calendar_view').addEventListener('change', updateCalendarFieldVisibility);
document.getElementById('calendar_week_days').addEventListener('input', updateCalendarFieldVisibility);
updateCalendarFieldVisibility();
document.getElementById('calendar-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
calendar_view: document.getElementById('calendar_view').value,
calendar_week_start: document.getElementById('calendar_week_start').value,
calendar_week_days: document.getElementById('calendar_week_days').value,
calendar_week_layout: document.getElementById('calendar_week_layout').value,
calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
// Each calendar's own include/mute toggle -- auto-saves on change, not
// batched into the form above, since it's a data-sharing choice (see
// api_widget_calendar_select), not a widget-wide setting. Works the
// same element for your own calendars (full add/remove) and other
// people's (mute only) -- the server enforces which direction is
// allowed and this just reverts the checkbox with an error message if
// rejected.
document.querySelectorAll('.calendar-toggle').forEach((el) => {
el.addEventListener('change', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/calendar-select`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: Number(el.dataset.userId),
calendar_key: el.dataset.key,
calendar_label: el.dataset.label,
included: el.checked,
}),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, el.checked ? 'Calendar included on this widget.' : 'Calendar removed from this widget.');
} catch (e) {
el.checked = !el.checked;
showStatus(false, e.message);
}
});
});
// Per-calendar color pin -- owner-only (the server enforces it; these
// buttons only ever render for the viewer's own calendars anyway).
// Clicking the currently-selected swatch again has no special
// "toggle off" behavior -- use the explicit Auto button.
document.querySelectorAll('.calendar-color-picker').forEach((picker) => {
const key = picker.dataset.key;
picker.querySelectorAll('.color-swatch').forEach((btn) => {
btn.addEventListener('click', async () => {
const colorIndex = btn.dataset.index === '' ? null : Number(btn.dataset.index);
try {
const resp = await fetch(`${window.FRAME_API}/calendar-color`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: key, color_index: colorIndex }),
});
if (!resp.ok) throw new Error(await apiError(resp));
picker.querySelectorAll('.color-swatch').forEach((el) => el.classList.remove('selected'));
btn.classList.add('selected');
showStatus(true, 'Color saved.');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
});
});
document.getElementById('weather-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
calendar_weather_enabled: String(document.getElementById('weather_enabled').checked),
calendar_weather_units: document.getElementById('weather_units').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
document.querySelectorAll('.weather-city-remove').forEach((el) => el.addEventListener('click', removeWeatherCity));
document.getElementById('weather-city-add').addEventListener('click', async () => {
const input = document.getElementById('weather-city-input');
const name = input.value.trim();
if (!name) return;
try {
const resp = await fetch(`${window.FRAME_API}/weather-cities/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!resp.ok) throw new Error(await apiError(resp));
const data = await resp.json();
addWeatherCityRow(data.city.label);
input.value = '';
showStatus(true, `Added ${data.city.label}.`);
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('tasks_enabled').addEventListener('change', async (e) => {
const el = e.target;
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ calendar_tasks_enabled: String(el.checked) }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadCalendarPreview();
} catch (e) {
el.checked = !el.checked;
showStatus(false, e.message);
}
});
// Choosing one of your own CalDAV task lists as this widget's source --
// owner-only (see api_widget_tasks_source), so these radios only ever
// render for the viewer's own calendars anyway.
document.querySelectorAll('.tasks-source-choice').forEach((el) => {
el.addEventListener('change', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: el.dataset.key }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Task list saved.');
const labelEl = el.closest('li').querySelector('label');
renderTasksCurrentSource(labelEl ? labelEl.textContent.trim() : '');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
});
const tasksSourceClear = document.getElementById('tasks-source-clear');
if (tasksSourceClear) {
tasksSourceClear.addEventListener('click', clearTasksSource);
}
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
loadCalendarPreview();
}
function closeCalendarDialog() {
// Nothing to tear down -- no poll interval, unlike the photos dialog.
}