Video Calendar: EPG-style guide with a shared time axis + premium polish

The 7 independent day-stacks had no shared time axis — you couldn't scan
'what's on in prime time' across the week. Restructured into a real guide:

- Rows = time-of-day bands (Morning / Afternoon / Prime Time / Late Night /
  Anytime) down a frozen left time-rail; 7 day columns; frozen header row +
  sticky corner. Untimed streaming drops land in Anytime; each card keeps its
  exact time. Empty bands (none all week) are hidden; empty cells show a dot.
- Live 'now' cue lights the today-column × current-time-band intersection.
- Prime Time rail glints gold.
- Keyboard nav (←/→ weeks, T = today), scoped to the visible page + no modal.
- Week-change crossfade; staggered card entrance runs only on load/week-change
  (not on filter re-renders); keyboard focus rings on cards.
- Subheader now shows owned/missing split.

Kept all existing interactions: tilt cards, breathing glows, skeleton shimmer,
parallax billboard, rich episode modal. Vanilla static files, no build step.
This commit is contained in:
BoulderBadgeDad 2026-06-16 00:35:35 -07:00
parent 57c5148207
commit 6d72b7ca11
2 changed files with 177 additions and 47 deletions

View file

@ -62,8 +62,16 @@
var el = $('[data-video-cal-sub]'); if (!el) return;
var a = parseISO(d.start), b = parseISO(d.end);
var range = MO[a.getMonth()] + ' ' + a.getDate() + ' ' + MO[b.getMonth()] + ' ' + b.getDate();
var n = d.total || 0;
el.textContent = range + (n ? ' · ' + n + (n === 1 ? ' episode' : ' episodes') : ' · nothing scheduled');
var eps = d.episodes || [], n = eps.length;
var owned = 0; for (var i = 0; i < n; i++) if (eps[i].has_file) owned++;
var miss = n - owned;
var parts = [range];
if (n) {
parts.push(n + (n === 1 ? ' episode' : ' episodes'));
if (owned) parts.push(owned + ' in library');
if (miss) parts.push(miss + ' missing');
} else { parts.push('nothing scheduled'); }
el.textContent = parts.join(' · ');
}
function epCell(ep, idx) {
@ -96,35 +104,93 @@
'</a>';
}
// Time-of-day bands — the rows of the guide. A real shared time axis so
// you can scan ACROSS the week ("what's on in Prime Time") instead of
// reading seven disconnected stacks. Untimed streaming drops land in
// "Anytime". Each card still carries its exact time.
var BANDS = [
{ key: 'morning', label: 'Morning', range: '5a12p', lo: 300, hi: 720 },
{ key: 'afternoon', label: 'Afternoon', range: '125p', lo: 720, hi: 1020 },
{ key: 'prime', label: 'Prime Time', range: '59p', lo: 1020, hi: 1260 },
{ key: 'late', label: 'Late Night', range: '9p5a', lo: 1260, hi: 1740 }, // wraps past midnight
{ key: 'anytime', label: 'Anytime', range: 'Streaming', lo: null, hi: null }
];
function bandKeyFor(mins) {
if (mins == null) return 'anytime';
// Late night wraps: 21:0004:59 (treat early-AM as +24h for the range test).
var m = mins < 300 ? mins + 1440 : mins;
for (var i = 0; i < BANDS.length; i++) {
var b = BANDS[i];
if (b.lo == null) continue;
if (m >= b.lo && m < b.hi) return b.key;
}
return 'anytime';
}
function renderGrid(d) {
var cols = $('[data-video-cal-cols]'); if (!cols) return;
var byDate = {};
(d.episodes || []).forEach(function (ep) { (byDate[ep.air_date] = byDate[ep.air_date] || []).push(ep); });
var start = parseISO(d.start);
var html = '';
var days = [];
for (var i = 0; i < COLS; i++) { var dt = new Date(start); dt.setDate(start.getDate() + i); days.push(dt); }
// grid[bandKey][dayIndex] = [episodes]
var grid = {};
BANDS.forEach(function (b) { grid[b.key] = []; for (var i = 0; i < COLS; i++) grid[b.key].push([]); });
var dayCount = [];
for (var i = 0; i < COLS; i++) dayCount.push(0);
(d.episodes || []).forEach(function (ep) {
for (var i = 0; i < COLS; i++) {
if (ep.air_date === isoOf(days[i])) {
grid[bandKeyFor(airMins(ep.airs_time))][i].push(ep);
dayCount[i]++;
break;
}
}
});
var byTime = function (a, b) {
var ma = airMins(a.airs_time), mb = airMins(b.airs_time);
if (ma == null && mb == null) return 0;
if (ma == null) return 1; if (mb == null) return -1;
return ma - mb;
};
// header row: corner + 7 day heads
var html = '<div class="vcal-guide-corner"></div>';
for (var i = 0; i < COLS; i++) {
var dt = new Date(start); dt.setDate(start.getDate() + i);
var eps = (byDate[isoOf(dt)] || []).slice();
eps.sort(function (a, b) {
var ma = airMins(a.airs_time), mb = airMins(b.airs_time);
if (ma == null && mb == null) return 0;
if (ma == null) return 1; if (mb == null) return -1;
return ma - mb;
});
var today = isoOf(dt) === d.today;
var cells = eps.length
? eps.map(epCell).join('')
: '<div class="vcal-col-empty">No episodes</div>';
html += '<section class="vcal-col' + (today ? ' vcal-col--today' : '') + '">' +
'<header class="vcal-col-head">' +
'<span class="vcal-col-wd">' + (today ? 'Today' : WD_FULL[dt.getDay()]) + '</span>' +
'<span class="vcal-col-date">' + WD[dt.getDay()] + ' ' + dt.getDate() + '</span>' +
(eps.length ? '<span class="vcal-col-n">' + eps.length + '</span>' : '') +
'</header>' +
'<div class="vcal-col-stack">' + cells + '</div>' +
'</section>';
var dt = days[i], today = isoOf(dt) === d.today;
html += '<div class="vcal-dayhead' + (today ? ' vcal-dayhead--today' : '') + '">' +
'<span class="vcal-dayhead-wd">' + (today ? 'Today' : WD_FULL[dt.getDay()]) + '</span>' +
'<span class="vcal-dayhead-date">' + WD[dt.getDay()] + ' ' + dt.getDate() + '</span>' +
(dayCount[i] ? '<span class="vcal-dayhead-n">' + dayCount[i] + '</span>' : '') +
'</div>';
}
// one row per active band (skip bands empty across the whole week)
var stagger = 0;
// "Now" cue — the band the wall-clock is currently in. Only the today
// column carries it (other weeks have no today column), so it lights up
// the live time-of-day intersection like a TV guide.
var nowD = new Date();
var nowBand = bandKeyFor(nowD.getHours() * 60 + nowD.getMinutes());
BANDS.forEach(function (b) {
var anyEps = false;
for (var i = 0; i < COLS; i++) if (grid[b.key][i].length) { anyEps = true; break; }
if (!anyEps) return;
html += '<div class="vcal-rail vcal-rail--' + b.key + '">' +
'<span class="vcal-rail-label">' + b.label + '</span>' +
'<small>' + b.range + '</small></div>';
for (var i = 0; i < COLS; i++) {
var cell = grid[b.key][i].slice().sort(byTime);
var today = isoOf(days[i]) === d.today;
var isNow = today && b.key === nowBand;
var inner = cell.length
? cell.map(function (ep) { return epCell(ep, stagger++); }).join('')
: '<span class="vcal-slot-dot" aria-hidden="true"></span>';
html += '<div class="vcal-slot' + (today ? ' vcal-slot--today' : '') +
(isNow ? ' vcal-slot--now' : '') +
(cell.length ? '' : ' vcal-slot--empty') + '">' + inner + '</div>';
}
});
cols.innerHTML = html;
}
@ -196,12 +262,24 @@
var view = { episodes: eps, total: eps.length, today: d.today, start: d.start, end: d.end, days: d.days };
var has = eps.length > 0;
showEmpty(!has);
if (has) { renderHero(view); renderGrid(view); }
else { if (hero) hero.innerHTML = ''; if (cols) cols.innerHTML = ''; }
if (has) {
renderHero(view); renderGrid(view);
// Card entrance only on a fresh load/week-change — NOT on filter
// re-renders (those would re-stagger every click and feel busy).
if (state.fresh && cols) {
cols.classList.add('vcal-animate-in');
setTimeout(function () { cols.classList.remove('vcal-animate-in'); }, 900);
}
} else { if (hero) hero.innerHTML = ''; if (cols) cols.innerHTML = ''; }
state.fresh = false;
var grid = $('[data-video-cal-grid]'); if (grid) grid.classList.remove('vcal-fading');
setSub(view);
}
function load() {
state.loaded = true;
state.loaded = true; state.fresh = true;
// Crossfade the grid out while the next week loads (only when we already
// have something on screen — first load uses the entrance instead).
var grid = $('[data-video-cal-grid]'); if (grid && state.data) grid.classList.add('vcal-fading');
showEmpty(false); showLoading(true);
var base = new Date(); base.setHours(0, 0, 0, 0); base.setDate(base.getDate() + state.offset * 7);
fetch(URL + '?days=7&start=' + isoOf(base), { headers: { 'Accept': 'application/json' } })
@ -266,6 +344,20 @@
for (var i = 0; i < fbs.length; i++) (function (b) {
b.addEventListener('click', function () { state.filter = b.getAttribute('data-video-cal-filter'); render(); });
})(fbs[i]);
// Keyboard nav for a page users live in: ← / → step weeks, T jumps to
// today. Only when the calendar is the visible page, no modal is open,
// and the user isn't typing in a field.
document.addEventListener('keydown', function (e) {
if (modalEl || e.metaKey || e.ctrlKey || e.altKey) return;
var tag = (e.target && e.target.tagName) || '';
if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag) || (e.target && e.target.isContentEditable)) return;
var g = $('[data-video-cal-cols]');
if (!g || g.offsetParent === null) return; // calendar page not visible
if (e.key === 'ArrowLeft') { e.preventDefault(); state.offset--; load(); }
else if (e.key === 'ArrowRight') { e.preventDefault(); state.offset++; load(); }
else if (e.key === 't' || e.key === 'T') { if (state.offset !== 0) { e.preventDefault(); state.offset = 0; load(); } }
});
}
// ── episode modal ─────────────────────────────────────────────────────────

