gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit4137be0f4137be0fantcolony#40: mission references point to the moved missionsmre4137be0f/components/ticket.hl

20.7 KB

  1. // components/ticket.hl — `/projects/:projectSlug/:ticketNumber`: one ticket, its
  2. // append-only event history, a comment form and the state change. (Old global URLs
  3. // `/tickets/<n>` are 301-redirected here by project.hl.) Live: an event on THIS ticket (from any browser or
  4. // the API) is appended here and the state badge follows, without a reload.
  5. // Ticket #6 (mission 014): the summary, comments and state notes are MARKDOWN, rendered by the
  6. // child component Markdown (components/markdown.hl) from markdown.hl's blocks — safe: no HTML
  7. // string, links checked. Ticket #8: the ticket's AUTHOR (a ticket from before the login: the
  8. // creator) gets "Edit" — subject + summary; the edit is a history event that keeps the previous
  9. // values ("edited the ticket", the previous summary folded under it), pushed live like a comment.
  10. // Ticket #4 (mission 017): RELATIONS — "Part of <parent>", the children with their states and
  11. // "all children confirmed" / "n of m children confirmed", "Blocked by" and "Blocks" (each with
  12. // states); logged-in users get the forms "Parent" (set / remove) and "Blocked by" (add / remove per
  13. // row) — the store decides who may (the author of either ticket, or the creator). Live: the
  14. // `ticketsRelated` push carries the relation view of every ticket involved in a write.
  15. // Ticket #12 (mission 024): the Markdown fields (edit summary, comment, state note) are
  16. // `<md-editor>`s around their textareas (shared/md-editor.js): edited visually, the textarea keeps
  17. // the plain Markdown and fires `input` as before, so the members and faces are unchanged. The state
  18. // note became a textarea (rows 1) because the editor writes line breaks. Ctrl/Cmd+Enter in the
  19. // editor submits the form (the textarea's `keyed` handler only runs without the editor).
  20. // Ticket #20 (roles): a member of the project may comment; `use` also moves the ticket between open and
  21. // review; `edit` / `admin` edit it, assign it (a member; "Assigned to …" is shown) and set any state; a
  22. // visitor without a role only reads. The server decides in every case; the page only shows what it may.
  23. // A ticket opened through a FORMER slug of its project shows a note with the current address.
  24. parent './main.hl'
  25. import { ticketRowAt, ticketRecord, assignChoices, eventRows, pageEventsOf, pageEventOf, stateChoices, memberRows, addComment, changeState, assignTicket, editTicket, mayEditId, isMember, relationViewOf, relatedViews, setParent, changeBlocker, noRef } from '../store.hl'
  26. import { markdownBlocks } from '../markdown.hl'
  27. import { siteName } from '../project.hl'
  28. import { canWriteSession, writerOfSession, userOfSession } from '../users.hl'
  29. import Markdown from './markdown.hl'
  30. projectSlug = null
  31. ticketNumber = null
  32. row = ticketRowAt(projectSlug, ticketNumber)
  33. present = row != null
  34. gone = row == null
  35. ticketId = row != null ? row.id : ''
  36. ref = row != null ? row.ref : ''
  37. movedNote = row != null && projectSlug != row.project
  38. currentHref = row != null ? row.href : ''
  39. assignee = row != null ? row.assignee : ''
  40. hasAssignee = row != null && row.hasAssignee
  41. createdBy = row != null ? row.createdBy : ''
  42. hasOld = row != null && row.hasOld
  43. oldRef = row != null ? row.oldRef : ''
  44. subject = row != null ? row.subject : 'No such ticket'
  45. summary = row != null ? row.summary : ''
  46. hasSummary = row != null && row.summary != ''
  47. summaryMd = row != null ? markdownBlocks(row.summary) : []
  48. project = row != null ? row.project : ''
  49. projectHref = row != null ? row.projectHref : '/'
  50. state = row != null ? row.state : ''
  51. stateSlug = row != null ? row.stateSlug : ''
  52. created = row != null ? row.created : ''
  53. updated = row != null ? row.updated : ''
  54. events = row != null ? pageEventsOf(eventRows(row.id)) : []
  55. __title = siteName + ' | ' + (row != null ? row.project + ' ' + row.ref + ' ' + row.subject : 'no such ticket')
  56. // the forms: only for a logged-in user with a display name (ticket #7); the author is that
  57. // user (the faces read the session). Confirm / reject: the server refuses a non-creator.
  58. session = null
  59. me = userOfSession(session)
  60. canWrite = row != null && me != null && isMember(row.projectId, me)
  61. mustLogIn = me == null
  62. notMember = me != null && !canWrite
  63. options = row != null ? stateChoices(ticketRecord(row.id), me) : []
  64. hasOptions = options.length > 0
  65. memberOptions = row != null ? assignChoices(row.projectId) : []
  66. commentText = ''
  67. newState = options.length > 0 ? options[0].value : ''
  68. stateNote = ''
  69. message = ''
  70. // editing (ticket #8): only the author (the server decides; the face checks again)
  71. canEdit = row != null && mayEditId(row.id, me)
  72. assignDraft = row != null ? row.assigneeId : ''
  73. assignMessage = ''
  74. editing = false
  75. showEdit = canEdit
  76. showEdit2 = canEdit
  77. editSubject = ''
  78. editSummary = ''
  79. editMessage = ''
  80. // relations (ticket #4): the view of store.hl relationViewOf, flat members for the View
  81. rel = row != null ? relationViewOf(row) : null
  82. hasRelations = rel != null && rel.hasRelations
  83. hasParent = rel != null && rel.hasParent
  84. parentRef = rel != null ? rel.parent : noRef
  85. hasChildren = rel != null && rel.hasChildren
  86. children = rel != null ? rel.children : []
  87. childNote = rel != null ? rel.childNote : ''
  88. childClass = rel != null ? rel.childClass : ''
  89. hasBlockedBy = rel != null && rel.hasBlockedBy
  90. blockedBy = rel != null ? rel.blockedBy : []
  91. hasBlocks = rel != null && rel.hasBlocks
  92. blocks = rel != null ? rel.blocks : []
  93. parentDraft = ''
  94. blockerDraft = ''
  95. linkMessage = ''
  96. View {
  97. ticketView {
  98. if (gone) { p { id = "gone" class = "empty" "There is no such ticket." } }
  99. if (present) {
  100. header {
  101. p { a { id = "project" href = projectHref project } " " ticketRef { id = "ref" ref }
  102. if (hasOld) { formerRef { id = "oldref" "formerly " oldRef } }
  103. }
  104. h1 { id = "subject" subject }
  105. ticketState { id = "state" class = stateSlug state }
  106. p { class = "people" "opened by " strong { id = "createdby" createdBy }
  107. if (hasAssignee) { " · assigned to " strong { id = "assignee" assignee } }
  108. }
  109. if (movedNote) { p { id = "movednote" class = "hint" "This project has a new address: " a { id = "movedlink" href = currentHref currentHref } } }
  110. p { class = "dates" "opened " time { created } " · updated " time { id = "updated" updated } }
  111. if (showEdit) { button { id = "editbutton" type = "button" class = "quiet" "Edit" on click(e) { emit startEdit(e) } } }
  112. }
  113. if (editing) {
  114. form { id = "editform"
  115. on submit(e) {
  116. e.preventDefault()
  117. emit saveEdit(e)
  118. }
  119. label { "Subject"
  120. input { id = "editsubject" name = "subject" required = "required" maxlength = "200" value = editSubject on input(e) { emit setEditSubject(e.target.value) } }
  121. }
  122. label { "Summary"
  123. mdEditor { textarea { id = "editsummary" name = "summary" rows = "8" value = editSummary on input(e) { emit setEditSummary(e.target.value) } } }
  124. }
  125. formButtons {
  126. button { id = "editsave" type = "submit" "Save" }
  127. button { id = "editcancel" type = "button" class = "quiet" "Cancel" on click(e) { emit cancelEdit(e) } }
  128. }
  129. p { id = "editmessage" class = "message" editMessage }
  130. }
  131. }
  132. if (hasSummary) { ticketSummary { id = "summary" Markdown { blocks = summaryMd } } }
  133. if (hasRelations) { ticketRelations { id = "relations"
  134. if (hasParent) { p { id = "parent" "Part of "
  135. a { href = parentRef.href parentRef.project " " parentRef.ref }
  136. " " span { class = "refsubject" parentRef.subject }
  137. " " ticketState { class = parentRef.stateSlug parentRef.state }
  138. } }
  139. if (hasChildren) { div { id = "childrenbox"
  140. h2 { "Children" }
  141. p { id = "childnote" class = childClass childNote }
  142. ul { id = "children" class = "refs"
  143. for (c of children) { li {
  144. ticketState { class = c.stateSlug c.state }
  145. a { href = c.href c.project " " c.ref }
  146. span { class = "refsubject" c.subject }
  147. } }
  148. }
  149. } }
  150. if (hasBlockedBy) { div { id = "blockedbybox"
  151. h2 { "Blocked by" }
  152. ul { id = "blockedby" class = "refs"
  153. for (c of blockedBy) { li {
  154. ticketState { class = c.stateSlug c.state }
  155. a { href = c.href c.project " " c.ref }
  156. span { class = "refsubject" c.subject }
  157. if (showEdit2) { button { type = "button" class = "quiet unblock" value = c.key "Remove" on click(e) { emit removeBlocker(e.target.value) } } }
  158. } }
  159. }
  160. } }
  161. if (hasBlocks) { div { id = "blocksbox"
  162. h2 { "Blocks" }
  163. ul { id = "blocks" class = "refs"
  164. for (c of blocks) { li {
  165. ticketState { class = c.stateSlug c.state }
  166. a { href = c.href c.project " " c.ref }
  167. span { class = "refsubject" c.subject }
  168. } }
  169. }
  170. } }
  171. } }
  172. section {
  173. h2 { "History" }
  174. ol { id = "events"
  175. for (ev of events) {
  176. li { class = ev.kind
  177. eventHead {
  178. strong { ev.author }
  179. " " ev.label
  180. if (ev.isState) { " " ticketState { class = ev.toSlug ev.to } }
  181. " " time { ev.when }
  182. }
  183. if (ev.isEdit) { eventText { class = "edit"
  184. if (ev.subjectChanged) { p { class = "was" "Subject before: " s { ev.oldSubject } } }
  185. if (ev.summaryChanged) { details { class = "was"
  186. summary { "Summary before" }
  187. Markdown { blocks = ev.oldSummaryMd }
  188. } }
  189. } }
  190. if (ev.hasText) { eventText { Markdown { blocks = ev.md } } }
  191. }
  192. }
  193. }
  194. }
  195. if (mustLogIn) {
  196. p { id = "loginhint" class = "hint" "Reading is open to everyone. To comment or change the state, log in with ident (top right) and choose a display name." }
  197. }
  198. if (notMember) {
  199. p { id = "memberhint" class = "hint" "You are not a member of this project — ask an admin to invite you to comment." }
  200. }
  201. if (canWrite) { section {
  202. h2 { "Respond" }
  203. form { id = "commentform"
  204. on submit(e) {
  205. e.preventDefault()
  206. emit sendComment(e)
  207. }
  208. label { "Comment"
  209. mdEditor { textarea { id = "commenttext" name = "comment" rows = "3" required = "required" placeholder = "Write a comment" value = commentText
  210. on input(e) { emit setComment(e.target.value) }
  211. on keydown(e) { emit keyed(e) }
  212. } }
  213. }
  214. button { id = "commentsend" type = "submit" "Comment" }
  215. }
  216. if (hasOptions) { form { id = "stateform"
  217. on submit(e) {
  218. e.preventDefault()
  219. emit sendState(e)
  220. }
  221. label { "State"
  222. select { id = "newstate" name = "state" value = newState
  223. on change(e) { emit setNewState(e.target.value) }
  224. for (o of options) { option { value = o.value o.label } }
  225. }
  226. }
  227. label { "Note (optional)"
  228. mdEditor { textarea { id = "statenote" name = "note" rows = "1" value = stateNote on input(e) { emit setStateNote(e.target.value) } } }
  229. }
  230. button { id = "statesend" type = "submit" "Change state" }
  231. } }
  232. p { id = "message" class = "message" message }
  233. } }
  234. if (showEdit2) { section {
  235. h2 { "Manage" }
  236. form { id = "assignform"
  237. on submit(e) {
  238. e.preventDefault()
  239. emit sendAssign(e)
  240. }
  241. label { "Assigned to"
  242. select { id = "assignselect" name = "assignee" value = assignDraft on change(e) { emit setAssignDraft(e.target.value) }
  243. option { value = "" "nobody" }
  244. for (o of memberOptions) { option { value = o.value o.label } }
  245. }
  246. }
  247. button { id = "assignsave" type = "submit" "Assign" }
  248. }
  249. p { id = "assignmessage" class = "message" assignMessage }
  250. form { id = "parentform"
  251. on submit(e) {
  252. e.preventDefault()
  253. emit sendParent(e)
  254. }
  255. label { "Parent ticket (project#number)"
  256. input { id = "parentkey" name = "parent" placeholder = "e.g. ident.worldapi.org#1" autocomplete = "off" value = parentDraft on input(e) { emit setParentDraft(e.target.value) } }
  257. }
  258. formButtons {
  259. button { id = "parentsave" type = "submit" "Set parent" }
  260. if (hasParent) { button { id = "parentremove" type = "button" class = "quiet" "Remove parent" on click(e) { emit removeParent(e) } } }
  261. }
  262. }
  263. form { id = "blockerform"
  264. on submit(e) {
  265. e.preventDefault()
  266. emit sendBlocker(e)
  267. }
  268. label { "Blocked by (project#number)"
  269. input { id = "blockerkey" name = "blocker" placeholder = "e.g. hybriel#12" autocomplete = "off" value = blockerDraft on input(e) { emit setBlockerDraft(e.target.value) } }
  270. }
  271. button { id = "blockeradd" type = "submit" "Add blocker" }
  272. }
  273. p { id = "linkmessage" class = "message" linkMessage }
  274. } }
  275. }
  276. }
  277. }
  278. on setComment(v) { commentText = v }
  279. on setEditSubject(v) { editSubject = v }
  280. on setEditSummary(v) { editSummary = v }
  281. on startEdit(e) {
  282. editSubject = subject
  283. editSummary = summary
  284. editMessage = ''
  285. editing = true
  286. showEdit = false
  287. }
  288. on cancelEdit(e) {
  289. editing = false
  290. showEdit = canEdit
  291. editMessage = ''
  292. }
  293. on saveEdit(e) {
  294. let r = emit server ticketEdit(ticketId, editSubject, editSummary)
  295. if (r != null && r.error != null) {
  296. editMessage = r.error
  297. return null
  298. }
  299. editing = false
  300. showEdit = canEdit
  301. editMessage = ''
  302. }
  303. on setNewState(v) { newState = v }
  304. on setAssignDraft(v) { assignDraft = v }
  305. on sendAssign(e) {
  306. let r = emit server ticketAssign(ticketId, assignDraft)
  307. if (r != null && r.error != null) { assignMessage = r.error } else { assignMessage = '' }
  308. }
  309. on setParentDraft(v) { parentDraft = v }
  310. on setBlockerDraft(v) { blockerDraft = v }
  311. // relations (ticket #4): the answer is only ok / error — the page follows the push
  312. on sendParent(e) {
  313. if (parentDraft.trim() == '') {
  314. linkMessage = 'name the parent as project#number'
  315. return null
  316. }
  317. let r = emit server ticketSetParent(ticketId, parentDraft)
  318. if (r != null && r.error != null) { linkMessage = r.error } else { linkMessage = '' parentDraft = '' }
  319. }
  320. on removeParent(e) {
  321. let r = emit server ticketSetParent(ticketId, '')
  322. if (r != null && r.error != null) { linkMessage = r.error } else { linkMessage = '' }
  323. }
  324. on sendBlocker(e) {
  325. if (blockerDraft.trim() == '') {
  326. linkMessage = 'name the blocking ticket as project#number'
  327. return null
  328. }
  329. let r = emit server ticketAddBlocker(ticketId, blockerDraft)
  330. if (r != null && r.error != null) { linkMessage = r.error } else { linkMessage = '' blockerDraft = '' }
  331. }
  332. on removeBlocker(key) {
  333. let r = emit server ticketRemoveBlocker(ticketId, key)
  334. if (r != null && r.error != null) { linkMessage = r.error } else { linkMessage = '' }
  335. }
  336. on setStateNote(v) { stateNote = v }
  337. on keyed(e) {
  338. if (e.key == 'Enter' && (e.ctrlKey || e.metaKey)) {
  339. e.preventDefault()
  340. e.target.form.requestSubmit()
  341. }
  342. }
  343. on sendComment(e) {
  344. if (commentText.trim() == '') { return null }
  345. let r = emit server commentOn(ticketId, commentText)
  346. if (r != null && r.error != null) { message = r.error } else { message = '' commentText = '' }
  347. }
  348. on sendState(e) {
  349. let r = emit server setTicketState(ticketId, newState, stateNote)
  350. if (r != null && r.error != null) { message = r.error } else { message = '' stateNote = '' }
  351. }
  352. // ---- what the server pushes: an event on some ticket -----------------------------------
  353. on client ticketEvent(evTicket, ev, changed, count) {
  354. if ('' + evTicket == '' + ticketId) {
  355. let next = []
  356. for (x of events) { next.push(x) }
  357. next.push(ev)
  358. events = next
  359. state = changed.state
  360. stateSlug = changed.stateSlug
  361. updated = changed.updated
  362. assignee = changed.assignee
  363. hasAssignee = changed.hasAssignee
  364. assignDraft = changed.assigneeId
  365. let rights = emit server ticketRights(ticketId)
  366. takeRights(rights)
  367. if (ev.isEdit == true) {
  368. subject = changed.subject
  369. summary = changed.summary
  370. hasSummary = changed.summary != ''
  371. summaryMd = ev.summaryMd
  372. }
  373. }
  374. }
  375. // relations changed (a write on this ticket or on a related one): take over this ticket's view
  376. on client ticketsRelated(views) {
  377. for (v of views) {
  378. if ('' + v.id == '' + ticketId) {
  379. hasRelations = v.hasRelations
  380. hasParent = v.hasParent
  381. parentRef = v.parent
  382. hasChildren = v.hasChildren
  383. children = v.children
  384. childNote = v.childNote
  385. childClass = v.childClass
  386. hasBlockedBy = v.hasBlockedBy
  387. blockedBy = v.blockedBy
  388. hasBlocks = v.hasBlocks
  389. blocks = v.blocks
  390. }
  391. }
  392. }
  393. // login / logout in the shell (this session's tabs): the forms come and go without a reload
  394. // what this session's user may do on this ticket now (the face answers from their role)
  395. takeRights = (r) => {
  396. if (r == null || r.error != null) { return null }
  397. canWrite = r.comment == true
  398. mustLogIn = false
  399. notMember = !canWrite
  400. canEdit = r.edit == true
  401. showEdit = canEdit && !editing
  402. showEdit2 = canEdit
  403. options = r.options
  404. hasOptions = r.options.length > 0
  405. memberOptions = r.members
  406. newState = r.options.length > 0 ? r.options[0].value : ''
  407. }
  408. on client signedIn(tag, info) {
  409. let r = emit server ticketRights(ticketId)
  410. takeRights(r)
  411. }
  412. on client signedOut(tag) {
  413. canWrite = false
  414. mustLogIn = true
  415. notMember = false
  416. canEdit = false
  417. showEdit = false
  418. showEdit2 = false
  419. hasOptions = false
  420. editing = false
  421. }
  422. // ---- the faces: the writer is the session's user (#31: a forged trailing argument is not
  423. // a real session → refused) ---------------------------------------------------------------
  424. on server commentOn(ticketId, text, session) {
  425. let w = writerOfSession(session)
  426. if (w.error != null) { return { error = w.error } }
  427. let r = addComment('' + ticketId, w.user, text)
  428. if (r.error != null) { return { error = r.error } }
  429. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  430. return { ok = true }
  431. }
  432. on server setTicketState(ticketId, state, note, session) {
  433. let w = writerOfSession(session)
  434. if (w.error != null) { return { error = w.error } }
  435. let r = changeState('' + ticketId, state, w.user, note)
  436. if (r.error != null) { return { error = r.error } }
  437. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  438. if (r.assignEvent != null) { emit client ticketEvent(r.ticket.id, pageEventOf(r.assignEvent), r.ticket) }
  439. emit client ticketsRelated(relatedViews(r.ticket.id, []))
  440. return { ok = true }
  441. }
  442. // ticket #20: assign the ticket to a member ('' = nobody); the store wants the role edit or admin
  443. on server ticketAssign(ticketId, userId, session) {
  444. let w = writerOfSession(session)
  445. if (w.error != null) { return { error = w.error } }
  446. if (hlTypeName(userId) != 'String') { return { error = 'the assignee must be text' } }
  447. let r = assignTicket('' + ticketId, w.user, userId)
  448. if (r.error != null) { return { error = r.error } }
  449. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  450. return { ok = true }
  451. }
  452. // ticket #8: the author edits subject + summary (the store refuses anyone else)
  453. on server ticketEdit(ticketId, subject, summary, session) {
  454. let w = writerOfSession(session)
  455. if (w.error != null) { return { error = w.error } }
  456. if (hlTypeName(subject) != 'String' || hlTypeName(summary) != 'String') { return { error = 'subject and summary must be text' } }
  457. let r = editTicket('' + ticketId, w.user, subject, summary)
  458. if (r.error != null) { return { error = r.error } }
  459. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  460. emit client ticketsRelated(relatedViews(r.ticket.id, []))
  461. return { ok = true }
  462. }
  463. // ticket #4: relations — the store refuses a user who may not (author of either ticket / creator)
  464. on server ticketSetParent(ticketId, key, session) {
  465. let w = writerOfSession(session)
  466. if (w.error != null) { return { error = w.error } }
  467. if (hlTypeName(key) != 'String') { return { error = 'the parent must be text' } }
  468. let r = setParent('' + ticketId, w.user, key)
  469. if (r.error != null) { return { error = r.error } }
  470. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  471. emit client ticketsRelated(r.related)
  472. return { ok = true }
  473. }
  474. on server ticketAddBlocker(ticketId, key, session) {
  475. let w = writerOfSession(session)
  476. if (w.error != null) { return { error = w.error } }
  477. if (hlTypeName(key) != 'String') { return { error = 'the blocker must be text' } }
  478. let r = changeBlocker('' + ticketId, w.user, key, true, 'add')
  479. if (r.error != null) { return { error = r.error } }
  480. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  481. emit client ticketsRelated(r.related)
  482. return { ok = true }
  483. }
  484. on server ticketRemoveBlocker(ticketId, key, session) {
  485. let w = writerOfSession(session)
  486. if (w.error != null) { return { error = w.error } }
  487. if (hlTypeName(key) != 'String') { return { error = 'the blocker must be text' } }
  488. let r = changeBlocker('' + ticketId, w.user, key, false, 'remove')
  489. if (r.error != null) { return { error = r.error } }
  490. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  491. emit client ticketsRelated(r.related)
  492. return { ok = true }
  493. }
  494. // what the session's user may do on this ticket: comment (a member), edit / assign (edit, admin), the states
  495. // they may move it to, and the members it can be assigned to
  496. on server ticketRights(ticketId, session) {
  497. let u = userOfSession(session)
  498. if (u == null) { return { error = 'log in first' } }
  499. let t = ticketRecord('' + ticketId)
  500. if (t == null) { return { error = 'no such ticket' } }
  501. return { comment = isMember(t.project, u) edit = mayEditId(t.id, u) options = stateChoices(t, u) members = assignChoices(t.project) }
  502. }

Branches

Latest commits

  • 4137be0fantcolony#40: mission references point to the moved missionsmre
  • 9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • c7bd2645tickets: Hybriel master 73267707 (#122 fixed); compactNow workaround removed (#110 covered)mre
  • 2ab91ee9tickets: gate checks rows appear once (session sync); re-vendor to ff51cf46 stopped on hybriel#122, stays 837fe120mre
  • e01c2b1dtickets#24: installable app (manifest, service worker, offline list), own icon; gate waits for the hello's pongmre
  • 752fbb7fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 38bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre
  • f12fa1bcState of 2026-09-27, before the move to gitoriamre