tracker
All repositories: gitoria
24.6 KB
// components/show.hl — `/shows/:slug` (tracker.worldapi.org#4): a show's own page. Creator's words:// "the poster on the left, right of it the title of the show, the genre pills, below the plot summary,// below that the artists just as normal links, separated with comma color red, genres are blue" (the// header) — "then all seasons in reverse order, latest on top, below them collapsable their episodes,// episodes first the round check icon, if the episode was watched by the user looking its solid, if// not its regular, then the episode number, then the title. the last season is not collapsed. click on// the check adds a watch. the season also has a check, if the season is checked or unchecked all its// episodes get checked or unchecked. artists link to /person/<artist-slug> the genre to /genre/<genre-slug>."//// THE ROWS ARE ONE FLAT LIST, not a nested for-loop over seasons > episodes: the vendored build may// still lack the fix for hybriel#86 (a nested for over a reassigned outer list keeps stale inner// cells) — a single-level `for (row of rows)` with a `kind` field, reassigned whole on every click,// is the same proven-safe shape components/main.hl's own header and ident's inbox.hl already use.// `rows` is rebuilt from scratch (seasons/episodes refetched by id, watch rows refetched) on every// toggle — cheap: a show has at most a few dozen episodes per season.//// Mission 047 (the creator, 2026-10-01): a caret at the season header's right edge (down = open, right = collapsed); episode// rows not indented; on a phone the title first, then the poster at full width, then genres/plot/cast; a Follow toggle// next to the title. SIGNED OUT the checks and Follow are visible too: a click opens the "sign in" modal (modal.hl, copied// from components.hybriel) and writes nothing.//// tracker.worldapi.org#17 (mission 057): the caret points DOWN while a season is closed and UP while it is open. Opening a// season shows SKELETON rows at once (min(episodes, 8), a CSS shimmer, styles.hl) and then its episodes from the face// `showSeasonEpisodes`, which takes NO session: a face with `session` makes hl:web re-mount the whole page on the server and// ship every session-derived member back as a `sync` (WebFramework.hl `inbound`) — that, not the episodes, was most of the// old 250–450 ms (real data). The watched state comes from `watchedEpisodes` (this show's watched episodes per season, rendered// with the page and re-synced after every watch face). Closing is client-only (no request). An ADULT show (only its// followers see it) keeps the session face `showRows`: the public face refuses adult seasons.//// tracker.worldapi.org#12: link icons under the title — the show on TMDB, IMDb, TheTVDB, TVmaze (a movie: TMDB + IMDb),// only for the ids it has (shows.hl externalLinksOf), each in a new tab.//// mission 058: #22 every episode row shows its air date (`release`, 'YYYY-MM-DD' like /my/unwatched; none → empty). #23 a// season's check is DERIVED: solid exactly when every one of its episodes is watched (a season without episodes: its own// season watch) — unchecking one episode turns the season off with the face's answer, checking it again turns it on; the// season click watches all when not all are watched, else unwatches all. #24 a MOVIE's page has its own watched check// (watch `targetType = 'movie'`, target = the title's id; face `showToggleMovie`), signed out → the sign-in modal.parent './main.hl'import Modal from './modal.hl'import { appTitle } from '../project.hl'import { userIdOfSession, loginHref } from '../users.hl'import { isFollowing, setFollowed } from '../follows.hl'import { showBySlug, showById, genreById, personById, seasonsOfShow, episodesOfSeason, seasonById, posterName, externalLinksOf } from '../shows.hl'import { watchesOfUser, isWatched, watchedSet, setWatched, setSeasonWatched, watchClassOf } from '../watches.hl'// ---- pure helpers ----------------------------------------------------------------------------// `static`, like calendar.hl's `soonOf` — a PLAIN instance member function that calls an// hl:mpackdb-backed import (seasonsOfShow, episodesOfSeason, watchesOfUser, …) comes back null// when called from an `on server` face (confirmed by running the gate: "'buildRows' is not// callable — it holds null" at the face's call site, even though the SAME call works fine as a// member's own initializer at the top of this file). `static` is the proven-safe shape (calendar's// `soonOf`, called from `calLoad`/`calSettingsLoad`, an mpackdb-backed helper exactly like this one).static initialCollapsed = (seasons) => {let m = {}let i = 0while (i < seasons.length) {if (i > 0) { m[seasons[i].id] = true }i = i + 1}return m}static genreRowsOf = (show) => {let out = []for (gid of show.genres) {let g = genreById(gid)if (g != null) { out.push({ id = g.id name = g.name href = '/genres/' + g.urlSegment }) }}return out}static castRowsOf = (show) => {let out = []for (c of show.cast) {let p = personById(c.person)if (p != null) { out.push({ name = c.actor href = '/person/' + p.urlSegment }) }}return out}// a View attribute must be a literal, a member or a field path — never an inline expression — so// every ternary (watched -> icon class, collapsed -> row class) is resolved HERE, once, into a// plain field the View just reads (row.watchClass, row.rowClass); `watchClassOf` itself lives in// watches.hl (shared with /unwatched and /schedule, "reuse, don't copy")// #22: an episode's air date as the lists show it ('YYYY-MM-DD'), '' when it has nonestatic airDateOf = (e) => { return e.release != null && hlTypeName(e.release) == 'String' ? e.release : '' }// #23: a season is watched when ALL its episodes are (by the ids it lists); one without episodes keeps its own season watchstatic seasonAllWatched = (s, watched) => {if (s.episodes.length == 0) { return watched['season:' + s.id] == true }for (eid of s.episodes) { if (watched['episode:' + eid] != true) { return false } }return true}static buildRows = (show, uid, collapsed) => {let watched = watchedSet(uid != null ? watchesOfUser(uid) : [])let out = []for (s of seasonsOfShow(show)) {let isCollapsed = collapsed[s.id] == truelet label = s.title != null && s.title != '' ? s.title : 'Season ' + s.seasonNumberlet seasonWatched = seasonAllWatched(s, watched)out.push({ kind = 'season' isSeason = true isEpisode = false isSkeleton = false id = s.id skeletonCount = s.episodes.length < 8 ? s.episodes.length : 8 label = label episodeCountText = s.episodes.length == 1 ? '1 episode' : s.episodes.length + ' episodes' watched = seasonWatched watchClass = watchClassOf(seasonWatched) collapsed = isCollapsed rowClass = isCollapsed ? 'collapsed' : 'expanded' })if (!isCollapsed) {for (e of episodesOfSeason(s)) {let epWatched = watched['episode:' + e.id] == trueout.push({ kind = 'episode' isSeason = false isEpisode = true isSkeleton = false id = e.id seasonId = s.id episodeNumber = e.episodeNumber title = e.title != null ? e.title : '' airDate = airDateOf(e) watched = epWatched watchClass = watchClassOf(epWatched) })}}}return out}// what the client marks the rows of a season it opens with (tracker#17) — only this show's, never the user's whole watch// list (every member is shipped to the browser). Per season with a watched episode: `{ all, except }` — `all` = most of// it is watched, then `except` holds the UNwatched ids, else the watched ones (a fully watched 142-episode season is// `{ all = true except = {} }`, not 142 ids). No entry = nothing watched.static watchedEpisodesOf = (show, uid) => {let m = {}if (uid == null) { return m }let watched = watchedSet(watchesOfUser(uid))for (s of seasonsOfShow(show)) {let yes = {}let no = {}let n = 0for (eid of s.episodes) {if (watched['episode:' + eid] == true) { yes[eid] = true n = n + 1 } else { no[eid] = true }}if (n > 0) { m[s.id] = n * 2 > s.episodes.length ? { all = true except = no } : { all = false except = yes } }}return m}// is this episode watched, by that per-season shape (client-side, pure)static watchedIn = (entry, episodeId) => {if (entry == null) { return false }let listed = entry.except[episodeId] == truereturn entry.all ? !listed : listed}// ---- the rows as the CLIENT changes them (tracker#17) — pure: no import is reached, so they run in the browser ----------// (`watchClassOf` is an import of the mpackdb-backed watches.hl, so `filledRows` writes its two classes itself)static seasonRowAs = (row, isCollapsed) => {return { kind = 'season' isSeason = true isEpisode = false isSkeleton = false id = row.id skeletonCount = row.skeletonCount label = row.label episodeCountText = row.episodeCountText watched = row.watched watchClass = row.watchClass collapsed = isCollapsed rowClass = isCollapsed ? 'collapsed' : 'expanded' }}// the season opened: its caret up at once, `skeletonCount` placeholder rows under itstatic openingRows = (rows, seasonId) => {let out = []for (row of rows) {if (row.isSeason && row.id == seasonId) {out.push(seasonRowAs(row, false))let i = 0while (i < row.skeletonCount) {out.push({ kind = 'skeleton' isSeason = false isEpisode = false isSkeleton = true id = seasonId + '-skeleton-' + i seasonId = seasonId })i = i + 1}} else { out.push(row) }}return out}// the season closed: its episode (and skeleton) rows gone, caret downstatic closedRows = (rows, seasonId) => {let out = []for (row of rows) {if (row.isSeason && row.id == seasonId) { out.push(seasonRowAs(row, true)) }else if (!row.isSeason && row.seasonId == seasonId) { }else { out.push(row) }}return out}// the episodes arrived: they replace the season's skeleton rows. No skeleton of it left (closed meanwhile, or a watch face// already rebuilt every row) → the rows stay as they are (no double rows)static filledRows = (rows, seasonId, episodes, watched) => {let out = []let done = falsefor (row of rows) {if (row.isSkeleton && row.seasonId == seasonId) {if (!done) {for (e of episodes) {let w = watchedIn(watched[seasonId], e.id)out.push({ kind = 'episode' isSeason = false isEpisode = true isSkeleton = false id = e.id seasonId = seasonId episodeNumber = e.episodeNumber title = e.title airDate = e.airDate watched = w watchClass = w ? 'checkicon solid' : 'checkicon regular' })}done = true}} else { out.push(row) }}return out}// a LOCAL static like buildRows (an imported mpackdb helper called straight from a member's initializer came back false// after a client-side navigation to the page — the gate's follow check)static followingOf = (show, uid) => { return uid != null && isFollowing(uid, show.id) }// #24: has this user watched this movie (a watch `movie:<title id>`)?static movieWatchedOf = (show, uid) => { return uid != null && isWatched(watchesOfUser(uid), 'movie', show.id) }// mission 056: an ADULT title's page (TMDB's flag `adult == true`, mission 054) is "Not found" for everyone who does not// follow it (signed out too); a follower sees it as before. Unknown (`null`) is not hidden here — only in the lists.// With hybriel ≥ 73267707 (#121/#122) `showRow`/`found` are server sites re-derived after every session face with the page's// slug, so an Unfollow on the page hides it at once (the gate proves it; the old build left the page up until a reload).// Mission 054's try (`found = … && !adultHiddenFor(showRow, me)`, `collapsed`/`rows` from `found`) broke the checks.static adultHiddenFor = (show, uid) => { return show.adult == true && !followingOf(show, uid) }// the page's show — null when there is none OR it is hidden for this user: every member is shipped to the browser, so the// row of a hidden title must never be onestatic visibleShow = (slug, uid) => {let s = showBySlug(slug)return s != null && adultHiddenFor(s, uid) ? null : s}// the initial open/closed seasons from the SLUG alone (not from `showRow`, which reads the session — see `collapsed`)static initialCollapsedOf = (slug) => {let s = showBySlug(slug)return s != null ? initialCollapsed(seasonsOfShow(s)) : {}}// ---- reactive members -----------------------------------------------------------------------slug = nullsession = nulluserId = userIdOfSession(session)signedIn = userId != null// SESSION SYNC (mission 047; since hybriel #121/#122, mission 056): a face taking `session` answers with a `sync` of every// member derived from session that reaches an import (here `showRow`, `found`, `following`, `rows`, …), re-derived on the// server with the page's route params but the MOUNT's other state — so client state (`collapsed`) must never derive from// them. `me` stays the plain copy mission 047 introduced.me = userIdshowRow = visibleShow(slug, me)found = showRow != nullnotFound = showRow == null// the Follow toggle: "Follow" = the inverted button (accent border), "Following" = the filled onefollowing = found ? followingOf(showRow, me) : falsefollowClass = following ? 'follow' : 'follow inverted'followText = following ? 'Following' : 'Follow'signInHref = loginHref// #24: a movie's watched check (same icon/classes as the episodes); the classes written out, not `watchClassOf` (an import of// the mpackdb-backed watches.hl cannot run in the browser when the member is re-derived there)isMovie = found && showRow.type == 'movie'movieWatched = isMovie ? movieWatchedOf(showRow, me) : falsemovieWatchClass = movieWatched ? 'checkicon solid' : 'checkicon regular'movieWatchText = movieWatched ? 'Watched' : 'Not watched'signInText = isMovie ? 'You need to sign in to follow movies and mark them watched.' : 'You need to sign in to follow shows and mark episodes.'title = found ? showRow.title : ''summary = found && showRow.summary != null ? showRow.summary : ''poster = found ? posterName(showRow) : nullhasPoster = poster != nullposterUrl = hasPoster ? '/posters/' + poster : ''genreRows = found ? genreRowsOf(showRow) : []hasGenres = genreRows.length > 0castRows = found ? castRowsOf(showRow) : []hasCast = castRows.length > 0linkRows = found ? externalLinksOf(showRow) : []hasLinks = linkRows.length > 0// NOT from `showRow`/`found`: they read the session (via `me`), so a member derived from them is re-derived on the server// after every session face with the MOUNT's state — `collapsed` (client state) would snap back to the initial seasons (mission// 056: the 2nd episode click in a re-opened season did nothing). The season ids of a hidden page are no secret.collapsed = initialCollapsedOf(slug)rows = found ? buildRows(showRow, me, collapsed) : []hasRows = rows.length > 0watchedEpisodes = found ? watchedEpisodesOf(showRow, me) : {}// an adult page (followers only) opens its seasons through the session face `showRows`adultShow = found && showRow.adult == true__title = found ? title + ' — ' + appTitle : appTitleView {showPage {if (notFound) {article { class = "main"h1 { "Not found" }p { "This show doesn't exist." }}}if (found) {showHeader {showPoster { if (hasPoster) { img { src = posterUrl alt = title } } }showMeta {showTitle {h1 { title }button { id = "follow" type = "button" class = followClass followText on click(e) { emit toggleFollow(e) } }}if (isMovie) {movieWatch {button { id = "moviewatch" type = "button" class = movieWatchClass "aria-label" = "mark movie watched"svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"circle { cx = "12" cy = "12" r = "10" }path { d = "M7 12.5 L10.5 16 L17 8.5" }}on click(e) { emit toggleMovie(e) }}movieWatchLabel { movieWatchText }}}if (hasLinks) {showLinks {for (l of linkRows) { a { class = "extlink" href = l.href target = "_blank" rel = "noopener" title = l.title "aria-label" = l.title "data-site" = l.site l.label } }}}if (hasGenres) {showGenres {for (g of genreRows) { a { class = "genre-pill" href = g.href g.name } }}}showSummary { summary }if (hasCast) {showCast {for (c of castRows) { a { class = "artist-link" href = c.href c.name } }}}}}if (hasRows) {showSeasons {for (row of rows) {if (row.isSeason) {seasonRow { class = row.rowClassbutton { type = "button" class = row.watchClass value = row.id "aria-label" = "mark season watched"svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"circle { cx = "12" cy = "12" r = "10" }path { d = "M7 12.5 L10.5 16 L17 8.5" }}on click(e) { emit toggleSeason(row.id) }}button { type = "button" class = "season-toggle" value = row.idseasonLabel { row.label }seasonCount { row.episodeCountText }seasonCaret { "aria-hidden" = "true"svg { viewBox = "0 0 24 24" width = "20" height = "20"path { d = "M6 9 L12 15 L18 9" }}}on click(e) { emit toggleCollapse(row.id) }}}}if (row.isEpisode) {episodeRow {button { type = "button" class = row.watchClass value = row.id "aria-label" = "mark episode watched"svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"circle { cx = "12" cy = "12" r = "10" }path { d = "M7 12.5 L10.5 16 L17 8.5" }}on click(e) { emit toggleEpisode(row.id) }}episodeNumber { row.episodeNumber }episodeTitle { row.title }episodeDate { row.airDate }}}if (row.isSkeleton) {skeletonRow { "aria-hidden" = "true"skeletonCheck { }skeletonNumber { }skeletonTitle { }}}}}}Modal { id = "signin" title = "Sign in"p { signInText }footer {button { type = "button" value = "close" "Close" }a { id = "signin-link" class = "button" href = signInHref "Sign in with ident" }}}}}}// Every rebuild of `rows` happens SERVER-SIDE, never by calling `buildRows` from a client handler:// a member function whose body reaches an hl:mpackdb-backed import (seasonsOfShow, episodesOfSeason,// watchesOfUser, …) compiles to nothing on the client (no case ever assigns it — a genuine Hybriel// gap, filed under `issues`) even though the client bundle still calls it, so `this.buildRows(...)`// throws "not a function" in the browser. The client here only ever ASSIGNS the `rows` the server// already built (`rows = r.rows`) — never calls a tainted local helper itself. `slug` goes along so// the face can refetch the show without depending on implicit access to a sibling member.//// tracker#17: opening shows the skeleton rows BEFORE the request; the answer replaces them unless the season was closed// meanwhile (then nothing). An error closes the season again. Closing never asks the server.on toggleCollapse(seasonId) {let m = collapsedif (collapsed[seasonId] != true) {m[seasonId] = truecollapsed = mrows = closedRows(rows, seasonId)return null}m[seasonId] = falsecollapsed = mrows = openingRows(rows, seasonId)if (adultShow) {let a = emit server showRows(slug, collapsed)if (collapsed[seasonId] == true) { return null }if (a == null || a.error != null) { m[seasonId] = true collapsed = m rows = closedRows(rows, seasonId) return null }rows = a.rowsreturn null}let r = emit server showSeasonEpisodes('' + seasonId)if (collapsed[seasonId] == true) { return null }if (r == null || r.error != null) { m[seasonId] = true collapsed = m rows = closedRows(rows, seasonId) return null }rows = filledRows(rows, seasonId, r.episodes, watchedEpisodes)}// signed out: the modal, nothing is senton toggleEpisode(episodeId) {if (!signedIn) { document.getElementById('signin').showModal() return null }let r = emit server showToggleEpisode(slug, '' + episodeId, collapsed)if (r == null || r.error != null) { return null }rows = r.rows}on toggleSeason(seasonId) {if (!signedIn) { document.getElementById('signin').showModal() return null }let r = emit server showToggleSeason(slug, '' + seasonId, collapsed)if (r == null || r.error != null) { return null }rows = r.rows}// #24: the movie's own check — signed out the modal, nothing senton toggleMovie(e) {if (!signedIn) { document.getElementById('signin').showModal() return null }let r = emit server showToggleMovie(slug)if (r == null || r.error != null) { return null }movieWatched = r.watchedmovieWatchClass = r.watched ? 'checkicon solid' : 'checkicon regular'movieWatchText = r.watched ? 'Watched' : 'Not watched'}on toggleFollow(e) {if (!signedIn) { document.getElementById('signin').showModal() return null }let r = emit server showToggleFollow(slug)if (r == null || r.error != null) { return null }following = r.followingfollowClass = r.following ? 'follow' : 'follow inverted'followText = r.following ? 'Following' : 'Follow'}// ---- the faces (the trailing `session` is always the server's: hybriel#16) ----// every row for these open/closed seasons — only an ADULT show's page still uses it (its followers; tracker#17)on server showRows(slug, collapsed, session) {let show = showBySlug(slug)let uid = userIdOfSession(session)if (show == null || adultHiddenFor(show, uid)) { return { error = 'no such show' } }return { ok = true rows = buildRows(show, uid, collapsed) }}// tracker#17: one season's episodes, in order — public data, so NO `session` (no page re-mount, no sync: the fast path).// Refuses a season of an adult title (those pages use showRows above).on server showSeasonEpisodes(seasonId) {if (seasonId == null || hlTypeName(seasonId) != 'String') { return { error = 'no such season' } }let season = seasonById(seasonId)if (season == null) { return { error = 'no such season' } }let show = showById(season.show)if (show == null || show.adult == true) { return { error = 'no such season' } }let out = []for (e of episodesOfSeason(season)) { out.push({ id = e.id episodeNumber = e.episodeNumber title = e.title != null ? e.title : '' airDate = airDateOf(e) }) }return { ok = true episodes = out }}on server showToggleEpisode(slug, episodeId, collapsed, session) {let show = showBySlug(slug)if (show == null) { return { error = 'no such show' } }let uid = userIdOfSession(session)if (uid == null) { return { error = 'you are not signed in' } }if (adultHiddenFor(show, uid)) { return { error = 'no such show' } }let rows = watchesOfUser(uid)let watched = isWatched(rows, 'episode', episodeId) == falselet ok = setWatched(uid, 'episode', episodeId, rows, watched)if (!ok) { return { error = 'could not save the watch' } }return { ok = true rows = buildRows(show, uid, collapsed) }}on server showToggleSeason(slug, seasonId, collapsed, session) {let show = showBySlug(slug)if (show == null) { return { error = 'no such show' } }let uid = userIdOfSession(session)if (uid == null) { return { error = 'you are not signed in' } }if (adultHiddenFor(show, uid)) { return { error = 'no such show' } }let season = seasonById(seasonId)if (season == null) { return { error = 'no such season' } }let rows = watchesOfUser(uid)// #23: not all episodes watched → watch them all; all watched → unwatch them alllet watched = !seasonAllWatched(season, watchedSet(rows))let ok = setSeasonWatched(uid, seasonId, season.episodes, rows, watched)if (!ok) { return { error = 'could not save the watch' } }return { ok = true rows = buildRows(show, uid, collapsed) }}// #24: a movie's watched check (only a movie: a series is watched by its episodes)on server showToggleMovie(slug, session) {let show = showBySlug(slug)if (show == null || show.type != 'movie') { return { error = 'no such movie' } }let uid = userIdOfSession(session)if (uid == null) { return { error = 'you are not signed in' } }if (adultHiddenFor(show, uid)) { return { error = 'no such movie' } }let rows = watchesOfUser(uid)let watched = isWatched(rows, 'movie', show.id) == falseif (!setWatched(uid, 'movie', show.id, rows, watched)) { return { error = 'could not save the watch' } }return { ok = true watched = watched }}on server showToggleFollow(slug, session) {let show = showBySlug(slug)if (show == null) { return { error = 'no such show' } }let uid = userIdOfSession(session)if (uid == null) { return { error = 'you are not signed in' } }let follow = isFollowing(uid, show.id) == falseif (!setFollowed(uid, show.id, follow)) { return { error = 'could not save the follow' } }return { ok = true following = follow }}
Branches
- mainmain branch
Latest commits
- f14db671tracker#22-#25 (mission 058): episode air dates, season check = all episodes watched, movie watched check (+ /my/movies count), /genres/<genre> pages (movies + series, newest first, paginated); gate 238, tests/realdata-058.mjs, README + STATUSmre
- 1704ec45tracker#17 (mission 057): season caret down/up, skeleton rows while a season loads, sessionless showSeasonEpisodes face (no page re-mount), client-only close; gate 214, tests/realdata-057.mjs, README + STATUSmre
- f2fe3e36mission 056: README + STATUS (merge, fixes, Hybriel 8590df63, real-data check), tests/realdata-056.mjs, tools/check-public-slugs.hlmre
- f40c250emission 056: re-vendor hybriel master 8590df63 (#121, #122); an adult title's page is Not found for non-followers; gate: leave the page before stopping the servermre
- 2b7fdd6cmission 056: signed-out header one row on phones ("Log in", nowrap), backfill skips adult titles' posters, gate checksmre
- 2c53d5efMerge branch 't16-person' (tracker#16 person pages) into main; filmography shows only public titles (054 adult flag), gate race fix (backfill start line)mre
- c171227emission 054: hide adult/unknown titles from the public lists and the search; in-app adult-flag backfill (TMDB details + poster per title, resumes), gate + real-data proofmre
- 139fafd8tracker#16: short bio (4 lines, click = all), real-data check script, README + STATUSmre
- 93be9476tracker#16: person pages /person/<slug> with the filmography fetched from TMDB on the first visit (step by step), gatemre
- 47a3cae6STATUS: mission 053 merge commit idsmre
- dcc5eecaMerge branch 't14-search'mre
- 03edc783Merge branch 't15-tvmaze'mre
- 71b46345tracker#15: numbering check by date or title, placeholder titles in other languages, docs + real-data proofmre
- 6bb2daf1tracker#13: homepage (tiles, intro, latest movies/shows), /shows, /movies/page/N, /my/movies; lists cached in memorymre
- b8bd1157tracker#14: README + STATUS (search, real-data numbers, gate, merge notes)mre
- 65c694a8tracker#14: search — header magnifier, /search/<text> (in-memory word-prefix index over titles + people), Fetch from web (TMDB search/multi, ours left out), Add = import via syncShow; gate +25 checks, real-data scriptmre
- 34f2c15btracker#15: TVmaze merge in the sync (gaps only: new episodes/seasons, empty titles/air dates; numbering check), fake TVmaze episodes + gatemre
- cbdc4ea7tracker#12: link icons TMDB/IMDb/TVDB/TVmaze; sync fills missing ids (TVmaze lookup); movies fetched via /movie/mre
- b105bcd8tracker#11: Hybriel master ff51cf46 (checks no longer vanish), mobile-first styles, carets, follow button, sign-in modal, inverted check, orange castmre
- 31b758aatracker#10: installable app (manifest, service worker, offline shell), own icon + faviconmre