Files
LEDMatrix/web_interface/static/v3/js/widgets/schedule-picker.js
T
ChuckandClaude Opus 5.5 1fe7965f5c feat(schedule): per-plugin schedules and hide-while-live (#799)
* feat(schedule): per-plugin schedules and hide-while-live

Users asked to turn off news and stocks on gameday so only scores show.
Each plugin can now have its own schedule (global or per-day window in
which it may show) plus hide_when_live, which hides it whenever any
plugin reports a live game. A hidden plugin is skipped in the rotation,
the live takeover and the Vegas ticker; it stays loaded, and on-demand
still shows it.

- src/plugin_schedule.py: the window rule, now shared with the display
  schedule (_check_schedule calls schedule_state)
- core config key plugin_schedules; GET/POST /api/v3/config/plugin-schedules
- Schedule page: Plugin Schedules card, one picker per enabled plugin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): tolerate wrong-shaped schedules, quiet skipped modes

- schedule_state treats a non-string mode, non-dict days or a non-dict
  day entry as missing instead of raising in the display loop
- the run loop no longer warns "No plugin manager found" for a mode it
  skips: _plugin_to_draw / _plugin_for_mode already log why (a
  scheduled-off plugin at DEBUG)
- Schedule page DOM test: every form is marked data-reports-result, not
  exactly two (each plugin adds one)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): guard plugin_schedules shape and the cross-thread cache

- a plugin_schedules that is not a dict (list, string) hides nothing
  instead of raising in the display loop
- the hidden-set cache keys on the schedules themselves and is published
  as one tuple, so a Vegas-thread call still holding the pre-reload
  config cannot leave its answer standing after a reload; the separate
  invalidation in _refresh_config_cache is gone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): no live hold on a scheduled-off mode; blank an empty rotation

- _still_live ignores a plugin its schedule hides, so a rotation landing
  on a scheduled-off live mode advances instead of holding a mode it skips
- once a whole rotation has had nothing to show (every plugin empty or
  scheduled off), the panel is cleared once instead of leaving the last
  screen up

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): push the empty-rotation blank to the web preview

clear() alone blanked the matrix but left the preview snapshot on the
last frame (seen on ledpi with every plugin scheduled off). Follow it
with update_display(), as the scheduled-off blank does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): write the empty-rotation blank to the preview

The preview throttle skipped the blank frame (written 12 s after the
last snapshot), and write_owed_snapshot() only runs after a display()
call, which an all-empty rotation never makes: on ledpi the preview
kept the startup splash while the panel was dark. Flush the owed
snapshot on each paused empty pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): quiet the once-a-second logs when nothing can show

With every plugin scheduled off (seen on ledpi), the loop retries once
a second and journaled three to four lines each time: Vegas's
"No plugins available" WARNING, the coordinator's "Failed to initialize
stream manager" ERROR, the 0-plugin rotation summary and "Switching to
mode". Vegas now warns once until its list fills again, the coordinator
line is DEBUG (initialize only fails for an empty list), and the
mode-switch line is DEBUG once a whole rotation has been empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): end the scroll before the empty blank; Vegas sees hidden changes sooner

- the empty-rotation blank is controller-drawn, so it ends the last
  scroll's state first (_end_scroll_before_core_screen), as the
  scheduled-off blank does
- StreamManager.refresh() rebuilds the plugin list when the hidden set
  changed, even inside its 30 s interval, so a newly hidden plugin leaves
  at the next scroll group rather than staying for later ones

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:16:47 -04:00

568 lines
26 KiB
JavaScript

