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:
2026-09-04 17:49:40 +02:00
co-authored by Claude Opus 5
commit 602a6db274
9 changed files with 1479 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
// Unit tests for ClockModel.js. Run with: node tests/model.test.js
// No dependencies — the model is plain ES5 so it runs identically here and in
// Qt's QML engine.
var assert = require("assert")
var M = require("../ClockModel.js")
var passed = 0
function test(name, fn) {
try { fn(); passed++ }
catch (e) { console.error("FAIL: " + name + "\n " + e.message); process.exitCode = 1 }
}
// 2026-09-04T15:24:00Z — a Friday. Johannesburg is +0200, so local is 17:24.
var NOW = Date.UTC(2026, 8, 4, 15, 24, 0)
var SAST = "+0200"
var OFFSETS = {
"America/Los_Angeles": "-0700", // PDT
"America/New_York": "-0400", // EDT
"Asia/Singapore": "+0800",
"Australia/Sydney": "+1000", // AEST
"Pacific/Auckland": "+1200" // NZST
}
var CONFIG = {
clocks: [
{ label: "NZ", timezone: "Pacific/Auckland" },
{ label: "LA", timezone: "America/Los_Angeles" },
{ label: "AUS", timezone: "Australia/Sydney" },
{ label: "NYC", timezone: "America/New_York" },
{ label: "SG", timezone: "Asia/Singapore" }
],
workingHours: { start: 8, end: 18, workdays: [1, 2, 3, 4, 5] }
}
test("parseOffset handles the tzdata forms", function () {
assert.strictEqual(M.parseOffset("+0200"), 120)
assert.strictEqual(M.parseOffset("-0700"), -420)
assert.strictEqual(M.parseOffset("+0545"), 345) // Kathmandu
assert.strictEqual(M.parseOffset("-0330"), -210) // Newfoundland
assert.strictEqual(M.parseOffset("+00:00"), 0)
assert.strictEqual(M.parseOffset(""), null)
assert.strictEqual(M.parseOffset("garbage"), null)
assert.strictEqual(M.parseOffset(null), null)
})
test("times are correct against known offsets", function () {
var bar = M.buildBar(CONFIG, OFFSETS, NOW, SAST)
var byLabel = {}
bar.left.concat(bar.right).forEach(function (c) { byLabel[c.label] = c })
assert.strictEqual(bar.center.time, "17:24", "local")
assert.strictEqual(byLabel.LA.time, "08:24")
assert.strictEqual(byLabel.NYC.time, "11:24")
assert.strictEqual(byLabel.SG.time, "23:24")
assert.strictEqual(byLabel.AUS.time, "01:24")
assert.strictEqual(byLabel.NZ.time, "03:24")
})
test("clocks land on the correct side of local", function () {
var bar = M.buildBar(CONFIG, OFFSETS, NOW, SAST)
assert.deepStrictEqual(bar.left.map(function (c) { return c.label }), ["LA", "NYC"])
assert.deepStrictEqual(bar.right.map(function (c) { return c.label }), ["SG", "AUS", "NZ"])
})
test("config order does not affect rendered order", function () {
var shuffled = { clocks: CONFIG.clocks.slice().reverse(), workingHours: CONFIG.workingHours }
var a = M.buildBar(CONFIG, OFFSETS, NOW, SAST)
var b = M.buildBar(shuffled, OFFSETS, NOW, SAST)
var names = function (bar) { return bar.left.concat(bar.right).map(function (c) { return c.label }) }
assert.deepStrictEqual(names(a), names(b))
})
test("day markers show +1 and -1", function () {
var bar = M.buildBar(CONFIG, OFFSETS, NOW, SAST)
var byLabel = {}
bar.left.concat(bar.right).forEach(function (c) { byLabel[c.label] = c })
assert.strictEqual(byLabel.AUS.marker, "+1", "Sydney is already tomorrow")
assert.strictEqual(byLabel.NZ.marker, "+1")
assert.strictEqual(byLabel.SG.marker, "", "Singapore is still today")
assert.strictEqual(byLabel.LA.marker, "", "LA is still today at 08:24")
// Same zones, but local is 01:24 on the 5th — LA is then on the 4th.
var early = Date.UTC(2026, 8, 4, 23, 24, 0)
var lateBar = M.buildBar(CONFIG, OFFSETS, early, SAST)
var late = {}
lateBar.left.concat(lateBar.right).forEach(function (c) { late[c.label] = c })
assert.strictEqual(lateBar.center.time, "01:24")
assert.strictEqual(late.LA.marker, "-1", "LA is yesterday")
assert.strictEqual(late.NYC.marker, "-1")
})
test("working hours are evaluated in the clock's own zone", function () {
var bar = M.buildBar(CONFIG, OFFSETS, NOW, SAST)
var byLabel = {}
bar.left.concat(bar.right).forEach(function (c) { byLabel[c.label] = c })
assert.strictEqual(byLabel.LA.working, true, "08:24 Fri in LA")
assert.strictEqual(byLabel.NYC.working, true, "11:24 Fri in NYC")
assert.strictEqual(byLabel.SG.working, false, "23:24 Fri in Singapore")
assert.strictEqual(byLabel.NZ.working, false, "03:24 Sat in Auckland")
})
test("weekends count as outside working hours", function () {
// 2026-09-05 is a Saturday. 10:00 local.
var sat = Date.UTC(2026, 8, 5, 8, 0, 0)
var bar = M.buildBar({ clocks: [{ label: "JHB", timezone: "Africa/Johannesburg" }] },
{ "Africa/Johannesburg": "+0200" }, sat, SAST)
assert.strictEqual(bar.right[0].time, "10:00")
assert.strictEqual(bar.right[0].working, false)
})
test("a window that wraps midnight is handled", function () {
var hours = { start: 22, end: 6, workdays: [0, 1, 2, 3, 4, 5, 6] }
assert.strictEqual(M.isWorkingHours(new Date(Date.UTC(2026, 8, 4, 23, 0)), hours), true)
assert.strictEqual(M.isWorkingHours(new Date(Date.UTC(2026, 8, 4, 3, 0)), hours), true)
assert.strictEqual(M.isWorkingHours(new Date(Date.UTC(2026, 8, 4, 12, 0)), hours), false)
})
test("an unresolvable zone is flagged, not rendered as UTC", function () {
var bar = M.buildBar({ clocks: [{ label: "BAD", timezone: "Not/AZone" }] }, {}, NOW, SAST)
var clock = bar.right[0]
assert.strictEqual(clock.known, false)
assert.strictEqual(clock.time, "--:--")
assert.strictEqual(bar.left.length, 0, "unknown zones never sort into the timeline")
})
test("half-hour and quarter-hour zones sort correctly", function () {
var cfg = {
clocks: [
{ label: "IND", timezone: "Asia/Kolkata" },
{ label: "NPL", timezone: "Asia/Kathmandu" },
{ label: "SG", timezone: "Asia/Singapore" }
]
}
var offs = { "Asia/Kolkata": "+0530", "Asia/Kathmandu": "+0545", "Asia/Singapore": "+0800" }
var bar = M.buildBar(cfg, offs, NOW, SAST)
assert.deepStrictEqual(bar.right.map(function (c) { return c.label }), ["IND", "NPL", "SG"])
assert.strictEqual(bar.right[0].time, "20:54")
assert.strictEqual(bar.right[1].time, "21:09")
})
test("a DST shift re-sorts the bar with no config change", function () {
// Sydney +1000 sits behind Auckland +1200; in the southern summer both move
// but keep order, while LA leaving PDT widens its gap from local.
var winter = M.buildBar(CONFIG, OFFSETS, NOW, SAST)
assert.strictEqual(winter.left[0].label, "LA")
assert.strictEqual(winter.left[0].relativeMinutes, -540)
var pst = {}
Object.keys(OFFSETS).forEach(function (k) { pst[k] = OFFSETS[k] })
pst["America/Los_Angeles"] = "-0800"
var summer = M.buildBar(CONFIG, pst, NOW, SAST)
assert.strictEqual(summer.left[0].relativeMinutes, -600)
})
test("zonesToProbe dedupes and rejects shell-unsafe names", function () {
var zones = M.zonesToProbe({
clocks: [
{ label: "A", timezone: "Europe/London" },
{ label: "B", timezone: "Europe/London" },
{ label: "C", timezone: "; rm -rf /" },
{ label: "D", timezone: "$(whoami)" },
{ label: "E", timezone: "" },
{ label: "F", timezone: "America/Argentina/Buenos_Aires" },
{ label: "G", timezone: "Etc/GMT+5" }
]
})
assert.deepStrictEqual(zones, ["Europe/London", "America/Argentina/Buenos_Aires", "Etc/GMT+5"])
})
test("parseOffsetReport reads the probe output", function () {
var got = M.parseOffsetReport("Europe/London\t+0100\nAsia/Tokyo\t+0900\n\n")
assert.deepStrictEqual(got, { "Europe/London": "+0100", "Asia/Tokyo": "+0900" })
})
test("an empty config renders an empty bar, not a crash", function () {
var bar = M.buildBar({}, {}, NOW, SAST)
assert.deepStrictEqual(bar.left, [])
assert.deepStrictEqual(bar.right, [])
assert.strictEqual(bar.center.time, "17:24")
assert.deepStrictEqual(M.buildBar(null, null, NOW, null).center.time, "15:24")
})
console.log(passed + " passed" + (process.exitCode ? ", some FAILED" : ""))