tracker
All repositories: gitoria
39.3 KB
// components/show.hl — a title's own page (tracker.worldapi.org#4): since tracker#21 (mission 068) `/series/:slug`, `/movies/:slug`// and `/shows/:slug` (the last through project.hl showsRoute: a show is rendered there, a series or movie answers 301 to its// own address). tracker#20: the heading is "<Type> | <title>" — Series / Show / Movie in the type's colour (styles.hl// `type-*`), the title white. 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 (tracker#34, mission 030: under the poster, as wide as the poster; next to the title the status pill, #35). 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.//// tracker.worldapi.org#26: an INCOMPLETE title (one a person page added from a filmography — details.hl `needsDetails`) shows// what it has plus skeletons (poster, cast line, season rows for a series) and completes itself WITHOUT a reload: the// invisible `showProbe` (endless short CSS animation — hl:web has no mount hook, the person page's trick) calls the face// `showComplete` until it is done — one step per call (details.hl completeStep: TMDB details + ids + cast; a series' seasons/// episodes in parts; poster, TVmaze) — and the last answer's parts replace the skeletons. No session (no page re-mount); adult// refused.//// tracker.worldapi.org#28: the WHOLE cast and the key crew (details.hl stores them: a series' cast of every season, main cast// first by episode count, then "Guest stars"; crew = Created by / Directed by / Written by / Screenplay / Story / Music). The// cast shows its first `castShown` (20) and a "Show all (N)" toggle, each crew job its first `crewShown` (6) and one "Show all// crew (N)" toggle — CLIENT-SIDE (the rows are all rendered, CSS hides the rest; `castClass`/`crewClass`, no request). Names// link to /person/<slug>; the character in brackets after the name (CSS `attr(data-role)` — one element per name: the SSR's// line breaks inside a wrapper element put a space before the comma).parent './main.hl'import Modal from './modal.hl'import FranchiseWidget from './franchisewidget.hl'import { appTitle } from '../project.hl'import { userIdOfSession, loginHref } from '../lib/users.hl'import { isFollowing, toggleFollowed } from '../lib/follows.hl'import { showBySlug, showById, genreById, personById, seasonsOfShow, episodesOfSeason, seasonById, posterName, externalLinksOf, summaryOf, titleKind, statusOf } from '../lib/shows.hl'import { watchesOfUser, isWatched, watchedSet, watchClassOf, seasonAllWatched, toggleWatched, toggleSeasonWatched } from '../lib/watches.hl'import { needsDetails, completeStep, completeLine, persistDetails } from '../lib/details.hl'import { widgetsOf } from '../lib/franchises.hl'import { listOr } from '../lib/util.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) => {m = {}let i = 0while (i < seasons.length) {if (i > 0) { m[seasons[i].id] = true }i = i + 1}return m}static genreRowsOf = (&show) => {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}// tracker#28: how many names show before "Show all" (cast; per crew job)static castShown = 20static crewShown = 6// the cast as flat rows (no nested for, see THE ROWS above): `isPerson` rows { name, href, roleText (' (Character)' or ''),// itemClass } and before the first guest one `isLabel` row "Guest stars". itemClass: `more` = hidden until "Show all", `end` =// no comma after it (the last of a group), `cut` = the last one shown while collapsed (no comma then)static castRowsOf = (show) => {people = []for (c of listOr(show.cast)) {let p = c != null && c.person != null ? personById(c.person) : null// mission 062: a guest without a person record — the link creates it on the first open (project.hl guestRoute)if (p != null) { people.push({ name = c.actor != null ? c.actor : p.name href = '/person/' + p.urlSegment role = c.character != null ? c.character : '' guest = c.guest == true }) }else if (c != null && c.person == null && c.tmdbId != null && c.actor != null) { people.push({ name = c.actor href = '/person/tmdb/' + c.tmdbId + '?show=' + show.id role = c.character != null ? c.character : '' guest = true }) }}out = []let k = 0let guests = falsewhile (k < people.length) {let c = people[k]if (c.guest && !guests && k > 0) {guests = trueout.push({ isLabel = true isPerson = false text = 'Guest stars' name = '' href = '' roleText = '' itemClass = k >= castShown ? 'more' : '' })}let last = k == people.length - 1 || (people[k + 1].guest && !c.guest)let cls = (k >= castShown ? ' more' : '') + (last ? ' end' : '') + (k == castShown - 1 ? ' cut' : '')out.push({ isLabel = false isPerson = true text = '' name = c.name href = c.href roleText = c.role != '' ? ' (' + c.role + ')' : '' itemClass = 'artist-link' + cls })k = k + 1}return out}static castCountOf = (&rows) => {let n = 0for (r of rows) { if (r.isPerson) { n = n + 1 } }return n}static crewLabels = { creator = 'Created by' director = 'Directed by' writer = 'Written by' screenplay = 'Screenplay' story = 'Story' composer = 'Music' }// the crew as flat rows: per job (in stored order) an `isLabel` row, then its people (itemClass as for the cast, `more` after// the first crewShown of a job)static crewRowsOf = (&show) => {people = []for (c of listOr(show.crew)) {let p = c != null ? personById(c.person) : nullif (p != null && crewLabels[c.job] != null) { people.push({ name = c.name != null ? c.name : p.name href = '/person/' + p.urlSegment job = c.job }) }}out = []let k = 0let inJob = 0while (k < people.length) {let c = people[k]if (k == 0 || people[k - 1].job != c.job) {inJob = 0out.push({ isLabel = true isPerson = false text = crewLabels[c.job] name = '' href = '' itemClass = '' })}let last = k == people.length - 1 || people[k + 1].job != c.jobout.push({ isLabel = false isPerson = true text = '' name = c.name href = c.href itemClass = 'crew-link' + (inJob >= crewShown ? ' more' : '') + (last ? ' end' : '') + (inJob == crewShown - 1 ? ' cut' : '') })inJob = inJob + 1k = k + 1}return out}// how many crew names are hidden while collapsed (0 = no toggle) and how many there arestatic crewHiddenOf = (&rows) => {let n = 0for (r of rows) { if (r.isPerson && r.itemClass.includes(' more')) { n = n + 1 } }return n}static crewCountOf = (&rows) => {let n = 0for (r of rows) { if (r.isPerson) { n = n + 1 } }return n}// 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 : '' }static buildRows = (&show, &uid, collapsed) => {watched = watchedSet(uid != null ? watchesOfUser(uid) : [])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) => {m = {}if (uid == null) { return m }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 }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) => {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) => {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) => {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) => {s = showBySlug(slug)return s != null && adultHiddenFor(s, uid) ? null : s}// tracker.worldapi.org#19 (mission 066): the franchise widget(s), one per timeline the title is in (components/franchisewidget.hl)static franchiseWidgetsOf = (&show) => { return widgetsOf(show.id) }// mission 062: what the page shows of the title, as a SMALL object — `showRow` reads the session, so hl:web ships it with the page// AND re-sends it after every watch/follow click (SESSION SYNC below): the whole record was ~1 MB on The Simpsons (300 cast + crew// entries). null = not found / hidden.static pageShowOf = (&slug, &uid) => {s = visibleShow(slug, uid)if (s == null) { return null }movie = s.type == 'movie'st = statusOf(s)return { id = s.id title = s.title summary = summaryOf(s) type = s.type adult = s.adult == true shortId = s.shortId != null ? '' + s.shortId : '' statusText = st.text statusTone = st.toneposter = posterName(s) genreRows = genreRowsOf(s) linkRows = externalLinksOf(s) completing = completingOf(s) following = followingOf(s, uid)movieWatched = movie ? movieWatchedOf(s, uid) : false }}// the seasons/episodes rows and the per-season watch state by the slug (the record itself stays on the server)static rowsOfSlug = (&slug, &uid, &collapsed) => {s = showBySlug(slug)return s != null ? buildRows(s, uid, collapsed) : []}static watchedEpisodesOfSlug = (&slug, &uid) => {s = showBySlug(slug)return s != null ? watchedEpisodesOf(s, uid) : {}}// the initial open/closed seasons and the title's kind (tracker#20/#21: the heading's type word) from the SLUG alone (not from// `showRow`, which reads the session — see `collapsed`) — one slug lookup for bothstatic slugInfoOf = (&slug) => {s = showBySlug(slug)return s != null ? { collapsed = initialCollapsed(seasonsOfShow(s)) kind = titleKind(s) } : { collapsed = {} kind = 'series' }}// mission 062: the cast and crew rows by the SLUG alone (not from `showRow`, which reads the session — see `castRows`). An// ADULT title (its page only for its followers) shows none: every member is shipped to the browser, and adult titles never get// credits anyway (details.hl skips them; at most a migrated cast)static publicShowOf = (&slug) => {s = showBySlug(slug)return s != null && s.adult != true ? s : null}static castOfSlug = (&slug) => {s = publicShowOf(slug)return s != null ? castRowsOf(s) : []}static crewOfSlug = (&slug) => {s = publicShowOf(slug)return s != null ? crewRowsOf(s) : []}// tracker#26: the page loads this title's full details on openstatic completingOf = (&show) => { return needsDetails(show) }// the face's work: ONE step of completing the title (details.hl completeStep — a series takes a few; a second viewer drives the// same job or finds it complete); when done, every part of the page that changes — the rows without anyone's watch state (a// title that was incomplete had no episodes to watch)static completedPageOf = (&slug) => {let show = showBySlug(slug)if (show == null || show.adult == true) { return { error = 'no such show' } }if (needsDetails(show)) {let r = completeStep(show.id)if (r.retry || r.adult == true) { return { error = r.error != '' ? r.error : 'no such show' } }if (!r.done) { return { done = false steps = r.sum.steps } }if (!r.skipped) {persistDetails()console.log('details on open: ' + completeLine(show.title, r.sum))}show = showById(show.id)}cl = initialCollapsed(seasonsOfShow(show))pn = posterName(show)crew = crewRowsOf(show)cast = castRowsOf(show)return { done = true title = show.title summary = summaryOf(show) poster = pn genreRows = genreRowsOf(show) castRows = castcastCount = castCountOf(cast) castMore = castCountOf(cast) > castShown crewRows = crew crewCount = crewCountOf(crew) crewMore = crewHiddenOf(crew) > 0linkRows = externalLinksOf(show) collapsed = cl rows = buildRows(show, null, cl) }}// ---- 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 = pageShowOf(slug, me)found = showRow != nullnotFound = showRow == null// the Follow toggle: "Follow" = the inverted button (accent text on a surface, no border since tracker#32), "Following" = the// filled one (the theme's `primary`)following = found ? showRow.following : falsefollowClass = following ? 'follow primary' : '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 ? showRow.movieWatched : 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 : ''// tracker#35: the status pill next to a series' title — TVmaze's status, else TMDB's (shows.hl statusOf); none on a moviestatusText = found ? showRow.statusText : ''hasStatus = statusText != ''statusClass = found ? 'status-pill status-' + showRow.statusTone : 'status-pill'// tracker#20: "Series | Reacher" — the kind from the slug (pure strings here: they are re-derived in the browser too)slugInfo = slugInfoOf(slug)pageKind = slugInfo.kindtypeText = pageKind == 'movie' ? 'Movie' : (pageKind == 'show' ? 'Show' : 'Series')typeClass = 'type-word type-' + pageKind// tracker#18: the creator's own text, else TMDB's, else TVmaze's (pageShowOf → shows.hl summaryOf)summary = found && showRow.summary != null ? showRow.summary : ''poster = found ? showRow.poster : nullhasPoster = poster != nullposterUrl = hasPoster ? '/posters/' + poster : ''// mission 060 (tracker.worldapi.org#27): the short id under the poster (none yet while the backfill has not reached it)shortId = found ? showRow.shortId : ''hasShortId = shortId != ''genreRows = found ? showRow.genreRows : []hasGenres = genreRows.length > 0// mission 062: cast + crew from the SLUG (`castOfSlug`, `crewOfSlug`), never from `showRow` — a member derived from the session is// re-derived and re-SENT after every watch/follow click (SESSION SYNC above): The Simpsons' 300 names took 0.7 s per clickcastRows = castOfSlug(slug)hasCast = castRows.length > 0// tracker#28: "Show all (N)" — the toggles' state is CLIENT state: literals, never derived from `showRow` (session sync)castCount = castCountOf(castRows)castMore = castCount > castShowncastAllText = 'Show all (' + castCount + ')'castClass = 'collapsed'crewRows = crewOfSlug(slug)hasCrew = crewRows.length > 0crewCount = crewCountOf(crewRows)crewMore = crewHiddenOf(crewRows) > 0crewAllText = 'Show all crew (' + crewCount + ')'crewClass = 'collapsed'linkRows = found ? showRow.linkRows : []franchiseWidgets = found ? franchiseWidgetsOf(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 = slugInfo.collapsed// mission 062: has this page opened/closed a season (client state) — then a watch face sends its own rows (toggleEpisode)collapsedChanged = falserows = found ? rowsOfSlug(slug, me, collapsed) : []hasRows = rows.length > 0watchedEpisodes = found ? watchedEpisodesOfSlug(slug, me) : {}// an adult page (followers only) opens its seasons through the session face `showRows`adultShow = found && showRow.adult == true// tracker#26: incomplete → skeletons + the probe until `showComplete` answeredcompleting = found ? showRow.completing : false// tracker#33: a title TMDB has no poster for (The Remaining) shows the placeholder in the poster's place — the column was empty// but for the short id, the page looked unfinished; while an incomplete title loads, the skeleton insteadposterLoading = completing && !hasPosternoPoster = !hasPoster && !completingcompleteBusy = falseisSeries = found && showRow.type != 'movie'skeletonSeasons = ['1', '2', '3']__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 } }if (noPoster) { img { class = "no-poster" src = "/posters/none" alt = "no poster" } }if (posterLoading) { skeletonPoster { "aria-hidden" = "true" } }// tracker#34: Follow directly under the poster, as wide as the poster (creator: "the follow button should go below// the picture … and expand to the size o the poster")button { id = "follow" type = "button" class = followClass followText on click(e) { emit toggleFollow(e) } }if (hasShortId) { shortIdLabel { id = "shortid" title = "Short id" shortId } }}showMeta {showTitle {h1 { class = "typed" typeWord { class = typeClass typeText } typeSep { "|" } typeName { title } }if (hasStatus) { statusPill { class = statusClass statusText } }}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 } }}}// mission 025: the franchise widget right under the title/poster block — on a phone (showMeta dissolves, see styles.hl)// after the poster and before the genres, plot and cast; from 40rem in the right column under the titleFranchiseWidget { widgets = franchiseWidgets }if (hasGenres) {showGenres {for (g of genreRows) { a { class = "genre-pill" href = g.href g.name } }}}showSummary { summary }if (hasCrew) {showCrew { class = crewClassfor (r of crewRows) {if (r.isLabel) { crewLabel { r.text } }if (r.isPerson) { a { class = r.itemClass href = r.href r.name } }}if (crewMore) {button { id = "crewtoggle" type = "button" class = "quiet showall"whenCollapsed { crewAllText }whenAll { "Show less" }on click(e) { emit toggleCrewAll(e) }}}}}if (hasCast) {showCast { class = castClasscastHeading { "Cast" }for (c of castRows) {if (c.isLabel) { castLabel { class = c.itemClass c.text } }if (c.isPerson) { a { class = c.itemClass href = c.href "data-role" = c.roleText c.name } }}if (castMore) {button { id = "casttoggle" type = "button" class = "quiet showall"whenCollapsed { castAllText }whenAll { "Show less" }on click(e) { emit toggleCastAll(e) }}}}}if (completing) { skeletonCast { "aria-hidden" = "true" } }}}if (completing) {showLoading { id = "showloading" "aria-busy" = "true"if (isSeries) {for (k of skeletonSeasons) {skeletonRow { "aria-hidden" = "true"skeletonCheck { }skeletonTitle { }}}}showProbe { "aria-hidden" = "true" on animationiteration(e) { emit completeTick(e) } }}}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 primary" 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) {collapsedChanged = truem = 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)}// tracker#26: the probe's tick — `showComplete` step by step until `done` (once per page: `completeBusy`; other requests are// served between two calls); the last answer replaces the skeletons. A lost answer (null) lets the next tick go on where the// server is; an error (TMDB down, adult) ends the loading and keeps what the page has.on completeTick(e) {if (completeBusy || !completing) { return null }completeBusy = trues = sluglet r = emit server showComplete(s)while (r != null && r.error == null && r.done != true && slug == s) { r = emit server showComplete(s) }completeBusy = falseif (r == null || slug != s) { return null }if (r.error != null) { completing = false return null }title = r.titlesummary = r.summaryposter = r.postergenreRows = r.genreRowscastRows = r.castRowscastCount = r.castCountcastMore = r.castMorecastAllText = 'Show all (' + r.castCount + ')'crewRows = r.crewRowscrewCount = r.crewCountcrewMore = r.crewMorecrewAllText = 'Show all crew (' + r.crewCount + ')'linkRows = r.linkRowscollapsed = r.collapsedcollapsedChanged = truerows = r.rowscompleting = false}// tracker#28: the cast / crew "Show all" toggles — client-only, no request (CSS shows the hidden names)on toggleCastAll(e) {castClass = castClass == 'all' ? 'collapsed' : 'all'}on toggleCrewAll(e) {crewClass = crewClass == 'all' ? 'collapsed' : 'all'}// signed out: the modal, nothing is sent// mission 062: the new `rows` come with the SAME ack as the session sync (SESSION SYNC above: `rows` derives from the session) —// but that sync is built with the MOUNT's open seasons. So the face sends its own rows only once this page opened/closed a season// (`collapsedChanged`, or a completed title); else they came twice (a second buildRows + 14 kB on The Simpsons: ~40 ms per click)on toggleEpisode(episodeId) {if (!signedIn) { document.getElementById('signin').showModal() return null }let r = emit server showToggleEpisode(slug, '' + episodeId, collapsed, collapsedChanged)if (r == null || r.error != null) { return null }if (r.rows != null) { rows = r.rows }}on toggleSeason(seasonId) {if (!signedIn) { document.getElementById('signin').showModal() return null }let r = emit server showToggleSeason(slug, '' + seasonId, collapsed, collapsedChanged)if (r == null || r.error != null) { return null }if (r.rows != 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 primary' : 'follow inverted'followText = r.following ? 'Following' : 'Follow'}// ---- the faces (the trailing `session` is always the server's: hybriel#16) ----// tracker#26: complete an incomplete title and answer the page's parts — public data, NO session (no page re-mount)on server showComplete(slug) {return completedPageOf(slug != null ? '' + slug : '')}// 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) {show = showBySlug(slug)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' } }season = seasonById(seasonId)if (season == null) { return { error = 'no such season' } }show = showById(season.show)if (show == null || show.adult == true) { return { error = 'no such season' } }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, withRows, session) {show = showBySlug(slug)if (show == null) { return { error = 'no such show' } }uid = userIdOfSession(session)if (uid == null) { return { error = 'you are not signed in' } }if (adultHiddenFor(show, uid)) { return { error = 'no such show' } }if (toggleWatched(uid, 'episode', episodeId) == null) { return { error = 'could not save the watch' } }return withRows == true ? { ok = true rows = buildRows(show, uid, collapsed) } : { ok = true }}on server showToggleSeason(slug, seasonId, collapsed, withRows, session) {show = showBySlug(slug)if (show == null) { return { error = 'no such show' } }uid = userIdOfSession(session)if (uid == null) { return { error = 'you are not signed in' } }if (adultHiddenFor(show, uid)) { return { error = 'no such show' } }season = seasonById(seasonId)if (season == null) { return { error = 'no such season' } }// #23: not all episodes watched → watch them all; all watched → unwatch them all (lib/watches.hl)if (toggleSeasonWatched(uid, season) == null) { return { error = 'could not save the watch' } }return withRows == true ? { ok = true rows = buildRows(show, uid, collapsed) } : { ok = true }}// #24: a movie's watched check (only a movie: a series is watched by its episodes)on server showToggleMovie(slug, session) {show = showBySlug(slug)if (show == null || show.type != 'movie') { return { error = 'no such movie' } }uid = userIdOfSession(session)if (uid == null) { return { error = 'you are not signed in' } }if (adultHiddenFor(show, uid)) { return { error = 'no such movie' } }watched = toggleWatched(uid, 'movie', show.id)if (watched == null) { return { error = 'could not save the watch' } }return { ok = true watched = watched }}on server showToggleFollow(slug, session) {show = showBySlug(slug)if (show == null) { return { error = 'no such show' } }uid = userIdOfSession(session)if (uid == null) { return { error = 'you are not signed in' } }follow = toggleFollowed(uid, show.id)if (follow == null) { return { error = 'could not save the follow' } }return { ok = true following = follow }}
Branches
- mainmain branch
Latest commits
- b638e99dMerge t38 (tracker#38 pagination, #35 Returning/Airing label) into main: LOG/STATUS keep both sides; pager gate follows #37's /people (everyone, last updated first: seed updatedAt); gates pager 229/0, browser 374/0mre
- f8ffa4dbtracker: report 032 + The Remaining + #39mre
- 7565a863tracker: LOG timemre
- b10f00c8tracker#39: double episodes — migrated episodes whose TMDB id TMDB replaced are adopted by their number in the sync (old id -> migratedTmdbId); merge.hl step 3 merges each season's doubles at start (keeper: most watches > synced > first; watches moved/parked; tombstones into mergedEpisodes, nothing deleted); tools/count-duplicate-episodes.hl; gate fixture + paths-m039; live copy 850 -> 0 in 64 s; gates 373/0, 32/0, 52/0mre
- 9448d643tracker#35 follow-up (mission 033): TVmaze 'Running' is labelled 'Returning', or 'Airing' while a non-special episode of the two newest seasons is released within today +-7 days (data unchanged); gate fixtures Running/Airing/Aired + a special; browser 366/0, kinds 32/0, franchises 52/0, pager 229/0, check-theme 0mre
- 8751adb8tracker: report 032mre
- 9bce1f65tracker mission 032: STATUS gate files + the hour-boundary flakemre
- 718bfb89tracker#37 (mission 032): /people = everyone, last updated first (updatedAt stamped by the person fill; view built at boot, touched people first at once), photo + name tiles (person colour) with the /movies pagination, /people/<letter> removed; photo = our file, tmdbProfile, a cast/crew entry's profile (in-memory map at boot), else the new 'no photo' placeholder; new cast/crew/created_by people keep tmdbProfile; search people rows with the photo; /settings = the heading only; util.hl sortDesc starts from sorted runs (same result, 105k: 1.6 s -> 0.15 s); gates 369/0, 32/0, 52/0, check-theme 0; README/STATUS/LOGmre
- 03ec792ftracker#38 (mission 033): pagination goes exactly to the clicked page — tilelist read the clicked button's text after pagination.hl's own handler had rebuilt the buttons (real clicks only); now li.current, else the button's own text; new gate tests/pager.mjs (5 lists x 11 pages, 390/1280, real + script clicks, Back/Forward) 229/0; browser 365/0, kinds 32/0, franchises 52/0, check-theme 0mre
- 96ba683adeploy.sh: a gate without a 'passed,' line (check-theme) no longer ends the scriptmre
- eb3b9205tracker: report 031mre
- 9b5d2e89tracker mission 031: README (What it does, Test: four gates + the #32 checks, Files: theme/, new pages), STATUS (real copy, A/B load, how to repeat, open points), LOGmre
- 39950e4ctracker#32 (mission 031): the WorldAPI theme (theme/ vendored verbatim from layouts.worldapi.org 85b5654; styles.hl inherits it: accent green-dark, type colours 1-6; own base/header rules, row lines, genre-pill and inverted-button frames removed, the season foldable keeps its line; check-theme 21 -> 0, 4th deploy gate; main actions class primary) and the #32 header (theme AppHeader/MainMenu/UserMenu/Sidebar/ContentFirst: desktop brand, search, Series|Shows|Movies|Genres|People, user icon with Unwatched..Settings, Logout; signed out the ident selector, phone the iD icon dropdown; phone menu in the sidebar overlay; marked entry by :has); /find -> /search/<q>, /genres, /people(/<letter>), /settings; main { ContentFirst { slot } } works around the hl:web one-line slot bug; gates 365/0, 32/0, 52/0, check-theme 0mre
- a386dc92tracker: reports 029 + 030mre
- 71e0fd7dtracker missions 029 + 030: README (What it does, Files, gate count), STATUS (real-copy numbers, how to repeat, open points), LOGmre
- d36ea6eatracker#34 + #35 (mission 030): Follow directly under the poster, as wide as the poster (show.hl, styles.hl); the status pill next to a series' title — TVmaze's status (new tvmazeStatus, stored by the sync's TVmaze merge) else TMDB's, TVmaze Ended + TMDB Canceled = Canceled, inverted (filled, dark text, no border), green running / yellow pending / red canceled / muted ended (shows.hl statusOf); the daily delta asks TVmaze's status of an unfollowed series TVmaze's change list names (dailysync.hl syncRunStep, sync.hl syncTvmazeStatus); the status backfill after the details repair (backfill.hl, jobs.hl statusTick; resumable, 550 ms per TVmaze request); gates 354/0, 32/0, 52/0mre
- 7d7d4487tracker#33 (mission 029): reduced titles — every title TMDB's details never went through this app (no detailsAt, no tmdbSync) is incomplete (shows.hl isIncomplete; the old tracker's migrated rows passed #26's test: 5,697 non-adult on the live copy, 691 series without seasons); the repair job does the visibly reduced first (shows.hl missingParts), the page completes one on open; a title TMDB has no poster for (The Remaining) shows the placeholder; tools/count-incomplete.hl; gate fixtures stand for synced titles (tmdbSync), tests/seed-reduced.hl + #33 checks; gates 347/0, 32/0, 52/0mre
- 661c2592tracker: report 028mre
- 27c916fatracker mission 028: README ("Code order", the new file map), STATUS (counts before/after, tests, how to repeat, open), LOGmre
- d924f398tracker mission 028: comments name the new files (sync.hl, dailysync.hl, backfill.hl, credits.hl, jobs.hl, images.hl …); tools/ref-params.py + tools/lambda-audit.py also scan lib/ (they globbed the root only), lambda-audit counts a plain `x = p` alias like `let x = p`mre