gitoriaLog in with ident

calendar

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit76edaa6276edaa62calendar: Hybriel master ff51cf46 (re-vendor round, static workaround removed)mre76edaa62/components/month-view/reminders.hl

7.0 KB

  1. // reminders.hl — THE REMINDERS (ticket calendar#4), shared by the server (events.hl checks and stores them) and the
  2. // month view in the browser (the editor rows, the notification times).
  3. // Stored as WorldAPI Event `$reminders` (https://worldapi.org/standards/event): `{ $before: 'PT30M' }` (30 minutes, or 'PT2H'
  4. // hours before the start) and `{ $days_before: 1, $time: '20:00' }` (that many days before, at that wall-clock time).
  5. // The page edits them as ROWS: { unit = 'minutes' | 'hours' | 'days' n = '30' time = '20:00' } (all text).
  6. // Event without `$reminders` = inherits the calendar's; `[]` = none.
  7. import { isText, isTime, pad, prevDay, nextDay } from './dates.hl'
  8. static maxRows = 8
  9. static defaultRows = [
  10. { unit = 'hours' n = '1' time = '09:00' }
  11. { unit = 'days' n = '1' time = '20:00' }
  12. ]
  13. // digits only, no sign, no leading noise; 1..5 digits
  14. static isCount = (s) => {
  15. if (!isText(s) || s.length < 1 || s.length > 5) { return false }
  16. let i = 0
  17. while (i < s.length) {
  18. let c = s.charCodeAt(i)
  19. if (c < 48 || c > 57) { return false }
  20. i = i + 1
  21. }
  22. return true
  23. }
  24. // rows the page sent → { error } or { reminders } (the stored `$reminders` list)
  25. static toReminders = (rows) => {
  26. if (rows == null || hlTypeName(rows) != 'Hybrid') { return { error = 'the reminders must be a list' } }
  27. if (rows.length > maxRows) { return { error = 'at most ' + maxRows + ' reminders' } }
  28. let out = []
  29. let i = 0
  30. while (i < rows.length) {
  31. let r = rows[i]
  32. if (r == null || hlTypeName(r) != 'Hybrid' || !isCount(r.n)) { return { error = 'a reminder needs a whole number' } }
  33. let n = toNumber(r.n)
  34. if (r.unit == 'minutes') {
  35. if (n < 1 || n > 10080) { return { error = 'minutes must be 1 to 10080' } }
  36. let o = {}
  37. o['$before'] = 'PT' + n + 'M'
  38. out.push(o)
  39. } else if (r.unit == 'hours') {
  40. if (n < 1 || n > 168) { return { error = 'hours must be 1 to 168' } }
  41. let o = {}
  42. o['$before'] = 'PT' + n + 'H'
  43. out.push(o)
  44. } else if (r.unit == 'days') {
  45. if (n > 30) { return { error = 'days must be 0 to 30' } }
  46. if (!isTime(r.time)) { return { error = 'a day reminder needs a time' } }
  47. let o = {}
  48. o['$days_before'] = n
  49. o['$time'] = r.time
  50. out.push(o)
  51. } else { return { error = 'a reminder is minutes, hours or days before' } }
  52. i = i + 1
  53. }
  54. return { reminders = out }
  55. }
  56. // the stored `$reminders` → rows for the editor (what is not ours to read is left out)
  57. static toRows = (list) => {
  58. let out = []
  59. if (list == null) { return out }
  60. let i = 0
  61. while (i < list.length) {
  62. let r = list[i]
  63. let b = r['$before']
  64. if (isText(b) && b.length > 3 && b.slice(0, 2) == 'PT') {
  65. let unit = b.slice(b.length - 1) == 'H' ? 'hours' : 'minutes'
  66. let digits = b.slice(2, b.length - 1)
  67. if ((b.slice(b.length - 1) == 'H' || b.slice(b.length - 1) == 'M') && isCount(digits)) { out.push({ unit = unit n = digits time = '09:00' }) }
  68. } else if (r['$days_before'] != null && isTime(r['$time'])) {
  69. out.push({ unit = 'days' n = '' + r['$days_before'] time = r['$time'] })
  70. }
  71. i = i + 1
  72. }
  73. return out
  74. }
  75. // a date moved by n days (n may be negative)
  76. static shift = (date, n) => {
  77. let d = date
  78. let k = n
  79. while (k > 0) { d = nextDay(d) k = k - 1 }
  80. while (k < 0) { d = prevDay(d) k = k + 1 }
  81. return d
  82. }
  83. // rows for a list in the editor: each carries its position `i` and the class that shows the time only for days
  84. static withIndex = (rows) => {
  85. let out = []
  86. let i = 0
  87. while (i < rows.length) {
  88. let r = rows[i]
  89. out.push({ i = i unit = r.unit n = r.n time = r.time cls = r.unit == 'days' ? 'days' : 'other' })
  90. i = i + 1
  91. }
  92. return out
  93. }
  94. static plural = (n, one, many) => { return n + ' ' + (n == 1 ? one : many) }
  95. // one row as words: "1 hour before", "the day before at 20:00", "2 days before at 09:00", "the same day at 08:00"
  96. static describeRow = (r) => {
  97. let n = toNumber(r.n)
  98. if (r.unit == 'minutes') { return plural(n, 'minute', 'minutes') + ' before' }
  99. if (r.unit == 'hours') { return plural(n, 'hour', 'hours') + ' before' }
  100. if (n == 0) { return 'the same day at ' + r.time }
  101. if (n == 1) { return 'the day before at ' + r.time }
  102. return n + ' days before at ' + r.time
  103. }
  104. static describe = (rows) => {
  105. if (rows == null || rows.length == 0) { return 'no reminders' }
  106. let out = ''
  107. let i = 0
  108. while (i < rows.length) {
  109. out = out + (i == 0 ? '' : ', ') + describeRow(rows[i])
  110. i = i + 1
  111. }
  112. return out
  113. }
  114. // ---- browser side: the time a reminder fires (epoch ms) --------------------------------------------------
  115. // The wall clock of a time zone as an offset (ms) from UTC at the instant t.
  116. static offsetOf = (zone, t) => {
  117. let f = Intl.DateTimeFormat('en-US', { timeZone = zone hourCycle = 'h23' year = 'numeric' month = 'numeric' day = 'numeric' hour = 'numeric' minute = 'numeric' second = 'numeric' })
  118. let parts = f.formatToParts(new Date(t))
  119. let v = { year = 0 month = 1 day = 1 hour = 0 minute = 0 second = 0 }
  120. let i = 0
  121. while (i < parts.length) {
  122. let p = parts[i]
  123. if (p.type != 'literal') { v[p.type] = toNumber(p.value) }
  124. i = i + 1
  125. }
  126. return Date.UTC(v.year, v.month - 1, v.day, v.hour, v.minute, v.second) - (t - t % 1000)
  127. }
  128. // 'YYYY-MM-DDTHH:MM:SS' wall clock in `zone` ('' = the device's own zone) → the instant, epoch ms
  129. static instantOf = (wall, zone) => {
  130. let y = toNumber(wall.slice(0, 4))
  131. let mo = toNumber(wall.slice(5, 7))
  132. let d = toNumber(wall.slice(8, 10))
  133. let h = toNumber(wall.slice(11, 13))
  134. let mi = toNumber(wall.slice(14, 16))
  135. if (zone == null || zone == '') { return new Date(y, mo - 1, d, h, mi, 0).getTime() }
  136. let guess = Date.UTC(y, mo - 1, d, h, mi, 0)
  137. let t = guess - offsetOf(zone, guess)
  138. return guess - offsetOf(zone, t)
  139. }
  140. // the words of the notification (title = the event's title)
  141. static bodyOf = (ev, r) => {
  142. let at = ev.allDay ? '' : ' at ' + ev.startTime
  143. let text = ''
  144. if (r.unit == 'minutes') { text = 'Starts in ' + plural(toNumber(r.n), 'minute', 'minutes') }
  145. else if (r.unit == 'hours') { text = 'Starts in ' + plural(toNumber(r.n), 'hour', 'hours') }
  146. else {
  147. let n = toNumber(r.n)
  148. text = n == 0 ? 'Today' + at : (n == 1 ? 'Tomorrow' + at : 'In ' + n + ' days' + at + ', ' + ev.startDate)
  149. }
  150. return ev.place != '' ? text + ' · ' + ev.place : text
  151. }
  152. // the events { id title allDay startDate startTime place zone rows } → the reminders still to come:
  153. // [{ key, at, title, body }] within `horizon` ms of `nowMs`
  154. static plan = (events, nowMs, horizon) => {
  155. let out = []
  156. let i = 0
  157. while (i < events.length) {
  158. let ev = events[i]
  159. let zone = ev.allDay ? '' : ev.zone
  160. let startWall = ev.startDate + 'T' + (ev.allDay ? '00:00' : ev.startTime) + ':00'
  161. let start = instantOf(startWall, zone)
  162. let j = 0
  163. while (j < ev.rows.length) {
  164. let r = ev.rows[j]
  165. let at = 0
  166. if (r.unit == 'minutes') { at = start - toNumber(r.n) * 60000 }
  167. else if (r.unit == 'hours') { at = start - toNumber(r.n) * 3600000 }
  168. else {
  169. let day = ev.startDate
  170. let k = toNumber(r.n)
  171. while (k > 0) { day = prevDay(day) k = k - 1 }
  172. at = instantOf(day + 'T' + r.time + ':00', zone)
  173. }
  174. if (at > nowMs && at < nowMs + horizon) {
  175. out.push({ key = ev.id + ':' + at at = at title = ev.title body = bodyOf(ev, r) })
  176. }
  177. j = j + 1
  178. }
  179. i = i + 1
  180. }
  181. return out
  182. }

Branches

Latest commits

  • 76edaa62calendar: Hybriel master ff51cf46 (re-vendor round, static workaround removed)mre
  • 90a3fc2cdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 6722b72ddeploy.sh: never send .git or .gitignore to Byrodinmre
  • be099807State of 2026-09-27, before the move to gitoriamre