gitoriaLog in with ident

calendar

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Main branchmain2881119fcalendar mission 001 (4/4): code order — README layout + same-output test, STATUS, LOG, report; tests/letcount.py (sub-folders), tests/realdata-baseline.mjs, tests/realdata-compare.pymremain/components/month-view/month-view.hl

26.5 KB

  1. // month-view.hl — THE MONTH VIEW: prev / next buttons on top, the month's name and the year between them, the
  2. // days below (weeks start on Monday; the days of the neighbouring months are dimmed; today is marked).
  3. // THE CALENDAR'S COPY of the pure-Hybriel month view of components.hybriel.worldapi.org (no .js), plus the user's
  4. // events on the days and the editor for them (add: click a day; edit / delete: click an event). Events come from
  5. // events.hl, one server round trip per change (faces calLoad / calSave / calDelete in calendar.hl). The date
  6. // arithmetic is written here (plain integers), so it runs the same on the server (first render) and in the browser.
  7. // What only the browser knows — today in the user's time zone, the month and weekday names in the user's language,
  8. // the time zone's name (`Date` and `Intl` are browser globals of View handlers) — is read the moment the pointer, a
  9. // finger or the keyboard reaches the month view; until then the server's UTC date and language-neutral names (month number, dots) show — the server never knows
  10. // the language, so it never guesses one: names appear once, in the browser's language, and stay.
  11. // The page reads `year` and `month` (1–12) as its own members. USE ONE PER PAGE: a composed component's
  12. // members are the page's state (COMPONENTS.md §12).
  13. import { serverToday } from './clock.hl'
  14. import { pad, daysIn, before } from './dates.hl'
  15. import { namesFor } from './names.hl'
  16. import { userIdOfSession } from '../../lib/users.hl'
  17. import { eventsBetween, upcoming } from '../../lib/events.hl'
  18. import { defaultsOf } from '../../lib/settings.hl'
  19. import { notify } from 'hl:web'
  20. import { plan, shift, describe, withIndex, defaultRows } from './reminders.hl'
  21. // the request's Accept-Language, handed in by the page (calendar.hl reads it from `headers`, hybriel #105): the first
  22. // render already writes the names
  23. acceptLanguage = null
  24. Hybrid known = namesFor(acceptLanguage)
  25. Boolean namesKnown = known != null
  26. String today = serverToday()
  27. Number year = toNumber(today.slice(0, 4))
  28. Number month = toNumber(today.slice(5, 7))
  29. Hybrid monthNames = namesKnown ? known.months : ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']
  30. Hybrid weekdayNames = namesKnown ? known.days : ['·', '·', '·', '·', '·', '·', '·']
  31. String title = monthNames[month - 1] + ' ' + year
  32. Hybrid base = build(year, month, today)
  33. Hybrid weekdays = weekdayNames
  34. session = null
  35. userId = userIdOfSession(session)
  36. Boolean signedIn = userId != null
  37. String winFirst = base[0].date
  38. String winLast = base[41].date
  39. Hybrid events = signedIn ? eventsBetween(userId, winFirst, winLast) : []
  40. Hybrid days = fill(base, events)
  41. // the reminders (ticket calendar#4): the events of the next days with the reminders that apply (`soon`, kept with the page
  42. // state, so an offline page still knows them), the calendar's defaults in words, the timers the browser holds
  43. Hybrid soon = signedIn ? upcoming(userId, shift(today, -2), shift(today, 9)) : []
  44. String defaultText = signedIn ? describe(defaultsOf(userId)) : ''
  45. Hybrid defRows = signedIn ? defaultsOf(userId) : []
  46. Boolean fCustom = false
  47. Hybrid remRows = []
  48. Hybrid setRows = []
  49. Hybrid timers = []
  50. Number lastArm = 0
  51. String notifyState = 'unknown'
  52. String setClass = 'closed'
  53. String setError = ''
  54. String barClass = signedIn ? 'bar' : 'hidden'
  55. String askClass = 'hidden'
  56. String blockedClass = 'hidden'
  57. String plannedText = ''
  58. String fId = ''
  59. String fTitle = ''
  60. Boolean fAllDay = false
  61. String fStartDate = ''
  62. String fStartTime = '09:00'
  63. String fEndDate = ''
  64. String fEndTime = '10:00'
  65. String fPlace = ''
  66. String fNotes = ''
  67. String fZone = 'UTC'
  68. String formClass = 'closed'
  69. String formError = ''
  70. String formHeading = 'New event'
  71. Boolean isNew = true
  72. String formKind = 'timed'
  73. String ownClass = 'quiet'
  74. String calClass = ''
  75. String defaultClass = ''
  76. String rowsClass = 'hidden'
  77. String addClass = 'quiet hidden'
  78. String timedButton = ''
  79. String alldayButton = 'quiet'
  80. String deleteClass = 'danger hidden'
  81. String prevLabel = namesKnown ? known.prev : '‹'
  82. String nextLabel = namesKnown ? known.next : '›'
  83. Number seen = 0
  84. Number paged = 0
  85. // 0 = Monday … 6 = Sunday (Sakamoto's method gives 0 = Sunday)
  86. weekdayOf(y, m, d) {
  87. t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]
  88. yy = m < 3 ? y - 1 : y
  89. sun = (yy + (yy - yy % 4) / 4 - (yy - yy % 100) / 100 + (yy - yy % 400) / 400 + t[m - 1] + d) % 7
  90. return (sun + 6) % 7
  91. }
  92. // 42 days (six weeks of seven), so the view keeps its height from month to month
  93. build(y, m, todayText) {
  94. py = m == 1 ? y - 1 : y
  95. pm = m == 1 ? 12 : m - 1
  96. ny = m == 12 ? y + 1 : y
  97. nm = m == 12 ? 1 : m + 1
  98. lead = weekdayOf(y, m, 1)
  99. let n = daysIn(y, m)
  100. pn = daysIn(py, pm)
  101. out = []
  102. let day = 1 - lead
  103. let w = 0
  104. while (w < 6) {
  105. let i = 0
  106. while (i < 7) {
  107. let cy = y
  108. let cm = m
  109. let cd = day
  110. let cls = ''
  111. if (day < 1) { cy = py cm = pm cd = pn + day cls = 'outside' }
  112. if (day > n) { cy = ny cm = nm cd = day - n cls = 'outside' }
  113. let date = cy + '-' + pad(cm) + '-' + pad(cd)
  114. if (date == todayText) { cls = 'today' }
  115. out.push({ n = cd date = date cls = cls })
  116. day = day + 1
  117. i = i + 1
  118. }
  119. w = w + 1
  120. }
  121. return out
  122. }
  123. // the events that touch a day, as four chips on the day cell (a nested `for` does not repaint when the outer list is
  124. // reassigned: hybriel #86 — so the chips are fixed members of the day: t = text, c = class, v = event id).
  125. // Timed events start with their time on the first day. More than four: the fourth chip says "+N more".
  126. fill(list, evs) {
  127. out = []
  128. let i = 0
  129. while (i < list.length) {
  130. let d = list[i]
  131. let hit = []
  132. let j = 0
  133. while (j < evs.length) {
  134. let e = evs[j]
  135. if (!before(d.date, e.first) && !before(e.last, d.date)) { hit.push(e) }
  136. j = j + 1
  137. }
  138. let row = { n = d.n date = d.date cls = d.cls }
  139. let k = 0
  140. while (k < 4) {
  141. let text = ''
  142. let cls = 'unused'
  143. let id = ''
  144. if (k < hit.length && (hit.length <= 4 || k < 3)) {
  145. let e = hit[k]
  146. text = (e.allDay || d.date != e.first ? '' : e.startTime + ' ') + e.title
  147. cls = 'event'
  148. id = e.id
  149. }
  150. if (k == 3 && hit.length > 4) { text = '+' + (hit.length - 3) + ' more' cls = 'more' }
  151. row['t' + k] = text
  152. row['c' + k] = cls
  153. row['v' + k] = id
  154. k = k + 1
  155. }
  156. out.push(row)
  157. i = i + 1
  158. }
  159. return out
  160. }
  161. // the form's fields for a new event on `date` (all text; allDay a Boolean); zone = the browser's time zone name
  162. blank(date, zone) {
  163. return { id = '' title = '' allDay = false startDate = date startTime = '09:00' endDate = date endTime = '10:00' place = '' notes = '' zone = zone custom = false rows = [] }
  164. }
  165. show(y, m, evs) {
  166. year = y
  167. month = m
  168. title = monthNames[m - 1] + ' ' + y
  169. base = build(y, m, today)
  170. winFirst = base[0].date
  171. winLast = base[41].date
  172. events = evs
  173. days = fill(base, evs)
  174. }
  175. // Colours: the semantic WorldAPI tokens as css variables, each with its palette value as fallback (a component's browser
  176. // module cannot import tokens.hl itself); the app sets --color-accent in its styles.hl.
  177. Style {
  178. 'month-view' { display = 'grid' gap = '.5rem' }
  179. 'month-view header' { display = 'flex' alignItems = 'center' justifyContent = 'space-between' gap = '.5rem' }
  180. 'month-view h2' { margin = '0' fontSize = '1.1rem' color = 'var(--color-text-strong, rgb(245, 250, 255))' textAlign = 'center' flex = '1' textTransform = 'capitalize' }
  181. 'month-view ol' { display = 'grid' gridTemplateColumns = 'repeat(7, minmax(0, 1fr))' margin = '0' padding = '0' listStyle = 'none' }
  182. 'month-view header button' { font = 'inherit' fontWeight = '600' padding = '.4rem .9rem' color = 'var(--color-text, rgb(195, 200, 205))' background = 'transparent' border = '1px solid var(--color-border, rgb(70, 75, 80))' borderRadius = '.4rem' cursor = 'pointer' }
  183. 'month-view header button:hover' { background = 'var(--color-surface, rgb(35, 40, 45))' }
  184. '#month-weekdays li' { padding = '.3rem 0' fontSize = '.8rem' fontWeight = '600' color = 'var(--color-text-muted, rgb(145, 150, 155))' textAlign = 'center' }
  185. '#month-days li' { minHeight = '3rem' padding = '.3rem' border = '1px solid var(--color-border, rgb(70, 75, 80))' textAlign = 'right' background = 'var(--color-surface, rgb(35, 40, 45))' cursor = 'pointer' overflow = 'hidden' }
  186. '#month-days time' { color = 'var(--color-text, rgb(195, 200, 205))' fontSize = '.95rem' }
  187. '#month-days li.outside' { background = 'var(--color-background, rgb(25, 30, 35))' }
  188. '#month-days li.outside time' { color = 'var(--color-text-muted, rgb(145, 150, 155))' }
  189. '#month-days li.today' { outline = '2px solid var(--color-accent, #569bd4)' outlineOffset = '-2px' }
  190. '#month-days li.today time' { color = 'var(--color-accent, #569bd4)' fontWeight = '800' }
  191. 'month-view bar' { display = 'flex' flexWrap = 'wrap' alignItems = 'center' gap = '.5rem' }
  192. 'month-view bar.hidden' { display = 'none' }
  193. 'month-view bar p' { margin = '0' fontSize = '.85rem' color = 'var(--color-text-muted, rgb(145, 150, 155))' }
  194. 'month-view .hidden' { display = 'none' }
  195. 'reminder-box' { display = 'grid' gap = '.4rem' }
  196. 'reminder-box h3' { margin = '0' fontSize = '.85rem' fontWeight = '600' color = 'var(--color-text-muted, rgb(145, 150, 155))' }
  197. 'reminder-box p' { margin = '0' fontSize = '.85rem' color = 'var(--color-text-muted, rgb(145, 150, 155))' }
  198. 'reminder-box ol' { display = 'grid' gap = '.4rem' }
  199. 'reminder-row' { display = 'flex' flexWrap = 'wrap' alignItems = 'center' gap = '.4rem' }
  200. 'reminder-row input[type=number]' { width = '5rem' }
  201. 'reminder-row select' { font = 'inherit' padding = '.4rem' color = 'var(--color-text-strong, rgb(245, 250, 255))' background = 'var(--color-background-sunken, rgb(20, 25, 30))' border = '1px solid var(--color-border, rgb(70, 75, 80))' borderRadius = '.4rem' }
  202. 'reminder-row.other input[type=time]' { display = 'none' }
  203. '@media (min-width: 40rem)' {
  204. '#month-days li' { minHeight = '5rem' }
  205. }
  206. }
  207. View {
  208. monthView { on pointerover(e) { emit monthEnv(e) } on focusin(e) { emit monthEnv(e) } on touchstart(e) { emit monthEnv(e) }
  209. header {
  210. button { id = "month-prev" type = "button" class = "quiet" title = prevLabel "‹" on click(e) { emit monthPrev(e) } }
  211. h2 { id = "month-title" title }
  212. button { id = "month-next" type = "button" class = "quiet" title = nextLabel "›" on click(e) { emit monthNext(e) } }
  213. }
  214. bar { id = "reminder-bar" class = barClass
  215. button { id = "notify-ask" type = "button" class = askClass "Turn on notifications" on click(e) { emit askNotify(e) } }
  216. p { id = "notify-blocked" class = blockedClass "Notifications are blocked for this site in the browser settings." }
  217. p { id = "notify-planned" plannedText }
  218. button { id = "settings-open" type = "button" class = "quiet" "Reminder settings" on click(e) { emit openSettings(e) } }
  219. }
  220. ol { id = "month-weekdays" for (name of weekdays) { li { name } } }
  221. ol { id = "month-days" on click(e) { emit dayClick(e) }
  222. for (d of days) {
  223. li { class = d.cls
  224. time { datetime = d.date d.n }
  225. dayEvents {
  226. button { class = d.c0 type = "button" value = d.v0 d.t0 }
  227. button { class = d.c1 type = "button" value = d.v1 d.t1 }
  228. button { class = d.c2 type = "button" value = d.v2 d.t2 }
  229. button { class = d.c3 type = "button" value = d.v3 d.t3 }
  230. }
  231. }
  232. }
  233. }
  234. eventEditor { id = "eventeditor" class = formClass
  235. form { id = "eventform" class = formKind
  236. on submit(e) {
  237. e.preventDefault()
  238. emit saveEvent(e)
  239. }
  240. h2 { id = "formheading" formHeading }
  241. kindSwitch {
  242. button { id = "kind-timed" type = "button" class = timedButton "At a time" on click(e) { emit setKind(false) } }
  243. button { id = "kind-allday" type = "button" class = alldayButton "All day" on click(e) { emit setKind(true) } }
  244. }
  245. label { "Title"
  246. input { id = "f-title" name = "title" maxlength = "200" autocomplete = "off" required = "required" value = fTitle on input(e) { emit setF('title', e.target.value) } }
  247. }
  248. fieldRow {
  249. label { "From"
  250. input { id = "f-startdate" name = "startDate" type = "date" required = "required" value = fStartDate on input(e) { emit setF('startDate', e.target.value) } }
  251. }
  252. label { class = "timed" "at"
  253. input { id = "f-starttime" name = "startTime" type = "time" value = fStartTime on input(e) { emit setF('startTime', e.target.value) } }
  254. }
  255. }
  256. fieldRow {
  257. label { "To"
  258. input { id = "f-enddate" name = "endDate" type = "date" value = fEndDate on input(e) { emit setF('endDate', e.target.value) } }
  259. }
  260. label { class = "timed" "at"
  261. input { id = "f-endtime" name = "endTime" type = "time" value = fEndTime on input(e) { emit setF('endTime', e.target.value) } }
  262. }
  263. }
  264. label { "Place"
  265. input { id = "f-place" name = "place" maxlength = "200" autocomplete = "off" value = fPlace on input(e) { emit setF('place', e.target.value) } }
  266. }
  267. label { "Notes"
  268. textarea { id = "f-notes" name = "notes" rows = "4" maxlength = "5000" value = fNotes on input(e) { emit setF('notes', e.target.value) } }
  269. }
  270. reminderBox {
  271. h3 { "Reminders" }
  272. kindSwitch {
  273. button { id = "rem-calendar" type = "button" class = calClass "Calendar's" on click(e) { emit setCustom(false) } }
  274. button { id = "rem-own" type = "button" class = ownClass "Own" on click(e) { emit setCustom(true) } }
  275. }
  276. p { id = "rem-default-text" class = defaultClass defaultText }
  277. ol { id = "rem-rows" class = rowsClass
  278. for (r of remRows) {
  279. reminderRow { class = r.cls
  280. input { type = "number" min = "0" value = r.n on change(e) { emit setRem('rem', r.i, 'n', e.target.value) } }
  281. select { value = r.unit on change(e) { emit setRem('rem', r.i, 'unit', e.target.value) }
  282. option { value = "minutes" "minutes before" }
  283. option { value = "hours" "hours before" }
  284. option { value = "days" "days before, at" }
  285. }
  286. input { type = "time" value = r.time on change(e) { emit setRem('rem', r.i, 'time', e.target.value) } }
  287. button { type = "button" class = "quiet" "×" on click(e) { emit delRem('rem', r.i) } }
  288. }
  289. }
  290. }
  291. button { id = "rem-add" type = "button" class = addClass "Add reminder" on click(e) { emit addRem('rem') } }
  292. }
  293. p { id = "formerror" class = "message" formError }
  294. formButtons {
  295. button { id = "event-save" type = "submit" "Save" }
  296. button { id = "event-cancel" type = "button" class = "quiet" "Cancel" on click(e) { emit closeForm(e) } }
  297. button { id = "event-delete" type = "button" class = deleteClass "Delete" on click(e) { emit deleteEvent(e) } }
  298. }
  299. }
  300. }
  301. eventEditor { id = "settingseditor" class = setClass
  302. form { id = "settingsform" class = "timed"
  303. on submit(e) {
  304. e.preventDefault()
  305. emit saveSettings(e)
  306. }
  307. h2 { "Reminder settings" }
  308. p { class = "muted" "Every event without reminders of its own is reminded like this:" }
  309. ol { id = "set-rows"
  310. for (r of setRows) {
  311. reminderRow { class = r.cls
  312. input { type = "number" min = "0" value = r.n on change(e) { emit setRem('set', r.i, 'n', e.target.value) } }
  313. select { value = r.unit on change(e) { emit setRem('set', r.i, 'unit', e.target.value) }
  314. option { value = "minutes" "minutes before" }
  315. option { value = "hours" "hours before" }
  316. option { value = "days" "days before, at" }
  317. }
  318. input { type = "time" value = r.time on change(e) { emit setRem('set', r.i, 'time', e.target.value) } }
  319. button { type = "button" class = "quiet" "×" on click(e) { emit delRem('set', r.i) } }
  320. }
  321. }
  322. }
  323. button { id = "set-add" type = "button" class = "quiet" "Add reminder" on click(e) { emit addRem('set') } }
  324. p { id = "settingserror" class = "message" setError }
  325. formButtons {
  326. button { id = "set-save" type = "submit" "Save" }
  327. button { id = "set-cancel" type = "button" class = "quiet" "Cancel" on click(e) { emit closeSettings(e) } }
  328. }
  329. }
  330. }
  331. input { id = "month-view-goto" type = "hidden" on change(e) { emit monthGoto(e.target.value) } }
  332. }
  333. }
  334. // the events of the six weeks shown, from the server (none while signed out)
  335. loadEvents(y, m) {
  336. if (!signedIn) { return [] }
  337. b = build(y, m, today)
  338. r = emit server calLoad(b[0].date, b[41].date)
  339. if (r != null && r.soon != null) { takeSoon(r) }
  340. return r == null || r.events == null ? [] : r.events
  341. }
  342. on monthPrev(e) {
  343. browserEnv()
  344. paged = 1
  345. y = month == 1 ? year - 1 : year
  346. m = month == 1 ? 12 : month - 1
  347. show(y, m, loadEvents(y, m))
  348. }
  349. on monthNext(e) {
  350. browserEnv()
  351. paged = 1
  352. y = month == 12 ? year + 1 : year
  353. m = month == 12 ? 1 : month + 1
  354. show(y, m, loadEvents(y, m))
  355. }
  356. // the browser's today, language and time zone, once: today follows the user's time zone, the names their language
  357. on monthEnv(e) {
  358. browserEnv()
  359. // every ten minutes of use the timers are set again (a long-open page keeps the next week planned)
  360. if (signedIn && Date.now() - lastArm > 600000) { arm() }
  361. }
  362. browserEnv() {
  363. if (seen == 1) { return null }
  364. seen = 1
  365. now = new Date()
  366. lang = navigator.language != null ? navigator.language : 'en'
  367. zone = Intl.DateTimeFormat().resolvedOptions().timeZone
  368. today = ('' + (10000 + now.getFullYear())).slice(1) + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate())
  369. // the server already wrote the names of a language it knows: they stay, so paging never switches them
  370. if (!namesKnown) {
  371. mf = Intl.DateTimeFormat(lang, { month = 'long' })
  372. df = Intl.DateTimeFormat(lang, { weekday = 'short' })
  373. ms = []
  374. ds = []
  375. let i = 0
  376. while (i < 12) { ms.push(mf.format(new Date(Date.UTC(2024, i, 15, 12)))) i = i + 1 }
  377. i = 0
  378. while (i < 7) { ds.push(df.format(new Date(Date.UTC(2024, 0, 1 + i, 12)))) i = i + 1 }
  379. monthNames = ms
  380. weekdayNames = ds
  381. weekdays = ds
  382. de = lang.slice(0, 2) == 'de'
  383. prevLabel = de ? 'Voriger Monat' : 'Previous month'
  384. nextLabel = de ? 'Nächster Monat' : 'Next month'
  385. }
  386. fZone = zone != null && zone != '' ? zone : 'UTC'
  387. readPermission()
  388. // the month shown follows the corrected today only while the user has not paged yet
  389. if (paged == 0) {
  390. y = toNumber(today.slice(0, 4))
  391. m = toNumber(today.slice(5, 7))
  392. show(y, m, loadEvents(y, m))
  393. } else { show(year, month, loadEvents(year, month)) }
  394. // (offline the server did not answer: the timers are set from the reminders the page already holds)
  395. arm()
  396. }
  397. // ---- the reminders: notifications the browser holds a timer for (they fire while the app or its worker is open, also offline) ----
  398. // the server's newest answer about the reminders to come; the timers are set again from it
  399. takeSoon(r) {
  400. soon = r.soon
  401. if (r.defaults != null) { defaultText = r.defaults }
  402. if (r.defaultRows != null) { defRows = r.defaultRows }
  403. arm()
  404. }
  405. // one timer per reminder that is still to come within a week; the old timers are dropped first
  406. arm() {
  407. if (!signedIn) { return null }
  408. let i = 0
  409. while (i < timers.length) { clearTimeout(timers[i]) i = i + 1 }
  410. nowMs = Date.now()
  411. list = plan(soon, nowMs, 604800000)
  412. ids = []
  413. i = 0
  414. while (i < list.length) {
  415. // `p` is a new binding on every pass (hybriel #106): each timer keeps ITS reminder
  416. let p = list[i]
  417. ids.push(setTimeout(() => { notify(p.title, { body = p.body tag = p.key icon = '/icons/icon-192.png' }) }, p.at - nowMs))
  418. i = i + 1
  419. }
  420. timers = ids
  421. lastArm = nowMs
  422. plannedText = list.length == 0 ? '' : (list.length == 1 ? '1 reminder' : list.length + ' reminders') + ' set for the next 7 days'
  423. }
  424. readPermission() {
  425. n = window.Notification
  426. notifyState = n == null ? 'unsupported' : n.permission
  427. askClass = notifyState == 'default' ? '' : 'hidden'
  428. blockedClass = notifyState == 'denied' ? '' : 'hidden'
  429. }
  430. // the button is the click the browser wants before it allows notifications
  431. on askNotify(e) {
  432. notify('Notifications are on', { body = 'Your reminders show up here.' })
  433. readPermission()
  434. }
  435. // from a host script (the date picker): "YYYY-MM-DD" → show that date's month
  436. on monthGoto(text) {
  437. paged = 1
  438. y = toNumber(text.slice(0, 4))
  439. m = toNumber(text.slice(5, 7))
  440. show(y, m, loadEvents(y, m))
  441. }
  442. // ---- the editor ----
  443. // the reminders switch: the calendar's or the event's own
  444. syncCustom() {
  445. calClass = fCustom ? 'quiet' : ''
  446. ownClass = fCustom ? '' : 'quiet'
  447. defaultClass = fCustom ? 'hidden' : ''
  448. rowsClass = fCustom ? '' : 'hidden'
  449. addClass = fCustom ? 'quiet' : 'quiet hidden'
  450. }
  451. // the classes that follow the form's kind and whether it edits an existing event
  452. syncForm() {
  453. formKind = fAllDay ? 'allday' : 'timed'
  454. timedButton = fAllDay ? 'quiet' : ''
  455. alldayButton = fAllDay ? '' : 'quiet'
  456. deleteClass = isNew ? 'danger hidden' : 'danger'
  457. }
  458. openForm(fields, heading, fresh) {
  459. fId = fields.id
  460. fTitle = fields.title
  461. fAllDay = fields.allDay
  462. fStartDate = fields.startDate
  463. fStartTime = fields.startTime
  464. fEndDate = fields.endDate
  465. fEndTime = fields.endTime
  466. fPlace = fields.place
  467. fNotes = fields.notes
  468. isNew = fresh
  469. fCustom = fields.custom
  470. remRows = withIndex(fields.rows)
  471. syncCustom()
  472. formHeading = heading
  473. formError = ''
  474. formClass = 'open'
  475. syncForm()
  476. }
  477. // one click on the day list: an event button edits that event, anywhere else on a day adds one to that day
  478. on dayClick(e) {
  479. if (!signedIn) { return null }
  480. li = e.target.closest('li')
  481. if (li == null) { return null }
  482. date = li.querySelector('time').getAttribute('datetime')
  483. b = e.target.closest('button.event')
  484. if (b == null) {
  485. fresh = blank(date, fZone)
  486. openForm(fresh, 'New event', true)
  487. return null
  488. }
  489. let j = 0
  490. while (j < events.length) {
  491. let ev = events[j]
  492. if (ev.id == b.value) {
  493. let fields = { id = ev.id title = ev.title allDay = ev.allDay startDate = ev.startDate startTime = ev.startTime endDate = ev.endDate endTime = ev.endTime place = ev.place notes = ev.notes zone = fZone custom = ev.custom rows = ev.rows }
  494. openForm(fields, 'Edit event', false)
  495. return null
  496. }
  497. j = j + 1
  498. }
  499. }
  500. on setF(name, value) {
  501. if (name == 'title') { fTitle = value }
  502. if (name == 'startDate') { fStartDate = value }
  503. if (name == 'startTime') { fStartTime = value }
  504. if (name == 'endDate') { fEndDate = value }
  505. if (name == 'endTime') { fEndTime = value }
  506. if (name == 'place') { fPlace = value }
  507. if (name == 'notes') { fNotes = value }
  508. }
  509. on setCustom(own) {
  510. fCustom = own
  511. // switching to own reminders starts from the calendar's, so nothing has to be typed for the usual case
  512. if (own && remRows.length == 0) { remRows = withIndex(defaultRows) }
  513. syncCustom()
  514. }
  515. // one reminder row of the event editor (which = 'rem') or of the settings (which = 'set')
  516. on setRem(which, i, field, value) {
  517. rows = which == 'rem' ? remRows : setRows
  518. if (i < 0 || i >= rows.length) { return null }
  519. out = []
  520. let k = 0
  521. while (k < rows.length) {
  522. let r = rows[k]
  523. let n = r.n
  524. let unit = r.unit
  525. let time = r.time
  526. if (k == i && field == 'n') { n = value }
  527. if (k == i && field == 'unit') { unit = value }
  528. if (k == i && field == 'time') { time = value }
  529. out.push({ unit = unit n = n time = time })
  530. k = k + 1
  531. }
  532. if (which == 'rem') { remRows = withIndex(out) } else { setRows = withIndex(out) }
  533. }
  534. on addRem(which) {
  535. rows = which == 'rem' ? remRows : setRows
  536. out = []
  537. let k = 0
  538. while (k < rows.length) { out.push({ unit = rows[k].unit n = rows[k].n time = rows[k].time }) k = k + 1 }
  539. out.push({ unit = 'minutes' n = '10' time = '09:00' })
  540. if (which == 'rem') { remRows = withIndex(out) } else { setRows = withIndex(out) }
  541. }
  542. on delRem(which, i) {
  543. rows = which == 'rem' ? remRows : setRows
  544. out = []
  545. let k = 0
  546. while (k < rows.length) {
  547. if (k != i) { out.push({ unit = rows[k].unit n = rows[k].n time = rows[k].time }) }
  548. k = k + 1
  549. }
  550. if (which == 'rem') { remRows = withIndex(out) } else { setRows = withIndex(out) }
  551. }
  552. // ---- the calendar settings: the default reminders ----
  553. on openSettings(e) {
  554. r = emit server calSettingsLoad()
  555. if (r == null || r.error != null) {
  556. setError = r == null ? 'the settings need the server: you are offline' : r.error
  557. setClass = 'open'
  558. return null
  559. }
  560. setRows = withIndex(r.rows)
  561. setError = ''
  562. setClass = 'open'
  563. }
  564. on closeSettings(e) {
  565. setClass = 'closed'
  566. }
  567. on saveSettings(e) {
  568. r = emit server calSettingsSave(setRows)
  569. if (r == null || r.error != null) {
  570. setError = r == null ? 'the settings could not be saved (offline?)' : r.error
  571. return null
  572. }
  573. setClass = 'closed'
  574. takeSoon(r)
  575. }
  576. on setKind(allDay) {
  577. fAllDay = allDay
  578. if (!allDay && fStartTime == '') { fStartTime = '09:00' }
  579. if (!allDay && fEndTime == '' && fEndDate != '') { fEndTime = '10:00' }
  580. syncForm()
  581. }
  582. on closeForm(e) {
  583. formClass = 'closed'
  584. }
  585. // what the form holds, as the face takes it
  586. formFields() {
  587. return { id = fId title = fTitle allDay = fAllDay startDate = fStartDate startTime = fStartTime endDate = fEndDate endTime = fEndTime place = fPlace notes = fNotes zone = fZone custom = fCustom reminders = remRows }
  588. }
  589. // the event as the page shows it, for an event saved while offline (the server sends the real one when it is back)
  590. localView(f) {
  591. let allDay = f.allDay
  592. let last = f.endDate != '' ? f.endDate : f.startDate
  593. mine = []
  594. let k = 0
  595. while (k < f.reminders.length) { mine.push({ unit = f.reminders[k].unit n = f.reminders[k].n time = f.reminders[k].time }) k = k + 1 }
  596. return { id = f.id title = f.title allDay = allDay first = f.startDate last = last
  597. startDate = f.startDate startTime = allDay ? '' : f.startTime endDate = f.endDate endTime = allDay ? '' : f.endTime
  598. place = f.place notes = f.notes start = f.startDate zone = f.zone custom = f.custom rows = f.custom ? mine : defRows }
  599. }
  600. on saveEvent(e) {
  601. f = formFields()
  602. if (fTitle.trim() == '' || fStartDate == '') {
  603. formError = 'the event needs a title and a start date'
  604. return null
  605. }
  606. r = emit server calSave(f, winFirst, winLast)
  607. if (r == null) {
  608. // OFFLINE: the save waits in the app's queue (it is sent when the connection is back); here the event and its
  609. // reminders are set at once, so the notification still comes
  610. if (f.id == '') { f.id = 'local-' + Date.now() }
  611. emit server calSaveQueued(f, winFirst, winLast)
  612. v = localView(f)
  613. evs = []
  614. let k = 0
  615. while (k < events.length) { if (events[k].id != v.id) { evs.push(events[k]) } k = k + 1 }
  616. evs.push(v)
  617. mine = []
  618. k = 0
  619. while (k < soon.length) { if (soon[k].id != v.id) { mine.push(soon[k]) } k = k + 1 }
  620. mine.push({ id = v.id title = v.title allDay = v.allDay startDate = v.startDate startTime = v.startTime place = v.place zone = v.zone rows = v.rows })
  621. soon = mine
  622. formClass = 'closed'
  623. show(year, month, evs)
  624. arm()
  625. return null
  626. }
  627. if (r.error != null) {
  628. formError = r.error
  629. return null
  630. }
  631. formClass = 'closed'
  632. takeSoon(r)
  633. show(year, month, r.events)
  634. }
  635. on deleteEvent(e) {
  636. r = emit server calDelete(fId, winFirst, winLast)
  637. if (r == null || r.error != null) {
  638. formError = r == null ? 'the event could not be deleted (offline?)' : r.error
  639. return null
  640. }
  641. formClass = 'closed'
  642. takeSoon(r)
  643. show(year, month, r.events)
  644. }

