Clock bar: an Omarchy 4 bottom bar of world clocks

A `panel` shell plugin drawing its own bottom layer-shell surface, one per
screen, with an exclusive zone so Hyprland tiles above it. Clocks are laid out
as a single timeline around local time: side and position derive from each
zone's live UTC offset, so config order is irrelevant and a DST shift re-sorts
the bar on its own.

Qt's QML engine ships no `Intl`, so an IANA zone cannot be rendered from inside
QML at all. Offsets come from the system's own tzdata via `date +%z` and the
arithmetic is done here, which stays correct across DST and tzdata updates
without encoding any of the rules. The probe runs on a slow timer and on hour
boundaries; the minute tick advances the clocks in between.

Config lives in ~/.config/omarchy/clockbar.json and is edited either by hand or
through a click-to-edit popup with a timezone picker. Both routes write the same
file and the bar reloads from it, so they cannot disagree.

Failures are shown rather than papered over: an unresolvable zone renders
`LABEL --:--` in the urgent colour instead of silently falling back to UTC, and
an unparseable config reddens the bar's hairline and flags the local clock while
the last good config keeps ticking.

Ordering, formatting, day offsets and working hours live in ClockModel.js, which
touches no QML types and is covered by tests that run under node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MhxtL7WzL5XmuTd8GHbcTY
This commit is contained in:
Harvey
2026-09-04 17:49:40 +02:00
co-authored by Claude Opus 5
commit b5f4569233
9 changed files with 1479 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
// Pure layout/formatting logic for the clock bar.
//
// Deliberately free of QML types so it can be unit-tested under node (see
// tests/model.test.js). The QML side owns IO — reading config, probing tzdata,
// ticking the clock — and hands the results in here as plain values.
//
// Everything is driven by UTC offsets rather than by conversion, because Qt's
// QML engine has no `Intl`, so there is no way to render an IANA zone from
// inside QML. Offsets come from `date +%z` against the system tzdata, which
// makes them DST-correct for the moment they were probed; the bar re-probes
// often enough that a transition is picked up within minutes.
var MINUTES = 60 * 1000
// "+0200" / "-0730" / "+02" -> minutes east of UTC. null when unparseable, so
// a zone that tzdata did not recognise can be shown as broken rather than
// silently rendered as UTC.
function parseOffset(raw) {
var s = String(raw === undefined || raw === null ? "" : raw).replace(/^\s+|\s+$/g, "")
var m = s.match(/^([+-])(\d{2}):?(\d{2})?$/)
if (!m) return null
var sign = m[1] === "-" ? -1 : 1
var hours = parseInt(m[2], 10)
var mins = m[3] ? parseInt(m[3], 10) : 0
if (!isFinite(hours) || !isFinite(mins) || mins > 59) return null
return sign * (hours * 60 + mins)
}
// The wall clock in a zone, expressed as a Date whose *UTC* fields carry that
// zone's local time. Reading it back with getUTC* keeps the arithmetic in one
// frame of reference no matter what the host machine's own zone is.
function wallClock(utcMs, offsetMinutes) {
return new Date(utcMs + offsetMinutes * MINUTES)
}
function pad2(n) {
return (n < 10 ? "0" : "") + n
}
function formatTime(wall, withSeconds) {
var out = pad2(wall.getUTCHours()) + ":" + pad2(wall.getUTCMinutes())
return withSeconds ? out + ":" + pad2(wall.getUTCSeconds()) : out
}
// Whole-day difference between two wall clocks, from their calendar dates
// alone. Comparing the dates rather than subtracting the times means a clock
// 30 minutes and one day apart still reports exactly 1.
function dayDelta(wall, localWall) {
var a = Date.UTC(wall.getUTCFullYear(), wall.getUTCMonth(), wall.getUTCDate())
var b = Date.UTC(localWall.getUTCFullYear(), localWall.getUTCMonth(), localWall.getUTCDate())
return Math.round((a - b) / (24 * 60 * MINUTES))
}
// "+1" / "-1" / "" — shown to the right of the time. Anything beyond a single
// day is still rendered honestly (no real zone pair reaches it, but a bad
// hand-edited offset should not silently truncate).
function dayMarker(delta) {
if (!delta) return ""
return (delta > 0 ? "+" : "-") + Math.abs(delta)
}
function normalizeWorkdays(raw) {
if (!raw || !raw.length) return [1, 2, 3, 4, 5]
var out = []
for (var i = 0; i < raw.length; i++) {
var n = Number(raw[i])
if (isFinite(n) && n >= 0 && n <= 6 && out.indexOf(n) === -1) out.push(n)
}
return out.length ? out : [1, 2, 3, 4, 5]
}
// Working hours are evaluated in the clock's *own* zone — the question the bar
// answers is "is this person around?", not "is this hour within my day?".
// A window that wraps midnight (22 -> 6) is treated as spanning the boundary.
function isWorkingHours(wall, hours) {
var start = hours && isFinite(Number(hours.start)) ? Number(hours.start) : 8
var end = hours && isFinite(Number(hours.end)) ? Number(hours.end) : 18
var days = normalizeWorkdays(hours && hours.workdays)
if (days.indexOf(wall.getUTCDay()) === -1) return false
var hour = wall.getUTCHours() + wall.getUTCMinutes() / 60
if (start === end) return true
if (start < end) return hour >= start && hour < end
return hour >= start || hour < end
}
function isValidLabel(label) {
return typeof label === "string" && label.replace(/^\s+|\s+$/g, "").length > 0
}
// One rendered clock. `offsets` maps zone -> "+0200" as probed from tzdata;
// a zone missing from it (or unparseable) yields `known: false` so the bar can
// show the label struck through instead of quietly lying about the time.
function buildClock(entry, offsets, utcMs, localOffset, options) {
var opts = options || {}
var timezone = String(entry && entry.timezone ? entry.timezone : "")
var label = isValidLabel(entry && entry.label) ? String(entry.label).replace(/^\s+|\s+$/g, "") : timezone
var offset = parseOffset(offsets ? offsets[timezone] : null)
if (offset === null) {
return {
label: label, timezone: timezone, known: false,
time: "--:--", marker: "", delta: 0, relativeMinutes: 0, working: false
}
}
var wall = wallClock(utcMs, offset)
var localWall = wallClock(utcMs, localOffset)
var delta = dayDelta(wall, localWall)
return {
label: label,
timezone: timezone,
known: true,
time: formatTime(wall, opts.showSeconds === true),
marker: dayMarker(delta),
delta: delta,
relativeMinutes: offset - localOffset,
working: isWorkingHours(wall, opts.workingHours)
}
}
// Sort key: how far ahead of (or behind) local this clock is. Equal offsets
// tie-break on label so the bar does not reshuffle between ticks.
function compareClocks(a, b) {
if (a.relativeMinutes !== b.relativeMinutes) return a.relativeMinutes - b.relativeMinutes
return a.label < b.label ? -1 : (a.label > b.label ? 1 : 0)
}
// The whole bar: one continuous timeline with local time at its centre.
// Behind-local runs out to the left, ahead-of-local out to the right, each
// group ordered so that reading left to right always moves forward in time.
// Side and position fall out of the live offset, so a DST shift re-sorts the
// bar on the next probe with no config change.
function buildBar(config, offsets, utcMs, localOffsetRaw) {
var cfg = config || {}
var localOffset = parseOffset(localOffsetRaw)
if (localOffset === null) localOffset = 0
var options = {
workingHours: cfg.workingHours,
showSeconds: cfg.showSeconds === true
}
var entries = Array.isArray(cfg.clocks) ? cfg.clocks : []
var left = []
var right = []
var unknown = []
for (var i = 0; i < entries.length; i++) {
var clock = buildClock(entries[i], offsets, utcMs, localOffset, options)
if (!clock.known) unknown.push(clock)
else if (clock.relativeMinutes < 0) left.push(clock)
else right.push(clock)
}
left.sort(compareClocks)
right.sort(compareClocks)
var localWall = wallClock(utcMs, localOffset)
return {
left: left,
// Unresolvable zones have no place on a timeline, so they sit at the end
// of the trailing group where they are visible but never mis-ordered.
right: right.concat(unknown),
center: {
time: formatTime(localWall, options.showSeconds),
date: localWall.getUTCFullYear() + "-" + pad2(localWall.getUTCMonth() + 1) + "-" + pad2(localWall.getUTCDate()),
offsetMinutes: localOffset
}
}
}
// Every zone the bar needs an offset for, deduplicated, for the probe command.
function zonesToProbe(config) {
var entries = config && Array.isArray(config.clocks) ? config.clocks : []
var seen = {}
var out = []
for (var i = 0; i < entries.length; i++) {
var tz = entries[i] && entries[i].timezone ? String(entries[i].timezone) : ""
// Zone names reach a shell command, so anything outside the tzdata naming
// shape is dropped here rather than quoted and hoped for.
if (!tz || !/^[A-Za-z0-9_+\-]+(\/[A-Za-z0-9_+\-]+)*$/.test(tz)) continue
if (seen[tz]) continue
seen[tz] = true
out.push(tz)
}
return out
}
// Parse the probe's "zone<TAB>+0200" lines back into a lookup.
function parseOffsetReport(raw) {
var out = {}
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].replace(/^\s+|\s+$/g, "")
if (!line) continue
var tab = line.indexOf("\t")
if (tab <= 0) continue
out[line.substring(0, tab)] = line.substring(tab + 1).replace(/^\s+|\s+$/g, "")
}
return out
}
if (typeof module !== "undefined" && module.exports) {
module.exports = {
parseOffset: parseOffset,
wallClock: wallClock,
formatTime: formatTime,
dayDelta: dayDelta,
dayMarker: dayMarker,
isWorkingHours: isWorkingHours,
buildClock: buildClock,
buildBar: buildBar,
zonesToProbe: zonesToProbe,
parseOffsetReport: parseOffsetReport
}
}