calendar
All repositories: gitoria
7.0 KB
// reminders.hl — THE REMINDERS (ticket calendar#4), shared by the server (events.hl checks and stores them) and the// month view in the browser (the editor rows, the notification times).// Stored as WorldAPI Event `$reminders` (https://worldapi.org/standards/event): `{ $before: 'PT30M' }` (30 minutes, or 'PT2H'// hours before the start) and `{ $days_before: 1, $time: '20:00' }` (that many days before, at that wall-clock time).// The page edits them as ROWS: { unit = 'minutes' | 'hours' | 'days' n = '30' time = '20:00' } (all text).// Event without `$reminders` = inherits the calendar's; `[]` = none.import { isText, isTime, pad, prevDay, nextDay } from './dates.hl'static maxRows = 8static defaultRows = [{ unit = 'hours' n = '1' time = '09:00' }{ unit = 'days' n = '1' time = '20:00' }]// digits only, no sign, no leading noise; 1..5 digitsstatic isCount = (s) => {if (!isText(s) || s.length < 1 || s.length > 5) { return false }let i = 0while (i < s.length) {let c = s.charCodeAt(i)if (c < 48 || c > 57) { return false }i = i + 1}return true}// rows the page sent → { error } or { reminders } (the stored `$reminders` list)static toReminders = (rows) => {if (rows == null || hlTypeName(rows) != 'Hybrid') { return { error = 'the reminders must be a list' } }if (rows.length > maxRows) { return { error = 'at most ' + maxRows + ' reminders' } }let out = []let i = 0while (i < rows.length) {let r = rows[i]if (r == null || hlTypeName(r) != 'Hybrid' || !isCount(r.n)) { return { error = 'a reminder needs a whole number' } }let n = toNumber(r.n)if (r.unit == 'minutes') {if (n < 1 || n > 10080) { return { error = 'minutes must be 1 to 10080' } }let o = {}o['$before'] = 'PT' + n + 'M'out.push(o)} else if (r.unit == 'hours') {if (n < 1 || n > 168) { return { error = 'hours must be 1 to 168' } }let o = {}o['$before'] = 'PT' + n + 'H'out.push(o)} else if (r.unit == 'days') {if (n > 30) { return { error = 'days must be 0 to 30' } }if (!isTime(r.time)) { return { error = 'a day reminder needs a time' } }let o = {}o['$days_before'] = no['$time'] = r.timeout.push(o)} else { return { error = 'a reminder is minutes, hours or days before' } }i = i + 1}return { reminders = out }}// the stored `$reminders` → rows for the editor (what is not ours to read is left out)static toRows = (list) => {let out = []if (list == null) { return out }let i = 0while (i < list.length) {let r = list[i]let b = r['$before']if (isText(b) && b.length > 3 && b.slice(0, 2) == 'PT') {let unit = b.slice(b.length - 1) == 'H' ? 'hours' : 'minutes'let digits = b.slice(2, b.length - 1)if ((b.slice(b.length - 1) == 'H' || b.slice(b.length - 1) == 'M') && isCount(digits)) { out.push({ unit = unit n = digits time = '09:00' }) }} else if (r['$days_before'] != null && isTime(r['$time'])) {out.push({ unit = 'days' n = '' + r['$days_before'] time = r['$time'] })}i = i + 1}return out}// a date moved by n days (n may be negative)static shift = (date, n) => {let d = datelet k = nwhile (k > 0) { d = nextDay(d) k = k - 1 }while (k < 0) { d = prevDay(d) k = k + 1 }return d}// rows for a list in the editor: each carries its position `i` and the class that shows the time only for daysstatic withIndex = (rows) => {let out = []let i = 0while (i < rows.length) {let r = rows[i]out.push({ i = i unit = r.unit n = r.n time = r.time cls = r.unit == 'days' ? 'days' : 'other' })i = i + 1}return out}static plural = (n, one, many) => { return n + ' ' + (n == 1 ? one : many) }// 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"static describeRow = (r) => {let n = toNumber(r.n)if (r.unit == 'minutes') { return plural(n, 'minute', 'minutes') + ' before' }if (r.unit == 'hours') { return plural(n, 'hour', 'hours') + ' before' }if (n == 0) { return 'the same day at ' + r.time }if (n == 1) { return 'the day before at ' + r.time }return n + ' days before at ' + r.time}static describe = (rows) => {if (rows == null || rows.length == 0) { return 'no reminders' }let out = ''let i = 0while (i < rows.length) {out = out + (i == 0 ? '' : ', ') + describeRow(rows[i])i = i + 1}return out}// ---- browser side: the time a reminder fires (epoch ms) --------------------------------------------------// The wall clock of a time zone as an offset (ms) from UTC at the instant t.static offsetOf = (zone, t) => {let f = Intl.DateTimeFormat('en-US', { timeZone = zone hourCycle = 'h23' year = 'numeric' month = 'numeric' day = 'numeric' hour = 'numeric' minute = 'numeric' second = 'numeric' })let parts = f.formatToParts(new Date(t))let v = { year = 0 month = 1 day = 1 hour = 0 minute = 0 second = 0 }let i = 0while (i < parts.length) {let p = parts[i]if (p.type != 'literal') { v[p.type] = toNumber(p.value) }i = i + 1}return Date.UTC(v.year, v.month - 1, v.day, v.hour, v.minute, v.second) - (t - t % 1000)}// 'YYYY-MM-DDTHH:MM:SS' wall clock in `zone` ('' = the device's own zone) → the instant, epoch msstatic instantOf = (wall, zone) => {let y = toNumber(wall.slice(0, 4))let mo = toNumber(wall.slice(5, 7))let d = toNumber(wall.slice(8, 10))let h = toNumber(wall.slice(11, 13))let mi = toNumber(wall.slice(14, 16))if (zone == null || zone == '') { return new Date(y, mo - 1, d, h, mi, 0).getTime() }let guess = Date.UTC(y, mo - 1, d, h, mi, 0)let t = guess - offsetOf(zone, guess)return guess - offsetOf(zone, t)}// the words of the notification (title = the event's title)static bodyOf = (ev, r) => {let at = ev.allDay ? '' : ' at ' + ev.startTimelet text = ''if (r.unit == 'minutes') { text = 'Starts in ' + plural(toNumber(r.n), 'minute', 'minutes') }else if (r.unit == 'hours') { text = 'Starts in ' + plural(toNumber(r.n), 'hour', 'hours') }else {let n = toNumber(r.n)text = n == 0 ? 'Today' + at : (n == 1 ? 'Tomorrow' + at : 'In ' + n + ' days' + at + ', ' + ev.startDate)}return ev.place != '' ? text + ' · ' + ev.place : text}// the events { id title allDay startDate startTime place zone rows } → the reminders still to come:// [{ key, at, title, body }] within `horizon` ms of `nowMs`static plan = (events, nowMs, horizon) => {let out = []let i = 0while (i < events.length) {let ev = events[i]let zone = ev.allDay ? '' : ev.zonelet startWall = ev.startDate + 'T' + (ev.allDay ? '00:00' : ev.startTime) + ':00'let start = instantOf(startWall, zone)let j = 0while (j < ev.rows.length) {let r = ev.rows[j]let at = 0if (r.unit == 'minutes') { at = start - toNumber(r.n) * 60000 }else if (r.unit == 'hours') { at = start - toNumber(r.n) * 3600000 }else {let day = ev.startDatelet k = toNumber(r.n)while (k > 0) { day = prevDay(day) k = k - 1 }at = instantOf(day + 'T' + r.time + ':00', zone)}if (at > nowMs && at < nowMs + horizon) {out.push({ key = ev.id + ':' + at at = at title = ev.title body = bodyOf(ev, r) })}j = j + 1}i = i + 1}return out}
Branches
- mainmain branch
Latest commits
- be099807State of 2026-09-27, before the move to gitoriamre