/**
* LEDMatrix Schedule Picker Widget
*
* Composite widget combining enable toggle, mode switch (global/per-day),
* and time range configurations. Composes day-selector and time-range widgets.
*
* Can be used standalone in schedule.html or by plugins via x-widget: "schedule-picker".
*
* Schema example:
* {
* "schedule": {
* "type": "object",
* "x-widget": "schedule-picker",
* "x-options": {
* "showModeToggle": true, // Allow switching global/per-day
* "showEnableToggle": true, // Show enabled checkbox
* "compactMode": false, // Compact layout for embedded use
* "defaultMode": "global" // Default mode: "global" or "per_day"
* }
* }
* }
*
* API-compatible output format:
* {
* enabled: boolean,
* mode: "global" | "per_day",
* start_time: "HH:MM", // if global mode
* end_time: "HH:MM", // if global mode
* days: { // if per_day mode
* monday: { enabled: boolean, start_time: "HH:MM", end_time: "HH:MM" },
* ...
* }
* }
*
* @module SchedulePickerWidget
*/
(function() {
'use strict';
const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
const DAY_LABELS = {
monday: 'Monday',
tuesday: 'Tuesday',
wednesday: 'Wednesday',
thursday: 'Thursday',
friday: 'Friday',
saturday: 'Saturday',
sunday: 'Sunday'
};
const DAY_LABEL_MAP = new Map(Object.entries(DAY_LABELS));
// Use BaseWidget utilities if available
const base = window.BaseWidget ? new window.BaseWidget('SchedulePicker', '1.0.0') : null;
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
}
function triggerChange(fieldId, value) {
if (base) {
base.triggerChange(fieldId, value);
} else {
const event = new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true,
cancelable: true
});
document.dispatchEvent(event);
}
}
/**
* Generate default schedule config
*/
function getDefaultSchedule() {
const days = {};
DAYS.forEach(day => {
days[day] = {
enabled: true,
start_time: '07:00',
end_time: '23:00'
};
});
return {
enabled: false,
mode: 'global',
start_time: '07:00',
end_time: '23:00',
days: days
};
}
/**
* Coerce a value to boolean, handling string 'true'/'false' values
* that may come from config files or form submissions.
*/
function coerceToBoolean(value) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const trimmed = value.trim().toLowerCase();
return trimmed === 'true' || trimmed === '1' || trimmed === 'on';
}
return Boolean(value);
}
/**
* Normalize mode value to handle both 'per_day' and 'per-day' variants.
*/
function normalizeMode(mode) {
if (!mode || typeof mode !== 'string') {
return 'global';
}
// Normalize: replace hyphens with underscores and check for per_day
const normalized = mode.trim().toLowerCase().replace(/-/g, '_');
return normalized === 'per_day' ? 'per_day' : 'global';
}
/**
* Merge user value with defaults
*/
function normalizeSchedule(value) {
const defaults = getDefaultSchedule();
if (!value || typeof value !== 'object') {
return defaults;
}
const schedule = {
enabled: coerceToBoolean(value.enabled),
mode: normalizeMode(value.mode),
start_time: value.start_time || defaults.start_time,
end_time: value.end_time || defaults.end_time,
days: {}
};
// Merge days
DAYS.forEach(day => {
const dayConfig = (value.days && value.days[day]) || defaults.days[day];
// Use coerceToBoolean but default to true if enabled is undefined
const dayEnabled = dayConfig.enabled === undefined ? true : coerceToBoolean(dayConfig.enabled);
schedule.days[day] = {
enabled: dayEnabled,
start_time: dayConfig.start_time || defaults.days[day].start_time,
end_time: dayConfig.end_time || defaults.days[day].end_time
};
});
return schedule;
}
window.LEDMatrixWidgets.register('schedule-picker', {
name: 'Schedule Picker Widget',
version: '1.0.0',
/**
* Render the schedule picker widget
* @param {HTMLElement} container - Container element
* @param {Object} config - Schema configuration
* @param {Object} value - Schedule configuration object
* @param {Object} options - Additional options (fieldId, pluginId)
*/
render: function(container, config, value, options) {
const fieldId = sanitizeId(options.fieldId || container.id || 'schedule');
const xOptions = config['x-options'] || config['x_options'] || {};
const showModeToggle = xOptions.showModeToggle !== false;
const showEnableToggle = xOptions.showEnableToggle !== false;
const schedule = normalizeSchedule(value);
let html = `<div id="${fieldId}_widget" class="schedule-picker-widget" data-field-id="${fieldId}">`;
// Hidden inputs for API-compatible form submission
html += this._renderHiddenInputs(fieldId, schedule);
// Enable toggle
if (showEnableToggle) {
html += `
<div class="bg-blue-50 rounded-lg p-4 mb-4">
<label class="flex items-center cursor-pointer">
<input type="checkbox"
id="${fieldId}_enabled"
${schedule.enabled ? 'checked' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onEnabledChange('${fieldId}', this.checked)"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Enable Schedule</span>
</label>
<p class="mt-1 text-sm text-gray-600">${window.LEDEscape.html(xOptions.enableDescription || 'When enabled, the display will only operate during specified hours.')}</p>
</div>
`;
}
// Mode selection
if (showModeToggle) {
html += `
<div class="bg-gray-50 rounded-lg p-4 mb-4">
<h3 class="text-md font-medium text-gray-900 mb-4">Schedule Mode</h3>
<div class="space-y-3">
<label class="flex items-center cursor-pointer">
<input type="radio"
name="${fieldId}_mode"
value="global"
id="${fieldId}_mode_global"
${schedule.mode === 'global' ? 'checked' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onModeChange('${fieldId}', 'global')"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300">
<span class="ml-2 text-sm font-medium text-gray-900">Global Schedule</span>
</label>
<p class="ml-6 text-sm text-gray-600">Use the same start and end time for all days of the week</p>
<label class="flex items-center cursor-pointer mt-4">
<input type="radio"
name="${fieldId}_mode"
value="per_day"
id="${fieldId}_mode_per_day"
${schedule.mode === 'per_day' ? 'checked' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onModeChange('${fieldId}', 'per_day')"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300">
<span class="ml-2 text-sm font-medium text-gray-900">Per-Day Schedule</span>
</label>
<p class="ml-6 text-sm text-gray-600">Set different times for each day of the week</p>
</div>
</div>
`;
}
// Global schedule section
const globalDisplay = schedule.mode === 'global' ? 'block' : 'none';
html += `
<div id="${fieldId}_global_section" class="bg-gray-50 rounded-lg p-4 mb-4" style="display: ${globalDisplay};">
<h3 class="text-md font-medium text-gray-900 mb-4">Global Times</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group">
<label for="${fieldId}_global_start" class="block text-sm font-medium text-gray-700">Start Time</label>
<input type="time"
id="${fieldId}_global_start"
value="${window.LEDEscape.html(schedule.start_time)}"
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onGlobalTimeChange('${fieldId}')"
class="form-control mt-1">
<p class="mt-1 text-sm text-gray-600">When to start displaying content (HH:MM)</p>
</div>
<div class="form-group">
<label for="${fieldId}_global_end" class="block text-sm font-medium text-gray-700">End Time</label>
<input type="time"
id="${fieldId}_global_end"
value="${window.LEDEscape.html(schedule.end_time)}"
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onGlobalTimeChange('${fieldId}')"
class="form-control mt-1">
<p class="mt-1 text-sm text-gray-600">When to stop displaying content (HH:MM)</p>
</div>
</div>
</div>
`;
// Per-day schedule section
const perDayDisplay = schedule.mode === 'per_day' ? 'block' : 'none';
html += `
<div id="${fieldId}_perday_section" style="display: ${perDayDisplay};">
<h3 class="text-md font-medium text-gray-900 mb-4">Day-Specific Times</h3>
<div class="bg-gray-50 rounded-lg p-4">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr class="bg-gray-100">
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-700 uppercase">Day</th>
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-700 uppercase">Enabled</th>
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-700 uppercase">Start</th>
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-700 uppercase">End</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
`;
// Render each day row
DAYS.forEach(day => {
const dayConfig = schedule.days[day];
const dayLabel = window.LEDEscape.html(DAY_LABEL_MAP.get(day) || day);
const disabled = !dayConfig.enabled;
const disabledClass = disabled ? 'bg-gray-100' : '';
html += `
<tr class="hover:bg-gray-50" id="${fieldId}_row_${day}">
<td class="px-3 py-2 whitespace-nowrap">
<span class="text-sm font-medium text-gray-900">${dayLabel}</span>
</td>
<td class="px-3 py-2 whitespace-nowrap">
<input type="checkbox"
id="${fieldId}_${day}_enabled"
aria-label="${dayLabel} enabled"
${dayConfig.enabled ? 'checked' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayEnabledChange('${fieldId}', '${day}', this.checked)"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
</td>
<td class="px-3 py-2 whitespace-nowrap">
<input type="time"
id="${fieldId}_${day}_start"
aria-label="${dayLabel} start time"
value="${window.LEDEscape.html(dayConfig.start_time)}"
${disabled ? 'disabled' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')"
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 ${disabledClass}">
</td>
<td class="px-3 py-2 whitespace-nowrap">
<input type="time"
id="${fieldId}_${day}_end"
aria-label="${dayLabel} end time"
value="${window.LEDEscape.html(dayConfig.end_time)}"
${disabled ? 'disabled' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')"
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 ${disabledClass}">
</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
</div>
</div>
`;
html += '</div>';
container.innerHTML = html;
},
/**
* Render hidden inputs for form submission
* These match the existing API format
*/
_renderHiddenInputs: function(fieldId, schedule) {
let html = '';
// Enabled state (hidden input ensures value is always sent, even when checkbox is unchecked)
html += `<input type="hidden" id="${fieldId}_enabled_hidden" name="enabled" value="${schedule.enabled}">`;
// Mode indicator (for the widget to track internally)
html += `<input type="hidden" id="${fieldId}_mode_value" name="mode" value="${schedule.mode}">`;
// Global times (used when mode is global)
html += `<input type="hidden" id="${fieldId}_start_time_hidden" name="start_time" value="${window.LEDEscape.html(schedule.start_time)}">`;
html += `<input type="hidden" id="${fieldId}_end_time_hidden" name="end_time" value="${window.LEDEscape.html(schedule.end_time)}">`;
// Per-day values (used when mode is per_day)
DAYS.forEach(day => {
const dayConfig = schedule.days[day];
html += `<input type="hidden" id="${fieldId}_${day}_enabled_hidden" name="${day}_enabled" value="${dayConfig.enabled}">`;
html += `<input type="hidden" id="${fieldId}_${day}_start_hidden" name="${day}_start" value="${window.LEDEscape.html(dayConfig.start_time)}">`;
html += `<input type="hidden" id="${fieldId}_${day}_end_hidden" name="${day}_end" value="${window.LEDEscape.html(dayConfig.end_time)}">`;
});
return html;
},
/**
* Get current schedule value
* @param {string} fieldId - Field ID
* @returns {Object} Schedule configuration object
*/
getValue: function(fieldId) {
const safeId = sanitizeId(fieldId);
const widget = document.getElementById(`${safeId}_widget`);
if (!widget) return getDefaultSchedule();
const enabledCheckbox = document.getElementById(`${safeId}_enabled`);
const modeGlobal = document.getElementById(`${safeId}_mode_global`);
const globalStart = document.getElementById(`${safeId}_global_start`);
const globalEnd = document.getElementById(`${safeId}_global_end`);
const schedule = {
enabled: enabledCheckbox ? enabledCheckbox.checked : false,
mode: (modeGlobal && modeGlobal.checked) ? 'global' : 'per_day',
start_time: globalStart ? globalStart.value : '07:00',
end_time: globalEnd ? globalEnd.value : '23:00',
days: {}
};
DAYS.forEach(day => {
const dayEnabled = document.getElementById(`${safeId}_${day}_enabled`);
const dayStart = document.getElementById(`${safeId}_${day}_start`);
const dayEnd = document.getElementById(`${safeId}_${day}_end`);
schedule.days[day] = {
enabled: dayEnabled ? dayEnabled.checked : true,
start_time: dayStart ? dayStart.value : '07:00',
end_time: dayEnd ? dayEnd.value : '23:00'
};
});
return schedule;
},
/**
* Set schedule value
* @param {string} fieldId - Field ID
* @param {Object} value - Schedule configuration object
*/
setValue: function(fieldId, value) {
const safeId = sanitizeId(fieldId);
const schedule = normalizeSchedule(value);
// Set enabled
const enabledCheckbox = document.getElementById(`${safeId}_enabled`);
if (enabledCheckbox) enabledCheckbox.checked = schedule.enabled;
// Set mode
const modeGlobal = document.getElementById(`${safeId}_mode_global`);
const modePerDay = document.getElementById(`${safeId}_mode_per_day`);
if (modeGlobal) modeGlobal.checked = schedule.mode === 'global';
if (modePerDay) modePerDay.checked = schedule.mode === 'per_day';
// Set global times
const globalStart = document.getElementById(`${safeId}_global_start`);
const globalEnd = document.getElementById(`${safeId}_global_end`);
if (globalStart) globalStart.value = schedule.start_time;
if (globalEnd) globalEnd.value = schedule.end_time;
// Set per-day values
DAYS.forEach(day => {
const dayConfig = schedule.days[day];
const dayEnabled = document.getElementById(`${safeId}_${day}_enabled`);
const dayStart = document.getElementById(`${safeId}_${day}_start`);
const dayEnd = document.getElementById(`${safeId}_${day}_end`);
if (dayEnabled) dayEnabled.checked = dayConfig.enabled;
if (dayStart) {
dayStart.value = dayConfig.start_time;
dayStart.disabled = !dayConfig.enabled;
dayStart.classList.toggle('bg-gray-100', !dayConfig.enabled);
}
if (dayEnd) {
dayEnd.value = dayConfig.end_time;
dayEnd.disabled = !dayConfig.enabled;
dayEnd.classList.toggle('bg-gray-100', !dayConfig.enabled);
}
});
// Update visibility
this.handlers.onModeChange(fieldId, schedule.mode);
// Update hidden inputs
this._updateHiddenInputs(fieldId);
},
/**
* Update all hidden inputs to match current state
*/
_updateHiddenInputs: function(fieldId) {
const safeId = sanitizeId(fieldId);
const schedule = this.getValue(fieldId);
// Enabled
const enabledHidden = document.getElementById(`${safeId}_enabled_hidden`);
if (enabledHidden) enabledHidden.value = schedule.enabled;
// Mode
const modeHidden = document.getElementById(`${safeId}_mode_value`);
if (modeHidden) modeHidden.value = schedule.mode;
// Global times
const startHidden = document.getElementById(`${safeId}_start_time_hidden`);
const endHidden = document.getElementById(`${safeId}_end_time_hidden`);
if (startHidden) startHidden.value = schedule.start_time;
if (endHidden) endHidden.value = schedule.end_time;
// Per-day values
DAYS.forEach(day => {
const dayConfig = schedule.days[day];
const enabledHidden = document.getElementById(`${safeId}_${day}_enabled_hidden`);
const startHidden = document.getElementById(`${safeId}_${day}_start_hidden`);
const endHidden = document.getElementById(`${safeId}_${day}_end_hidden`);
if (enabledHidden) enabledHidden.value = dayConfig.enabled;
if (startHidden) startHidden.value = dayConfig.start_time;
if (endHidden) endHidden.value = dayConfig.end_time;
});
},
handlers: {
/**
* Handle enabled toggle change
*/
onEnabledChange: function(fieldId, enabled) {
const widget = window.LEDMatrixWidgets.get('schedule-picker');
widget._updateHiddenInputs(fieldId);
triggerChange(fieldId, widget.getValue(fieldId));
},
/**
* Handle mode switch
*/
onModeChange: function(fieldId, mode) {
const safeId = sanitizeId(fieldId);
const globalSection = document.getElementById(`${safeId}_global_section`);
const perDaySection = document.getElementById(`${safeId}_perday_section`);
if (globalSection) globalSection.style.display = mode === 'global' ? 'block' : 'none';
if (perDaySection) perDaySection.style.display = mode === 'per_day' ? 'block' : 'none';
const widget = window.LEDMatrixWidgets.get('schedule-picker');
widget._updateHiddenInputs(fieldId);
triggerChange(fieldId, widget.getValue(fieldId));
},
/**
* Handle global time change
*/
onGlobalTimeChange: function(fieldId) {
const widget = window.LEDMatrixWidgets.get('schedule-picker');
widget._updateHiddenInputs(fieldId);
triggerChange(fieldId, widget.getValue(fieldId));
},
/**
* Handle day enabled change
*/
onDayEnabledChange: function(fieldId, day, enabled) {
const safeId = sanitizeId(fieldId);
const dayStart = document.getElementById(`${safeId}_${day}_start`);
const dayEnd = document.getElementById(`${safeId}_${day}_end`);
if (dayStart) {
dayStart.disabled = !enabled;
dayStart.classList.toggle('bg-gray-100', !enabled);
// Set default value only when enabling and input is empty
if (enabled && !dayStart.value) {
dayStart.value = '07:00';
}
}
if (dayEnd) {
dayEnd.disabled = !enabled;
dayEnd.classList.toggle('bg-gray-100', !enabled);
// Set default value only when enabling and input is empty
if (enabled && !dayEnd.value) {
dayEnd.value = '23:00';
}
}
const widget = window.LEDMatrixWidgets.get('schedule-picker');
widget._updateHiddenInputs(fieldId);
triggerChange(fieldId, widget.getValue(fieldId));
},
/**
* Handle day time change
*/
onDayTimeChange: function(fieldId, day) {
const widget = window.LEDMatrixWidgets.get('schedule-picker');
widget._updateHiddenInputs(fieldId);
triggerChange(fieldId, widget.getValue(fieldId));
}
}
});
// Expose constants for external use
window.LEDMatrixWidgets.get('schedule-picker').DAYS = DAYS;
window.LEDMatrixWidgets.get('schedule-picker').DAY_LABELS = DAY_LABELS;
window.LEDMatrixWidgets.get('schedule-picker').getDefaultSchedule = getDefaultSchedule;
window.LEDMatrixWidgets.get('schedule-picker').normalizeSchedule = normalizeSchedule;
})();