gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit94716fd294716fd2tracker 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 identicalmre94716fd2/lib/franchises.hl

19.0 KB

  1. // lib/franchises.hl — FRANCHISES AND TIMELINES (tracker.worldapi.org#19, mission 066). The creator: "we have franchises, and
  2. // timelines, and some franchises have multiple timelines ... make them sortable by timeline or release date at shows the
  3. // release date of the last episode counts. in a show or movie page we show a franchise widget with < Prequel | TimelineName
  4. // | Sequel > and if the timeline is part of a franchise that franchise is above it and when you click on it you get the
  5. // franchise index."
  6. //
  7. // franchisesTable pk @id index !slug { name, slug, shortId (null: not given out yet), summary, created }
  8. // timelinesTable pk @id index !slug { name, slug, franchise (@id | null), order [show @id …], source ('tmdb' |
  9. // 'editor'), tmdbCollection (TMDB collection id | null), created }
  10. // storage/mpackdb/{franchises,timelines}.db (new tables; nothing in shows.db changes; the seed's collectionchecks.db: collections.hl)
  11. //
  12. // A TITLE MAY BE IN SEVERAL TIMELINES (Star Trek Prime and Kelvin, or a TMDB collection and a hand-made one): `byShow` (show
  13. // @id → [timeline @id]) is built once at start (`ensureFranchises`, one scan of the timelines) and kept up to date by every
  14. // edit — the show page's widget reads only its own timelines, never all of them.
  15. //
  16. // THE ORDER of a timeline is its in-world order (`order`, editable). The pages can sort it by RELEASE instead: a movie by its
  17. // `release`, a SERIES by the release date of its LAST episode (the newest one already out; none out yet → the newest
  18. // announced one; no episode dates at all → the series' own `release`). Titles without any date go last.
  19. //
  20. // PUBLIC LIKE THE OTHER LISTS (mission 054): the pages and the widget's prequel/sequel links show only titles with
  21. // `adult == false` (shows.hl isPublicTitle); the order keeps them, they are just skipped.
  22. //
  23. // THE SEED (TMDB movie collections → timelines): collections.hl.
  24. //
  25. // ONE PROCESS PER STORAGE (hybriel#21): the tables are opened here, in the app's process only.
  26. import { MPackDB } from 'hl:mpackdb'
  27. import { env } from 'hl:proc'
  28. import { now } from 'hl:time'
  29. import { storageDir, newId, todayStr, dateOr, firstOf } from './util.hl'
  30. import { showById, seasonsOfShow, episodesOfSeason, titleWithYear, posterUrlOf, isPublicTitle, titlePath } from './shows.hl'
  31. import { userOfSession } from './users.hl'
  32. import { dbSearch } from './search.hl'
  33. import { slugBaseOf } from './search-helpers.hl'
  34. static franchisesTable = new MPackDB(file = storageDir + '/franchises.db', primaryKey = '@id', indexes = ['!slug'])
  35. static timelinesTable = new MPackDB(file = storageDir + '/timelines.db', primaryKey = '@id', indexes = ['!slug'])
  36. // a name as typed: trimmed, null when there is none (the editor's names and summaries)
  37. static trimmedOr = (&v) => { return v != null && hlTypeName(v) == 'String' && v.trim() != '' ? v.trim() : null }
  38. // ---- who may edit: the creator (ident short id az5b2); TRACKER_EDITORS = a comma list of ident ids (the gate sets its own) ----
  39. static editorIds = () => {
  40. let v = env('TRACKER_EDITORS')
  41. let raw = v != null && v.trim() != '' ? v : 'az5b2'
  42. let out = []
  43. for (p of raw.split(',')) { if (p.trim() != '') { out.push(p.trim()) } }
  44. return out
  45. }
  46. static editors = editorIds()
  47. static isEditorSession = (&session) => {
  48. if (session == null || hlTypeName(session) != 'Instance') { return false }
  49. let u = userOfSession(session)
  50. return u != null && u.identity != null && editors.includes(u.identity)
  51. }
  52. // a list's index of x, -1 when it is not in it (hand-written: list helpers beyond includes/push are not trusted, hybriel#1)
  53. static posOf = (&list, &x) => {
  54. let i = 0
  55. while (i < list.length) {
  56. if (list[i] == x) { return i }
  57. i = i + 1
  58. }
  59. return -1
  60. }
  61. // ---- slugs: "Star Trek" → "Star-Trek" (the shows' urlSegment form, search-helpers.hl slugBaseOf; none → 'timeline') -------
  62. static freeSlug = (&table, &name) => {
  63. let base = slugBaseOf(name, 'timeline')
  64. // 'edit' is the editor's own address (/franchises/edit)
  65. if (firstOf(table.find('slug', base)) == null && base != 'edit') { return base }
  66. let n = 2
  67. while (firstOf(table.find('slug', base + '-' + n)) != null) { n = n + 1 }
  68. return base + '-' + n
  69. }
  70. static franchiseBySlug = (&slug) => {
  71. if (slug == null || hlTypeName(slug) != 'String' || slug == '') { return null }
  72. return firstOf(franchisesTable.find('slug', slug))
  73. }
  74. static timelineBySlug = (&slug) => {
  75. if (slug == null || hlTypeName(slug) != 'String' || slug == '') { return null }
  76. return firstOf(timelinesTable.find('slug', slug))
  77. }
  78. static franchiseById = (&id) => { return id == null || hlTypeName(id) != 'String' || id == '' ? null : franchisesTable.fetch(id) }
  79. static timelineById = (&id) => { return id == null || hlTypeName(id) != 'String' || id == '' ? null : timelinesTable.fetch(id) }
  80. // ---- byShow: show @id → [timeline @id] ------------------------------------------------------------------------------------
  81. static byShow = {}
  82. static state = { built = false timelines = 0 ms = 0 }
  83. static indexAdd = (&showId, &timelineId) => {
  84. let l = byShow[showId]
  85. if (l == null) { l = [] }
  86. if (!l.includes(timelineId)) { l.push(timelineId) }
  87. byShow[showId] = l
  88. return true
  89. }
  90. static indexRemove = (&showId, &timelineId) => {
  91. let l = byShow[showId]
  92. if (l == null) { return true }
  93. let out = []
  94. for (x of l) { if (x != timelineId) { out.push(x) } }
  95. byShow[showId] = out
  96. return true
  97. }
  98. static ensureFranchises = () => {
  99. if (state.built) { return true }
  100. state.built = true
  101. let t0 = now()
  102. let n = 0
  103. for (t of timelinesTable.find(null, null)) {
  104. n = n + 1
  105. if (t.order != null) { for (sid of t.order) { indexAdd(sid, t.id) } }
  106. }
  107. state.timelines = n
  108. state.ms = now() - t0
  109. console.log('franchises: ' + n + ' timelines, ' + franchisesTable.count() + ' franchises (' + state.ms + ' ms)')
  110. return true
  111. }
  112. static timelineIdsOfShow = (&showId) => {
  113. ensureFranchises()
  114. let l = byShow[showId]
  115. return l != null ? l : []
  116. }
  117. // ---- a title as the pages list it ------------------------------------------------------------------------------------------
  118. // the date the release sort uses: a movie's release; a series' LAST episode (newest out ≤ today, else newest announced, else
  119. // its own release)
  120. static releaseKeyOf = (&show) => {
  121. if (show.type != 'series') { return dateOr(show.release) }
  122. let today = todayStr()
  123. let lastOut = ''
  124. let lastAny = ''
  125. for (s of seasonsOfShow(show)) {
  126. for (e of episodesOfSeason(s)) {
  127. let d = dateOr(e.release)
  128. if (d != '') {
  129. if (d > lastAny) { lastAny = d }
  130. if (d <= today && d > lastOut) { lastOut = d }
  131. }
  132. }
  133. }
  134. if (lastOut != '') { return lastOut }
  135. if (lastAny != '') { return lastAny }
  136. return dateOr(show.release)
  137. }
  138. static itemOf = (&show, pos) => {
  139. let series = show.type == 'series'
  140. let d = releaseKeyOf(show)
  141. let label = d == '' ? 'no date' : (series ? 'last episode ' + d : d)
  142. return { id = show.id pos = pos title = titleWithYear(show) href = titlePath(show) posterUrl = posterUrlOf(show)
  143. kind = series ? 'Series' : 'Movie' date = d dateLabel = label }
  144. }
  145. // the public titles of a timeline, in its order (`pos` = the place in the in-world order, 1-based)
  146. static itemsOf = (&t) => {
  147. let out = []
  148. let pos = 0
  149. if (t.order == null) { return out }
  150. for (sid of t.order) {
  151. let s = showById(sid)
  152. if (s != null && isPublicTitle(s)) {
  153. pos = pos + 1
  154. out.push(itemOf(s, pos))
  155. }
  156. }
  157. return out
  158. }
  159. // release order: oldest first, no date last; equal dates keep the timeline order (a stable insertion)
  160. static byReleaseOf = (&items) => {
  161. let out = []
  162. for (it of items) {
  163. let next = []
  164. let done = false
  165. for (x of out) {
  166. if (!done && it.date != '' && (x.date == '' || it.date < x.date)) { next.push(it) done = true }
  167. next.push(x)
  168. }
  169. if (!done) { next.push(it) }
  170. out = next
  171. }
  172. return out
  173. }
  174. static blockOf = (&t) => {
  175. let items = itemsOf(t)
  176. return { id = t.id name = t.name slug = t.slug href = '/timelines/' + t.slug items = items byRelease = byReleaseOf(items)
  177. countText = items.length == 1 ? '1 title' : items.length + ' titles' first = items.length > 0 ? byReleaseOf(items)[0].date : '' }
  178. }
  179. // ---- the pages -------------------------------------------------------------------------------------------------------------
  180. // /franchises: every franchise (name, how many timelines), then the timelines WITHOUT one (TMDB's collections, by name)
  181. static byNameOf = (&rows) => {
  182. let out = []
  183. for (r of rows) {
  184. let next = []
  185. let done = false
  186. let k = r.sortName
  187. for (x of out) {
  188. if (!done && k < x.sortName) { next.push(r) done = true }
  189. next.push(x)
  190. }
  191. if (!done) { next.push(r) }
  192. out = next
  193. }
  194. return out
  195. }
  196. static franchiseIndex = () => {
  197. let counts = {}
  198. let loose = []
  199. for (t of timelinesTable.find(null, null)) {
  200. let n = t.order != null ? t.order.length : 0
  201. if (t.franchise != null && t.franchise != '') { counts[t.franchise] = (counts[t.franchise] != null ? counts[t.franchise] : 0) + 1 }
  202. else if (n > 0) { loose.push({ id = t.id name = t.name href = '/timelines/' + t.slug countText = n == 1 ? '1 title' : n + ' titles' sortName = ('' + t.name).toLowerCase() }) }
  203. }
  204. let fr = []
  205. for (f of franchisesTable.find(null, null)) {
  206. let c = counts[f.id] != null ? counts[f.id] : 0
  207. fr.push({ id = f.id name = f.name href = '/franchises/' + f.slug summary = f.summary != null ? f.summary : ''
  208. countText = c == 1 ? '1 timeline' : c + ' timelines' sortName = ('' + f.name).toLowerCase() })
  209. }
  210. return { franchises = byNameOf(fr) timelines = byNameOf(loose) }
  211. }
  212. // /franchises/<slug>: the franchise and its timelines (oldest first by their first release) — or null
  213. static franchisePage = (slug) => {
  214. let f = franchiseBySlug(slug)
  215. if (f == null) { return null }
  216. let blocks = []
  217. for (t of timelinesTable.find(null, null)) {
  218. if (t.franchise == f.id) {
  219. let b = blockOf(t)
  220. let next = []
  221. let done = false
  222. for (x of blocks) {
  223. if (!done && b.first != '' && (x.first == '' || b.first < x.first)) { next.push(b) done = true }
  224. next.push(x)
  225. }
  226. if (!done) { next.push(b) }
  227. blocks = next
  228. }
  229. }
  230. return { kind = 'franchise' name = f.name slug = f.slug summary = f.summary != null ? f.summary : '' franchiseName = '' franchiseHref = '' blocks = blocks editHref = '' }
  231. }
  232. // /timelines/<slug>: one timeline, its franchise above it — or null
  233. static timelinePage = (slug) => {
  234. let t = timelineBySlug(slug)
  235. if (t == null) { return null }
  236. let f = franchiseById(t.franchise)
  237. return { kind = 'timeline' name = t.name slug = t.slug summary = '' franchiseName = f != null ? f.name : '' franchiseHref = f != null ? '/franchises/' + f.slug : ''
  238. blocks = [blockOf(t)] editHref = '/timelines/' + t.slug + '/edit' }
  239. }
  240. // ---- the widget on a show / movie page: one per timeline the title is in ----------------------------------------------------
  241. // { id, name, href, hasFranchise, franchiseName, franchiseHref, hasPrev, noPrev, prevHref, prevTitle, hasNext, noNext, … }:
  242. // prequel / sequel = the nearest PUBLIC title before / after this one in the timeline's order
  243. static widgetsOf = (&showId) => {
  244. let out = []
  245. if (showId == null) { return out }
  246. for (tid of timelineIdsOfShow(showId)) {
  247. let t = timelinesTable.fetch(tid)
  248. if (t != null && t.order != null && t.order.includes(showId)) {
  249. let at = posOf(t.order, showId)
  250. let prev = null
  251. let i = at - 1
  252. while (prev == null && i >= 0) {
  253. let s = showById(t.order[i])
  254. if (s != null && isPublicTitle(s)) { prev = s }
  255. i = i - 1
  256. }
  257. let next = null
  258. let j = at + 1
  259. while (next == null && j < t.order.length) {
  260. let s = showById(t.order[j])
  261. if (s != null && isPublicTitle(s)) { next = s }
  262. j = j + 1
  263. }
  264. let f = franchiseById(t.franchise)
  265. out.push({ id = t.id name = t.name href = '/timelines/' + t.slug
  266. hasFranchise = f != null franchiseName = f != null ? f.name : '' franchiseHref = f != null ? '/franchises/' + f.slug : ''
  267. hasPrev = prev != null noPrev = prev == null prevHref = prev != null ? titlePath(prev) : '' prevTitle = prev != null ? titleWithYear(prev) : ''
  268. hasNext = next != null noNext = next == null nextHref = next != null ? titlePath(next) : '' nextTitle = next != null ? titleWithYear(next) : '' })
  269. }
  270. }
  271. return out
  272. }
  273. // ---- the editor (the creator only — every caller checks isEditorSession first) ----------------------------------------------
  274. static createFranchise = (name, summary) => {
  275. let n = trimmedOr(name)
  276. if (n == null || n.length > 120) { return { error = 'a franchise needs a name (at most 120 characters)' } }
  277. let s = trimmedOr(summary)
  278. let rec = { id = newId() name = n slug = freeSlug(franchisesTable, n) shortId = null summary = s != null ? s : '' created = now() }
  279. if (franchisesTable.put(rec) == null) { return { error = 'not stored: ' + franchisesTable.lastError() } }
  280. franchisesTable.persist()
  281. console.log('franchises: new franchise "' + n + '" /franchises/' + rec.slug)
  282. return { ok = true slug = rec.slug id = rec.id }
  283. }
  284. // a new timeline (the editor's, or the seed's with its collection id)
  285. static putTimeline = (name, &franchiseId, order, source, &collection) => {
  286. let rec = { id = newId() name = name slug = freeSlug(timelinesTable, name) franchise = franchiseId order = order source = source
  287. tmdbCollection = collection created = now() }
  288. if (timelinesTable.put(rec) == null) { return null }
  289. for (sid of order) { indexAdd(sid, rec.id) }
  290. return rec
  291. }
  292. static createTimeline = (&name) => {
  293. let n = trimmedOr(name)
  294. if (n == null || n.length > 120) { return { error = 'a timeline needs a name (at most 120 characters)' } }
  295. let rec = putTimeline(n, null, [], 'editor', null)
  296. if (rec == null) { return { error = 'not stored: ' + timelinesTable.lastError() } }
  297. timelinesTable.persist()
  298. console.log('franchises: new timeline "' + n + '" /timelines/' + rec.slug)
  299. return { ok = true slug = rec.slug id = rec.id }
  300. }
  301. // the editor's view of one timeline: every title in its order (adult ones too — the creator sees what is there)
  302. static editRowsOf = (&t) => {
  303. let out = []
  304. if (t == null || t.order == null) { return out }
  305. let i = 0
  306. let last = t.order.length - 1
  307. for (sid of t.order) {
  308. let s = showById(sid)
  309. if (s != null) {
  310. out.push({ id = s.id pos = i + 1 title = titleWithYear(s) posterUrl = posterUrlOf(s) typeLabel = s.type == 'series' ? 'Series' : 'Movie'
  311. upClass = i == 0 ? 'quiet mv off' : 'quiet mv' downClass = i == last ? 'quiet mv off' : 'quiet mv' })
  312. }
  313. i = i + 1
  314. }
  315. return out
  316. }
  317. static chipsOf = (&t) => {
  318. let out = [{ id = '' name = 'None' cls = t == null || t.franchise == null || t.franchise == '' ? 'chip on' : 'chip' }]
  319. let list = []
  320. for (f of franchisesTable.find(null, null)) { list.push({ id = f.id name = f.name cls = t != null && t.franchise == f.id ? 'chip on' : 'chip' sortName = ('' + f.name).toLowerCase() }) }
  321. for (c of byNameOf(list)) { out.push(c) }
  322. return out
  323. }
  324. static editPageOf = (slug) => {
  325. let t = timelineBySlug(slug)
  326. if (t == null) { return { found = false id = '' name = '' slug = '' viewHref = '' rows = [] chips = [] } }
  327. return { found = true id = t.id name = t.name slug = t.slug viewHref = '/timelines/' + t.slug rows = editRowsOf(t) chips = chipsOf(t) }
  328. }
  329. static storeOrder = (t, order) => {
  330. let old = t.order != null ? t.order : []
  331. let rec = t
  332. rec.order = order
  333. if (timelinesTable.update(rec.id, rec) == null) { return { error = 'not stored: ' + timelinesTable.lastError() } }
  334. timelinesTable.persist()
  335. for (sid of old) { if (!order.includes(sid)) { indexRemove(sid, rec.id) } }
  336. for (sid of order) { indexAdd(sid, rec.id) }
  337. return { ok = true rows = editRowsOf(rec) }
  338. }
  339. // tracker#31 (merge.hl): a duplicate title merged into its keeper — in every timeline that lists it, the keeper takes its place
  340. // (once: a timeline that already lists the keeper just drops it) → the number of timelines changed
  341. static titleMerged = (&fromId, &toId) => {
  342. ensureFranchises()
  343. let n = 0
  344. let tids = []
  345. if (byShow[fromId] != null) { for (x of byShow[fromId]) { tids.push(x) } }
  346. for (tid of tids) {
  347. let t = timelinesTable.fetch(tid)
  348. if (t != null && t.order != null) {
  349. let order = []
  350. for (sid of t.order) {
  351. let want = sid == fromId ? toId : sid
  352. if (!order.includes(want)) { order.push(want) }
  353. }
  354. let r = storeOrder(t, order)
  355. if (r.error != null) { console.log('merge: timeline ' + tid + ': ' + r.error) } else { n = n + 1 }
  356. }
  357. }
  358. return n
  359. }
  360. static addToTimeline = (&timelineId, &showId) => {
  361. let t = timelineById(timelineId)
  362. if (t == null) { return { error = 'no such timeline' } }
  363. if (showById(showId) == null) { return { error = 'no such title' } }
  364. let order = []
  365. for (sid of t.order) { order.push(sid) }
  366. if (order.includes(showId)) { return { ok = true rows = editRowsOf(t) } }
  367. order.push(showId)
  368. return storeOrder(t, order)
  369. }
  370. static removeFromTimeline = (&timelineId, &showId) => {
  371. let t = timelineById(timelineId)
  372. if (t == null) { return { error = 'no such timeline' } }
  373. let order = []
  374. for (sid of t.order) { if (sid != showId) { order.push(sid) } }
  375. return storeOrder(t, order)
  376. }
  377. // one place up (delta -1) or down (+1); at an end nothing changes
  378. static moveInTimeline = (&timelineId, &showId, &delta) => {
  379. let t = timelineById(timelineId)
  380. if (t == null) { return { error = 'no such timeline' } }
  381. let at = posOf(t.order, showId)
  382. let to = at + (delta < 0 ? -1 : 1)
  383. if (at < 0 || to < 0 || to >= t.order.length) { return { ok = true rows = editRowsOf(t) } }
  384. let order = []
  385. let i = 0
  386. while (i < t.order.length) {
  387. if (i == at) { order.push(t.order[to]) } else if (i == to) { order.push(t.order[at]) } else { order.push(t.order[i]) }
  388. i = i + 1
  389. }
  390. return storeOrder(t, order)
  391. }
  392. static setTimelineFranchise = (&timelineId, franchiseId) => {
  393. let t = timelineById(timelineId)
  394. if (t == null) { return { error = 'no such timeline' } }
  395. let fid = null
  396. if (franchiseId != null && franchiseId != '') {
  397. if (franchiseById(franchiseId) == null) { return { error = 'no such franchise' } }
  398. fid = franchiseId
  399. }
  400. let rec = t
  401. rec.franchise = fid
  402. if (timelinesTable.update(rec.id, rec) == null) { return { error = 'not stored: ' + timelinesTable.lastError() } }
  403. timelinesTable.persist()
  404. return { ok = true chips = chipsOf(rec) }
  405. }
  406. // the editor's title search: our database (search.hl's index), the titles not in the timeline yet
  407. static editSearch = (&timelineId, text) => {
  408. let t = timelineById(timelineId)
  409. let have = t != null && t.order != null ? t.order : []
  410. let out = []
  411. let r = dbSearch(text != null ? '' + text : '')
  412. for (x of r.titles) {
  413. let s = showById(x.id)
  414. if (s != null && !have.includes(s.id)) { out.push({ id = s.id title = titleWithYear(s) posterUrl = posterUrlOf(s) typeLabel = s.type == 'series' ? 'Series' : 'Movie' }) }
  415. }
  416. return { text = text rows = out note = r.tooShort ? 'Type at least 2 letters.' : (out.length == 0 ? 'Nothing found.' : '') }
  417. }
  418. // the editor's timeline finder: by name, at most 30
  419. static findTimelines = (&text) => {
  420. let q = text != null ? ('' + text).trim().toLowerCase() : ''
  421. let out = []
  422. if (q.length < 2) { return out }
  423. for (t of timelinesTable.find(null, null)) {
  424. if (out.length < 30 && ('' + t.name).toLowerCase().includes(q)) {
  425. let n = t.order != null ? t.order.length : 0
  426. out.push({ id = t.id name = t.name href = '/timelines/' + t.slug + '/edit' countText = n == 1 ? '1 title' : n + ' titles' })
  427. }
  428. }
  429. return out
  430. }

