Add many UX improvements to channel schedule

This commit is contained in:
Speng Reb 2026-06-01 01:46:00 +02:00
parent 4ec1e83337
commit dd1bf9d55b
11 changed files with 571 additions and 31 deletions

View file

@ -163,6 +163,64 @@ async function upsertExternalEvent({ channelId, showId, integrationId, provider,
}); });
} }
function buildGoogleCalendarUrl(calendarId) {
return `https://calendar.google.com/calendar/u/0/r?cid=${encodeURIComponent(calendarId)}`;
}
function buildGoogleEventUrl(eventId, calendarId) {
const raw = `${eventId} ${calendarId}`;
const eid = Buffer.from(raw, 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
return `https://calendar.google.com/calendar/u/0/r/eventedit/${eid}`;
}
async function getGoogleLinksForShows(channelId, showIds) {
if (!Array.isArray(showIds) || showIds.length === 0) {
return {};
}
const integration = await getByChannelProvider(channelId, 'google');
if (!integration || integration.status !== 'connected' || !integration.last_sync_at) {
return {};
}
const calendarId = integration.config && integration.config.calendar_id
? String(integration.config.calendar_id).trim()
: '';
if (!calendarId) {
return {};
}
const rows = await knex()('channel_show_external_events')
.where({
channel_id: channelId,
integration_id: integration.id,
provider: 'google'
})
.whereIn('show_id', showIds)
.select('show_id', 'external_event_id');
const calendarUrl = buildGoogleCalendarUrl(calendarId);
const links = {};
showIds.forEach(showId => {
links[showId] = { calendar_url: calendarUrl };
});
rows.forEach(row => {
if (!links[row.show_id]) {
links[row.show_id] = { calendar_url: calendarUrl };
}
if (row.external_event_id) {
links[row.show_id].event_url = buildGoogleEventUrl(row.external_event_id, calendarId);
}
});
return links;
}
module.exports = { module.exports = {
listByChannel, listByChannel,
getByChannelProvider, getByChannelProvider,
@ -170,5 +228,6 @@ module.exports = {
disconnectIntegration, disconnectIntegration,
updateIntegrationSyncResult, updateIntegrationSyncResult,
getExternalEvent, getExternalEvent,
upsertExternalEvent upsertExternalEvent,
getGoogleLinksForShows
}; };

View file

@ -31,6 +31,7 @@ function parseShowRow(row) {
playlist, playlist,
timezone: row.timezone, timezone: row.timezone,
scheduled_for: row.scheduled_for, scheduled_for: row.scheduled_for,
estimated_end_at: row.estimated_end_at || null,
next_run_at: row.next_run_at, next_run_at: row.next_run_at,
status: row.status, status: row.status,
recurrence: row.recurrence, recurrence: row.recurrence,
@ -56,6 +57,7 @@ function serializeShowInput(input) {
playlist: JSON.stringify(input.playlist || []), playlist: JSON.stringify(input.playlist || []),
timezone: input.timezone, timezone: input.timezone,
scheduled_for: input.scheduled_for, scheduled_for: input.scheduled_for,
estimated_end_at: input.estimated_end_at || null,
next_run_at: input.next_run_at, next_run_at: input.next_run_at,
status: input.status, status: input.status,
recurrence: input.recurrence, recurrence: input.recurrence,

View file

@ -189,6 +189,7 @@ export async function initTables() {
t.specificType('playlist', 'mediumtext character set utf8mb4 not null'); t.specificType('playlist', 'mediumtext character set utf8mb4 not null');
t.string('timezone', 64).notNullable().defaultTo('UTC'); t.string('timezone', 64).notNullable().defaultTo('UTC');
t.bigInteger('scheduled_for').notNullable(); t.bigInteger('scheduled_for').notNullable();
t.bigInteger('estimated_end_at').nullable();
t.bigInteger('next_run_at').notNullable(); t.bigInteger('next_run_at').notNullable();
t.string('status', 20).notNullable().defaultTo('draft'); t.string('status', 20).notNullable().defaultTo('draft');
t.string('recurrence', 20).notNullable().defaultTo('none'); t.string('recurrence', 20).notNullable().defaultTo('none');

View file

@ -3,7 +3,7 @@ import Promise from 'bluebird';
const LOGGER = require('@calzoneman/jsli')('database/update'); const LOGGER = require('@calzoneman/jsli')('database/update');
const DB_VERSION = 15; const DB_VERSION = 16;
var hasUpdates = []; var hasUpdates = [];
module.exports.checkVersion = function () { module.exports.checkVersion = function () {
@ -59,6 +59,8 @@ function update(version, cb) {
addCalendarIntegrationTables(cb); addCalendarIntegrationTables(cb);
} else if (version < 15) { } else if (version < 15) {
addCalendarIntegrationAuditColumns(cb); addCalendarIntegrationAuditColumns(cb);
} else if (version < 16) {
addShowsEstimatedEndColumn(cb);
} }
} }
@ -258,3 +260,17 @@ function addCalendarIntegrationAuditColumns(cb) {
} }
); );
} }
function addShowsEstimatedEndColumn(cb) {
db.query(
"ALTER TABLE channel_shows ADD COLUMN estimated_end_at BIGINT NULL",
error => {
if (error) {
LOGGER.error(`Failed to add shows estimated_end_at column: ${error}`);
cb(error);
return;
}
cb();
}
);
}