Branches

Latest commits

  • 2881119fcalendar mission 001 (4/4): code order — README layout + same-output test, STATUS, LOG, report; tests/letcount.py (sub-folders), tests/realdata-baseline.mjs, tests/realdata-compare.pymre
  • 3b2a4cd0calendar mission 001 (3/4): code order — let only where reassigned (147 dropped incl. components/month-view; 110 left: 67 reassigned, 43 loop-bound); gates 78/0 (3 of 4 runs; 1 known 'next month' flake) + 18/0, live-data run = step 2mre
  • f5ce6b1dcalendar mission 001 (2/4): code order — topics, map, thin faces: lib/util.hl, lib/events(-helpers).hl (+calendarView/settingsView/soonOf), lib/settings.hl, lib/users.hl (+userOfLoginCode, tagOf), lib/api(-helpers).hl; project.hl = map; login route takes &req/&sessions (failed-login reason now kept); gates 78/0 + 18/0mre
  • c6fbd011calendar mission 001 (1/4): code order — files moved: lib/events.hl, lib/users.hl, components/styles.hl (imports only); gates 78/0 + 18/0, live-data run identicalmre
  • d6c59290calendar: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gates 78/0 + 18/0mre
  • 7bd0337ccalendar: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gates 78/0 + 18/0mre
  • ff41310ccalendar: Hybriel master 8efba065 (#126 memory, #48 lambda copy; audit: no & needed)mre
  • 14ba08c7antcolony#40: mission references point to the moved missionsmre
  • 99c73346antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 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