Video Calendar: week navigation + owned/missing filter

Finish the TV calendar as a planning tool.

- Week nav: ‹ prev / Today / next › steps the 7-day window (endpoint takes
  ?start=; each window starts on today's weekday so "current day first" holds).
  Title reflects it (This Week / Next Week / In N weeks); Today highlight only
  shows when the real today is in view.
- Filter: All / In library / Missing, applied client-side (no refetch) — the
  hero + grid + count all respect it.
- Hero eyebrow is context-aware (NEXT UP this week, FEATURED on other weeks).
This commit is contained in:
BoulderBadgeDad 2026-06-15 19:32:57 -07:00
parent 5856eefd7c
commit 57c5148207
4 changed files with 103 additions and 16 deletions

View file

@ -7,7 +7,7 @@ via VideoDatabase, writes nothing, never touches the music side.
from __future__ import annotations
from datetime import date, timedelta
from datetime import date, datetime, timedelta
from flask import jsonify, request
@ -22,8 +22,18 @@ def register_routes(bp):
from . import get_video_db
try:
days = request.args.get("days", default=7, type=int) or 7
days = max(1, min(days, 90)) # clamp: today .. +90d
start = date.today()
days = max(1, min(days, 31)) # one week (or a few) per view
today = date.today()
# Optional ?start=YYYY-MM-DD for week navigation; default = today.
start = today
start_s = request.args.get("start")
if start_s:
try:
start = datetime.strptime(start_s, "%Y-%m-%d").date()
except ValueError:
start = today
if abs((start - today).days) > 400: # sane bound around today
start = today
end = start + timedelta(days=days - 1)
db = get_video_db()
@ -50,8 +60,8 @@ def register_routes(bp):
if e.get("has_file"):
owned += 1
return jsonify({
"today": start.isoformat(),
"start": start.isoformat(),
"today": today.isoformat(), # real today (for the highlight)
"start": start.isoformat(), # window start (may be a future week)
"end": end.isoformat(),
"days": days,
"counts_by_date": counts,

View file

@ -869,8 +869,24 @@
<div class="vcal-page">
<div class="vcal-head">
<div class="vcal-head-glow" aria-hidden="true"></div>
<h1 class="vcal-title">This Week</h1>
<p class="vcal-sub" data-video-cal-sub>Upcoming episodes from your shows</p>
<div class="vcal-head-row">
<div class="vcal-head-titles">
<h1 class="vcal-title" data-video-cal-title>This Week</h1>
<p class="vcal-sub" data-video-cal-sub>Upcoming episodes from your shows</p>
</div>
<div class="vcal-controls">
<div class="vcal-nav" role="group" aria-label="Week">
<button class="vcal-nav-btn" type="button" data-video-cal-prev aria-label="Previous week">&#8249;</button>
<button class="vcal-nav-today" type="button" data-video-cal-today>Today</button>
<button class="vcal-nav-btn" type="button" data-video-cal-next aria-label="Next week">&#8250;</button>
</div>
<div class="vcal-filter" role="group" aria-label="Filter">
<button class="vcal-filter-btn vcal-filter-btn--on" type="button" data-video-cal-filter="all">All</button>
<button class="vcal-filter-btn" type="button" data-video-cal-filter="owned">In library</button>
<button class="vcal-filter-btn" type="button" data-video-cal-filter="missing">Missing</button>
</div>
</div>
</div>
</div>
<div class="vcal-body">
<div class="vcal-loading hidden" data-video-cal-loading>

View file

@ -11,14 +11,14 @@
'use strict';
var PAGE_ID = 'video-calendar';
var URL = '/api/video/calendar?days=7';
var URL = '/api/video/calendar';
var COLS = 7;
var WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
var WD_FULL = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
var MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
var MO_FULL = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August',
'September', 'October', 'November', 'December'];
var state = { loaded: false, eps: {} };
var state = { loaded: false, eps: {}, data: null, offset: 0, filter: 'all' };
function $(s) { return document.querySelector(s); }
function esc(s) {
@ -161,7 +161,8 @@
(bg ? '<div class="vcal-bb-bg" style="background-image:url(\'' + bg + '\')"></div>' : '') +
'<div class="vcal-bb-scrim"></div>' +
'<div class="vcal-bb-content">' +
'<div class="vcal-bb-eyebrow"><span class="vcal-bb-dot"></span>NEXT UP · ' + esc(whenLabel(ep, d.today)) + '</div>' +
'<div class="vcal-bb-eyebrow"><span class="vcal-bb-dot"></span>' +
(state.offset === 0 ? 'NEXT UP' : 'FEATURED') + ' · ' + esc(whenLabel(ep, d.today)) + '</div>' +
'<h2 class="vcal-bb-title">' + esc(ep.show_title) + '</h2>' +
'<div class="vcal-bb-sub"><span class="vcal-bb-se">' + se + '</span>' + (epTitle ? ' · ' + esc(epTitle) : '') + '</div>' +
'<div class="vcal-bb-actions"><span class="vcal-bb-btn">View details</span>' + owned + '</div>' +
@ -169,20 +170,51 @@
'</div>';
}
function filterEps(eps) {
if (state.filter === 'owned') return eps.filter(function (e) { return e.has_file; });
if (state.filter === 'missing') return eps.filter(function (e) { return !e.has_file; });
return eps;
}
function updateChrome() {
var t = $('[data-video-cal-title]');
if (t) t.textContent = state.offset === 0 ? 'This Week'
: state.offset === 1 ? 'Next Week' : state.offset === -1 ? 'Last Week'
: state.offset > 0 ? 'In ' + state.offset + ' weeks' : Math.abs(state.offset) + ' weeks ago';
var today = $('[data-video-cal-today]');
if (today) today.disabled = state.offset === 0;
var fbs = document.querySelectorAll('[data-video-cal-filter]');
for (var i = 0; i < fbs.length; i++)
fbs[i].classList.toggle('vcal-filter-btn--on', fbs[i].getAttribute('data-video-cal-filter') === state.filter);
}
// Render from the cached payload + the active filter (no refetch).
function render() {
var d = state.data;
updateChrome();
var hero = $('[data-video-cal-hero]'), cols = $('[data-video-cal-cols]');
if (!d) { showEmpty(true); if (hero) hero.innerHTML = ''; if (cols) cols.innerHTML = ''; return; }
var eps = filterEps(d.episodes || []);
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 = ''; }
setSub(view);
}
function load() {
state.loaded = true;
showEmpty(false); showLoading(true);
fetch(URL, { headers: { 'Accept': 'application/json' } })
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' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
showLoading(false);
if (!d || d.error) { showEmpty(true); return; }
if (!(d.episodes && d.episodes.length)) { showEmpty(true); return; }
if (!d || d.error) { state.data = null; render(); return; }
state.data = d;
state.eps = {};
d.episodes.forEach(function (e) { state.eps[e.id] = e; });
renderHero(d); renderGrid(d); setSub(d);
(d.episodes || []).forEach(function (e) { state.eps[e.id] = e; });
render();
})
.catch(function () { showLoading(false); showEmpty(true); });
.catch(function () { showLoading(false); state.data = null; render(); });
}
function openFrom(target) {
@ -225,6 +257,15 @@
wireTilt(hero, '.vcal-bb', 3);
}
wireTilt(cols, '.vcal-cell', 7);
var prev = $('[data-video-cal-prev]'); if (prev) prev.addEventListener('click', function () { state.offset--; load(); });
var next = $('[data-video-cal-next]'); if (next) next.addEventListener('click', function () { state.offset++; load(); });
var today = $('[data-video-cal-today]');
if (today) today.addEventListener('click', function () { if (state.offset !== 0) { state.offset = 0; load(); } });
var fbs = document.querySelectorAll('[data-video-cal-filter]');
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]);
}
// ── episode modal ─────────────────────────────────────────────────────────