View file

@ -205,7 +205,8 @@ function unpackTokens(integrationRow) {
async function upsertGoogleCalendarEvent(accessToken, calendarId, show) { async function upsertGoogleCalendarEvent(accessToken, calendarId, show) {
const start = new Date(show.scheduled_for); const start = new Date(show.scheduled_for);
const end = new Date(start.getTime() + 60 * 60 * 1000); const endMs = Number(show.estimated_end_at || 0);
const end = new Date(endMs > start.getTime() ? endMs : (start.getTime() + 60 * 60 * 1000));
const body = { const body = {
summary: show.name, summary: show.name,
description: show.notes || '', description: show.notes || '',
@ -230,7 +231,8 @@ async function upsertGoogleCalendarEvent(accessToken, calendarId, show) {
async function updateGoogleCalendarEvent(accessToken, calendarId, eventId, show) { async function updateGoogleCalendarEvent(accessToken, calendarId, eventId, show) {
const start = new Date(show.scheduled_for); const start = new Date(show.scheduled_for);
const end = new Date(start.getTime() + 60 * 60 * 1000); const endMs = Number(show.estimated_end_at || 0);
const end = new Date(endMs > start.getTime() ? endMs : (start.getTime() + 60 * 60 * 1000));
const body = { const body = {
summary: show.name, summary: show.name,
description: show.notes || '', description: show.notes || '',

89
src/util/markdown.js Normal file
View file

@ -0,0 +1,89 @@
const XSS = require('../xss');
function escapeHtml(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function applyInlineMarkdown(text) {
let out = escapeHtml(text);
out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
out = out.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, '<img src="$2" alt="$1">');
out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
out = out.replace(/__([^_]+)__/g, '<strong>$1</strong>');
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
out = out.replace(/_([^_]+)_/g, '<em>$1</em>');
return out;
}
function markdownToHtml(markdown) {
const lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n');
const blocks = [];
let paragraph = [];
let listItems = [];
function flushParagraph() {
if (!paragraph.length) return;
const rendered = applyInlineMarkdown(paragraph.join('\n')).replace(/\n/g, '<br>');
blocks.push('<p>' + rendered + '</p>');
paragraph = [];
}
function flushList() {
if (!listItems.length) return;
blocks.push('<ul>' + listItems.map(item => '<li>' + applyInlineMarkdown(item) + '</li>').join('') + '</ul>');
listItems = [];
}
lines.forEach(raw => {
const line = raw.trim();
if (!line) {
flushParagraph();
flushList();
return;
}
if (/^[-*]\s+/.test(line)) {
flushParagraph();
listItems.push(line.replace(/^[-*]\s+/, ''));
return;
}
if (/^#{1,3}\s+/.test(line)) {
flushParagraph();
flushList();
const level = line.match(/^#{1,3}/)[0].length;
const content = line.replace(/^#{1,3}\s+/, '');
blocks.push('<h' + level + '>' + applyInlineMarkdown(content) + '</h' + level + '>');
return;
}
paragraph.push(raw);
});
flushParagraph();
flushList();
return blocks.join('');
}
function looksLikeHtml(input) {
return /<\/?[a-z][\s\S]*>/i.test(input || '');
}
function renderNotesHtml(input) {
const notes = String(input || '').trim();
if (!notes) return null;
const html = looksLikeHtml(notes) ? notes : markdownToHtml(notes);
return XSS.sanitizeHTML(html);
}
module.exports = {
renderNotesHtml
};

View file

@ -1,10 +1,11 @@
const express = require('express'); const express = require('express');
const webserver = require('../../webserver'); const webserver = require('../../webserver');
const showDB = require('../../../database/shows'); const showDB = require('../../../database/shows');
const calendarDB = require('../../../database/calendar-integrations');
const shows = require('../../../shows'); const shows = require('../../../shows');
const botDB = require('../../../database/bots'); const botDB = require('../../../database/bots');
const infoGetter = require('../../../get-info'); const infoGetter = require('../../../get-info');
const XSS = require('../../../xss'); const { renderNotesHtml } = require('../../../util/markdown');
const { getChannelRow, getUserEffectiveRank, hashToken } = require('./middleware'); const { getChannelRow, getUserEffectiveRank, hashToken } = require('./middleware');
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
@ -22,6 +23,25 @@ const ACTION_MIN_RANK = {
}; };
const PUBLIC_SHOW_STATUSES = new Set(['scheduled', 'running', 'paused', 'completed']); const PUBLIC_SHOW_STATUSES = new Set(['scheduled', 'running', 'paused', 'completed']);
async function attachGoogleCalendarLinks(channelId, showsList) {
const ids = (showsList || []).map(show => show.id);
const linksByShowId = await calendarDB.getGoogleLinksForShows(channelId, ids);
return (showsList || []).map(show => {
const withNotesHtml = Object.assign({}, show, {
notes_html: renderNotesHtml(show.notes)
});
const googleLinks = linksByShowId[show.id];
if (!googleLinks) {
return withNotesHtml;
}
return Object.assign({}, withNotesHtml, {
calendar_links: {
google: googleLinks
}
});
});
}
function sanitizePlaylist(list) { function sanitizePlaylist(list) {
if (!Array.isArray(list)) return []; if (!Array.isArray(list)) return [];
return list return list
@ -69,6 +89,20 @@ function validateShowPayload(body, old = null) {
if (!scheduledFor) { if (!scheduledFor) {
return { error: 'scheduled_for must be a valid date or timestamp' }; return { error: 'scheduled_for must be a valid date or timestamp' };
} }
const estimatedEndInput = body.estimated_end_at !== undefined
? body.estimated_end_at
: (old ? old.estimated_end_at : null);
const estimatedEndAt = estimatedEndInput === null || estimatedEndInput === ''
? null
: (typeof estimatedEndInput === 'number' ? estimatedEndInput : parseSchedule(estimatedEndInput));
if (estimatedEndAt !== null) {
if (!estimatedEndAt) {
return { error: 'estimated_end_at must be a valid date or timestamp' };
}
if (estimatedEndAt < scheduledFor) {
return { error: 'estimated_end_at must be later than scheduled_for' };
}
}
const recurrence = String(body.recurrence || (old && old.recurrence) || 'none'); const recurrence = String(body.recurrence || (old && old.recurrence) || 'none');
if (!RECURRENCES.has(recurrence)) { if (!RECURRENCES.has(recurrence)) {
@ -105,7 +139,7 @@ function validateShowPayload(body, old = null) {
const notesRaw = body.notes !== undefined ? body.notes : (old ? old.notes : null); const notesRaw = body.notes !== undefined ? body.notes : (old ? old.notes : null);
let notes = null; let notes = null;
if (typeof notesRaw === 'string' && notesRaw.trim() !== '') { if (typeof notesRaw === 'string' && notesRaw.trim() !== '') {
notes = XSS.sanitizeHTML(notesRaw.substring(0, 20000)); notes = notesRaw.substring(0, 20000).trim();
} }
const colorRaw = body.color !== undefined ? body.color : (old ? old.color : null); const colorRaw = body.color !== undefined ? body.color : (old ? old.color : null);
@ -126,6 +160,7 @@ function validateShowPayload(body, old = null) {
playlist, playlist,
timezone, timezone,
scheduled_for: scheduledFor, scheduled_for: scheduledFor,
estimated_end_at: estimatedEndAt,
next_run_at: nextRunAt, next_run_at: nextRunAt,
status, status,
recurrence, recurrence,
@ -198,7 +233,8 @@ router.get('/', async (req, res) => {
if (!auth) return; if (!auth) return;
const showsList = await showDB.listShows(auth.channelRow.id); const showsList = await showDB.listShows(auth.channelRow.id);
res.json(showsList); const withLinks = await attachGoogleCalendarLinks(auth.channelRow.id, showsList);
res.json(withLinks);
}); });
router.get('/public', async (req, res) => { router.get('/public', async (req, res) => {
@ -210,7 +246,9 @@ router.get('/public', async (req, res) => {
} }
const showsList = await showDB.listShows(channelRow.id); const showsList = await showDB.listShows(channelRow.id);
res.json(showsList.filter(show => PUBLIC_SHOW_STATUSES.has(show.status))); const filtered = showsList.filter(show => PUBLIC_SHOW_STATUSES.has(show.status));
const withLinks = await attachGoogleCalendarLinks(channelRow.id, filtered);
res.json(withLinks);
}); });
router.get('/:id', async (req, res) => { router.get('/:id', async (req, res) => {
@ -221,7 +259,9 @@ router.get('/:id', async (req, res) => {
if (isNaN(id)) return res.status(400).json({ error: 'Invalid show id' }); if (isNaN(id)) return res.status(400).json({ error: 'Invalid show id' });
const show = await showDB.getShowById(id, auth.channelRow.id); const show = await showDB.getShowById(id, auth.channelRow.id);
if (!show) return res.status(404).json({ error: 'Show not found' }); if (!show) return res.status(404).json({ error: 'Show not found' });
res.json(show); res.json(Object.assign({}, show, {
notes_html: renderNotesHtml(show.notes)
}));
}); });
router.post('/resolve-media', async (req, res) => { router.post('/resolve-media', async (req, res) => {
@ -278,7 +318,9 @@ router.post('/', async (req, res) => {
}); });
const row = await showDB.getShowById(id, auth.channelRow.id); const row = await showDB.getShowById(id, auth.channelRow.id);
res.status(201).json(row); res.status(201).json(Object.assign({}, row, {
notes_html: renderNotesHtml(row.notes)
}));
}); });
router.put('/:id', async (req, res) => { router.put('/:id', async (req, res) => {
@ -304,7 +346,9 @@ router.put('/:id', async (req, res) => {
}); });
const row = await showDB.getShowById(id, auth.channelRow.id); const row = await showDB.getShowById(id, auth.channelRow.id);
res.json(row); res.json(Object.assign({}, row, {
notes_html: renderNotesHtml(row.notes)
}));
}); });
router.delete('/:id', async (req, res) => { router.delete('/:id', async (req, res) => {
@ -394,7 +438,9 @@ router.post('/:id/action', async (req, res) => {
} }
const row = await showDB.getShowById(id, auth.channelRow.id); const row = await showDB.getShowById(id, auth.channelRow.id);
res.json(row); res.json(Object.assign({}, row, {
notes_html: renderNotesHtml(row.notes)
}));
}); });
module.exports = router; module.exports = router;

View file

@ -272,10 +272,18 @@ html(lang="en")
strong Time: strong Time:
| |
span#showdetails-time span#showdetails-time
p
strong Estimated End:
|
span#showdetails-estimated-end
p p
strong Status: strong Status:
| |
span#showdetails-status span#showdetails-status
p#showdetails-calendar-links(style="display:none;")
strong Calendar:
|
span#showdetails-calendar-links-content
hr hr
#showdetails-notes #showdetails-notes
.modal-footer .modal-footer

View file

@ -267,6 +267,10 @@ mixin shows
label.control-label.col-sm-3(for="cs-shows-scheduled-for") Scheduled For label.control-label.col-sm-3(for="cs-shows-scheduled-for") Scheduled For
.col-sm-9 .col-sm-9
input#cs-shows-scheduled-for.form-control(type="datetime-local") input#cs-shows-scheduled-for.form-control(type="datetime-local")
.form-group
label.control-label.col-sm-3(for="cs-shows-estimated-end-at") Estimated End
.col-sm-9
input#cs-shows-estimated-end-at.form-control(type="datetime-local")
.form-group .form-group
label.control-label.col-sm-3(for="cs-shows-color") Color label.control-label.col-sm-3(for="cs-shows-color") Color
.col-sm-3 .col-sm-3
@ -305,8 +309,10 @@ mixin shows
.form-group .form-group
label.control-label.col-sm-3(for="cs-shows-notes") Notes label.control-label.col-sm-3(for="cs-shows-notes") Notes
.col-sm-9 .col-sm-9
textarea#cs-shows-notes.form-control(rows="6", placeholder="Optional rich notes (same HTML support as MOTD)") button#cs-shows-notes-toggle.btn.btn-xs.btn-default.pull-right(type="button", style="margin-bottom:6px") Preview
p.text-muted.small(style="margin-top:6px") Supports MOTD-style HTML. Optional. textarea#cs-shows-notes.form-control(rows="8", placeholder="Optional Markdown notes")
#cs-shows-notes-rendered.form-control(style="display:none; height:auto; min-height:170px; overflow:auto;")
p#cs-shows-notes-help.text-muted.small(style="margin-top:6px") Supports Markdown: headings, lists, bold, italics, inline code, images, and links.
.form-group .form-group
label.control-label.col-sm-3(for="cs-shows-mediaurl") Show Playlist label.control-label.col-sm-3(for="cs-shows-mediaurl") Show Playlist
.col-sm-9 .col-sm-9
@ -329,8 +335,10 @@ mixin shows
th Name th Name
th Status th Status
th Next Run th Next Run
th Est. End
th Timezone th Timezone
th Recurrence th Recurrence
th Calendar
th Actions th Actions
tbody#cs-shows-list tbody#cs-shows-list

View file

@ -242,6 +242,11 @@ li.ui-sortable-helper, li.ui-sortable-placeholder + li.queue_entry {
padding: 2px !important; padding: 2px !important;
} }
.showschedule-block-cell {
border-top: 2px solid rgba(0, 0, 0, 0.35) !important;
border-bottom: 2px solid rgba(0, 0, 0, 0.35) !important;
}
.showschedule-cell.showschedule-admin { .showschedule-cell.showschedule-admin {
cursor: pointer; cursor: pointer;
} }
@ -258,6 +263,40 @@ li.ui-sortable-helper, li.ui-sortable-placeholder + li.queue_entry {
text-decoration: none; text-decoration: none;
} }
.showschedule-block-cell .showschedule-show {
font-weight: 700;
margin-bottom: 4px;
}
.showschedule-notes {
margin-top: 2px;
max-height: 72px;
overflow: hidden;
line-height: 1.2;
color: rgba(255, 255, 255, 0.95);
}
.showschedule-notes p,
.showschedule-notes ul,
.showschedule-notes ol {
margin: 0 0 4px 0;
}
.showschedule-notes img {
display: block;
max-width: 100%;
max-height: 42px;
width: auto;
height: auto;
border-radius: 2px;
margin: 2px 0;
}
.showschedule-notes a {
color: #fff;
text-decoration: underline;
}
.showschedule-show.status-scheduled { background: #337ab7; } .showschedule-show.status-scheduled { background: #337ab7; }
.showschedule-show.status-running { background: #5cb85c; } .showschedule-show.status-running { background: #5cb85c; }
.showschedule-show.status-paused { background: #f0ad4e; color: #222; } .showschedule-show.status-paused { background: #f0ad4e; color: #222; }

View file

@ -1436,6 +1436,7 @@ var CSTShows = (function () {
var resolvingTitles = false; var resolvingTitles = false;
var weekOffset = 0; var weekOffset = 0;
var cachedShows = []; var cachedShows = [];
var notesEditorMode = 'edit';
function apiBase() { function apiBase() {
return '/api/v1/channels/' + CHANNEL.name + '/shows'; return '/api/v1/channels/' + CHANNEL.name + '/shows';
@ -1487,6 +1488,121 @@ var CSTShows = (function () {
pad(d.getMinutes()); pad(d.getMinutes());
} }
function escapeHtml(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderMarkdownNotesPreview(markdown) {
var lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n');
var blocks = [];
var paragraph = [];
var listItems = [];
function inline(text) {
var out = escapeHtml(text || '');
out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
out = out.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, '<img src="$2" alt="$1">');
out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
out = out.replace(/__([^_]+)__/g, '<strong>$1</strong>');
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
out = out.replace(/_([^_]+)_/g, '<em>$1</em>');
return out;
}
function flushParagraph() {
if (!paragraph.length) return;
var rendered = inline(paragraph.join('\n')).replace(/\n/g, '<br>');
blocks.push('<p>' + rendered + '</p>');
paragraph = [];
}
function flushList() {
if (!listItems.length) return;
blocks.push('<ul>' + listItems.map(function (item) { return '<li>' + inline(item) + '</li>'; }).join('') + '</ul>');
listItems = [];
}
lines.forEach(function (raw) {
var line = raw.trim();
if (!line) {
flushParagraph();
flushList();
return;
}
if (/^[-*]\s+/.test(line)) {
flushParagraph();
listItems.push(line.replace(/^[-*]\s+/, ''));
return;
}
if (/^#{1,3}\s+/.test(line)) {
flushParagraph();
flushList();
var level = line.match(/^#{1,3}/)[0].length;
blocks.push('<h' + level + '>' + inline(line.replace(/^#{1,3}\s+/, '')) + '</h' + level + '>');
return;
}
paragraph.push(raw);
});
flushParagraph();
flushList();
return blocks.join('');
}
function updateNotesPreview() {
var notes = $('#cs-shows-notes').val() || '';
if (!/\S/.test(notes)) {
$('#cs-shows-notes-rendered').html('<span class="text-muted">Nothing to preview.</span>');
return;
}
$('#cs-shows-notes-rendered').html(renderMarkdownNotesPreview(notes));
}
function setNotesEditorMode(mode) {
notesEditorMode = mode === 'preview' ? 'preview' : 'edit';
if (notesEditorMode === 'preview') {
$('#cs-shows-notes').hide();
$('#cs-shows-notes-rendered').show();
$('#cs-shows-notes-help').text('Preview mode');
$('#cs-shows-notes-toggle').text('Edit');
updateNotesPreview();
return;
}
$('#cs-shows-notes-rendered').hide();
$('#cs-shows-notes').show();
$('#cs-shows-notes-help').text('Supports Markdown: headings, lists, bold, italics, inline code, images, and links.');
$('#cs-shows-notes-toggle').text('Preview');
}
function getShowOccurrenceEndMs(show, startMs) {
if (!show || !show.estimated_end_at || !show.scheduled_for) {
return startMs + 60 * 60 * 1000;
}
var baseStart = Number(show.scheduled_for);
var baseEnd = Number(show.estimated_end_at);
var duration = baseEnd - baseStart;
if (!isFinite(duration) || duration <= 0) {
return startMs + 60 * 60 * 1000;
}
return startMs + duration;
}
function getShowBlockColor(show) {
if (show && show.color) {
return show.color;
}
var status = (show && show.status) || 'scheduled';
if (status === 'running') return '#5cb85c';
if (status === 'paused') return '#f0ad4e';
if (status === 'completed') return '#777777';
return '#337ab7';
}
function renderDraftPlaylist() { function renderDraftPlaylist() {
var ul = $('#cs-shows-playlist-list').empty(); var ul = $('#cs-shows-playlist-list').empty();
if (!draftPlaylist.length) { if (!draftPlaylist.length) {
@ -1627,6 +1743,7 @@ var CSTShows = (function () {
function readFormPayload() { function readFormPayload() {
var scheduledRaw = $('#cs-shows-scheduled-for').val(); var scheduledRaw = $('#cs-shows-scheduled-for').val();
var estimatedEndRaw = $('#cs-shows-estimated-end-at').val();
var timezone = $('#cs-shows-timezone').val().trim(); var timezone = $('#cs-shows-timezone').val().trim();
var notes = $('#cs-shows-notes').val(); var notes = $('#cs-shows-notes').val();
var colorHex = ($('#cs-shows-color-hex').val() || '').trim(); var colorHex = ($('#cs-shows-color-hex').val() || '').trim();
@ -1644,6 +1761,7 @@ var CSTShows = (function () {
notes: notes && notes.trim() ? notes : null, notes: notes && notes.trim() ? notes : null,
color: colorHex ? colorHex.toUpperCase() : null, color: colorHex ? colorHex.toUpperCase() : null,
scheduled_for: scheduledRaw ? new Date(scheduledRaw).toISOString() : null, scheduled_for: scheduledRaw ? new Date(scheduledRaw).toISOString() : null,
estimated_end_at: estimatedEndRaw ? new Date(estimatedEndRaw).toISOString() : null,
timezone: timezone, timezone: timezone,
recurrence: $('#cs-shows-recurrence').val(), recurrence: $('#cs-shows-recurrence').val(),
fill_mode: $('#cs-shows-fill-mode').val(), fill_mode: $('#cs-shows-fill-mode').val(),
@ -1662,6 +1780,7 @@ var CSTShows = (function () {
$('#cs-shows-name').val(''); $('#cs-shows-name').val('');
$('#cs-shows-notes').val(''); $('#cs-shows-notes').val('');
$('#cs-shows-scheduled-for').val(''); $('#cs-shows-scheduled-for').val('');
$('#cs-shows-estimated-end-at').val('');
var detectedTz = 'UTC'; var detectedTz = 'UTC';
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) { if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
@ -1679,6 +1798,8 @@ var CSTShows = (function () {
$('#cs-shows-mediaurl').val(''); $('#cs-shows-mediaurl').val('');
draftPlaylist = []; draftPlaylist = [];
renderDraftPlaylist(); renderDraftPlaylist();
updateNotesPreview();
setNotesEditorMode('edit');
} }
function selectShow(show) { function selectShow(show) {
@ -1687,6 +1808,7 @@ var CSTShows = (function () {
$('#cs-shows-name').val(show.name); $('#cs-shows-name').val(show.name);
$('#cs-shows-notes').val(show.notes || ''); $('#cs-shows-notes').val(show.notes || '');
$('#cs-shows-scheduled-for').val(toLocalDateInput(show.scheduled_for)); $('#cs-shows-scheduled-for').val(toLocalDateInput(show.scheduled_for));
$('#cs-shows-estimated-end-at').val(toLocalDateInput(show.estimated_end_at));
var showTz = show.timezone || 'UTC'; var showTz = show.timezone || 'UTC';
if ($('#cs-shows-timezone option[value="' + showTz + '"]').length === 0) { if ($('#cs-shows-timezone option[value="' + showTz + '"]').length === 0) {
$('<option>').attr('value', showTz).text(showTz).appendTo('#cs-shows-timezone'); $('<option>').attr('value', showTz).text(showTz).appendTo('#cs-shows-timezone');
@ -1707,6 +1829,8 @@ var CSTShows = (function () {
}; };
}); });
renderDraftPlaylist(); renderDraftPlaylist();
updateNotesPreview();
setNotesEditorMode('edit');
resolveDraftTitles(); resolveDraftTitles();
} }
@ -1751,26 +1875,106 @@ var CSTShows = (function () {
function openShowDetailsModal(show, when) { function openShowDetailsModal(show, when) {
$('#showdetails-title').text(show.name || 'Show Details'); $('#showdetails-title').text(show.name || 'Show Details');
$('#showdetails-time').text(when.toLocaleString()); var startAt = when ? new Date(when) : (show.scheduled_for ? new Date(show.scheduled_for) : new Date());
var endAt = new Date(getShowOccurrenceEndMs(show, startAt.getTime()));
$('#showdetails-time').text(startAt.toLocaleString());
$('#showdetails-estimated-end').text(endAt.toLocaleString());
$('#showdetails-status').text(show.status || 'scheduled'); $('#showdetails-status').text(show.status || 'scheduled');
var notes = (show.notes || '').trim(); var linksWrap = $('#showdetails-calendar-links');
if (!notes) { var linksContent = $('#showdetails-calendar-links-content').empty();
var googleLinks = show && show.calendar_links && show.calendar_links.google
? show.calendar_links.google
: null;
if (googleLinks && (googleLinks.event_url || googleLinks.calendar_url)) {
if (googleLinks.event_url) {
$('<a>')
.attr('href', googleLinks.event_url)
.attr('target', '_blank')
.attr('rel', 'noopener noreferrer')
.text('View in Google Calendar')
.appendTo(linksContent);
}
if (googleLinks.calendar_url) {
if (googleLinks.event_url) {
linksContent.append(' | ');
}
$('<a>')
.attr('href', googleLinks.calendar_url)
.attr('target', '_blank')
.attr('rel', 'noopener noreferrer')
.text('Open Google Calendar')
.appendTo(linksContent);
}
linksWrap.show();
} else {
linksWrap.hide();
}
var notesHtml = (show.notes_html || '').trim();
if (!notesHtml) {
$('#showdetails-notes').html('<p class="text-muted">No notes for this show.</p>'); $('#showdetails-notes').html('<p class="text-muted">No notes for this show.</p>');
} else { } else {
$('#showdetails-notes').html(notes); $('#showdetails-notes').html(notesHtml);
} }
$('#showdetails').modal(); $('#showdetails').modal();
} }
var byCell = {}; var blocksByStart = {};
var covered = {};
var firstSegmentPlaced = {};
function ensureDayMap(dayIdx) {
if (!covered[dayIdx]) covered[dayIdx] = {};
}
function markCovered(dayIdx, startHour, span) {
ensureDayMap(dayIdx);
for (var h = startHour + 1; h < startHour + span; h++) {
covered[dayIdx][h] = true;
}
}
function dayIndexFor(date) {
var midnight = new Date(date.getFullYear(), date.getMonth(), date.getDate());
var weekMidnight = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate());
return Math.floor((midnight.getTime() - weekMidnight.getTime()) / (24 * 60 * 60 * 1000));
}
shows.forEach(function (show) { shows.forEach(function (show) {
var at = show.next_run_at || show.scheduled_for; var at = show.next_run_at || show.scheduled_for;
if (!at) return; if (!at) return;
var d = new Date(at); var start = new Date(at);
if (d < weekStart || d > new Date(weekEnd.getTime() + 86399999)) return; var end = show.estimated_end_at ? new Date(show.estimated_end_at) : new Date(start.getTime() + 60 * 60 * 1000);
var key = toCellKey(d); if (end <= start) end = new Date(start.getTime() + 60 * 60 * 1000);
if (!byCell[key]) byCell[key] = [];
byCell[key].push({ show: show, date: d }); var occurrenceEnd = new Date(getShowOccurrenceEndMs(show, start.getTime()));
var visibleStart = start < weekStart ? new Date(weekStart.getTime()) : start;
var visibleEnd = occurrenceEnd > new Date(weekEnd.getTime() + 86399999) ? new Date(weekEnd.getTime() + 86399999) : occurrenceEnd;
if (visibleEnd <= visibleStart) return;
var cursor = new Date(visibleStart.getTime());
while (cursor < visibleEnd) {
var dayStart = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate(), 0, 0, 0, 0);
var dayEnd = new Date(dayStart.getTime() + (24 * 60 * 60 * 1000));
var segEnd = visibleEnd < dayEnd ? visibleEnd : dayEnd;
var dayIdx = dayIndexFor(cursor);
if (dayIdx >= 0 && dayIdx < 7) {
var slotStart = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate(), cursor.getHours(), 0, 0, 0);
var startHour = slotStart.getHours();
var span = Math.max(1, Math.ceil((segEnd.getTime() - slotStart.getTime()) / (60 * 60 * 1000)));
if (startHour + span > 24) span = 24 - startHour;
var startKey = dayIdx + '-' + startHour;
if (!blocksByStart[startKey]) blocksByStart[startKey] = [];
var isFirstVisibleSegment = !firstSegmentPlaced[show.id];
blocksByStart[startKey].push({
show: show,
date: start,
end: occurrenceEnd,
span: span,
isStart: isFirstVisibleSegment
});
if (isFirstVisibleSegment) firstSegmentPlaced[show.id] = true;
if (span > 1) {
markCovered(dayIdx, startHour, span);
}
}
cursor = dayEnd;
}
}); });
var isAdmin = CLIENT.rank >= 2; var isAdmin = CLIENT.rank >= 2;
@ -1791,7 +1995,9 @@ var CSTShows = (function () {
var cellDate = new Date(weekStart.getTime()); var cellDate = new Date(weekStart.getTime());
cellDate.setDate(weekStart.getDate() + col); cellDate.setDate(weekStart.getDate() + col);
cellDate.setHours(hour, 0, 0, 0); cellDate.setHours(hour, 0, 0, 0);
var key = toCellKey(cellDate); if (covered[col] && covered[col][hour]) {
continue;
}
var cell = $('<td class="showschedule-cell">').appendTo(tr); var cell = $('<td class="showschedule-cell">').appendTo(tr);
if (isAdmin) { if (isAdmin) {
cell.addClass('showschedule-admin').attr('title', 'Click to create show at this time'); cell.addClass('showschedule-admin').attr('title', 'Click to create show at this time');
@ -1804,23 +2010,53 @@ var CSTShows = (function () {
})(new Date(cellDate.getTime())); })(new Date(cellDate.getTime()));
} }
var items = byCell[key] || []; var startKey = col + '-' + hour;
var items = blocksByStart[startKey] || [];
items.sort(function (a, b) { return a.date - b.date; }); items.sort(function (a, b) { return a.date - b.date; });
if (items.length === 1 && items[0].span > 1) {
cell.attr('rowspan', String(items[0].span));
cell
.addClass('showschedule-block-cell')
.css('background-color', getShowBlockColor(items[0].show));
}
items.forEach(function (item) { items.forEach(function (item) {
var label = item.date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ' ' + item.show.name; var startLabel = item.date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
var endLabel = item.end
? item.end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: null;
var label = endLabel
? (startLabel + '-' + endLabel + ' ' + item.show.name)
: (startLabel + ' ' + item.show.name);
var notesPreview = (item.show.notes || '').trim().replace(/\s+/g, ' ');
if (notesPreview.length > 120) {
notesPreview = notesPreview.substring(0, 117) + '...';
}
$('<a href="javascript:void(0)" class="showschedule-show">') $('<a href="javascript:void(0)" class="showschedule-show">')
.addClass('status-' + (item.show.status || 'scheduled')) .addClass('status-' + (item.show.status || 'scheduled'))
.text(label) .text(label)
.css('background', item.show.color || '') .css('background', items.length === 1 ? 'transparent' : (item.show.color || ''))
.css('opacity', item.isStart ? '1' : '0.85')
.attr('title', notesPreview || label)
.on('click', function () { .on('click', function () {
if (isAdmin) { if (isAdmin) {
openShowsEditor(); openShowsEditor();
selectShow(item.show); selectShow(item.show);
} else { } else {
openShowDetailsModal(item.show, item.date); openShowDetailsModal(item.show, item.date.getTime());
} }
}) })
.appendTo(cell); .appendTo(cell);
if (item.isStart) {
if (item.show.notes_html) {
$('<div class="showschedule-notes small">')
.html(item.show.notes_html)
.appendTo(cell);
} else if (notesPreview) {
$('<div class="showschedule-notes small">')
.text(notesPreview)
.appendTo(cell);
}
}
}); });
} }
} }
@ -1845,7 +2081,7 @@ var CSTShows = (function () {
function render(shows) { function render(shows) {
var tbody = $('#cs-shows-list').empty(); var tbody = $('#cs-shows-list').empty();
if (!shows.length) { if (!shows.length) {
tbody.append('<tr><td colspan="6" class="text-muted">No shows configured</td></tr>'); tbody.append('<tr><td colspan="8" class="text-muted">No shows configured</td></tr>');
return; return;
} }
@ -1856,8 +2092,37 @@ var CSTShows = (function () {
)); ));
row.append($('<td>').text(show.status)); row.append($('<td>').text(show.status));
row.append($('<td>').text(show.next_run_at ? new Date(show.next_run_at).toLocaleString(undefined, { timeZone: show.timezone || 'UTC' }) : 'N/A')); row.append($('<td>').text(show.next_run_at ? new Date(show.next_run_at).toLocaleString(undefined, { timeZone: show.timezone || 'UTC' }) : 'N/A'));
row.append($('<td>').text(show.estimated_end_at ? new Date(show.estimated_end_at).toLocaleString(undefined, { timeZone: show.timezone || 'UTC' }) : 'N/A'));
row.append($('<td>').text(show.timezone || 'UTC')); row.append($('<td>').text(show.timezone || 'UTC'));
row.append($('<td>').text(show.recurrence || 'none')); row.append($('<td>').text(show.recurrence || 'none'));
var calendarTd = $('<td>');
var googleLinks = show && show.calendar_links && show.calendar_links.google
? show.calendar_links.google
: null;
if (googleLinks && (googleLinks.event_url || googleLinks.calendar_url)) {
if (googleLinks.event_url) {
$('<a>')
.attr('href', googleLinks.event_url)
.attr('target', '_blank')
.attr('rel', 'noopener noreferrer')
.text('Event')
.appendTo(calendarTd);
}
if (googleLinks.calendar_url) {
if (googleLinks.event_url) {
calendarTd.append(' | ');
}
$('<a>')
.attr('href', googleLinks.calendar_url)
.attr('target', '_blank')
.attr('rel', 'noopener noreferrer')
.text('Calendar')
.appendTo(calendarTd);
}
} else {
calendarTd.append($('<span class="text-muted">').text('Not synced'));
}
row.append(calendarTd);
var actions = $('<td>'); var actions = $('<td>');
$('<button class=\"btn btn-xs btn-primary\" style=\"margin-right:4px\">Run</button>') $('<button class=\"btn btn-xs btn-primary\" style=\"margin-right:4px\">Run</button>')
@ -1901,7 +2166,7 @@ var CSTShows = (function () {
renderScheduleCalendar(cachedShows); renderScheduleCalendar(cachedShows);
}).fail(function () { }).fail(function () {
if (CLIENT.rank >= 2) { if (CLIENT.rank >= 2) {
$('#cs-shows-list').html('<tr><td colspan=\"6\" class=\"text-danger\">Failed to load shows</td></tr>'); $('#cs-shows-list').html('<tr><td colspan=\"8\" class=\"text-danger\">Failed to load shows</td></tr>');
} }
$('#showschedule-grid').html('<div class=\"text-danger\">Failed to load schedule</div>'); $('#showschedule-grid').html('<div class=\"text-danger\">Failed to load schedule</div>');
}); });
@ -1960,6 +2225,10 @@ var CSTShows = (function () {
$('#cs-shows-color').val(v); $('#cs-shows-color').val(v);
} }
}); });
$('#cs-shows-notes').on('input', updateNotesPreview);
$('#cs-shows-notes-toggle').on('click', function () {
setNotesEditorMode(notesEditorMode === 'edit' ? 'preview' : 'edit');
});
$('#cs-shows-playlist-list').sortable({ $('#cs-shows-playlist-list').sortable({
update: function () { update: function () {
var nextDraft = []; var nextDraft = [];
@ -1989,6 +2258,7 @@ var CSTShows = (function () {
}); });
renderDraftPlaylist(); renderDraftPlaylist();
clearForm(); clearForm();
setNotesEditorMode('edit');
load(); load();
return { load: load, selectShow: selectShow, prefillScheduledDate: prefillScheduledDate }; return { load: load, selectShow: selectShow, prefillScheduledDate: prefillScheduledDate };