calendar
All repositories: gitoria
26.5 KB
// month-view.hl — THE MONTH VIEW: prev / next buttons on top, the month's name and the year between them, the// days below (weeks start on Monday; the days of the neighbouring months are dimmed; today is marked).// THE CALENDAR'S COPY of the pure-Hybriel month view of components.hybriel.worldapi.org (no .js), plus the user's// events on the days and the editor for them (add: click a day; edit / delete: click an event). Events come from// events.hl, one server round trip per change (faces calLoad / calSave / calDelete in calendar.hl). The date// arithmetic is written here (plain integers), so it runs the same on the server (first render) and in the browser.// What only the browser knows — today in the user's time zone, the month and weekday names in the user's language,// the time zone's name (`Date` and `Intl` are browser globals of View handlers) — is read the moment the pointer, a// 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// the language, so it never guesses one: names appear once, in the browser's language, and stay.// The page reads `year` and `month` (1–12) as its own members. USE ONE PER PAGE: a composed component's// members are the page's state (COMPONENTS.md §12).import { serverToday } from './clock.hl'import { pad, daysIn, before } from './dates.hl'import { namesFor } from './names.hl'import { userIdOfSession } from '../../lib/users.hl'import { eventsBetween, upcoming } from '../../lib/events.hl'import { defaultsOf } from '../../lib/settings.hl'import { notify } from 'hl:web'import { plan, shift, describe, withIndex, defaultRows } from './reminders.hl'// the request's Accept-Language, handed in by the page (calendar.hl reads it from `headers`, hybriel #105): the first// render already writes the namesacceptLanguage = nullHybrid known = namesFor(acceptLanguage)Boolean namesKnown = known != nullString today = serverToday()Number year = toNumber(today.slice(0, 4))Number month = toNumber(today.slice(5, 7))Hybrid monthNames = namesKnown ? known.months : ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']Hybrid weekdayNames = namesKnown ? known.days : ['·', '·', '·', '·', '·', '·', '·']String title = monthNames[month - 1] + ' ' + yearHybrid base = build(year, month, today)Hybrid weekdays = weekdayNamessession = nulluserId = userIdOfSession(session)Boolean signedIn = userId != nullString winFirst = base[0].dateString winLast = base[41].dateHybrid events = signedIn ? eventsBetween(userId, winFirst, winLast) : []Hybrid days = fill(base, events)// the reminders (ticket calendar#4): the events of the next days with the reminders that apply (`soon`, kept with the page// state, so an offline page still knows them), the calendar's defaults in words, the timers the browser holdsHybrid soon = signedIn ? upcoming(userId, shift(today, -2), shift(today, 9)) : []String defaultText = signedIn ? describe(defaultsOf(userId)) : ''Hybrid defRows = signedIn ? defaultsOf(userId) : []Boolean fCustom = falseHybrid remRows = []Hybrid setRows = []Hybrid timers = []Number lastArm = 0String notifyState = 'unknown'String setClass = 'closed'String setError = ''String barClass = signedIn ? 'bar' : 'hidden'String askClass = 'hidden'String blockedClass = 'hidden'String plannedText = ''String fId = ''String fTitle = ''Boolean fAllDay = falseString fStartDate = ''String fStartTime = '09:00'String fEndDate = ''String fEndTime = '10:00'String fPlace = ''String fNotes = ''String fZone = 'UTC'String formClass = 'closed'String formError = ''String formHeading = 'New event'Boolean isNew = trueString formKind = 'timed'String ownClass = 'quiet'String calClass = ''String defaultClass = ''String rowsClass = 'hidden'String addClass = 'quiet hidden'String timedButton = ''String alldayButton = 'quiet'String deleteClass = 'danger hidden'String prevLabel = namesKnown ? known.prev : '‹'String nextLabel = namesKnown ? known.next : '›'Number seen = 0Number paged = 0// 0 = Monday … 6 = Sunday (Sakamoto's method gives 0 = Sunday)weekdayOf(y, m, d) {t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]yy = m < 3 ? y - 1 : ysun = (yy + (yy - yy % 4) / 4 - (yy - yy % 100) / 100 + (yy - yy % 400) / 400 + t[m - 1] + d) % 7return (sun + 6) % 7}// 42 days (six weeks of seven), so the view keeps its height from month to monthbuild(y, m, todayText) {py = m == 1 ? y - 1 : ypm = m == 1 ? 12 : m - 1ny = m == 12 ? y + 1 : ynm = m == 12 ? 1 : m + 1lead = weekdayOf(y, m, 1)let n = daysIn(y, m)pn = daysIn(py, pm)out = []let day = 1 - leadlet w = 0while (w < 6) {let i = 0while (i < 7) {let cy = ylet cm = mlet cd = daylet cls = ''if (day < 1) { cy = py cm = pm cd = pn + day cls = 'outside' }if (day > n) { cy = ny cm = nm cd = day - n cls = 'outside' }let date = cy + '-' + pad(cm) + '-' + pad(cd)if (date == todayText) { cls = 'today' }out.push({ n = cd date = date cls = cls })day = day + 1i = i + 1}w = w + 1}return out}// the events that touch a day, as four chips on the day cell (a nested `for` does not repaint when the outer list is// reassigned: hybriel #86 — so the chips are fixed members of the day: t = text, c = class, v = event id).// Timed events start with their time on the first day. More than four: the fourth chip says "+N more".fill(list, evs) {out = []let i = 0while (i < list.length) {let d = list[i]let hit = []let j = 0while (j < evs.length) {let e = evs[j]if (!before(d.date, e.first) && !before(e.last, d.date)) { hit.push(e) }j = j + 1}let row = { n = d.n date = d.date cls = d.cls }let k = 0while (k < 4) {let text = ''let cls = 'unused'let id = ''if (k < hit.length && (hit.length <= 4 || k < 3)) {let e = hit[k]text = (e.allDay || d.date != e.first ? '' : e.startTime + ' ') + e.titlecls = 'event'id = e.id}if (k == 3 && hit.length > 4) { text = '+' + (hit.length - 3) + ' more' cls = 'more' }row['t' + k] = textrow['c' + k] = clsrow['v' + k] = idk = k + 1}out.push(row)i = i + 1}return out}// the form's fields for a new event on `date` (all text; allDay a Boolean); zone = the browser's time zone nameblank(date, zone) {return { id = '' title = '' allDay = false startDate = date startTime = '09:00' endDate = date endTime = '10:00' place = '' notes = '' zone = zone custom = false rows = [] }}show(y, m, evs) {year = ymonth = mtitle = monthNames[m - 1] + ' ' + ybase = build(y, m, today)winFirst = base[0].datewinLast = base[41].dateevents = evsdays = fill(base, evs)}// Colours: the semantic WorldAPI tokens as css variables, each with its palette value as fallback (a component's browser// module cannot import tokens.hl itself); the app sets --color-accent in its styles.hl.Style {'month-view' { display = 'grid' gap = '.5rem' }'month-view header' { display = 'flex' alignItems = 'center' justifyContent = 'space-between' gap = '.5rem' }'month-view h2' { margin = '0' fontSize = '1.1rem' color = 'var(--color-text-strong, rgb(245, 250, 255))' textAlign = 'center' flex = '1' textTransform = 'capitalize' }'month-view ol' { display = 'grid' gridTemplateColumns = 'repeat(7, minmax(0, 1fr))' margin = '0' padding = '0' listStyle = 'none' }'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' }'month-view header button:hover' { background = 'var(--color-surface, rgb(35, 40, 45))' }'#month-weekdays li' { padding = '.3rem 0' fontSize = '.8rem' fontWeight = '600' color = 'var(--color-text-muted, rgb(145, 150, 155))' textAlign = 'center' }'#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' }'#month-days time' { color = 'var(--color-text, rgb(195, 200, 205))' fontSize = '.95rem' }'#month-days li.outside' { background = 'var(--color-background, rgb(25, 30, 35))' }'#month-days li.outside time' { color = 'var(--color-text-muted, rgb(145, 150, 155))' }'#month-days li.today' { outline = '2px solid var(--color-accent, #569bd4)' outlineOffset = '-2px' }'#month-days li.today time' { color = 'var(--color-accent, #569bd4)' fontWeight = '800' }'month-view bar' { display = 'flex' flexWrap = 'wrap' alignItems = 'center' gap = '.5rem' }'month-view bar.hidden' { display = 'none' }'month-view bar p' { margin = '0' fontSize = '.85rem' color = 'var(--color-text-muted, rgb(145, 150, 155))' }'month-view .hidden' { display = 'none' }'reminder-box' { display = 'grid' gap = '.4rem' }'reminder-box h3' { margin = '0' fontSize = '.85rem' fontWeight = '600' color = 'var(--color-text-muted, rgb(145, 150, 155))' }'reminder-box p' { margin = '0' fontSize = '.85rem' color = 'var(--color-text-muted, rgb(145, 150, 155))' }'reminder-box ol' { display = 'grid' gap = '.4rem' }'reminder-row' { display = 'flex' flexWrap = 'wrap' alignItems = 'center' gap = '.4rem' }'reminder-row input[type=number]' { width = '5rem' }'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' }'reminder-row.other input[type=time]' { display = 'none' }'@media (min-width: 40rem)' {'#month-days li' { minHeight = '5rem' }}}View {monthView { on pointerover(e) { emit monthEnv(e) } on focusin(e) { emit monthEnv(e) } on touchstart(e) { emit monthEnv(e) }header {button { id = "month-prev" type = "button" class = "quiet" title = prevLabel "‹" on click(e) { emit monthPrev(e) } }h2 { id = "month-title" title }button { id = "month-next" type = "button" class = "quiet" title = nextLabel "›" on click(e) { emit monthNext(e) } }}bar { id = "reminder-bar" class = barClassbutton { id = "notify-ask" type = "button" class = askClass "Turn on notifications" on click(e) { emit askNotify(e) } }p { id = "notify-blocked" class = blockedClass "Notifications are blocked for this site in the browser settings." }p { id = "notify-planned" plannedText }button { id = "settings-open" type = "button" class = "quiet" "Reminder settings" on click(e) { emit openSettings(e) } }}ol { id = "month-weekdays" for (name of weekdays) { li { name } } }ol { id = "month-days" on click(e) { emit dayClick(e) }for (d of days) {li { class = d.clstime { datetime = d.date d.n }dayEvents {button { class = d.c0 type = "button" value = d.v0 d.t0 }button { class = d.c1 type = "button" value = d.v1 d.t1 }button { class = d.c2 type = "button" value = d.v2 d.t2 }button { class = d.c3 type = "button" value = d.v3 d.t3 }}}}}eventEditor { id = "eventeditor" class = formClassform { id = "eventform" class = formKindon submit(e) {e.preventDefault()emit saveEvent(e)}h2 { id = "formheading" formHeading }kindSwitch {button { id = "kind-timed" type = "button" class = timedButton "At a time" on click(e) { emit setKind(false) } }button { id = "kind-allday" type = "button" class = alldayButton "All day" on click(e) { emit setKind(true) } }}label { "Title"input { id = "f-title" name = "title" maxlength = "200" autocomplete = "off" required = "required" value = fTitle on input(e) { emit setF('title', e.target.value) } }}fieldRow {label { "From"input { id = "f-startdate" name = "startDate" type = "date" required = "required" value = fStartDate on input(e) { emit setF('startDate', e.target.value) } }}label { class = "timed" "at"input { id = "f-starttime" name = "startTime" type = "time" value = fStartTime on input(e) { emit setF('startTime', e.target.value) } }}}fieldRow {label { "To"input { id = "f-enddate" name = "endDate" type = "date" value = fEndDate on input(e) { emit setF('endDate', e.target.value) } }}label { class = "timed" "at"input { id = "f-endtime" name = "endTime" type = "time" value = fEndTime on input(e) { emit setF('endTime', e.target.value) } }}}label { "Place"input { id = "f-place" name = "place" maxlength = "200" autocomplete = "off" value = fPlace on input(e) { emit setF('place', e.target.value) } }}label { "Notes"textarea { id = "f-notes" name = "notes" rows = "4" maxlength = "5000" value = fNotes on input(e) { emit setF('notes', e.target.value) } }}reminderBox {h3 { "Reminders" }kindSwitch {button { id = "rem-calendar" type = "button" class = calClass "Calendar's" on click(e) { emit setCustom(false) } }button { id = "rem-own" type = "button" class = ownClass "Own" on click(e) { emit setCustom(true) } }}p { id = "rem-default-text" class = defaultClass defaultText }ol { id = "rem-rows" class = rowsClassfor (r of remRows) {reminderRow { class = r.clsinput { type = "number" min = "0" value = r.n on change(e) { emit setRem('rem', r.i, 'n', e.target.value) } }select { value = r.unit on change(e) { emit setRem('rem', r.i, 'unit', e.target.value) }option { value = "minutes" "minutes before" }option { value = "hours" "hours before" }option { value = "days" "days before, at" }}input { type = "time" value = r.time on change(e) { emit setRem('rem', r.i, 'time', e.target.value) } }button { type = "button" class = "quiet" "×" on click(e) { emit delRem('rem', r.i) } }}}}button { id = "rem-add" type = "button" class = addClass "Add reminder" on click(e) { emit addRem('rem') } }}p { id = "formerror" class = "message" formError }formButtons {button { id = "event-save" type = "submit" "Save" }button { id = "event-cancel" type = "button" class = "quiet" "Cancel" on click(e) { emit closeForm(e) } }button { id = "event-delete" type = "button" class = deleteClass "Delete" on click(e) { emit deleteEvent(e) } }}}}eventEditor { id = "settingseditor" class = setClassform { id = "settingsform" class = "timed"on submit(e) {e.preventDefault()emit saveSettings(e)}h2 { "Reminder settings" }p { class = "muted" "Every event without reminders of its own is reminded like this:" }ol { id = "set-rows"for (r of setRows) {reminderRow { class = r.clsinput { type = "number" min = "0" value = r.n on change(e) { emit setRem('set', r.i, 'n', e.target.value) } }select { value = r.unit on change(e) { emit setRem('set', r.i, 'unit', e.target.value) }option { value = "minutes" "minutes before" }option { value = "hours" "hours before" }option { value = "days" "days before, at" }}input { type = "time" value = r.time on change(e) { emit setRem('set', r.i, 'time', e.target.value) } }button { type = "button" class = "quiet" "×" on click(e) { emit delRem('set', r.i) } }}}}button { id = "set-add" type = "button" class = "quiet" "Add reminder" on click(e) { emit addRem('set') } }p { id = "settingserror" class = "message" setError }formButtons {button { id = "set-save" type = "submit" "Save" }button { id = "set-cancel" type = "button" class = "quiet" "Cancel" on click(e) { emit closeSettings(e) } }}}}input { id = "month-view-goto" type = "hidden" on change(e) { emit monthGoto(e.target.value) } }}}// the events of the six weeks shown, from the server (none while signed out)loadEvents(y, m) {if (!signedIn) { return [] }b = build(y, m, today)r = emit server calLoad(b[0].date, b[41].date)if (r != null && r.soon != null) { takeSoon(r) }return r == null || r.events == null ? [] : r.events}on monthPrev(e) {browserEnv()paged = 1y = month == 1 ? year - 1 : yearm = month == 1 ? 12 : month - 1show(y, m, loadEvents(y, m))}on monthNext(e) {browserEnv()paged = 1y = month == 12 ? year + 1 : yearm = month == 12 ? 1 : month + 1show(y, m, loadEvents(y, m))}// the browser's today, language and time zone, once: today follows the user's time zone, the names their languageon monthEnv(e) {browserEnv()// every ten minutes of use the timers are set again (a long-open page keeps the next week planned)if (signedIn && Date.now() - lastArm > 600000) { arm() }}browserEnv() {if (seen == 1) { return null }seen = 1now = new Date()lang = navigator.language != null ? navigator.language : 'en'zone = Intl.DateTimeFormat().resolvedOptions().timeZonetoday = ('' + (10000 + now.getFullYear())).slice(1) + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate())// the server already wrote the names of a language it knows: they stay, so paging never switches themif (!namesKnown) {mf = Intl.DateTimeFormat(lang, { month = 'long' })df = Intl.DateTimeFormat(lang, { weekday = 'short' })ms = []ds = []let i = 0while (i < 12) { ms.push(mf.format(new Date(Date.UTC(2024, i, 15, 12)))) i = i + 1 }i = 0while (i < 7) { ds.push(df.format(new Date(Date.UTC(2024, 0, 1 + i, 12)))) i = i + 1 }monthNames = msweekdayNames = dsweekdays = dsde = lang.slice(0, 2) == 'de'prevLabel = de ? 'Voriger Monat' : 'Previous month'nextLabel = de ? 'Nächster Monat' : 'Next month'}fZone = zone != null && zone != '' ? zone : 'UTC'readPermission()// the month shown follows the corrected today only while the user has not paged yetif (paged == 0) {y = toNumber(today.slice(0, 4))m = toNumber(today.slice(5, 7))show(y, m, loadEvents(y, m))} else { show(year, month, loadEvents(year, month)) }// (offline the server did not answer: the timers are set from the reminders the page already holds)arm()}// ---- the reminders: notifications the browser holds a timer for (they fire while the app or its worker is open, also offline) ----// the server's newest answer about the reminders to come; the timers are set again from ittakeSoon(r) {soon = r.soonif (r.defaults != null) { defaultText = r.defaults }if (r.defaultRows != null) { defRows = r.defaultRows }arm()}// one timer per reminder that is still to come within a week; the old timers are dropped firstarm() {if (!signedIn) { return null }let i = 0while (i < timers.length) { clearTimeout(timers[i]) i = i + 1 }nowMs = Date.now()list = plan(soon, nowMs, 604800000)ids = []i = 0while (i < list.length) {// `p` is a new binding on every pass (hybriel #106): each timer keeps ITS reminderlet p = list[i]ids.push(setTimeout(() => { notify(p.title, { body = p.body tag = p.key icon = '/icons/icon-192.png' }) }, p.at - nowMs))i = i + 1}timers = idslastArm = nowMsplannedText = list.length == 0 ? '' : (list.length == 1 ? '1 reminder' : list.length + ' reminders') + ' set for the next 7 days'}readPermission() {n = window.NotificationnotifyState = n == null ? 'unsupported' : n.permissionaskClass = notifyState == 'default' ? '' : 'hidden'blockedClass = notifyState == 'denied' ? '' : 'hidden'}// the button is the click the browser wants before it allows notificationson askNotify(e) {notify('Notifications are on', { body = 'Your reminders show up here.' })readPermission()}// from a host script (the date picker): "YYYY-MM-DD" → show that date's monthon monthGoto(text) {paged = 1y = toNumber(text.slice(0, 4))m = toNumber(text.slice(5, 7))show(y, m, loadEvents(y, m))}// ---- the editor ----// the reminders switch: the calendar's or the event's ownsyncCustom() {calClass = fCustom ? 'quiet' : ''ownClass = fCustom ? '' : 'quiet'defaultClass = fCustom ? 'hidden' : ''rowsClass = fCustom ? '' : 'hidden'addClass = fCustom ? 'quiet' : 'quiet hidden'}// the classes that follow the form's kind and whether it edits an existing eventsyncForm() {formKind = fAllDay ? 'allday' : 'timed'timedButton = fAllDay ? 'quiet' : ''alldayButton = fAllDay ? '' : 'quiet'deleteClass = isNew ? 'danger hidden' : 'danger'}openForm(fields, heading, fresh) {fId = fields.idfTitle = fields.titlefAllDay = fields.allDayfStartDate = fields.startDatefStartTime = fields.startTimefEndDate = fields.endDatefEndTime = fields.endTimefPlace = fields.placefNotes = fields.notesisNew = freshfCustom = fields.customremRows = withIndex(fields.rows)syncCustom()formHeading = headingformError = ''formClass = 'open'syncForm()}// one click on the day list: an event button edits that event, anywhere else on a day adds one to that dayon dayClick(e) {if (!signedIn) { return null }li = e.target.closest('li')if (li == null) { return null }date = li.querySelector('time').getAttribute('datetime')b = e.target.closest('button.event')if (b == null) {fresh = blank(date, fZone)openForm(fresh, 'New event', true)return null}let j = 0while (j < events.length) {let ev = events[j]if (ev.id == b.value) {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 }openForm(fields, 'Edit event', false)return null}j = j + 1}}on setF(name, value) {if (name == 'title') { fTitle = value }if (name == 'startDate') { fStartDate = value }if (name == 'startTime') { fStartTime = value }if (name == 'endDate') { fEndDate = value }if (name == 'endTime') { fEndTime = value }if (name == 'place') { fPlace = value }if (name == 'notes') { fNotes = value }}on setCustom(own) {fCustom = own// switching to own reminders starts from the calendar's, so nothing has to be typed for the usual caseif (own && remRows.length == 0) { remRows = withIndex(defaultRows) }syncCustom()}// one reminder row of the event editor (which = 'rem') or of the settings (which = 'set')on setRem(which, i, field, value) {rows = which == 'rem' ? remRows : setRowsif (i < 0 || i >= rows.length) { return null }out = []let k = 0while (k < rows.length) {let r = rows[k]let n = r.nlet unit = r.unitlet time = r.timeif (k == i && field == 'n') { n = value }if (k == i && field == 'unit') { unit = value }if (k == i && field == 'time') { time = value }out.push({ unit = unit n = n time = time })k = k + 1}if (which == 'rem') { remRows = withIndex(out) } else { setRows = withIndex(out) }}on addRem(which) {rows = which == 'rem' ? remRows : setRowsout = []let k = 0while (k < rows.length) { out.push({ unit = rows[k].unit n = rows[k].n time = rows[k].time }) k = k + 1 }out.push({ unit = 'minutes' n = '10' time = '09:00' })if (which == 'rem') { remRows = withIndex(out) } else { setRows = withIndex(out) }}on delRem(which, i) {rows = which == 'rem' ? remRows : setRowsout = []let k = 0while (k < rows.length) {if (k != i) { out.push({ unit = rows[k].unit n = rows[k].n time = rows[k].time }) }k = k + 1}if (which == 'rem') { remRows = withIndex(out) } else { setRows = withIndex(out) }}// ---- the calendar settings: the default reminders ----on openSettings(e) {r = emit server calSettingsLoad()if (r == null || r.error != null) {setError = r == null ? 'the settings need the server: you are offline' : r.errorsetClass = 'open'return null}setRows = withIndex(r.rows)setError = ''setClass = 'open'}on closeSettings(e) {setClass = 'closed'}on saveSettings(e) {r = emit server calSettingsSave(setRows)if (r == null || r.error != null) {setError = r == null ? 'the settings could not be saved (offline?)' : r.errorreturn null}setClass = 'closed'takeSoon(r)}on setKind(allDay) {fAllDay = allDayif (!allDay && fStartTime == '') { fStartTime = '09:00' }if (!allDay && fEndTime == '' && fEndDate != '') { fEndTime = '10:00' }syncForm()}on closeForm(e) {formClass = 'closed'}// what the form holds, as the face takes itformFields() {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 }}// the event as the page shows it, for an event saved while offline (the server sends the real one when it is back)localView(f) {let allDay = f.allDaylet last = f.endDate != '' ? f.endDate : f.startDatemine = []let k = 0while (k < f.reminders.length) { mine.push({ unit = f.reminders[k].unit n = f.reminders[k].n time = f.reminders[k].time }) k = k + 1 }return { id = f.id title = f.title allDay = allDay first = f.startDate last = laststartDate = f.startDate startTime = allDay ? '' : f.startTime endDate = f.endDate endTime = allDay ? '' : f.endTimeplace = f.place notes = f.notes start = f.startDate zone = f.zone custom = f.custom rows = f.custom ? mine : defRows }}on saveEvent(e) {f = formFields()if (fTitle.trim() == '' || fStartDate == '') {formError = 'the event needs a title and a start date'return null}r = emit server calSave(f, winFirst, winLast)if (r == null) {// OFFLINE: the save waits in the app's queue (it is sent when the connection is back); here the event and its// reminders are set at once, so the notification still comesif (f.id == '') { f.id = 'local-' + Date.now() }emit server calSaveQueued(f, winFirst, winLast)v = localView(f)evs = []let k = 0while (k < events.length) { if (events[k].id != v.id) { evs.push(events[k]) } k = k + 1 }evs.push(v)mine = []k = 0while (k < soon.length) { if (soon[k].id != v.id) { mine.push(soon[k]) } k = k + 1 }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 })soon = mineformClass = 'closed'show(year, month, evs)arm()return null}if (r.error != null) {formError = r.errorreturn null}formClass = 'closed'takeSoon(r)show(year, month, r.events)}on deleteEvent(e) {r = emit server calDelete(fId, winFirst, winLast)if (r == null || r.error != null) {formError = r == null ? 'the event could not be deleted (offline?)' : r.errorreturn null}formClass = 'closed'takeSoon(r)show(year, month, r.events)}
Branches
- mainmain branch
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