gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitd924f398d924f398tracker 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`mred924f398/components/show.hl

38.1 KB

  1. // components/show.hl — a title's own page (tracker.worldapi.org#4): since tracker#21 (mission 068) `/series/:slug`, `/movies/:slug`
  2. // and `/shows/:slug` (the last through project.hl showsRoute: a show is rendered there, a series or movie answers 301 to its
  3. // own address). tracker#20: the heading is "<Type> | <title>" — Series / Show / Movie in the type's colour (styles.hl
  4. // `type-*`), the title white. Creator's words:
  5. // "the poster on the left, right of it the title of the show, the genre pills, below the plot summary,
  6. // below that the artists just as normal links, separated with comma color red, genres are blue" (the
  7. // header) — "then all seasons in reverse order, latest on top, below them collapsable their episodes,
  8. // episodes first the round check icon, if the episode was watched by the user looking its solid, if
  9. // not its regular, then the episode number, then the title. the last season is not collapsed. click on
  10. // the check adds a watch. the season also has a check, if the season is checked or unchecked all its
  11. // episodes get checked or unchecked. artists link to /person/<artist-slug> the genre to /genre/<genre-slug>."
  12. //
  13. // THE ROWS ARE ONE FLAT LIST, not a nested for-loop over seasons > episodes: the vendored build may
  14. // still lack the fix for hybriel#86 (a nested for over a reassigned outer list keeps stale inner
  15. // cells) — a single-level `for (row of rows)` with a `kind` field, reassigned whole on every click,
  16. // is the same proven-safe shape components/main.hl's own header and ident's inbox.hl already use.
  17. // `rows` is rebuilt from scratch (seasons/episodes refetched by id, watch rows refetched) on every
  18. // toggle — cheap: a show has at most a few dozen episodes per season.
  19. //
  20. // Mission 047 (the creator, 2026-10-01): a caret at the season header's right edge (down = open, right = collapsed); episode
  21. // rows not indented; on a phone the title first, then the poster at full width, then genres/plot/cast; a Follow toggle
  22. // next to the title. SIGNED OUT the checks and Follow are visible too: a click opens the "sign in" modal (modal.hl, copied
  23. // from components.hybriel) and writes nothing.
  24. //
  25. // tracker.worldapi.org#17 (mission 057): the caret points DOWN while a season is closed and UP while it is open. Opening a
  26. // season shows SKELETON rows at once (min(episodes, 8), a CSS shimmer, styles.hl) and then its episodes from the face
  27. // `showSeasonEpisodes`, which takes NO session: a face with `session` makes hl:web re-mount the whole page on the server and
  28. // ship every session-derived member back as a `sync` (WebFramework.hl `inbound`) — that, not the episodes, was most of the
  29. // old 250–450 ms (real data). The watched state comes from `watchedEpisodes` (this show's watched episodes per season, rendered
  30. // with the page and re-synced after every watch face). Closing is client-only (no request). An ADULT show (only its
  31. // followers see it) keeps the session face `showRows`: the public face refuses adult seasons.
  32. //
  33. // tracker.worldapi.org#12: link icons under the title — the show on TMDB, IMDb, TheTVDB, TVmaze (a movie: TMDB + IMDb),
  34. // only for the ids it has (shows.hl externalLinksOf), each in a new tab.
  35. //
  36. // mission 058: #22 every episode row shows its air date (`release`, 'YYYY-MM-DD' like /my/unwatched; none → empty). #23 a
  37. // season's check is DERIVED: solid exactly when every one of its episodes is watched (a season without episodes: its own
  38. // season watch) — unchecking one episode turns the season off with the face's answer, checking it again turns it on; the
  39. // season click watches all when not all are watched, else unwatches all. #24 a MOVIE's page has its own watched check
  40. // (watch `targetType = 'movie'`, target = the title's id; face `showToggleMovie`), signed out → the sign-in modal.
  41. //
  42. // tracker.worldapi.org#26: an INCOMPLETE title (one a person page added from a filmography — details.hl `needsDetails`) shows
  43. // what it has plus skeletons (poster, cast line, season rows for a series) and completes itself WITHOUT a reload: the
  44. // invisible `showProbe` (endless short CSS animation — hl:web has no mount hook, the person page's trick) calls the face
  45. // `showComplete` until it is done — one step per call (details.hl completeStep: TMDB details + ids + cast; a series' seasons/
  46. // episodes in parts; poster, TVmaze) — and the last answer's parts replace the skeletons. No session (no page re-mount); adult
  47. // refused.
  48. //
  49. // tracker.worldapi.org#28: the WHOLE cast and the key crew (details.hl stores them: a series' cast of every season, main cast
  50. // first by episode count, then "Guest stars"; crew = Created by / Directed by / Written by / Screenplay / Story / Music). The
  51. // cast shows its first `castShown` (20) and a "Show all (N)" toggle, each crew job its first `crewShown` (6) and one "Show all
  52. // crew (N)" toggle — CLIENT-SIDE (the rows are all rendered, CSS hides the rest; `castClass`/`crewClass`, no request). Names
  53. // link to /person/<slug>; the character in brackets after the name (CSS `attr(data-role)` — one element per name: the SSR's
  54. // line breaks inside a wrapper element put a space before the comma).
  55. parent './main.hl'
  56. import Modal from './modal.hl'
  57. import FranchiseWidget from './franchisewidget.hl'
  58. import { appTitle } from '../project.hl'
  59. import { userIdOfSession, loginHref } from '../lib/users.hl'
  60. import { isFollowing, toggleFollowed } from '../lib/follows.hl'
  61. import { showBySlug, showById, genreById, personById, seasonsOfShow, episodesOfSeason, seasonById, posterName, externalLinksOf, summaryOf, titleKind } from '../lib/shows.hl'
  62. import { watchesOfUser, isWatched, watchedSet, watchClassOf, seasonAllWatched, toggleWatched, toggleSeasonWatched } from '../lib/watches.hl'
  63. import { needsDetails, completeStep, completeLine, persistDetails } from '../lib/details.hl'
  64. import { widgetsOf } from '../lib/franchises.hl'
  65. import { listOr } from '../lib/util.hl'
  66. // ---- pure helpers ----------------------------------------------------------------------------
  67. // `static`, like calendar.hl's `soonOf` — a PLAIN instance member function that calls an
  68. // hl:mpackdb-backed import (seasonsOfShow, episodesOfSeason, watchesOfUser, …) comes back null
  69. // when called from an `on server` face (confirmed by running the gate: "'buildRows' is not
  70. // callable — it holds null" at the face's call site, even though the SAME call works fine as a
  71. // member's own initializer at the top of this file). `static` is the proven-safe shape (calendar's
  72. // `soonOf`, called from `calLoad`/`calSettingsLoad`, an mpackdb-backed helper exactly like this one).
  73. static initialCollapsed = (&seasons) => {
  74. m = {}
  75. let i = 0
  76. while (i < seasons.length) {
  77. if (i > 0) { m[seasons[i].id] = true }
  78. i = i + 1
  79. }
  80. return m
  81. }
  82. static genreRowsOf = (&show) => {
  83. out = []
  84. for (gid of show.genres) {
  85. let g = genreById(gid)
  86. if (g != null) { out.push({ id = g.id name = g.name href = '/genres/' + g.urlSegment }) }
  87. }
  88. return out
  89. }
  90. // tracker#28: how many names show before "Show all" (cast; per crew job)
  91. static castShown = 20
  92. static crewShown = 6
  93. // the cast as flat rows (no nested for, see THE ROWS above): `isPerson` rows { name, href, roleText (' (Character)' or ''),
  94. // itemClass } and before the first guest one `isLabel` row "Guest stars". itemClass: `more` = hidden until "Show all", `end` =
  95. // no comma after it (the last of a group), `cut` = the last one shown while collapsed (no comma then)
  96. static castRowsOf = (show) => {
  97. people = []
  98. for (c of listOr(show.cast)) {
  99. let p = c != null && c.person != null ? personById(c.person) : null
  100. // mission 062: a guest without a person record — the link creates it on the first open (project.hl guestRoute)
  101. 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 }) }
  102. 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 }) }
  103. }
  104. out = []
  105. let k = 0
  106. let guests = false
  107. while (k < people.length) {
  108. let c = people[k]
  109. if (c.guest && !guests && k > 0) {
  110. guests = true
  111. out.push({ isLabel = true isPerson = false text = 'Guest stars' name = '' href = '' roleText = '' itemClass = k >= castShown ? 'more' : '' })
  112. }
  113. let last = k == people.length - 1 || (people[k + 1].guest && !c.guest)
  114. let cls = (k >= castShown ? ' more' : '') + (last ? ' end' : '') + (k == castShown - 1 ? ' cut' : '')
  115. out.push({ isLabel = false isPerson = true text = '' name = c.name href = c.href roleText = c.role != '' ? ' (' + c.role + ')' : '' itemClass = 'artist-link' + cls })
  116. k = k + 1
  117. }
  118. return out
  119. }
  120. static castCountOf = (&rows) => {
  121. let n = 0
  122. for (r of rows) { if (r.isPerson) { n = n + 1 } }
  123. return n
  124. }
  125. static crewLabels = { creator = 'Created by' director = 'Directed by' writer = 'Written by' screenplay = 'Screenplay' story = 'Story' composer = 'Music' }
  126. // the crew as flat rows: per job (in stored order) an `isLabel` row, then its people (itemClass as for the cast, `more` after
  127. // the first crewShown of a job)
  128. static crewRowsOf = (&show) => {
  129. people = []
  130. for (c of listOr(show.crew)) {
  131. let p = c != null ? personById(c.person) : null
  132. if (p != null && crewLabels[c.job] != null) { people.push({ name = c.name != null ? c.name : p.name href = '/person/' + p.urlSegment job = c.job }) }
  133. }
  134. out = []
  135. let k = 0
  136. let inJob = 0
  137. while (k < people.length) {
  138. let c = people[k]
  139. if (k == 0 || people[k - 1].job != c.job) {
  140. inJob = 0
  141. out.push({ isLabel = true isPerson = false text = crewLabels[c.job] name = '' href = '' itemClass = '' })
  142. }
  143. let last = k == people.length - 1 || people[k + 1].job != c.job
  144. out.push({ isLabel = false isPerson = true text = '' name = c.name href = c.href itemClass = 'crew-link' + (inJob >= crewShown ? ' more' : '') + (last ? ' end' : '') + (inJob == crewShown - 1 ? ' cut' : '') })
  145. inJob = inJob + 1
  146. k = k + 1
  147. }
  148. return out
  149. }
  150. // how many crew names are hidden while collapsed (0 = no toggle) and how many there are
  151. static crewHiddenOf = (&rows) => {
  152. let n = 0
  153. for (r of rows) { if (r.isPerson && r.itemClass.includes(' more')) { n = n + 1 } }
  154. return n
  155. }
  156. static crewCountOf = (&rows) => {
  157. let n = 0
  158. for (r of rows) { if (r.isPerson) { n = n + 1 } }
  159. return n
  160. }
  161. // a View attribute must be a literal, a member or a field path — never an inline expression — so
  162. // every ternary (watched -> icon class, collapsed -> row class) is resolved HERE, once, into a
  163. // plain field the View just reads (row.watchClass, row.rowClass); `watchClassOf` itself lives in
  164. // watches.hl (shared with /unwatched and /schedule, "reuse, don't copy")
  165. // #22: an episode's air date as the lists show it ('YYYY-MM-DD'), '' when it has none
  166. static airDateOf = (&e) => { return e.release != null && hlTypeName(e.release) == 'String' ? e.release : '' }
  167. static buildRows = (&show, &uid, collapsed) => {
  168. watched = watchedSet(uid != null ? watchesOfUser(uid) : [])
  169. out = []
  170. for (s of seasonsOfShow(show)) {
  171. let isCollapsed = collapsed[s.id] == true
  172. let label = s.title != null && s.title != '' ? s.title : 'Season ' + s.seasonNumber
  173. let seasonWatched = seasonAllWatched(s, watched)
  174. 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' })
  175. if (!isCollapsed) {
  176. for (e of episodesOfSeason(s)) {
  177. let epWatched = watched['episode:' + e.id] == true
  178. out.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) })
  179. }
  180. }
  181. }
  182. return out
  183. }
  184. // what the client marks the rows of a season it opens with (tracker#17) — only this show's, never the user's whole watch
  185. // list (every member is shipped to the browser). Per season with a watched episode: `{ all, except }` — `all` = most of
  186. // it is watched, then `except` holds the UNwatched ids, else the watched ones (a fully watched 142-episode season is
  187. // `{ all = true except = {} }`, not 142 ids). No entry = nothing watched.
  188. static watchedEpisodesOf = (&show, &uid) => {
  189. m = {}
  190. if (uid == null) { return m }
  191. watched = watchedSet(watchesOfUser(uid))
  192. for (s of seasonsOfShow(show)) {
  193. let yes = {}
  194. let no = {}
  195. let n = 0
  196. for (eid of s.episodes) {
  197. if (watched['episode:' + eid] == true) { yes[eid] = true n = n + 1 } else { no[eid] = true }
  198. }
  199. if (n > 0) { m[s.id] = n * 2 > s.episodes.length ? { all = true except = no } : { all = false except = yes } }
  200. }
  201. return m
  202. }
  203. // is this episode watched, by that per-season shape (client-side, pure)
  204. static watchedIn = (&entry, &episodeId) => {
  205. if (entry == null) { return false }
  206. listed = entry.except[episodeId] == true
  207. return entry.all ? !listed : listed
  208. }
  209. // ---- the rows as the CLIENT changes them (tracker#17) — pure: no import is reached, so they run in the browser ----------
  210. // (`watchClassOf` is an import of the mpackdb-backed watches.hl, so `filledRows` writes its two classes itself)
  211. static seasonRowAs = (&row, &isCollapsed) => {
  212. 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' }
  213. }
  214. // the season opened: its caret up at once, `skeletonCount` placeholder rows under it
  215. static openingRows = (&rows, seasonId) => {
  216. out = []
  217. for (row of rows) {
  218. if (row.isSeason && row.id == seasonId) {
  219. out.push(seasonRowAs(row, false))
  220. let i = 0
  221. while (i < row.skeletonCount) {
  222. out.push({ kind = 'skeleton' isSeason = false isEpisode = false isSkeleton = true id = seasonId + '-skeleton-' + i seasonId = seasonId })
  223. i = i + 1
  224. }
  225. } else { out.push(row) }
  226. }
  227. return out
  228. }
  229. // the season closed: its episode (and skeleton) rows gone, caret down
  230. static closedRows = (&rows, &seasonId) => {
  231. out = []
  232. for (row of rows) {
  233. if (row.isSeason && row.id == seasonId) { out.push(seasonRowAs(row, true)) }
  234. else if (!row.isSeason && row.seasonId == seasonId) { }
  235. else { out.push(row) }
  236. }
  237. return out
  238. }
  239. // the episodes arrived: they replace the season's skeleton rows. No skeleton of it left (closed meanwhile, or a watch face
  240. // already rebuilt every row) → the rows stay as they are (no double rows)
  241. static filledRows = (&rows, seasonId, &episodes, watched) => {
  242. out = []
  243. let done = false
  244. for (row of rows) {
  245. if (row.isSkeleton && row.seasonId == seasonId) {
  246. if (!done) {
  247. for (e of episodes) {
  248. let w = watchedIn(watched[seasonId], e.id)
  249. 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' })
  250. }
  251. done = true
  252. }
  253. } else { out.push(row) }
  254. }
  255. return out
  256. }
  257. // a LOCAL static like buildRows (an imported mpackdb helper called straight from a member's initializer came back false
  258. // after a client-side navigation to the page — the gate's follow check)
  259. static followingOf = (&show, &uid) => { return uid != null && isFollowing(uid, show.id) }
  260. // #24: has this user watched this movie (a watch `movie:<title id>`)?
  261. static movieWatchedOf = (&show, &uid) => { return uid != null && isWatched(watchesOfUser(uid), 'movie', show.id) }
  262. // mission 056: an ADULT title's page (TMDB's flag `adult == true`, mission 054) is "Not found" for everyone who does not
  263. // follow it (signed out too); a follower sees it as before. Unknown (`null`) is not hidden here — only in the lists.
  264. // With hybriel ≥ 73267707 (#121/#122) `showRow`/`found` are server sites re-derived after every session face with the page's
  265. // 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).
  266. // Mission 054's try (`found = … && !adultHiddenFor(showRow, me)`, `collapsed`/`rows` from `found`) broke the checks.
  267. static adultHiddenFor = (&show, &uid) => { return show.adult == true && !followingOf(show, uid) }
  268. // 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
  269. // row of a hidden title must never be one
  270. static visibleShow = (&slug, &uid) => {
  271. s = showBySlug(slug)
  272. return s != null && adultHiddenFor(s, uid) ? null : s
  273. }
  274. // tracker.worldapi.org#19 (mission 066): the franchise widget(s), one per timeline the title is in (components/franchisewidget.hl)
  275. static franchiseWidgetsOf = (&show) => { return widgetsOf(show.id) }
  276. // 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
  277. // AND re-sends it after every watch/follow click (SESSION SYNC below): the whole record was ~1 MB on The Simpsons (300 cast + crew
  278. // entries). null = not found / hidden.
  279. static pageShowOf = (&slug, &uid) => {
  280. s = visibleShow(slug, uid)
  281. if (s == null) { return null }
  282. movie = s.type == 'movie'
  283. return { id = s.id title = s.title summary = summaryOf(s) type = s.type adult = s.adult == true shortId = s.shortId != null ? '' + s.shortId : ''
  284. poster = posterName(s) genreRows = genreRowsOf(s) linkRows = externalLinksOf(s) completing = completingOf(s) following = followingOf(s, uid)
  285. movieWatched = movie ? movieWatchedOf(s, uid) : false }
  286. }
  287. // the seasons/episodes rows and the per-season watch state by the slug (the record itself stays on the server)
  288. static rowsOfSlug = (&slug, &uid, &collapsed) => {
  289. s = showBySlug(slug)
  290. return s != null ? buildRows(s, uid, collapsed) : []
  291. }
  292. static watchedEpisodesOfSlug = (&slug, &uid) => {
  293. s = showBySlug(slug)
  294. return s != null ? watchedEpisodesOf(s, uid) : {}
  295. }
  296. // the initial open/closed seasons and the title's kind (tracker#20/#21: the heading's type word) from the SLUG alone (not from
  297. // `showRow`, which reads the session — see `collapsed`) — one slug lookup for both
  298. static slugInfoOf = (&slug) => {
  299. s = showBySlug(slug)
  300. return s != null ? { collapsed = initialCollapsed(seasonsOfShow(s)) kind = titleKind(s) } : { collapsed = {} kind = 'series' }
  301. }
  302. // mission 062: the cast and crew rows by the SLUG alone (not from `showRow`, which reads the session — see `castRows`). An
  303. // ADULT title (its page only for its followers) shows none: every member is shipped to the browser, and adult titles never get
  304. // credits anyway (details.hl skips them; at most a migrated cast)
  305. static publicShowOf = (&slug) => {
  306. s = showBySlug(slug)
  307. return s != null && s.adult != true ? s : null
  308. }
  309. static castOfSlug = (&slug) => {
  310. s = publicShowOf(slug)
  311. return s != null ? castRowsOf(s) : []
  312. }
  313. static crewOfSlug = (&slug) => {
  314. s = publicShowOf(slug)
  315. return s != null ? crewRowsOf(s) : []
  316. }
  317. // tracker#26: the page loads this title's full details on open
  318. static completingOf = (&show) => { return needsDetails(show) }
  319. // the face's work: ONE step of completing the title (details.hl completeStep — a series takes a few; a second viewer drives the
  320. // same job or finds it complete); when done, every part of the page that changes — the rows without anyone's watch state (a
  321. // title that was incomplete had no episodes to watch)
  322. static completedPageOf = (&slug) => {
  323. let show = showBySlug(slug)
  324. if (show == null || show.adult == true) { return { error = 'no such show' } }
  325. if (needsDetails(show)) {
  326. let r = completeStep(show.id)
  327. if (r.retry || r.adult == true) { return { error = r.error != '' ? r.error : 'no such show' } }
  328. if (!r.done) { return { done = false steps = r.sum.steps } }
  329. if (!r.skipped) {
  330. persistDetails()
  331. console.log('details on open: ' + completeLine(show.title, r.sum))
  332. }
  333. show = showById(show.id)
  334. }
  335. cl = initialCollapsed(seasonsOfShow(show))
  336. pn = posterName(show)
  337. crew = crewRowsOf(show)
  338. cast = castRowsOf(show)
  339. return { done = true title = show.title summary = summaryOf(show) poster = pn genreRows = genreRowsOf(show) castRows = cast
  340. castCount = castCountOf(cast) castMore = castCountOf(cast) > castShown crewRows = crew crewCount = crewCountOf(crew) crewMore = crewHiddenOf(crew) > 0
  341. linkRows = externalLinksOf(show) collapsed = cl rows = buildRows(show, null, cl) }
  342. }
  343. // ---- reactive members -----------------------------------------------------------------------
  344. slug = null
  345. session = null
  346. userId = userIdOfSession(session)
  347. signedIn = userId != null
  348. // SESSION SYNC (mission 047; since hybriel #121/#122, mission 056): a face taking `session` answers with a `sync` of every
  349. // member derived from session that reaches an import (here `showRow`, `found`, `following`, `rows`, …), re-derived on the
  350. // server with the page's route params but the MOUNT's other state — so client state (`collapsed`) must never derive from
  351. // them. `me` stays the plain copy mission 047 introduced.
  352. me = userId
  353. showRow = pageShowOf(slug, me)
  354. found = showRow != null
  355. notFound = showRow == null
  356. // the Follow toggle: "Follow" = the inverted button (accent border), "Following" = the filled one
  357. following = found ? showRow.following : false
  358. followClass = following ? 'follow' : 'follow inverted'
  359. followText = following ? 'Following' : 'Follow'
  360. signInHref = loginHref
  361. // #24: a movie's watched check (same icon/classes as the episodes); the classes written out, not `watchClassOf` (an import of
  362. // the mpackdb-backed watches.hl cannot run in the browser when the member is re-derived there)
  363. isMovie = found && showRow.type == 'movie'
  364. movieWatched = isMovie ? showRow.movieWatched : false
  365. movieWatchClass = movieWatched ? 'checkicon solid' : 'checkicon regular'
  366. movieWatchText = movieWatched ? 'Watched' : 'Not watched'
  367. 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.'
  368. title = found ? showRow.title : ''
  369. // tracker#20: "Series | Reacher" — the kind from the slug (pure strings here: they are re-derived in the browser too)
  370. slugInfo = slugInfoOf(slug)
  371. pageKind = slugInfo.kind
  372. typeText = pageKind == 'movie' ? 'Movie' : (pageKind == 'show' ? 'Show' : 'Series')
  373. typeClass = 'type-word type-' + pageKind
  374. // tracker#18: the creator's own text, else TMDB's, else TVmaze's (pageShowOf → shows.hl summaryOf)
  375. summary = found && showRow.summary != null ? showRow.summary : ''
  376. poster = found ? showRow.poster : null
  377. hasPoster = poster != null
  378. posterUrl = hasPoster ? '/posters/' + poster : ''
  379. // mission 060 (tracker.worldapi.org#27): the short id under the poster (none yet while the backfill has not reached it)
  380. shortId = found ? showRow.shortId : ''
  381. hasShortId = shortId != ''
  382. genreRows = found ? showRow.genreRows : []
  383. hasGenres = genreRows.length > 0
  384. // mission 062: cast + crew from the SLUG (`castOfSlug`, `crewOfSlug`), never from `showRow` — a member derived from the session is
  385. // re-derived and re-SENT after every watch/follow click (SESSION SYNC above): The Simpsons' 300 names took 0.7 s per click
  386. castRows = castOfSlug(slug)
  387. hasCast = castRows.length > 0
  388. // tracker#28: "Show all (N)" — the toggles' state is CLIENT state: literals, never derived from `showRow` (session sync)
  389. castCount = castCountOf(castRows)
  390. castMore = castCount > castShown
  391. castAllText = 'Show all (' + castCount + ')'
  392. castClass = 'collapsed'
  393. crewRows = crewOfSlug(slug)
  394. hasCrew = crewRows.length > 0
  395. crewCount = crewCountOf(crewRows)
  396. crewMore = crewHiddenOf(crewRows) > 0
  397. crewAllText = 'Show all crew (' + crewCount + ')'
  398. crewClass = 'collapsed'
  399. linkRows = found ? showRow.linkRows : []
  400. franchiseWidgets = found ? franchiseWidgetsOf(showRow) : []
  401. hasLinks = linkRows.length > 0
  402. // NOT from `showRow`/`found`: they read the session (via `me`), so a member derived from them is re-derived on the server
  403. // after every session face with the MOUNT's state — `collapsed` (client state) would snap back to the initial seasons (mission
  404. // 056: the 2nd episode click in a re-opened season did nothing). The season ids of a hidden page are no secret.
  405. collapsed = slugInfo.collapsed
  406. // mission 062: has this page opened/closed a season (client state) — then a watch face sends its own rows (toggleEpisode)
  407. collapsedChanged = false
  408. rows = found ? rowsOfSlug(slug, me, collapsed) : []
  409. hasRows = rows.length > 0
  410. watchedEpisodes = found ? watchedEpisodesOfSlug(slug, me) : {}
  411. // an adult page (followers only) opens its seasons through the session face `showRows`
  412. adultShow = found && showRow.adult == true
  413. // tracker#26: incomplete → skeletons + the probe until `showComplete` answered
  414. completing = found ? showRow.completing : false
  415. completeBusy = false
  416. isSeries = found && showRow.type != 'movie'
  417. skeletonSeasons = ['1', '2', '3']
  418. __title = found ? title + ' — ' + appTitle : appTitle
  419. View {
  420. showPage {
  421. if (notFound) {
  422. article { class = "main"
  423. h1 { "Not found" }
  424. p { "This show doesn't exist." }
  425. }
  426. }
  427. if (found) {
  428. showHeader {
  429. showPoster {
  430. if (hasPoster) { img { src = posterUrl alt = title } }
  431. if (completing) { skeletonPoster { "aria-hidden" = "true" } }
  432. if (hasShortId) { shortIdLabel { id = "shortid" title = "Short id" shortId } }
  433. }
  434. showMeta {
  435. showTitle {
  436. h1 { class = "typed" typeWord { class = typeClass typeText } typeSep { "|" } typeName { title } }
  437. button { id = "follow" type = "button" class = followClass followText on click(e) { emit toggleFollow(e) } }
  438. }
  439. if (isMovie) {
  440. movieWatch {
  441. button { id = "moviewatch" type = "button" class = movieWatchClass "aria-label" = "mark movie watched"
  442. svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"
  443. circle { cx = "12" cy = "12" r = "10" }
  444. path { d = "M7 12.5 L10.5 16 L17 8.5" }
  445. }
  446. on click(e) { emit toggleMovie(e) }
  447. }
  448. movieWatchLabel { movieWatchText }
  449. }
  450. }
  451. if (hasLinks) {
  452. showLinks {
  453. 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 } }
  454. }
  455. }
  456. // mission 025: the franchise widget right under the title/poster block — on a phone (showMeta dissolves, see styles.hl)
  457. // after the poster and before the genres, plot and cast; from 40rem in the right column under the title
  458. FranchiseWidget { widgets = franchiseWidgets }
  459. if (hasGenres) {
  460. showGenres {
  461. for (g of genreRows) { a { class = "genre-pill" href = g.href g.name } }
  462. }
  463. }
  464. showSummary { summary }
  465. if (hasCrew) {
  466. showCrew { class = crewClass
  467. for (r of crewRows) {
  468. if (r.isLabel) { crewLabel { r.text } }
  469. if (r.isPerson) { a { class = r.itemClass href = r.href r.name } }
  470. }
  471. if (crewMore) {
  472. button { id = "crewtoggle" type = "button" class = "quiet showall"
  473. whenCollapsed { crewAllText }
  474. whenAll { "Show less" }
  475. on click(e) { emit toggleCrewAll(e) }
  476. }
  477. }
  478. }
  479. }
  480. if (hasCast) {
  481. showCast { class = castClass
  482. castHeading { "Cast" }
  483. for (c of castRows) {
  484. if (c.isLabel) { castLabel { class = c.itemClass c.text } }
  485. if (c.isPerson) { a { class = c.itemClass href = c.href "data-role" = c.roleText c.name } }
  486. }
  487. if (castMore) {
  488. button { id = "casttoggle" type = "button" class = "quiet showall"
  489. whenCollapsed { castAllText }
  490. whenAll { "Show less" }
  491. on click(e) { emit toggleCastAll(e) }
  492. }
  493. }
  494. }
  495. }
  496. if (completing) { skeletonCast { "aria-hidden" = "true" } }
  497. }
  498. }
  499. if (completing) {
  500. showLoading { id = "showloading" "aria-busy" = "true"
  501. if (isSeries) {
  502. for (k of skeletonSeasons) {
  503. skeletonRow { "aria-hidden" = "true"
  504. skeletonCheck { }
  505. skeletonTitle { }
  506. }
  507. }
  508. }
  509. showProbe { "aria-hidden" = "true" on animationiteration(e) { emit completeTick(e) } }
  510. }
  511. }
  512. if (hasRows) {
  513. showSeasons {
  514. for (row of rows) {
  515. if (row.isSeason) {
  516. seasonRow { class = row.rowClass
  517. button { type = "button" class = row.watchClass value = row.id "aria-label" = "mark season watched"
  518. svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"
  519. circle { cx = "12" cy = "12" r = "10" }
  520. path { d = "M7 12.5 L10.5 16 L17 8.5" }
  521. }
  522. on click(e) { emit toggleSeason(row.id) }
  523. }
  524. button { type = "button" class = "season-toggle" value = row.id
  525. seasonLabel { row.label }
  526. seasonCount { row.episodeCountText }
  527. seasonCaret { "aria-hidden" = "true"
  528. svg { viewBox = "0 0 24 24" width = "20" height = "20"
  529. path { d = "M6 9 L12 15 L18 9" }
  530. }
  531. }
  532. on click(e) { emit toggleCollapse(row.id) }
  533. }
  534. }
  535. }
  536. if (row.isEpisode) {
  537. episodeRow {
  538. button { type = "button" class = row.watchClass value = row.id "aria-label" = "mark episode watched"
  539. svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"
  540. circle { cx = "12" cy = "12" r = "10" }
  541. path { d = "M7 12.5 L10.5 16 L17 8.5" }
  542. }
  543. on click(e) { emit toggleEpisode(row.id) }
  544. }
  545. episodeNumber { row.episodeNumber }
  546. episodeTitle { row.title }
  547. episodeDate { row.airDate }
  548. }
  549. }
  550. if (row.isSkeleton) {
  551. skeletonRow { "aria-hidden" = "true"
  552. skeletonCheck { }
  553. skeletonNumber { }
  554. skeletonTitle { }
  555. }
  556. }
  557. }
  558. }
  559. }
  560. Modal { id = "signin" title = "Sign in"
  561. p { signInText }
  562. footer {
  563. button { type = "button" value = "close" "Close" }
  564. a { id = "signin-link" class = "button" href = signInHref "Sign in with ident" }
  565. }
  566. }
  567. }
  568. }
  569. }
  570. // Every rebuild of `rows` happens SERVER-SIDE, never by calling `buildRows` from a client handler:
  571. // a member function whose body reaches an hl:mpackdb-backed import (seasonsOfShow, episodesOfSeason,
  572. // watchesOfUser, …) compiles to nothing on the client (no case ever assigns it — a genuine Hybriel
  573. // gap, filed under `issues`) even though the client bundle still calls it, so `this.buildRows(...)`
  574. // throws "not a function" in the browser. The client here only ever ASSIGNS the `rows` the server
  575. // already built (`rows = r.rows`) — never calls a tainted local helper itself. `slug` goes along so
  576. // the face can refetch the show without depending on implicit access to a sibling member.
  577. //
  578. // tracker#17: opening shows the skeleton rows BEFORE the request; the answer replaces them unless the season was closed
  579. // meanwhile (then nothing). An error closes the season again. Closing never asks the server.
  580. on toggleCollapse(seasonId) {
  581. collapsedChanged = true
  582. m = collapsed
  583. if (collapsed[seasonId] != true) {
  584. m[seasonId] = true
  585. collapsed = m
  586. rows = closedRows(rows, seasonId)
  587. return null
  588. }
  589. m[seasonId] = false
  590. collapsed = m
  591. rows = openingRows(rows, seasonId)
  592. if (adultShow) {
  593. let a = emit server showRows(slug, collapsed)
  594. if (collapsed[seasonId] == true) { return null }
  595. if (a == null || a.error != null) { m[seasonId] = true collapsed = m rows = closedRows(rows, seasonId) return null }
  596. rows = a.rows
  597. return null
  598. }
  599. let r = emit server showSeasonEpisodes('' + seasonId)
  600. if (collapsed[seasonId] == true) { return null }
  601. if (r == null || r.error != null) { m[seasonId] = true collapsed = m rows = closedRows(rows, seasonId) return null }
  602. rows = filledRows(rows, seasonId, r.episodes, watchedEpisodes)
  603. }
  604. // tracker#26: the probe's tick — `showComplete` step by step until `done` (once per page: `completeBusy`; other requests are
  605. // served between two calls); the last answer replaces the skeletons. A lost answer (null) lets the next tick go on where the
  606. // server is; an error (TMDB down, adult) ends the loading and keeps what the page has.
  607. on completeTick(e) {
  608. if (completeBusy || !completing) { return null }
  609. completeBusy = true
  610. s = slug
  611. let r = emit server showComplete(s)
  612. while (r != null && r.error == null && r.done != true && slug == s) { r = emit server showComplete(s) }
  613. completeBusy = false
  614. if (r == null || slug != s) { return null }
  615. if (r.error != null) { completing = false return null }
  616. title = r.title
  617. summary = r.summary
  618. poster = r.poster
  619. genreRows = r.genreRows
  620. castRows = r.castRows
  621. castCount = r.castCount
  622. castMore = r.castMore
  623. castAllText = 'Show all (' + r.castCount + ')'
  624. crewRows = r.crewRows
  625. crewCount = r.crewCount
  626. crewMore = r.crewMore
  627. crewAllText = 'Show all crew (' + r.crewCount + ')'
  628. linkRows = r.linkRows
  629. collapsed = r.collapsed
  630. collapsedChanged = true
  631. rows = r.rows
  632. completing = false
  633. }
  634. // tracker#28: the cast / crew "Show all" toggles — client-only, no request (CSS shows the hidden names)
  635. on toggleCastAll(e) {
  636. castClass = castClass == 'all' ? 'collapsed' : 'all'
  637. }
  638. on toggleCrewAll(e) {
  639. crewClass = crewClass == 'all' ? 'collapsed' : 'all'
  640. }
  641. // signed out: the modal, nothing is sent
  642. // mission 062: the new `rows` come with the SAME ack as the session sync (SESSION SYNC above: `rows` derives from the session) —
  643. // 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
  644. // (`collapsedChanged`, or a completed title); else they came twice (a second buildRows + 14 kB on The Simpsons: ~40 ms per click)
  645. on toggleEpisode(episodeId) {
  646. if (!signedIn) { document.getElementById('signin').showModal() return null }
  647. let r = emit server showToggleEpisode(slug, '' + episodeId, collapsed, collapsedChanged)
  648. if (r == null || r.error != null) { return null }
  649. if (r.rows != null) { rows = r.rows }
  650. }
  651. on toggleSeason(seasonId) {
  652. if (!signedIn) { document.getElementById('signin').showModal() return null }
  653. let r = emit server showToggleSeason(slug, '' + seasonId, collapsed, collapsedChanged)
  654. if (r == null || r.error != null) { return null }
  655. if (r.rows != null) { rows = r.rows }
  656. }
  657. // #24: the movie's own check — signed out the modal, nothing sent
  658. on toggleMovie(e) {
  659. if (!signedIn) { document.getElementById('signin').showModal() return null }
  660. let r = emit server showToggleMovie(slug)
  661. if (r == null || r.error != null) { return null }
  662. movieWatched = r.watched
  663. movieWatchClass = r.watched ? 'checkicon solid' : 'checkicon regular'
  664. movieWatchText = r.watched ? 'Watched' : 'Not watched'
  665. }
  666. on toggleFollow(e) {
  667. if (!signedIn) { document.getElementById('signin').showModal() return null }
  668. let r = emit server showToggleFollow(slug)
  669. if (r == null || r.error != null) { return null }
  670. following = r.following
  671. followClass = r.following ? 'follow' : 'follow inverted'
  672. followText = r.following ? 'Following' : 'Follow'
  673. }
  674. // ---- the faces (the trailing `session` is always the server's: hybriel#16) ----
  675. // tracker#26: complete an incomplete title and answer the page's parts — public data, NO session (no page re-mount)
  676. on server showComplete(slug) {
  677. return completedPageOf(slug != null ? '' + slug : '')
  678. }
  679. // every row for these open/closed seasons — only an ADULT show's page still uses it (its followers; tracker#17)
  680. on server showRows(slug, collapsed, session) {
  681. show = showBySlug(slug)
  682. uid = userIdOfSession(session)
  683. if (show == null || adultHiddenFor(show, uid)) { return { error = 'no such show' } }
  684. return { ok = true rows = buildRows(show, uid, collapsed) }
  685. }
  686. // tracker#17: one season's episodes, in order — public data, so NO `session` (no page re-mount, no sync: the fast path).
  687. // Refuses a season of an adult title (those pages use showRows above).
  688. on server showSeasonEpisodes(seasonId) {
  689. if (seasonId == null || hlTypeName(seasonId) != 'String') { return { error = 'no such season' } }
  690. season = seasonById(seasonId)
  691. if (season == null) { return { error = 'no such season' } }
  692. show = showById(season.show)
  693. if (show == null || show.adult == true) { return { error = 'no such season' } }
  694. out = []
  695. for (e of episodesOfSeason(season)) { out.push({ id = e.id episodeNumber = e.episodeNumber title = e.title != null ? e.title : '' airDate = airDateOf(e) }) }
  696. return { ok = true episodes = out }
  697. }
  698. on server showToggleEpisode(slug, episodeId, collapsed, withRows, session) {
  699. show = showBySlug(slug)
  700. if (show == null) { return { error = 'no such show' } }
  701. uid = userIdOfSession(session)
  702. if (uid == null) { return { error = 'you are not signed in' } }
  703. if (adultHiddenFor(show, uid)) { return { error = 'no such show' } }
  704. if (toggleWatched(uid, 'episode', episodeId) == null) { return { error = 'could not save the watch' } }
  705. return withRows == true ? { ok = true rows = buildRows(show, uid, collapsed) } : { ok = true }
  706. }
  707. on server showToggleSeason(slug, seasonId, collapsed, withRows, session) {
  708. show = showBySlug(slug)
  709. if (show == null) { return { error = 'no such show' } }
  710. uid = userIdOfSession(session)
  711. if (uid == null) { return { error = 'you are not signed in' } }
  712. if (adultHiddenFor(show, uid)) { return { error = 'no such show' } }
  713. season = seasonById(seasonId)
  714. if (season == null) { return { error = 'no such season' } }
  715. // #23: not all episodes watched → watch them all; all watched → unwatch them all (lib/watches.hl)
  716. if (toggleSeasonWatched(uid, season) == null) { return { error = 'could not save the watch' } }
  717. return withRows == true ? { ok = true rows = buildRows(show, uid, collapsed) } : { ok = true }
  718. }
  719. // #24: a movie's watched check (only a movie: a series is watched by its episodes)
  720. on server showToggleMovie(slug, session) {
  721. show = showBySlug(slug)
  722. if (show == null || show.type != 'movie') { return { error = 'no such movie' } }
  723. uid = userIdOfSession(session)
  724. if (uid == null) { return { error = 'you are not signed in' } }
  725. if (adultHiddenFor(show, uid)) { return { error = 'no such movie' } }
  726. watched = toggleWatched(uid, 'movie', show.id)
  727. if (watched == null) { return { error = 'could not save the watch' } }
  728. return { ok = true watched = watched }
  729. }
  730. on server showToggleFollow(slug, session) {
  731. show = showBySlug(slug)
  732. if (show == null) { return { error = 'no such show' } }
  733. uid = userIdOfSession(session)
  734. if (uid == null) { return { error = 'you are not signed in' } }
  735. follow = toggleFollowed(uid, show.id)
  736. if (follow == null) { return { error = 'could not save the follow' } }
  737. return { ok = true following = follow }
  738. }

Branches

Latest commits

  • 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
  • 2e89b968tracker mission 028 (code order) 5/5 let: `let` only where a variable is reassigned — 667 never-reassigned lets became plain declarations (project.hl, lib/, components/, tools/, tests/); kept: 264 in loop bodies (a plain declaration there is 'Cannot reassign' on the 2nd pass), 234 reassigned, 27 whose name is also a member/outer/free name (a plain write would rebind it); tools/let-audit.py decides and fixes (README 'Code order'); tests/realdata-m028.{sh,mjs} = the page-output diff on a real copy; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
  • 54796ff2tracker mission 028 (code order) 4/5 thin faces + last copies: the show page's check/follow faces call lib/watches.hl toggleWatched / toggleSeasonWatched (seasonAllWatched moved there) and lib/follows.hl toggleFollowed; both logins (header selector face, /login/callback) share lib/users.hl userOfCode; todayStr/listOf copies in components and the export readers copied into tools/migrate.hl + tools/old-short-ids.hl now once (lib/util.hl, lib/export.hl); gates 342/0, 32/0, 52/0; old-short-ids output byte-identical, migrate output identicalmre
  • 06b078e3tracker mission 028 (code order) 3/5 project.hl is the map: config, routes, wiring and a feature → file index (914 → 258 lines); the background jobs (daily sync run, backfills, details repair, credits job, merge, short ids, collection seed) moved unchanged into lib/jobs.hl (a class: their state is reassigned every step, a static cannot be; one instance made after the server), the login callback into lib/users.hl, poster/photo serving into lib/images.hl, the /shows/<slug> rule into lib/shows.hl showsMovedPath; route handlers are thin wrappers; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
  • 94716fd2tracker mission 028 (code order) 2/5 util + topics: lib/util.hl holds envOr, storageDir, postersDir, profilesDir, newId, hexDigits, todayStr, dateOr, textOr, hasId, listOr, firstOf, sortDesc once (were copied into up to 5 files); tmdbsync.hl split into tmdb.hl (TMDB/TVmaze requests), sync.hl (one title's sync), sync-helpers.hl, backfill.hl; details.hl split into details.hl, credits.hl, credits-helpers.hl (isIncomplete to shows.hl); search-helpers.hl (words, query, ranking, slugs); collections.hl (the TMDB collection seed, out of franchises.hl); deltasync.hl renamed dailysync.hl; no behaviour change: gates 342/0, 32/0, 52/0, real-copy pages identicalmre
  • 186079b0tracker mission 028 (code order) 1/5 move: every root .hl except project.hl into lib/ (styles.hl into components/), import paths only; gates 342/0, 32/0, 52/0; real-copy pages identicalmre
  • 4f47f181tracker: report 027mre
  • dc1d4be4tracker mission 027: Hybriel master 06617221 vendored (plugin allocator fixes 3a781359 + 413f60e4); real copy RSS through first-start jobs + 400 loads flat ~2.55 GB (190aa11d 2.3 -> 5.6 GB), page times <= 1.1x; gates 342/0, 32/0, 52/0mre
  • 84e1b3e1tracker: reports 025 + 026mre
  • dc40d859tracker#31 (mission 026): duplicate titles merged — the 68 type+tmdbId pairs held by 157 records were the old tracker's (all migrated); merge.hl repair job (own clock, before the TMDB jobs) keeps one keeper per title (follows/watches > old short id > oldest), moves follows, watches, seasons, cast, credits, timelines, tombstones the rest (mergedInto, never deleted), slugs + short ids 301 to the keeper; stray seasons merged into their listed twin (Reacher S3 watches) or linked when watched; search import re-checks before its put; deploy.sh waits up to 90 s for 200; real copy 68 -> 0 dup ids, az5b2 follows/watches equal; gates 342/0, 32/0, 52/0mre
  • 2667da05tracker#30 (mission 025): /my/ pages from slim cached title cards, episode rows and watch sets (after the jobs /my/series 1.8 s -> 0.06 s, /my/unwatched 4.1 -> 0.18 s); timeline page shows its name once; franchise widget under the poster/title; movies with TV leftovers (First Contact) go through the details repair; tools/count-tmdb-ids.hl; gates 327/0, 52/0, 32/0mre
  • 3909810dantcolony#40: mission references in README/STATUS/docs point to the moved missionsmre
  • e63b1d28antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 1cda451dtracker: Hybriel master 190aa11d (#127 both shapes, GC correctness fc838894) — conductor adopts despite /my/series 2.4x after jobs (memory 8.0 → 1.7 GB boot); see reports/071mre
  • c1fa2f2etracker (mission 071): Hybriel master 190aa11d measured on the real copy vs the live binary 8590df63 — NOT adopted (after the first-start jobs /my/series 2.4x slower, /series 1.6x, RSS swings 7.4-12.2 GB; fresh it is flat at 1.7-2.2 GB and /my/unwatched faster), vendor stays 8efba065, candidate kept in .scratch/w071/vendor-190aa11d; tests/kinds.mjs: collection seed off (its TMDB request broke check 1 in 1 of 4 runs); tests/realdata-071.sh + realdata-071-bench.mjs + tools/realdata-071-table.py; README + STATUS (numbers, how to repeat); gates 325/0, 32/0, 50/0mre
  • 8081350atracker docs (mission 070): README (summary, Config HL_GC_BYTES — kept at Hybriel's default, the 256 MiB setting is taken out of docker-compose.yml again: the jobs grew to 12+ GB with it too, see STATUS), Test (three gates), Deploy (first start ~50 min: kinds then seed, restart once after collections done, memory numbers), Vendored Hybriel 8efba065 + #48 audit, Files; STATUS mission 070 entry (merges, migrated counts, lambda audit, gates, RSS old vs new, how to repeat, open points); docs/kinds.md + docs/franchises.md job order; tests/realdata-070-*.sh, tools/count-migrated.hl, tools/ref-params.py, tools/lambda-audit.pymre
  • 1ad19c8ctracker: re-vendor Hybriel master 8efba065 (#126 GC by bytes, #48 lambda parameters copy) (mission 070): bin/hybriel sha256 50361e95…, plugins core crypto data fetch fs http http1 mpackdb proc smtp time web; lambda audit: 13 lambdas change a passed record/list (11 through a local alias), no caller relies on it — unchanged; 319 read-only lambda parameters get & (no copy per call: /my/schedule 2.35 → 0.46 s, /my/unwatched 13.7 → 5.7 s on the real copy); /my/unwatched one merge sort instead of n² inserts; docker-compose HL_GC_BYTES=268435456; deploy.sh runs kinds.mjs + franchises.mjs too (default ports 8700–8710); tests/realdata-070.mjs; gates browser 325/0, kinds 32/0, franchises 50/0mre
  • 46b21389tracker (mission 070, conductor): the sync never destroys migrated data — the one-time summary step MOVES a copied summary to migratedSummary (marker summaries-moved.txt) instead of clearing it; a migrated record's first title/genres/homepage/tagline TMDB replaces → migratedTitle/migratedGenres/migratedHomepage/migratedTagline, a migrated season's/episode's title/summary → migratedTitle/migratedSummary (set once); tests/peek-shows.hl prints them; gate 325/0mre
  • 749019b0Merge t19 (tracker#19 franchises + timelines) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs/faketmdb.mjs, both sides kept; franchise/timeline pages get #20's typed heading (Franchise | …, Timeline | …), their title links via titlePath (/movies|/series|/shows); the collection seed waits for repair, kinds and credits too; franchises.mjs URLs + 2 new checks; gates browser 323/0, kinds 32/0, franchises 50/0mre
  • daf49feaMerge t20 (tracker#20 typed headings + #21 series/shows split) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/search.hl/components/search.hl/browser.mjs/faketmdb.mjs, both sides kept; clock order backfill → repair → kinds → credits; withDetailsFields stores tmdbType + kind; gates: browser.mjs URLs → /series|/movies, typed h1 selectors; kinds.mjs repair/credits off, fixture name = seed name; browser 323/0, kinds 32/0mre