| Say when each thing was said dbf3b86 nandi 20d ago | 1 | (ns frq.clock |
| 2 | "Wall-clock time, in the reader's own zone. |
| 3 | |
| 4 | Messages carry an IRCv3 `time` tag in UTC; a channel is read in local time. |
| 5 | jolt's time library works in epoch days and nanoseconds-of-day, and the |
| 6 | offset needs a zone name, which no single call hands over — so the zone is |
| 7 | found once here and everything else is arithmetic." |
| 8 | (:require [clojure.string :as str] |
| 9 | [jolt.host :as host] |
| 10 | [jolt.time.local :as local])) |
| 11 | |
| 12 | (def ^:private zone |
| 13 | ;; TZ if it is set, otherwise whatever /etc/localtime points at, otherwise |
| 14 | ;; UTC — which is wrong by hours but never wrong by a day's worth of parsing. |
| Read the clock in the reader's zone when /etc/localtime is a file bffe879 nandi 18d ago | 15 | ;; |
| 16 | ;; /etc/localtime is not always a symlink. In a container it is often the |
| 17 | ;; bytes themselves — distrobox's Arch has it as an overlay-mounted regular |
| 18 | ;; file — and then `readlink -f` answers with the path it was given and there |
| 19 | ;; is no zone name to read out of it at all. That fell through to UTC, so a |
| 20 | ;; channel read seven hours ahead of the clock on the same screen. |
| 21 | ;; |
| 22 | ;; The colon form is what libc has for exactly this: `TZ=:/etc/localtime` |
| 23 | ;; names the tzfile by path rather than by zone, and tzset reads it with the |
| 24 | ;; transitions intact — the offset it gives is DST-correct per instant, not a |
| 25 | ;; fixed one. Nothing downstream cares that this is a path: the zone is only |
| 26 | ;; ever handed back to tz-offset-seconds, never shown. |
| Say when each thing was said dbf3b86 nandi 20d ago | 27 | (delay |
| 28 | (or (let [tz (host/getenv "TZ")] (when (seq tz) tz)) |
| 29 | (try |
| 30 | (second (re-find #"/zoneinfo/(.+)$" |
| 31 | (str/trim (str (host/sh-out "readlink -f /etc/localtime"))))) |
| 32 | (catch Exception _ nil)) |
| Read the clock in the reader's zone when /etc/localtime is a file bffe879 nandi 18d ago | 33 | (when (host/file-exists? "/etc/localtime") ":/etc/localtime") |
| Read the clock in the reader's zone on Android too 0daacbb nandi 18d ago | 34 | ;; Android has neither: no TZ in the environment and no /etc/localtime |
| 35 | ;; to point at or read, so both branches above come up empty and the |
| 36 | ;; APK read every channel in UTC. The zone is a system property there, |
| 37 | ;; and bionic resolves the name against its own tzdata just as glibc |
| 38 | ;; resolves it against /usr/share/zoneinfo — so the name is all we need. |
| 39 | (try |
| 40 | (let [tz (str/trim (str (host/sh-out "getprop persist.sys.timezone")))] |
| 41 | (when (seq tz) tz)) |
| 42 | (catch Exception _ nil)) |
| Say when each thing was said dbf3b86 nandi 20d ago | 43 | "UTC"))) |
| 44 | |
| 45 | (defn now-ms [] (quot (host/wall-nanos) 1000000)) |
| 46 | |
| 47 | (defn parse-time-tag |
| 48 | "The `time=` value of an IRCv3 tag string as epoch milliseconds, or nil. |
| 49 | |
| 50 | Fixed format, always UTC: `2026-08-30T07:05:09.000Z`. Read by hand rather |
| 51 | than through a parser, since this runs once per message of a hundred-message |
| 52 | backlog." |
| 53 | [tags] |
| 54 | (when-let [[_ y mo d h mi s] (re-find #"time=(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})" |
| 55 | (or tags ""))] |
| 56 | (let [[y mo d h mi s] (map parse-long [y mo d h mi s]) |
| 57 | ;; days from the civil date, by Howard Hinnant's算 — the same one |
| 58 | ;; jolt's own local-date uses in reverse. |
| 59 | y (if (<= mo 2) (dec y) y) |
| 60 | era (quot (if (>= y 0) y (- y 399)) 400) |
| 61 | yoe (- y (* era 400)) |
| 62 | doy (+ (quot (+ (* 153 (+ mo (if (> mo 2) -3 9))) 2) 5) (dec d)) |
| 63 | doe (+ (* yoe 365) (quot yoe 4) (- (quot yoe 100)) doy) |
| 64 | days (+ (* era 146097) doe -719468)] |
| 65 | (* 1000 (+ (* days 86400) (* h 3600) (* mi 60) s))))) |
| 66 | |
| 67 | (defn- local-parts |
| 68 | "`[date hour minute]` in the reader's zone, the hour on a 24-clock." |
| 69 | [ms] |
| 70 | (let [secs (quot ms 1000) |
| 71 | secs (+ secs (host/tz-offset-seconds @zone secs)) |
| 72 | text (str (local/local-dt (Math/floorDiv secs 86400) |
| 73 | (* (Math/floorMod secs 86400) 1000000000)))] |
| 74 | [(subs text 0 10) (parse-long (subs text 11 13)) (subs text 14 16)])) |
| 75 | |
| 76 | (defn clock-time |
| 77 | "A twelve-hour time: `9:05 AM`, `12:30 PM`." |
| 78 | [ms] |
| 79 | (let [[_ hour minute] (local-parts ms) |
| 80 | display (cond (zero? hour) 12 |
| 81 | (> hour 12) (- hour 12) |
| 82 | :else hour)] |
| 83 | (str display ":" minute " " (if (< hour 12) "AM" "PM")))) |
| 84 | |
| 85 | (defn day [ms] (first (local-parts ms))) |
| 86 | |
| 87 | (defn day-label |
| 88 | "The heading for a day's messages: today and yesterday by name, anything |
| 89 | older by date." |
| 90 | [ms] |
| 91 | (let [d (day ms) |
| 92 | today (day (now-ms)) |
| 93 | yesterday (day (- (now-ms) 86400000))] |
| 94 | (cond (= d today) "Today" |
| 95 | (= d yesterday) "Yesterday" |
| 96 | :else d))) |