/* * pages/schedule.js -- the Schedule tab (templates/v3/partials/schedule.html): * when the display is on, and when it dims. * * Both forms stay plain htmx (hx-post with json-enc). This module: * - draws the two schedule-picker widgets from the saved config the * partial carries in data-schedule-config / data-dim-schedule-config * - reports each form's save in one notification. The forms used to call * window.handleScheduleResponse / handleDimScheduleResponse from an * hx-on attribute; now one htmx:afterRequest listener on the page root * does it, and the forms carry data-reports-result so app.js does not * show the server's message a second time * - keeps the "30%" label next to the dim brightness slider current * * The widget registry (LEDMatrixWidgets) and the schedule-picker widget are * classic deferred scripts and normally load before this module runs; the * short retry covers a page that arrives first anyway. * * The old globals are deprecated aliases made in core/boot.js; they forward * to the exports of the same name at the bottom of this file. */ const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; const RETRY_MS = 100; const MAX_TRIES = 50; const PICKERS = [ { label: 'Schedule', container: 'schedule_picker_container', data: 'data-schedule-config', fieldId: 'schedule', start: '07:00', end: '23:00', // The display schedule fills in days only when it has no "days" at // all; the dim schedule also when "days" is empty. fillEmptyDays: false, }, { label: 'DimSchedule', container: 'dim_schedule_picker_container', data: 'data-dim-schedule-config', fieldId: 'dim_schedule', start: '20:00', end: '07:00', fillEmptyDays: true, }, ]; // Each plugin's picker (the Plugin Schedules card): the window in which the // plugin may show. Unset, it offers the whole day. const PLUGIN_PICKER = { label: 'PluginSchedule', start: '00:00', end: '23:59', fillEmptyDays: true }; const FORMS = { schedule_form: { saved: 'Schedule settings saved', failed: 'Error saving schedule' }, dim_schedule_form: { saved: 'Dim schedule settings saved', failed: 'Error saving dim schedule' }, }; const PLUGIN_FORM = { saved: 'Plugin schedule saved', failed: 'Error saving plugin schedule' }; /** The notification labels for a form on this page, or null. */ function formLabels(form) { if (FORMS[form.id]) return FORMS[form.id]; return form.classList.contains('plugin-schedule-form') ? PLUGIN_FORM : null; } const WIDGET_OPTIONS = { showModeToggle: true, showEnableToggle: true, compactMode: false }; const PLUGIN_WIDGET_OPTIONS = Object.assign({}, WIDGET_OPTIONS, { enableDescription: 'When enabled, this plugin only shows during the hours below.', }); // The mounted page, for the deprecated aliases (boot.js). let active = null; function readConfig(root, attribute) { try { const parsed = JSON.parse(root.getAttribute(attribute) || 'null'); return parsed && typeof parsed === 'object' ? parsed : {}; } catch { return {}; } } /** The saved config, in the shape the schedule-picker widget takes. */ export function widgetValue(config, spec) { config = config || {}; let mode = 'global'; if (config.mode) { // Normalize mode value (handle both 'per_day' and 'per-day') mode = config.mode.replace('-', '_'); } else if (config.days) { mode = 'per_day'; } const value = { enabled: config.enabled || false, mode: mode, start_time: config.start_time || spec.start, end_time: config.end_time || spec.end, days: config.days || {}, }; const noDays = !config.days || (spec.fillEmptyDays && Object.keys(config.days).length === 0); if (noDays) { value.days = Object.fromEntries(DAYS.map(function(day) { return [day, { enabled: true, start_time: spec.start, end_time: spec.end }]; })); } return value; } /** One notification for a schedule form's answer. */ function report(notify, xhr, labels) { let response; try { response = JSON.parse(xhr.responseText); } catch { response = { status: 'error', message: 'Invalid response from server' }; } if (!response || typeof response !== 'object') { response = { status: 'error', message: 'Invalid response from server' }; } const message = response.message || (response.status === 'success' ? labels.saved : labels.failed); notify(message, response.status || 'info'); } function drawPickers(root, ctx) { const win = root.ownerDocument.defaultView; let tries = 0; function attempt() { ctx.state.timer = null; if (ctx.signal.aborted) return; const registry = win.LEDMatrixWidgets; const widget = registry && registry.get('schedule-picker'); if (!widget) { if (++tries < MAX_TRIES) { ctx.state.timer = win.setTimeout(attempt, RETRY_MS); } else { win.console.error(registry ? '[Schedule] schedule-picker widget not registered' : '[Schedule] LEDMatrixWidgets registry not available'); } return; } PICKERS.forEach(function(spec) { const container = root.querySelector('#' + spec.container); if (!container) { win.console.error('[' + spec.label + '] Container not found'); return; } widget.render(container, { 'x-options': WIDGET_OPTIONS }, widgetValue(readConfig(root, spec.data), spec), { fieldId: spec.fieldId }); }); root.querySelectorAll('[data-plugin-schedule-config]').forEach(function(container) { widget.render(container, { 'x-options': PLUGIN_WIDGET_OPTIONS }, widgetValue(readConfig(container, 'data-plugin-schedule-config'), PLUGIN_PICKER), { fieldId: container.id }); }); } attempt(); } export function init(root, ctx) { const on = { signal: ctx.signal }; root.addEventListener('htmx:afterRequest', function(event) { const form = event.target && event.target.closest ? event.target.closest('form') : null; const labels = form && root.contains(form) ? formLabels(form) : null; if (labels && event.detail && event.detail.xhr) report(ctx.notify, event.detail.xhr, labels); }, on); const slider = root.querySelector('#dim_brightness'); const label = root.querySelector('#dim_brightness_display'); if (slider && label) { slider.addEventListener('input', function() { label.textContent = slider.value + '%'; }, on); } drawPickers(root, ctx); active = ctx; } export function destroy(root, ctx) { if (ctx.state.timer) { root.ownerDocument.defaultView.clearTimeout(ctx.state.timer); ctx.state.timer = null; } if (active === ctx) active = null; } // ── the old globals, kept as deprecated aliases (boot.js) ──────────────────── function notifier(event) { if (active) return active.notify; const win = event && event.target && event.target.ownerDocument ? event.target.ownerDocument.defaultView : globalThis; return function(message, type) { return win.showNotification(message, type); }; } /** window.handleScheduleResponse(event): an htmx:afterRequest event. */ export function handleScheduleResponse(event) { report(notifier(event), event.detail.xhr, FORMS.schedule_form); } /** window.handleDimScheduleResponse(event): an htmx:afterRequest event. */ export function handleDimScheduleResponse(event) { report(notifier(event), event.detail.xhr, FORMS.dim_schedule_form); }