gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit7d4b293d7d4b293dtracker#40: "Franchises" in the main menu (desktop header after People, phone sidebar) → /franchises, marked on franchise and timeline pages; gates 365/0, 32/0, 53/0, 24/0, check-theme 0mre7d4b293d/lib/franchises.hl

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

Branches

Latest commits

  • 7d4b293dtracker#40: "Franchises" in the main menu (desktop header after People, phone sidebar) → /franchises, marked on franchise and timeline pages; gates 365/0, 32/0, 53/0, 24/0, check-theme 0mre
  • 93dfb0batracker#40 (mission 034): the curated franchises — data/franchises.json (17 franchises, 31 timelines, 285 TMDB titles, movies + series, in-universe/release order, 12 TMDB collections attached); lib/franchiseseed.hl + jobs.hl franchiseSeedTick (last start job, imports missing titles via details.hl importWithCredits = the search's Add, one per step paced, then one build; franchiseseed.db: editor changes win, the creator's same-name franchise adopted / timeline left alone, 404 remembered, resumable, idempotent); timeline heads 'N titles · in-universe order' (orderKind) and wrap on a phone; series pages show the widget; new gate tests/franchiseseed.mjs (5th in deploy.sh), the others run with TRACKER_FRANCHISE_SEED=0; gates 365/0, 32/0, 52/0, 24/0, check-theme 0; real copy 196 imported, 0 failed, 7 min, restart unchanged=31mre
  • 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
  • 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