gitoriaLog in with ident

calendar

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitbe099807be099807State of 2026-09-27, before the move to gitoriamrebe099807/components/month-view/month-view.hl

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

Branches

Latest commits

  • be099807State of 2026-09-27, before the move to gitoriamre