Branches

Latest commits

  • 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
  • 20e09d89Merge t18 (tracker#18 delta sync) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs, both sides kept; pageShowOf summary = summaryOfmre
  • f83571c3tracker#18 (mission 067): daily sync by change lists — TMDB /tv|movie/changes (since the stored day, paged) + TVmaze /updates/shows → only our changed titles (followed: full step, unfollowed: light step — changed seasons, no TVmaze), full walk on first run / gap > 14 days / failed list; show record refreshed (title, tmdbSummary, tagline, status, genres …; renamed titles re-indexed); summary = the creator's own text (page: summary > tmdbSummary > tvmazeSummary), one-time clear of copied summaries (9,647 on the live copy); gate 261, tests/realdata-018*.mjs, README + STATUSmre
  • 1ed5457etracker#20 + #21 (mission 068): typed headings "<Type> | <name>" in type colours; TV titles split into Series (/series) and Shows (/shows) by TMDB type + Reality/Talk/News genres — kind stored by sync/import/adult backfill + new kind backfill (resumes), /movies/<slug>, /shows/<slug> of a series/movie → 301, /my/series + /my/shows, home 5 tiles + 3 rows, search/filmography labels; gates kinds 32 + browser 266, tests/realdata-068.mjs, tools/count-kinds.hl, docs/kinds.md, README + STATUSmre
  • f2b00674Merge t26 (tracker#26 + #28) into main (mission 062): short ids for every new person (castPersonId, guest route), guest stars stored on the title and created as people only when opened (/person/tmdb/<id>?show=<id> → 302), lean watch/follow clicks (showRow a small object, cast/crew from the slug, watches cached per user, face rows only after a season toggle); gate 311, tests/realdata-062.mjs, README + STATUSmre