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 = {
listByChannel,
getByChannelProvider,
@ -170,5 +228,6 @@ module.exports = {
disconnectIntegration,
updateIntegrationSyncResult,
getExternalEvent,
upsertExternalEvent
upsertExternalEvent,
getGoogleLinksForShows
};

View file

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

View file

@ -189,6 +189,7 @@ export async function initTables() {
t.specificType('playlist', 'mediumtext character set utf8mb4 not null');
t.string('timezone', 64).notNullable().defaultTo('UTC');
t.bigInteger('scheduled_for').notNullable();
t.bigInteger('estimated_end_at').nullable();
t.bigInteger('next_run_at').notNullable();
t.string('status', 20).notNullable().defaultTo('draft');
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 DB_VERSION = 15;
const DB_VERSION = 16;
var hasUpdates = [];
module.exports.checkVersion = function () {
@ -59,6 +59,8 @@ function update(version, cb) {
addCalendarIntegrationTables(cb);
} else if (version < 15) {
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) {
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 = {
summary: show.name,
description: show.notes || '',
@ -230,7 +231,8 @@ async function upsertGoogleCalendarEvent(accessToken, calendarId, show) {
async function updateGoogleCalendarEvent(accessToken, calendarId, eventId, show) {
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 = {
summary: show.name,
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 webserver = require('../../webserver');
const showDB = require('../../../database/shows');
const calendarDB = require('../../../database/calendar-integrations');
const shows = require('../../../shows');
const botDB = require('../../../database/bots');
const infoGetter = require('../../../get-info');
const XSS = require('../../../xss');
const { renderNotesHtml } = require('../../../util/markdown');
const { getChannelRow, getUserEffectiveRank, hashToken } = require('./middleware');
const router = express.Router({ mergeParams: true });
@ -22,6 +23,25 @@ const ACTION_MIN_RANK = {
};
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) {
if (!Array.isArray(list)) return [];
return list
@ -69,6 +89,20 @@ function validateShowPayload(body, old = null) {
if (!scheduledFor) {
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');
if (!RECURRENCES.has(recurrence)) {
@ -105,7 +139,7 @@ function validateShowPayload(body, old = null) {
const notesRaw = body.notes !== undefined ? body.notes : (old ? old.notes : null);
let notes = null;
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);
@ -126,6 +160,7 @@ function validateShowPayload(body, old = null) {
playlist,
timezone,
scheduled_for: scheduledFor,
estimated_end_at: estimatedEndAt,
next_run_at: nextRunAt,
status,
recurrence,
@ -198,7 +233,8 @@ router.get('/', async (req, res) => {
if (!auth) return;
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) => {
@ -210,7 +246,9 @@ router.get('/public', async (req, res) => {
}
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) => {
@ -221,7 +259,9 @@ router.get('/:id', async (req, res) => {
if (isNaN(id)) return res.status(400).json({ error: 'Invalid show id' });
const show = await showDB.getShowById(id, auth.channelRow.id);
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) => {
@ -278,7 +318,9 @@ router.post('/', async (req, res) => {
});
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) => {
@ -304,7 +346,9 @@ router.put('/:id', async (req, res) => {
});
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) => {
@ -394,7 +438,9 @@ router.post('/:id/action', async (req, res) => {
}
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;

View file

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

View file

@ -267,6 +267,10 @@ mixin shows
label.control-label.col-sm-3(for="cs-shows-scheduled-for") Scheduled For
.col-sm-9
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
label.control-label.col-sm-3(for="cs-shows-color") Color
.col-sm-3
@ -305,8 +309,10 @@ mixin shows
.form-group
label.control-label.col-sm-3(for="cs-shows-notes") Notes
.col-sm-9
textarea#cs-shows-notes.form-control(rows="6", placeholder="Optional rich notes (same HTML support as MOTD)")
p.text-muted.small(style="margin-top:6px") Supports MOTD-style HTML. Optional.
button#cs-shows-notes-toggle.btn.btn-xs.btn-default.pull-right(type="button", style="margin-bottom:6px") Preview
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
label.control-label.col-sm-3(for="cs-shows-mediaurl") Show Playlist
.col-sm-9
@ -329,8 +335,10 @@ mixin shows
th Name
th Status
th Next Run
th Est. End
th Timezone
th Recurrence
th Calendar
th Actions
tbody#cs-shows-list

View file

@ -242,6 +242,11 @@ li.ui-sortable-helper, li.ui-sortable-placeholder + li.queue_entry {
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 {
cursor: pointer;
}
@ -258,6 +263,40 @@ li.ui-sortable-helper, li.ui-sortable-placeholder + li.queue_entry {
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-running { background: #5cb85c; }
.showschedule-show.status-paused { background: #f0ad4e; color: #222; }

View file

@ -1436,6 +1436,7 @@ var CSTShows = (function () {
var resolvingTitles = false;
var weekOffset = 0;
var cachedShows = [];
var notesEditorMode = 'edit';
function apiBase() {
return '/api/v1/channels/' + CHANNEL.name + '/shows';
@ -1487,6 +1488,121 @@ var CSTShows = (function () {
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() {
var ul = $('#cs-shows-playlist-list').empty();
if (!draftPlaylist.length) {
@ -1627,6 +1743,7 @@ var CSTShows = (function () {
function readFormPayload() {
var scheduledRaw = $('#cs-shows-scheduled-for').val();
var estimatedEndRaw = $('#cs-shows-estimated-end-at').val();
var timezone = $('#cs-shows-timezone').val().trim();
var notes = $('#cs-shows-notes').val();
var colorHex = ($('#cs-shows-color-hex').val() || '').trim();
@ -1644,6 +1761,7 @@ var CSTShows = (function () {
notes: notes && notes.trim() ? notes : null,
color: colorHex ? colorHex.toUpperCase() : null,
scheduled_for: scheduledRaw ? new Date(scheduledRaw).toISOString() : null,
estimated_end_at: estimatedEndRaw ? new Date(estimatedEndRaw).toISOString() : null,
timezone: timezone,
recurrence: $('#cs-shows-recurrence').val(),
fill_mode: $('#cs-shows-fill-mode').val(),
@ -1662,6 +1780,7 @@ var CSTShows = (function () {
$('#cs-shows-name').val('');
$('#cs-shows-notes').val('');
$('#cs-shows-scheduled-for').val('');
$('#cs-shows-estimated-end-at').val('');
var detectedTz = 'UTC';
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
@ -1679,6 +1798,8 @@ var CSTShows = (function () {
$('#cs-shows-mediaurl').val('');
draftPlaylist = [];
renderDraftPlaylist();
updateNotesPreview();
setNotesEditorMode('edit');
}
function selectShow(show) {
@ -1687,6 +1808,7 @@ var CSTShows = (function () {
$('#cs-shows-name').val(show.name);
$('#cs-shows-notes').val(show.notes || '');
$('#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';
if ($('#cs-shows-timezone option[value="' + showTz + '"]').length === 0) {
$('<option>').attr('value', showTz).text(showTz).appendTo('#cs-shows-timezone');
@ -1707,6 +1829,8 @@ var CSTShows = (function () {
};
});
renderDraftPlaylist();
updateNotesPreview();
setNotesEditorMode('edit');
resolveDraftTitles();
}
@ -1751,26 +1875,106 @@ var CSTShows = (function () {
function openShowDetailsModal(show, when) {
$('#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');
var notes = (show.notes || '').trim();
if (!notes) {
var linksWrap = $('#showdetails-calendar-links');
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>');
} else {
$('#showdetails-notes').html(notes);
$('#showdetails-notes').html(notesHtml);
}
$('#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) {
var at = show.next_run_at || show.scheduled_for;
if (!at) return;
var d = new Date(at);
if (d < weekStart || d > new Date(weekEnd.getTime() + 86399999)) return;
var key = toCellKey(d);
if (!byCell[key]) byCell[key] = [];
byCell[key].push({ show: show, date: d });
var start = new Date(at);
var end = show.estimated_end_at ? new Date(show.estimated_end_at) : new Date(start.getTime() + 60 * 60 * 1000);
if (end <= start) end = new Date(start.getTime() + 60 * 60 * 1000);
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;
@ -1791,7 +1995,9 @@ var CSTShows = (function () {
var cellDate = new Date(weekStart.getTime());
cellDate.setDate(weekStart.getDate() + col);
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);
if (isAdmin) {
cell.addClass('showschedule-admin').attr('title', 'Click to create show at this time');
@ -1804,23 +2010,53 @@ var CSTShows = (function () {
})(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; });
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) {
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">')
.addClass('status-' + (item.show.status || 'scheduled'))
.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 () {
if (isAdmin) {
openShowsEditor();
selectShow(item.show);
} else {
openShowDetailsModal(item.show, item.date);
openShowDetailsModal(item.show, item.date.getTime());
}
})
.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) {
var tbody = $('#cs-shows-list').empty();
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;
}
@ -1856,8 +2092,37 @@ var CSTShows = (function () {
));
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.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.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>');
$('<button class=\"btn btn-xs btn-primary\" style=\"margin-right:4px\">Run</button>')
@ -1901,7 +2166,7 @@ var CSTShows = (function () {
renderScheduleCalendar(cachedShows);
}).fail(function () {
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>');
});
@ -1960,6 +2225,10 @@ var CSTShows = (function () {
$('#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({
update: function () {
var nextDraft = [];
@ -1989,6 +2258,7 @@ var CSTShows = (function () {
});
renderDraftPlaylist();
clearForm();
setNotesEditorMode('edit');
load();
return { load: load, selectShow: selectShow, prefillScheduledDate: prefillScheduledDate };