mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

checkin-view.ts

Language TypeScript22.5 KB557 lines

apps/dashboard/src/checkin-view.ts

  1. /**
  2. * The end-of-day note, rendered: the form one person fills in, and the log its
  3. * readers see.
  4. *
  5. * What this file is careful about is not the same thing the rest of the board
  6. * is careful about. Every other surface turns five live sources into
  7. * numbers, and its hardest job is keeping an absent number from rendering as a
  8. * zero. Here there are no numbers at all. The hardest job is the same rule
  9. * pointed at prose: a day with no note, a day nobody was asked about, a store
  10. * that could not be read, and a store that was never set up are four different
  11. * facts, and the one that matters ("she has not written one since Tuesday")
  12. * is only true if the other three cannot be mistaken for it.
  13. *
  14. * So each has its own words on the page, and none of them is silence.
  15. *
  16. * Runtime-neutral (rule 7). The Worker imports this to stamp the block into a
  17. * pre-rendered page at serve time; the D1 access that feeds it lives in
  18. * apps/worker/src/checkin.ts, which is the only part that needs Worker types.
  19. */
  20. import { MERIDIAN_TZ, calendarDate, localDay, localStamp } from '@meridian/ninety-client'
  21. import {
  22. CHECKIN_FIELD_CAP,
  23. CHECKIN_GRACE_UNTIL_HOUR,
  24. CHECKIN_HISTORY_DAYS,
  25. CHECKIN_OPENS_AT_HOUR,
  26. CHECKIN_QUESTIONS,
  27. CHECKIN_READERS,
  28. CHECKIN_WEEKDAYS_ONLY,
  29. canWriteCheckin,
  30. checkinAuthorFor,
  31. checkinExpectedFrom,
  32. readableCheckinAuthors,
  33. } from '../../../config/checkin.js'
  34. import { esc, join, map } from './html.js'
  35. import type { Viewer } from './viewer.js'
  36. /** One stored note. Mirrors the `checkin` table, camel-cased. */
  37. export interface CheckinNote {
  38. person: string
  39. day: string
  40. did: string
  41. unsure: string
  42. need: string
  43. submittedAt: string
  44. updatedAt: string
  45. revisions: number
  46. authorEmail: string
  47. }
  48. /** One superseded version, from the append-only `checkin_revision` table. */
  49. export interface CheckinRevision {
  50. person: string
  51. day: string
  52. revision: number
  53. did: string
  54. unsure: string
  55. need: string
  56. writtenAt: string
  57. }
  58. /**
  59. * What came back from the store, and the three ways it can fail to be notes.
  60. *
  61. * `unconfigured` and `failed` are different on purpose. The first means nobody
  62. * has created the D1 database or bound it yet: a deploy step, fixable in two
  63. * commands, and nothing has ever been written. The second means the store
  64. * exists and would not answer: rows probably do exist and cannot be shown.
  65. * Collapsing them into one "unavailable" would say the same sentence in the
  66. * one case where the reader's next action differs. Neither may ever be rendered as
  67. * an empty list; that is rule 1 and the prime invariant meeting.
  68. */
  69. export type CheckinStore =
  70. | { state: 'ok'; notes: CheckinNote[]; earlier: CheckinRevision[]; truncated: boolean }
  71. | { state: 'unconfigured' }
  72. | { state: 'failed'; error: string }
  73. /** What the last submission did, echoed back through the redirect. */
  74. export type CheckinOutcome = 'saved' | 'updated' | 'unchanged' | 'empty' | 'stale' | 'failed'
  75. const OUTCOMES: readonly CheckinOutcome[] = [
  76. 'saved',
  77. 'updated',
  78. 'unchanged',
  79. 'empty',
  80. 'stale',
  81. 'failed',
  82. ]
  83. /** Narrow a query-string value, so only our own words reach the page. */
  84. export function parseOutcome(raw: string | null | undefined): CheckinOutcome | null {
  85. const v = (raw ?? '').trim()
  86. return (OUTCOMES as readonly string[]).includes(v) ? (v as CheckinOutcome) : null
  87. }
  88. // --- days ---------------------------------------------------------------------------
  89. /**
  90. * The hour of the day in Meridian time, 0-23.
  91. *
  92. * `hourCycle: 'h23'` rather than `hour12: false`, which renders midnight as 24
  93. * in some ICU builds and would have made the grace window below silently never
  94. * fire on exactly one hour of the day.
  95. */
  96. export function localHour(now: Date, tz: string = MERIDIAN_TZ): number {
  97. const part = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', hourCycle: 'h23' })
  98. .formatToParts(now)
  99. .find((p) => p.type === 'hour')
  100. const n = Number(part?.value ?? NaN)
  101. return Number.isFinite(n) ? n % 24 : 0
  102. }
  103. /** The calendar day before this one. Pure string arithmetic, no timezone. */
  104. export function previousDay(day: string): string {
  105. const [y, m, d] = day.split('-').map(Number)
  106. const t = new Date(Date.UTC(y!, m! - 1, d!) - 86_400_000)
  107. return t.toISOString().slice(0, 10)
  108. }
  109. /**
  110. * Which day a note submitted now is about.
  111. *
  112. * Before 4 AM Meridian time it is about the day that just ended, not the one that
  113. * just started. Somebody finishing at 00:30 would otherwise file two facts at
  114. * once, both wrong: an empty day behind them, which the log reports as missed,
  115. * and a note on a day they have not worked yet.
  116. *
  117. * Every read and every write goes through this one function, so the day the
  118. * form claims and the day the row lands on cannot drift apart.
  119. */
  120. export function checkinDay(now: Date, tz: string = MERIDIAN_TZ): string {
  121. const today = localDay(now, tz)
  122. return localHour(now, tz) < CHECKIN_GRACE_UNTIL_HOUR ? previousDay(today) : today
  123. }
  124. /** Is the form past its "end of day" mark, in Meridian time? */
  125. export function checkinIsOpen(now: Date, tz: string = MERIDIAN_TZ): boolean {
  126. const h = localHour(now, tz)
  127. // The small hours are the tail of yesterday's evening, so the form is open.
  128. return h >= CHECKIN_OPENS_AT_HOUR || h < CHECKIN_GRACE_UNTIL_HOUR
  129. }
  130. /** Monday-Friday. `day` is YYYY-MM-DD, read as a plain calendar date. */
  131. export function isWeekday(day: string): boolean {
  132. const [y, m, d] = day.split('-').map(Number)
  133. const dow = new Date(Date.UTC(y!, m! - 1, d!)).getUTCDay()
  134. return dow >= 1 && dow <= 5
  135. }
  136. /**
  137. * The days the log should account for: newest first, back to whichever came
  138. * later: the window, their start date, or the day the habit began.
  139. *
  140. * The list is of days, not of notes, and that is the whole design. A list built
  141. * from the rows that exist can only show what was written, so the one thing
  142. * this feature is for, noticing that nothing was written, would be invisible
  143. * in it. See rule 9 in docs/ARCHITECTURE.md: collecting a thing and showing it
  144. * are not the same step, and the last one is the one that goes missing.
  145. */
  146. export function expectedDays(
  147. through: string,
  148. from: string | null,
  149. days: number = CHECKIN_HISTORY_DAYS,
  150. weekdaysOnly: boolean = CHECKIN_WEEKDAYS_ONLY,
  151. ): string[] {
  152. if (!from) return []
  153. const out: string[] = []
  154. let day = through
  155. for (let i = 0; i < days && day >= from; i++) {
  156. if (!weekdaysOnly || isWeekday(day)) out.push(day)
  157. day = previousDay(day)
  158. }
  159. return out
  160. }
  161. /**
  162. * The day range one person's log needs, and the row ceiling for it.
  163. *
  164. * Exported so the query and the render cannot disagree. If the store fetched a
  165. * narrower window than the card lists, days with notes would render as "no
  166. * note": a missing row rendered as a missed day, which is the one thing this
  167. * feature must never say. One function, both callers.
  168. */
  169. export function checkinWindow(
  170. person: string,
  171. through: string,
  172. ): { from: string; limit: number } {
  173. const expected = expectedDays(through, checkinExpectedFrom(person))
  174. const from = expected[expected.length - 1] ?? through
  175. // Weekends inside the window can hold notes too, so the ceiling is the span
  176. // in calendar days, not the count of weekdays. A limit sized to the weekday
  177. // count would drop a Saturday note off the oldest end and show that day as
  178. // "no note".
  179. //
  180. // The +1 means `truncated` cannot fire while one-row-per-person-per-day
  181. // holds, and that is the point: if it ever does fire, the unique constraint
  182. // has been lost and the reader is told the list is short rather than being
  183. // handed a quietly clipped one (rule 5).
  184. return { from, limit: CHECKIN_HISTORY_DAYS + 1 }
  185. }
  186. // --- rendering ----------------------------------------------------------------------
  187. /** "left blank", never an empty line: an unanswered question is a fact. */
  188. const answerHtml = (text: string): string =>
  189. text.trim()
  190. ? `<p class="ci-answer">${esc(text)}</p>`
  191. : `<p class="ci-answer ci-blank">left blank</p>`
  192. /** The store's own failure states, in the store's own words. Never an empty list. */
  193. function storeBanner(store: CheckinStore): string {
  194. if (store.state === 'unconfigured') {
  195. return `<p class="ci-banner ci-bad"><b>The note store is not set up.</b>
  196. This board has no D1 binding for <code>CHECKINS</code>, so nothing can be read and nothing
  197. can be saved. That is a deploy step, not a missing note
  198. (see <code>apps/worker/migrations/0001_checkin.sql</code>).</p>`
  199. }
  200. if (store.state === 'failed') {
  201. // What happened, not why (rule 1). This says what the store said and
  202. // stops.
  203. return `<p class="ci-banner ci-bad"><b>The note store could not be read.</b>
  204. ${esc(store.error)}, so this list is missing, not empty. Notes written earlier are still
  205. in the database.</p>`
  206. }
  207. return store.truncated
  208. ? `<p class="ci-banner">Showing the most recent ${CHECKIN_HISTORY_DAYS} days. There are older
  209. notes than these.</p>`
  210. : ''
  211. }
  212. /** The line that confirms what just happened to a submission. */
  213. function outcomeBanner(outcome: CheckinOutcome | null, day: string): string {
  214. if (!outcome) return ''
  215. const label = esc(calendarDate(day))
  216. switch (outcome) {
  217. case 'saved':
  218. return `<p class="ci-banner ci-good">Saved. That is ${label} written up.</p>`
  219. case 'updated':
  220. return `<p class="ci-banner ci-good">Updated. The earlier version is kept.</p>`
  221. case 'unchanged':
  222. return `<p class="ci-banner">Nothing had changed, so nothing was written.</p>`
  223. case 'empty':
  224. return `<p class="ci-banner ci-bad">All three boxes were empty, so nothing was saved:
  225. a blank note reads exactly like no note at all. Even &ldquo;quiet day, nothing needed&rdquo;
  226. is worth more than a blank.</p>`
  227. case 'stale':
  228. return `<p class="ci-banner ci-bad">That page was open from an earlier day, so nothing was
  229. saved rather than filed against the wrong one. Reload and it will be ${label} again.</p>`
  230. case 'failed':
  231. return `<p class="ci-banner ci-bad">The note was <b>not</b> saved; the store refused the
  232. write. Copy what you typed somewhere before you reload.</p>`
  233. }
  234. }
  235. /** One question's box. `value` pre-fills an edit. */
  236. function questionField(
  237. q: (typeof CHECKIN_QUESTIONS)[number],
  238. value: string,
  239. autofocus: boolean,
  240. ): string {
  241. return `<div class="ci-field">
  242. <label class="ci-q" for="ci-${q.id}">${esc(q.prompt)}</label>
  243. <textarea class="ci-input" id="ci-${q.id}" name="${q.id}" rows="3"
  244. maxlength="${CHECKIN_FIELD_CAP}" placeholder="${esc(q.placeholder)}"
  245. ${autofocus ? 'autofocus' : ''}>${esc(value)}</textarea>
  246. </div>`
  247. }
  248. /**
  249. * The author's card: her form, plus what she has already written.
  250. *
  251. * Folded before the hour, never locked. The note is for the end of the day,
  252. * so before 4 PM the card is shut and says so. It still
  253. * opens, and a submission at 2 PM is accepted: a form that refuses text
  254. * somebody has already typed is a form that throws it away, and there is no
  255. * version of this feature where losing what she wrote is the safe outcome.
  256. */
  257. function authorCard(
  258. store: CheckinStore,
  259. day: string,
  260. now: Date,
  261. outcome: CheckinOutcome | null,
  262. ): string {
  263. const today = store.state === 'ok' ? store.notes.find((n) => n.day === day) : undefined
  264. const open = checkinIsOpen(now)
  265. const hour12 = CHECKIN_OPENS_AT_HOUR % 12 || 12
  266. const openLabel = `${hour12}:00 ${CHECKIN_OPENS_AT_HOUR < 12 ? 'AM' : 'PM'}`
  267. const writable = store.state === 'ok'
  268. // A note typed after midnight is about yesterday, and it says so where she can
  269. // see it. Otherwise the only clue is a date she is not looking at.
  270. const backdated = day !== localDay(now)
  271. const mine =
  272. store.state === 'ok' ? store.notes.filter((n) => n.day !== day).slice(0, 5) : []
  273. return `<div class="card ci-card">
  274. <div class="rule"></div>
  275. <div class="ci-head">
  276. <div>
  277. <div class="eyebrow">End of day</div>
  278. <h1 class="ci-title">Your three lines for ${esc(calendarDate(day))}</h1>
  279. </div>
  280. <span class="ci-chip ${today ? 'ci-done' : open ? 'ci-due' : ''}">${
  281. today ? 'written' : open ? 'not written yet' : `opens at ${esc(openLabel)}`
  282. }</span>
  283. </div>
  284. <div class="ci-body">
  285. <p class="ci-intro">What you did, what you are unsure about, what you need. It takes two
  286. minutes and it means nobody has to hover, and a question you write down tonight gets
  287. answered tomorrow instead of in three weeks.
  288. ${
  289. // Who reads it, on the form itself. Somebody answering "what I'm
  290. // unsure about" honestly is entitled to know exactly who sees it,
  291. // before they type rather than after.
  292. `Read by ${esc(CHECKIN_READERS.length === 1 ? 'one person' : `${CHECKIN_READERS.length} people`)}:
  293. ${esc(CHECKIN_READERS.join(', '))}. Nobody else on the board can see this card at all.`
  294. }
  295. </p>
  296. ${outcomeBanner(outcome, day)}
  297. ${storeBanner(store)}
  298. ${
  299. backdated
  300. ? `<p class="ci-banner">It is past midnight, so this is <b>${esc(calendarDate(day))}</b>'s
  301. note: the day that just ended, not the one that just started.</p>`
  302. : ''
  303. }
  304. ${
  305. writable
  306. ? `<details class="ci-form-wrap" ${open || today ? 'open' : ''}>
  307. <summary class="ci-summary">${
  308. today ? 'Edit tonight&rsquo;s note' : open ? 'Write it' : 'Finishing early? Write it now'
  309. }</summary>
  310. <form method="post" action="/checkin" class="ci-form">
  311. <input type="hidden" name="day" value="${esc(day)}">
  312. ${map(CHECKIN_QUESTIONS, (q, i) =>
  313. questionField(q, today ? String(today[q.id]) : '', i === 0 && open && !today),
  314. )}
  315. <div class="ci-bar">
  316. <button type="submit" class="ci-save">${today ? 'Update the note' : 'Save the note'}</button>
  317. <span class="ci-hint">${
  318. today
  319. ? 'Edits are kept. The earlier version stays on the record and is visible to the same two people.'
  320. : 'One box is enough if that is the honest answer. An all-blank note is not saved.'
  321. }</span>
  322. </div>
  323. </form>
  324. </details>`
  325. : // The store could not be read. The form is still rendered: a read
  326. // failure is not a write failure, and hiding the boxes guarantees
  327. // the loss that showing them only risks.
  328. `<details class="ci-form-wrap" open>
  329. <summary class="ci-summary">Write it anyway</summary>
  330. <form method="post" action="/checkin" class="ci-form">
  331. <input type="hidden" name="day" value="${esc(day)}">
  332. ${map(CHECKIN_QUESTIONS, (q) => questionField(q, '', false))}
  333. <div class="ci-bar">
  334. <button type="submit" class="ci-save">Save the note</button>
  335. <span class="ci-hint">The store would not answer a read just now, so this may not
  336. save either. Keep a copy of anything long until you see it land.</span>
  337. </div>
  338. </form>
  339. </details>`
  340. }
  341. ${
  342. mine.length
  343. ? `<div class="ci-mine">
  344. <h3 class="ci-sub">What you wrote before</h3>
  345. ${map(mine, (n) => noteBlock(n, []))}
  346. </div>`
  347. : ''
  348. }
  349. </div>
  350. </div>`
  351. }
  352. /** One written note, with its questions as headings. */
  353. function noteBlock(note: CheckinNote, earlier: CheckinRevision[]): string {
  354. const prior = earlier
  355. .filter((r) => r.day === note.day && r.revision < note.revisions)
  356. .sort((a, b) => b.revision - a.revision)
  357. return `<div class="ci-note">
  358. <div class="ci-note-head">
  359. <span class="ci-day">${esc(calendarDate(note.day))}</span>
  360. <span class="ci-when">${esc(localStamp(note.submittedAt))}</span>
  361. ${
  362. note.revisions > 1
  363. ? `<span class="ci-chip">edited &middot; ${note.revisions} versions</span>`
  364. : ''
  365. }
  366. </div>
  367. ${map(CHECKIN_QUESTIONS, (q) => `<div class="ci-a">
  368. <div class="ci-a-label">${esc(q.label)}</div>
  369. ${answerHtml(String(note[q.id]))}
  370. </div>`)}
  371. ${
  372. prior.length
  373. ? `<details class="ci-prior">
  374. <summary>What it said before</summary>
  375. ${map(prior, (r) => `<div class="ci-note ci-old">
  376. <div class="ci-note-head">
  377. <span class="ci-day">version ${r.revision}</span>
  378. <span class="ci-when">${esc(localStamp(r.writtenAt))}</span>
  379. </div>
  380. ${map(CHECKIN_QUESTIONS, (q) => `<div class="ci-a">
  381. <div class="ci-a-label">${esc(q.label)}</div>
  382. ${answerHtml(String(r[q.id]))}
  383. </div>`)}
  384. </div>`)}
  385. </details>`
  386. : ''
  387. }
  388. </div>`
  389. }
  390. /**
  391. * The readers' card: one row per day, including the days with nothing in them.
  392. *
  393. * The empty rows are the feature. A log of the notes that exist can say what
  394. * she wrote; only a log of the days can say she stopped writing on Tuesday,
  395. * which is what a reader is looking for.
  396. */
  397. function readerCard(person: string, store: CheckinStore, day: string, now: Date): string {
  398. const from = checkinExpectedFrom(person)
  399. const notes = store.state === 'ok' ? store.notes : []
  400. const byDay = new Map(notes.map((n) => [n.day, n]))
  401. const earlier = store.state === 'ok' ? store.earlier : []
  402. // The days a note was expected: weekdays, back to the day the habit began.
  403. const expected = expectedDays(day, from)
  404. const oldest = expected[expected.length - 1] ?? day
  405. // Plus any day inside that window that has a note but was not expected: a
  406. // Saturday she worked. Fetched and then not shown is exactly the bug shape
  407. // rule 9 describes, and a weekend note is the one somebody most wants read.
  408. const extra = notes
  409. .map((n) => n.day)
  410. .filter((d) => d <= day && d >= oldest && !expected.includes(d))
  411. const days = [...new Set([...expected, ...extra])].sort().reverse()
  412. // Today is not a missed day until the day is over. Before the form even
  413. // opens, "no note yet" is the correct and uninteresting answer.
  414. const pending = !byDay.has(day) && !checkinIsOpen(now)
  415. const written = days.filter((d) => byDay.has(d)).length
  416. const missed = expected.filter((d) => !byDay.has(d) && !(d === day && pending)).length
  417. return `<div class="card ci-card">
  418. <div class="rule"></div>
  419. <div class="ci-head">
  420. <div>
  421. <div class="eyebrow">End of day &middot; ${esc(person)}</div>
  422. <h1 class="ci-title">What she did, what she is unsure about, what she needs</h1>
  423. </div>
  424. ${
  425. // The count and the list under it must account for each other (rule 9).
  426. // Every day in these two numbers is a row below.
  427. store.state === 'ok' && days.length
  428. ? `<span class="ci-chip ${missed ? 'ci-due' : 'ci-done'}">${written} written &middot; ${missed} weekday${
  429. missed === 1 ? '' : 's'
  430. } missed</span>`
  431. : ''
  432. }
  433. </div>
  434. <div class="ci-body">
  435. <p class="ci-intro">Her own words, the evening she wrote them. Visible to you and
  436. ${esc(CHECKIN_READERS.length - 1 === 1 ? 'one other person' : `${CHECKIN_READERS.length - 1} other people`)}
  437. and nobody else, including the people who can see her onboarding plan.</p>
  438. ${storeBanner(store)}
  439. ${
  440. store.state !== 'ok'
  441. ? '' // The banner said what happened. A list here would be a claim.
  442. : !from
  443. ? `<p class="ci-empty">${esc(person)} has no onboarding plan, so no note has been
  444. asked for. This is not a run of missed days.</p>`
  445. : !days.length
  446. ? `<p class="ci-empty">Nothing has been asked for yet. The daily note starts
  447. ${esc(calendarDate(from))}.</p>`
  448. : map(days, (d) => {
  449. const note = byDay.get(d)
  450. if (note) return noteBlock(note, earlier)
  451. return `<div class="ci-note ci-none">
  452. <div class="ci-note-head">
  453. <span class="ci-day">${esc(calendarDate(d))}</span>
  454. <span class="ci-when">${
  455. d === day && pending
  456. ? 'not written yet; the day is not over'
  457. : 'no note'
  458. }</span>
  459. </div>
  460. </div>`
  461. })
  462. }
  463. </div>
  464. </div>`
  465. }
  466. /**
  467. * The whole block for one viewer: a form, a log, both, or nothing.
  468. *
  469. * Nothing is the default. A viewer with no grant gets an empty string, and the
  470. * caller writes an empty string into the page: not a hidden div, not a
  471. * disabled card. Anything behind `hidden` is readable from view-source.
  472. */
  473. export function checkinBlock(
  474. viewer: Viewer,
  475. stores: Record<string, CheckinStore>,
  476. now: Date = new Date(),
  477. outcome: CheckinOutcome | null = null,
  478. ): string {
  479. const day = checkinDay(now)
  480. const own = checkinAuthorFor(viewer)
  481. const readable = readableCheckinAuthors(viewer)
  482. if (!readable.length && !own) return ''
  483. const missing: CheckinStore = { state: 'failed', error: 'no answer from the store' }
  484. return join([
  485. own && canWriteCheckin(viewer)
  486. ? authorCard(stores[own] ?? missing, day, now, outcome)
  487. : '',
  488. // A reader's own card is the form above, which already lists what she wrote.
  489. ...readable
  490. .filter((p) => p !== own)
  491. .map((p) => readerCard(p, stores[p] ?? missing, day, now)),
  492. ])
  493. }
  494. /**
  495. * The markers the Worker replaces at serve time.
  496. *
  497. * A placeholder and not a rendered card, because Every page on this board is
  498. * pre-rendered by a cron and read back out of KV, so a card rendered at sync
  499. * time would show a form for a day that had not started and a log missing
  500. * everything written since. The theme stamp, the notes injection and the sync
  501. * line all solve this the same way.
  502. *
  503. * The fallback between them is not empty, deliberately. `pnpm dashboard` writes
  504. * a file with no Worker behind it, and an empty div there is a feature that
  505. * silently is not there (rule 8). The text says which is which.
  506. */
  507. export const CHECKIN_START = '<!--checkin:start-->'
  508. export const CHECKIN_END = '<!--checkin:end-->'
  509. export const checkinPlaceholder = (): string =>
  510. `${CHECKIN_START}<div class="card ci-card"><div class="rule"></div>
  511. <div class="ci-body"><p class="ci-empty"><b>The end-of-day note lives on the live board.</b>
  512. This copy was rendered to a file, which has no way to read the note store or accept a
  513. form. Open the deployed board to write or read one.</p></div>
  514. </div>${CHECKIN_END}`
  515. /**
  516. * Swap the placeholder for the real block. A page without the markers is
  517. * returned untouched, which is what a viewer with no grant gets.
  518. */
  519. export function stampCheckin(html: string, block: string): string {
  520. const from = html.indexOf(CHECKIN_START)
  521. if (from === -1) return html
  522. const to = html.indexOf(CHECKIN_END, from)
  523. if (to === -1) return html
  524. return html.slice(0, from) + block + html.slice(to + CHECKIN_END.length)
  525. }