View file

@ -1532,27 +1532,65 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vcal-grid { border-radius: 20px; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.07);
background: linear-gradient(180deg, #0d0d12, #0a0a0e); box-shadow: 0 30px 80px rgba(0, 0, 0, 0.45); }
.vcal-grid.hidden { display: none; }
.vcal-cols { display: grid; grid-template-columns: repeat(7, minmax(168px, 1fr)); align-items: start; }
.vcal-col { border-left: 1px solid rgba(255, 255, 255, 0.06); min-width: 0; }
.vcal-col:first-child { border-left: 0; }
.vcal-col--today { background: linear-gradient(180deg, rgba(var(--vcal-accent), 0.10), rgba(var(--vcal-accent), 0.02) 30%, transparent 60%); }
/* EPG guide a frozen time-rail (rows = time-of-day) × 7 day columns with a
frozen header row, so the whole week reads on ONE shared time axis. */
.vcal-cols { display: grid; grid-template-columns: 84px repeat(7, minmax(150px, 1fr)); align-items: stretch; }
/* Day header — sticky */
.vcal-col-head { position: sticky; top: 0; z-index: 4; padding: 15px 15px 13px;
background: linear-gradient(180deg, rgba(15, 15, 20, 0.96), rgba(13, 13, 18, 0.85));
/* sticky top-left corner */
.vcal-guide-corner { position: sticky; top: 0; left: 0; z-index: 6;
background: linear-gradient(180deg, rgba(15, 15, 20, 0.97), rgba(13, 13, 18, 0.88));
backdrop-filter: blur(12px); border-bottom: 1px solid rgba(255, 255, 255, 0.07); }
.vcal-col-wd { display: block; font-size: 14px; font-weight: 800; letter-spacing: -0.01em; color: #fff; }
.vcal-col-date { display: block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.4); margin-top: 2px; }
.vcal-col-n { position: absolute; top: 14px; right: 14px; font-size: 11px; font-weight: 800; color: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.08); padding: 3px 9px; border-radius: 999px; }
.vcal-col--today .vcal-col-wd { color: hsl(232, 100%, 82%); }
.vcal-col--today .vcal-col-head { border-bottom-color: rgba(var(--vcal-accent), 0.5);
box-shadow: 0 1px 0 rgba(var(--vcal-accent), 0.5), 0 10px 26px -12px rgba(var(--vcal-accent), 0.5); }
.vcal-col--today .vcal-col-n { background: rgba(var(--vcal-accent), 0.88); color: #fff; }
.vcal-col-stack { display: flex; flex-direction: column; gap: 16px; padding: 16px 14px 20px; }
.vcal-col-empty { padding: 26px 8px; text-align: center; font-size: 12px; font-weight: 600; color: rgba(255, 255, 255, 0.22); }
/* day header — sticky top */
.vcal-dayhead { position: sticky; top: 0; z-index: 4; padding: 15px 14px 13px;
background: linear-gradient(180deg, rgba(15, 15, 20, 0.96), rgba(13, 13, 18, 0.85));
backdrop-filter: blur(12px); border-bottom: 1px solid rgba(255, 255, 255, 0.07);
border-left: 1px solid rgba(255, 255, 255, 0.05); }
.vcal-dayhead-wd { display: block; font-size: 14px; font-weight: 800; letter-spacing: -0.01em; color: #fff; }
.vcal-dayhead-date { display: block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.4); margin-top: 2px; }
.vcal-dayhead-n { position: absolute; top: 14px; right: 13px; font-size: 11px; font-weight: 800; color: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.08); padding: 3px 9px; border-radius: 999px; }
.vcal-dayhead--today { background: linear-gradient(180deg, rgba(var(--vcal-accent), 0.24), rgba(13, 13, 18, 0.86));
border-bottom-color: rgba(var(--vcal-accent), 0.55);
box-shadow: 0 1px 0 rgba(var(--vcal-accent), 0.55), 0 12px 28px -14px rgba(var(--vcal-accent), 0.6); }
.vcal-dayhead--today .vcal-dayhead-wd { color: hsl(232, 100%, 84%); }
.vcal-dayhead--today .vcal-dayhead-n { background: rgba(var(--vcal-accent), 0.9); color: #fff; }
/* time rail — sticky left, labels the band */
.vcal-rail { position: sticky; left: 0; z-index: 2; display: flex; flex-direction: column; gap: 2px;
padding: 17px 12px; border-top: 1px solid rgba(255, 255, 255, 0.05);
background: linear-gradient(90deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.004)); }
.vcal-rail-label { font-size: 12.5px; font-weight: 800; letter-spacing: -0.01em; color: rgba(255, 255, 255, 0.8); }
.vcal-rail small { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(255, 255, 255, 0.3); }
.vcal-rail--prime .vcal-rail-label { color: hsl(44, 100%, 72%); }
.vcal-rail--prime small { color: hsla(44, 90%, 60%, 0.6); }
/* one day×band cell */
.vcal-slot { display: flex; flex-direction: column; gap: 15px; padding: 17px 13px; min-width: 0;
border-top: 1px solid rgba(255, 255, 255, 0.05); border-left: 1px solid rgba(255, 255, 255, 0.05); }
.vcal-slot--today { background: linear-gradient(180deg, rgba(var(--vcal-accent), 0.07), rgba(var(--vcal-accent), 0.015)); }
.vcal-slot--empty { align-items: center; justify-content: center; padding: 0; }
.vcal-slot-dot { width: 4px; height: 4px; border-radius: 50%; background: rgba(255, 255, 255, 0.07); }
/* live "now" intersection — today column × current time-of-day band */
.vcal-slot--now { box-shadow: inset 3px 0 0 rgba(var(--vcal-accent), 0.95), inset 0 0 34px -12px rgba(var(--vcal-accent), 0.5);
background: linear-gradient(180deg, rgba(var(--vcal-accent), 0.12), rgba(var(--vcal-accent), 0.03)); }
/* staggered entrance only on a fresh load/week-change (class added by JS),
keyed off the per-card --i (cycles every 24). Filter re-renders don't get it. */
.vcal-cols.vcal-animate-in .vcal-card { animation: vcalCardIn 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) both;
animation-delay: calc(var(--i, 0) * 0.022s); }
@keyframes vcalCardIn { from { opacity: 0; transform: translateY(10px) scale(0.985); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .vcal-cols.vcal-animate-in .vcal-card { animation: none; } }
/* week-change crossfade */
.vcal-grid { transition: opacity 0.22s ease; }
.vcal-grid.vcal-fading { opacity: 0.32; }
/* keyboard focus — mirrors hover + a clear ring (cards are real <a>s) */
.vcal-cell:focus-visible { outline: none; --ty: -6px; --sc: 1.04; z-index: 3; }
.vcal-cell:focus-visible .vcal-card { border-color: hsla(var(--vcal-show), 80%, 64%, 0.85);
box-shadow: 0 0 0 2px hsla(var(--vcal-show), 82%, 62%, 0.9), 0 18px 40px rgba(0, 0, 0, 0.5); }
/* Episode cell — one clean card (art + info), breathing colour halo behind it */
.vcal-cell { --vcal-show: var(--vcal-h, 230); position: relative; display: block; text-decoration: none; color: inherit;