View file

@ -1716,3 +1716,23 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vcal-hero-wrap { padding-left: 18px; padding-right: 18px; }
.vcal-bb-title { font-size: 30px; } .vcal-bb { min-height: 240px; }
}
/* ── Calendar — header controls (week nav + filter) ───────────────────────── */
.vcal-head-row { position: relative; z-index: 1; display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
.vcal-controls { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.vcal-nav { display: inline-flex; align-items: center; gap: 2px; padding: 4px; border-radius: 12px;
background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.08); }
.vcal-nav-btn { width: 34px; height: 32px; border: 0; background: transparent; color: rgba(255, 255, 255, 0.75);
font-size: 20px; font-weight: 700; cursor: pointer; border-radius: 8px; line-height: 1; transition: all 0.15s ease; }
.vcal-nav-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
.vcal-nav-today { border: 0; background: transparent; color: rgba(255, 255, 255, 0.82); font-size: 12.5px; font-weight: 800;
padding: 7px 12px; border-radius: 8px; cursor: pointer; transition: all 0.15s ease; }
.vcal-nav-today:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); color: #fff; }
.vcal-nav-today:disabled { opacity: 0.34; cursor: default; }
.vcal-filter { display: inline-flex; gap: 3px; padding: 4px; border-radius: 12px;
background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.08); }
.vcal-filter-btn { border: 0; background: transparent; color: rgba(255, 255, 255, 0.7); font-size: 12.5px; font-weight: 700;
padding: 7px 14px; border-radius: 8px; cursor: pointer; transition: all 0.15s ease; }
.vcal-filter-btn:hover { color: #fff; background: rgba(255, 255, 255, 0.08); }
.vcal-filter-btn--on { color: #fff; background: rgba(var(--vcal-accent), 0.9); box-shadow: 0 4px 14px rgba(var(--vcal-accent), 0.32); }
@media (max-width: 720px) { .vcal-head-row { flex-direction: column; align-items: stretch; } .vcal-controls { justify-content: space-between; } }