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
+2
View File
@@ -0,0 +1,2 @@
# Screenshots and scratch output, not part of the plugin
*.png
+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
}
}
+266
View File
@@ -0,0 +1,266 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.Commons
import qs.Ui
import "ClockModel.js" as Model
// A second bar along the bottom edge of every screen, carrying world clocks
// laid out as a single timeline: furthest behind local time at the far left,
// furthest ahead at the far right, local time dead centre.
//
// It is a `panel` plugin rather than a set of bar widgets because it is its
// own layer-shell surface — the built-in bar owns the top edge and only one
// bar plugin is active at a time, so sharing it was never an option. Being a
// separate surface also means it reserves its own space and survives
// `omarchy refresh shell` untouched.
Item {
id: root
// Injected by the shell host when the plugin is mounted.
property string omarchyPath: ""
property var shell: null
property var manifest: null
readonly property var config: configFile.config
readonly property var bar: Model.buildBar(config, zones.offsets, root.nowMs, zones.localOffset)
property double nowMs: Date.now()
property int lastProbedHour: -1
// Follows the theme's bar height unless the config pins one, so the two bars
// stay the same weight when the theme or font size changes.
readonly property int barSize: config && config.barSize > 0 ? config.barSize : Style.bar.sizeHorizontal
readonly property color background: Color.bar.background
readonly property color foreground: Color.bar.text
readonly property color muted: Color.muted
readonly property color accent: Color.accent
readonly property color urgent: Color.urgent
readonly property int edgePadding: Style.space(12)
readonly property int clockGap: Style.space(16)
readonly property int labelGap: Style.space(6)
Config { id: configFile }
Zones {
id: zones
zones: Model.zonesToProbe(root.config)
}
// Minute precision is all the display needs; seconds are opt-in because
// ticking every second wakes the whole shell sixty times as often for a
// readout nobody reads that closely.
SystemClock {
id: clock
precision: root.config && root.config.showSeconds ? SystemClock.Seconds : SystemClock.Minutes
onDateChanged: {
root.nowMs = date.getTime()
// Offsets only move at a transition, and transitions land on the hour.
var hour = date.getHours()
if (hour !== root.lastProbedHour) {
root.lastProbedHour = hour
zones.probe()
}
}
}
function openEditor() { editorLoader.active = true; if (editorLoader.item) editorLoader.item.open() }
function closeEditor() { if (editorLoader.item) editorLoader.item.close() }
readonly property bool editorOpen: editorLoader.item ? editorLoader.item.opened : false
Loader {
id: editorLoader
active: false
asynchronous: true
source: Qt.resolvedUrl("Editor.qml")
onLoaded: {
item.configFile = configFile
item.zones = zones
item.open()
}
}
IpcHandler {
target: "nerdnation.clockbar"
function edit(): void { root.openEditor() }
function open(): void { root.openEditor() }
function close(): void { root.closeEditor() }
function toggle(): void { root.editorOpen ? root.closeEditor() : root.openEditor() }
function refresh(): void { zones.probe() }
function reload(): void { configFile.file.reload() }
}
// ------------------------------------------------------------- one clock
component Clock: Row {
id: entry
required property var clock
readonly property bool dimmed: root.config && root.config.dimOutsideWorkingHours
&& entry.clock.known && !entry.clock.working
readonly property color tint: !entry.clock.known ? root.urgent
: (entry.dimmed ? root.muted : root.foreground)
spacing: root.labelGap
anchors.verticalCenter: parent ? parent.verticalCenter : undefined
Text {
anchors.verticalCenter: parent.verticalCenter
text: entry.clock.label
color: entry.tint
// The label is a fixed caption next to a changing number, so it sits a
// step down the scale — present for identification, not competing with
// the time for the glance.
font.family: Style.font.family
font.pixelSize: Style.font.caption
// Held back from the time it captions, but only once: a dimmed clock is
// already carrying the muted tint, and compounding the two put its label
// under the legibility floor.
opacity: entry.dimmed || !entry.clock.known ? 1.0 : 0.75
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: entry.clock.time
color: entry.tint
font.family: Style.font.family
font.pixelSize: Style.font.body
}
Text {
anchors.verticalCenter: parent.verticalCenter
// Collapsed rather than blanked, so a clock with no marker does not
// carry an empty slot plus its Row spacing. The group re-flows when a
// marker appears at midnight, which is the cheaper trade against dead
// width on every clock, every day.
visible: entry.clock.marker !== ""
text: entry.clock.marker
color: entry.tint
font.family: Style.font.family
font.pixelSize: Style.font.caption
opacity: 0.7
}
}
// ------------------------------------------------------------ the surface
component ClockPanel: PanelWindow {
id: panel
color: root.background
// Bottom edge, full width, reserving its own height so Hyprland tiles
// windows above it exactly as it does for the top bar.
anchors { bottom: true; left: true; right: true }
implicitHeight: root.barSize
exclusionMode: ExclusionMode.Auto
surfaceFormat.opaque: false
WlrLayershell.namespace: "nerdnation-clockbar"
WlrLayershell.layer: WlrLayer.Top
ScreenMoveRemap {
id: remapGuard
window: panel
}
visible: !remapGuard.remapping
MouseArea {
id: surfaceHover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: root.openEditor()
}
// A hairline along the top edge separates the bar from the window below
// it, which otherwise meets it as one flat field of colour. It doubles as
// the config-error signal: full-width chrome is the one thing on the bar
// that cannot be overlapped by a long list of clocks.
Rectangle {
anchors { top: parent.top; left: parent.left; right: parent.right }
height: configFile.parseError !== "" ? 2 : 1
color: configFile.parseError !== "" ? root.urgent : root.foreground
opacity: configFile.parseError !== "" ? 0.9 : 0.12
}
Row {
id: leftGroup
anchors { left: parent.left; leftMargin: root.edgePadding; verticalCenter: parent.verticalCenter }
spacing: root.clockGap
Repeater {
model: root.bar.left
delegate: Clock {
required property var modelData
clock: modelData
}
}
}
// Local time is centred on the screen rather than between its neighbours,
// so it holds still while the groups either side change width.
Row {
id: centerGroup
anchors { horizontalCenter: parent.horizontalCenter; verticalCenter: parent.verticalCenter }
spacing: root.labelGap
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.bar.center.time
color: root.accent
font.family: Style.font.family
font.pixelSize: Style.font.body
font.bold: true
}
// Only while clockbar.json will not parse. The clocks either side are
// the last good config still ticking, which is worth keeping on screen —
// but not worth passing off as current.
Text {
anchors.verticalCenter: parent.verticalCenter
visible: configFile.parseError !== ""
text: "config error"
color: root.urgent
font.family: Style.font.family
font.pixelSize: Style.font.caption
}
}
Row {
id: rightGroup
anchors { right: parent.right; rightMargin: root.edgePadding; verticalCenter: parent.verticalCenter }
spacing: root.clockGap
Repeater {
model: root.bar.right
delegate: Clock {
required property var modelData
clock: modelData
}
}
}
// A broken config file is signalled on the hairline (see above) rather
// than in a text label. The earlier label was anchored to the right edge
// with the same margin as the right-hand clock group, so it rendered on
// top of those clocks — and any fixed position on a bar whose contents
// grow with the config has that same collision waiting for it.
}
Variants {
model: Quickshell.screens
delegate: Component {
ClockPanel {
required property var modelData
screen: modelData
}
}
}
}
+159
View File
@@ -0,0 +1,159 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "ClockModel.js" as Model
// Owns ~/.config/omarchy/clockbar.json: reads it, watches it, writes it back
// when the editor saves.
//
// The file is the source of truth in both directions — the editor writes it
// and the watcher picks the change straight back up, so a hand-edit and an
// edit through the popup are the same operation as far as the bar is
// concerned. That is what makes the two config routes impossible to
// desynchronise.
QtObject {
id: root
readonly property string home: Quickshell.env("HOME")
readonly property string path: home + "/.config/omarchy/clockbar.json"
property var config: defaults()
property bool loaded: false
// Set when the file on disk is present but unparseable. The bar keeps
// rendering the last good config and says so, rather than emptying itself
// and losing the user's clocks behind a stray comma.
property string parseError: ""
signal changed()
function defaults() {
return {
version: 1,
clocks: [
{ label: "LA", timezone: "America/Los_Angeles" },
{ label: "NYC", timezone: "America/New_York" },
{ label: "SG", timezone: "Asia/Singapore" },
{ label: "AUS", timezone: "Australia/Sydney" },
{ label: "NZ", timezone: "Pacific/Auckland" }
],
workingHours: { start: 8, end: 18, workdays: [1, 2, 3, 4, 5] },
dimOutsideWorkingHours: true,
showSeconds: false,
barSize: 0
}
}
// Every field is coerced rather than trusted, so a half-finished hand-edit
// degrades to the default for that one key instead of breaking the bar.
function normalize(raw) {
var base = defaults()
if (!raw || typeof raw !== "object") return base
var out = base
if (Array.isArray(raw.clocks)) {
var clocks = []
for (var i = 0; i < raw.clocks.length; i++) {
var entry = raw.clocks[i]
if (!entry || typeof entry !== "object") continue
var tz = String(entry.timezone || "").replace(/^\s+|\s+$/g, "")
if (!tz) continue
var label = String(entry.label === undefined || entry.label === null ? "" : entry.label).replace(/^\s+|\s+$/g, "")
clocks.push({ label: label || tz, timezone: tz })
}
out.clocks = clocks
}
var wh = raw.workingHours
if (wh && typeof wh === "object") {
var start = Number(wh.start)
var end = Number(wh.end)
if (isFinite(start) && start >= 0 && start <= 24) out.workingHours.start = start
if (isFinite(end) && end >= 0 && end <= 24) out.workingHours.end = end
if (Array.isArray(wh.workdays)) {
var days = []
for (var d = 0; d < wh.workdays.length; d++) {
var n = Number(wh.workdays[d])
if (isFinite(n) && n >= 0 && n <= 6 && days.indexOf(n) === -1) days.push(n)
}
if (days.length) out.workingHours.workdays = days
}
}
if (typeof raw.dimOutsideWorkingHours === "boolean") out.dimOutsideWorkingHours = raw.dimOutsideWorkingHours
if (typeof raw.showSeconds === "boolean") out.showSeconds = raw.showSeconds
var size = Number(raw.barSize)
if (isFinite(size) && size >= 0 && size <= 200) out.barSize = size
return out
}
// Applying what was read. This path NEVER writes.
//
// FileView can emit `loaded` more than once while starting up, and the early
// emission can carry empty text before the read has actually landed. Seeding
// defaults from that and saving them destroyed the real file — the user's
// clocks were replaced by the stock five on the next shell restart. So an
// empty or unparseable read now leaves both the file and the last good
// config alone, and only a confirmed missing file seeds anything.
function apply(raw) {
var text = String(raw || "").replace(/^\s+|\s+$/g, "")
if (!text) {
root.loaded = true
return
}
var parsed
try {
parsed = JSON.parse(text)
} catch (e) {
root.parseError = String(e.message || e)
console.warn("clockbar: " + root.path + " is not valid JSON, keeping the last good config: " + root.parseError)
root.loaded = true
return
}
root.parseError = ""
root.config = normalize(parsed)
root.loaded = true
root.changed()
}
// The one place defaults are written, and only when the file genuinely is
// not there. A permissions error or an unknown failure must not be answered
// by overwriting whatever is on disk.
function seed() {
root.config = defaults()
root.parseError = ""
root.loaded = true
save(root.config)
}
function save(next) {
var clean = normalize(next)
root.config = clean
file.setText(JSON.stringify(clean, null, 2) + "\n")
root.parseError = ""
root.changed()
}
property FileView file: FileView {
path: root.path
watchChanges: true
atomicWrites: true
printErrors: false
onLoaded: root.apply(text())
onLoadFailed: function (error) {
if (error === FileViewError.FileNotFound) {
// First run. Seed the file so the user has something to edit and so
// the watcher has a real file to follow from here on.
root.seed()
return
}
console.warn("clockbar: could not read " + root.path + ": " + FileViewError.toString(error))
root.loaded = true
}
onFileChanged: reload()
}
Component.onCompleted: Qt.callLater(function () { root.file.reload() })
}
+383
View File
@@ -0,0 +1,383 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Ui
import "ClockModel.js" as Model
// The click-to-edit surface for the clock bar: add, rename, retime and remove
// clocks without touching JSON.
//
// It edits a draft copy and only writes on Save, so an abandoned edit leaves
// the file untouched — and because the write goes through the same Config
// object the file watcher feeds, the bar updates from disk either way. There
// is no second source of truth for the two config routes to disagree about.
Item {
id: root
property var configFile: null
property var zones: null
readonly property bool opened: controller.open
property var draft: []
property int workStart: 8
property int workEnd: 18
property bool dimOutside: true
readonly property color background: Color.popups.background
readonly property color foreground: Color.popups.text
readonly property color muted: Color.muted
readonly property color accent: Color.accent
readonly property color urgent: Color.urgent
readonly property var zoneOptions: {
var list = root.zones ? root.zones.allZones : []
var out = []
for (var i = 0; i < list.length; i++) out.push({ value: list[i], label: list[i] })
return out
}
QtObject {
id: controller
property bool open: false
}
function open() {
loadDraft()
controller.open = true
}
function close() { controller.open = false }
// A deep copy: editing the live config array in place would push
// half-finished rows straight onto the bar.
function loadDraft() {
var cfg = root.configFile ? root.configFile.config : null
var clocks = cfg && Array.isArray(cfg.clocks) ? cfg.clocks : []
var copy = []
for (var i = 0; i < clocks.length; i++)
copy.push({ label: String(clocks[i].label || ""), timezone: String(clocks[i].timezone || "") })
root.draft = copy
if (cfg && cfg.workingHours) {
root.workStart = Number(cfg.workingHours.start)
root.workEnd = Number(cfg.workingHours.end)
}
root.dimOutside = cfg ? cfg.dimOutsideWorkingHours !== false : true
}
// QML only re-renders a var array when the reference changes, so every
// mutation rebuilds it rather than editing in place.
function replaceDraft(next) { root.draft = next }
function updateRow(index, key, value) {
var next = root.draft.slice()
if (!next[index]) return
var row = { label: next[index].label, timezone: next[index].timezone }
row[key] = value
next[index] = row
replaceDraft(next)
}
function removeRow(index) {
var next = root.draft.slice()
next.splice(index, 1)
replaceDraft(next)
}
function addRow() {
var next = root.draft.slice()
next.push({ label: "", timezone: "" })
replaceDraft(next)
}
// A row is only saveable once it names a zone; the label falls back to the
// zone's own name so a clock can never render blank.
function saveableRows() {
var out = []
for (var i = 0; i < root.draft.length; i++) {
var tz = String(root.draft[i].timezone || "").replace(/^\s+|\s+$/g, "")
if (!tz) continue
var label = String(root.draft[i].label || "").replace(/^\s+|\s+$/g, "")
out.push({ label: label || tz.split("/").pop().replace(/_/g, " "), timezone: tz })
}
return out
}
function save() {
if (!root.configFile) return
var cfg = root.configFile.config
root.configFile.save({
version: 1,
clocks: saveableRows(),
workingHours: {
start: root.workStart,
end: root.workEnd,
workdays: cfg && cfg.workingHours ? cfg.workingHours.workdays : [1, 2, 3, 4, 5]
},
dimOutsideWorkingHours: root.dimOutside,
showSeconds: cfg ? cfg.showSeconds === true : false,
barSize: cfg ? cfg.barSize : 0
})
close()
}
// ------------------------------------------------------------ the surface
PanelWindow {
id: window
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "nerdnation-clockbar-editor"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.45)
}
// Click-away closes; the card swallows its own clicks so a click inside
// does not fall through to this one.
MouseArea {
anchors.fill: parent
onClicked: root.close()
}
BorderSurface {
id: card
anchors.centerIn: parent
width: Style.space(560)
height: Math.min(parent.height - Style.space(80), content.implicitHeight + card.contentTopInset + card.contentBottomInset)
radius: Style.cornerRadius
color: root.background
borderSpec: Border.localOrSurfaceSpec("popups", "border", Color.popups.border, Color.popups.border, Math.max(1, Style.space(2)))
padding: Style.spacing.popupPadding
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
anchors.fill: parent
anchors.margins: card.padding
focus: true
Keys.onEscapePressed: root.close()
ColumnLayout {
id: content
anchors.fill: parent
spacing: Style.space(10)
PanelSectionHeader {
Layout.fillWidth: true
text: "Clock bar"
}
Text {
Layout.fillWidth: true
text: "Clocks are placed automatically: behind your local time to the left, ahead to the right."
color: root.muted
wrapMode: Text.WordWrap
font.family: Style.font.family
font.pixelSize: Style.font.caption
}
// ------------------------------------------------------- the rows
Flickable {
Layout.fillWidth: true
// Tall enough for the rows it has, up to a cap that keeps the card
// on screen with a long clock list; past that it scrolls.
Layout.preferredHeight: Math.min(rows.implicitHeight, Style.space(300))
contentHeight: rows.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
ColumnLayout {
id: rows
width: parent.width
spacing: Style.space(6)
Repeater {
model: root.draft
delegate: RowLayout {
id: row
required property int index
required property var modelData
Layout.fillWidth: true
spacing: Style.space(8)
TextField {
Layout.preferredWidth: Style.space(74)
text: row.modelData.label
placeholderText: "Label"
foreground: root.foreground
accent: root.accent
onEditingFinished: root.updateRow(row.index, "label", text)
}
SearchableDropdown {
Layout.fillWidth: true
showLabel: false
value: row.modelData.timezone
options: root.zoneOptions
placeholderText: "Search timezones..."
triggerLabel: row.modelData.timezone || "Choose a timezone"
foreground: root.foreground
accent: root.accent
onChanged: function (value) { root.updateRow(row.index, "timezone", value) }
}
// The live offset, so the effect of a choice is visible
// before saving rather than only once the bar redraws.
Text {
Layout.preferredWidth: Style.space(52)
horizontalAlignment: Text.AlignRight
text: {
var off = root.zones && root.zones.offsets ? root.zones.offsets[row.modelData.timezone] : null
return off ? off : (row.modelData.timezone ? "—" : "")
}
color: root.muted
font.family: Style.font.family
font.pixelSize: Style.font.caption
}
Button {
iconText: "󰅙"
tooltipText: "Remove this clock"
foreground: root.foreground
accent: root.urgent
onClicked: root.removeRow(row.index)
}
}
}
Text {
Layout.fillWidth: true
visible: root.draft.length === 0
text: "No clocks yet — add one below."
color: root.muted
font.family: Style.font.family
font.pixelSize: Style.font.body
}
}
}
Button {
Layout.alignment: Qt.AlignLeft
text: "+ Add clock"
bordered: true
foreground: root.foreground
accent: root.accent
onClicked: root.addRow()
}
PanelSeparator { Layout.fillWidth: true }
// -------------------------------------------------- working hours
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
ToggleSwitch {
checked: root.dimOutside
foreground: root.foreground
accent: root.accent
onToggled: root.dimOutside = !root.dimOutside
}
Text {
Layout.fillWidth: true
text: "Dim clocks outside working hours"
color: root.foreground
elide: Text.ElideRight
font.family: Style.font.family
font.pixelSize: Style.font.body
}
}
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
opacity: root.dimOutside ? 1.0 : 0.45
Text {
Layout.fillWidth: true
text: "Working hours (local to each clock)"
color: root.muted
elide: Text.ElideRight
font.family: Style.font.family
font.pixelSize: Style.font.caption
}
NumberField {
Layout.preferredWidth: Style.spacing.numberFieldWidth
enabled: root.dimOutside
value: root.workStart
from: 0
to: 24
foreground: root.foreground
accent: root.accent
onModified: function (v) { root.workStart = v }
}
Text {
text: "to"
color: root.muted
font.family: Style.font.family
font.pixelSize: Style.font.body
}
NumberField {
Layout.preferredWidth: Style.spacing.numberFieldWidth
enabled: root.dimOutside
value: root.workEnd
from: 0
to: 24
foreground: root.foreground
accent: root.accent
onModified: function (v) { root.workEnd = v }
}
}
PanelSeparator { Layout.fillWidth: true }
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Text {
Layout.fillWidth: true
text: root.configFile ? root.configFile.path : ""
color: root.muted
elide: Text.ElideMiddle
font.family: Style.font.family
font.pixelSize: Style.font.caption
}
Button {
text: "Cancel"
bordered: true
foreground: root.foreground
accent: root.accent
onClicked: root.close()
}
Button {
text: "Save"
bordered: true
selected: true
foreground: root.foreground
accent: root.accent
onClicked: root.save()
}
}
}
}
}
}
}
+138
View File
@@ -0,0 +1,138 @@
# Clock bar
A second bar along the bottom edge of every screen, carrying world clocks laid
out as **one timeline**: furthest behind your local time at the far left,
furthest ahead at the far right, your own time dead centre.
```
LA 08:24 NYC 11:24 LDN 16:24 17:24 SG 23:24 TOK 00:24 +1 AUS 01:24 +1 NZ 03:24 +1
```
An Omarchy 4 shell plugin (Quickshell). It reserves its own space, so Hyprland
tiles windows above it exactly as it does for the top bar.
## What it does
- **Timeline layout.** Side and position are derived from each zone's live UTC
offset, not from config order. Reading left to right always moves forward in
time, and a DST shift re-sorts the bar on its own.
- **24-hour times**, with `+1` / `-1` against clocks on a different date to you.
- **Labels** to the left of each time. The centre clock is your local time and
carries no label.
- **Dimmed outside working hours**, evaluated in each clock's *own* timezone
(weekends included), so a glance tells you who is actually around.
- **Themed.** Colours, fonts, spacing and bar height all come from the active
Omarchy theme; it follows a theme switch with no restart.
## Install
On any Omarchy 4 machine:
```bash
omarchy plugin add <git-url-of-this-repo> --enable --yes
```
The plugin lands in `~/.config/omarchy/plugins/nerdnation.clockbar/`, and
`~/.config/omarchy/clockbar.json` is seeded on first run.
To update it later, and to remove it:
```bash
omarchy plugin update nerdnation.clockbar
omarchy plugin remove nerdnation.clockbar
```
> Plugins run as unsandboxed QML inside `omarchy-shell`, so `omarchy plugin add`
> clones disabled by default and wants you to read the code first. `--enable`
> skips that.
## Configuring it
Two routes, one source of truth — both read and write
`~/.config/omarchy/clockbar.json`, and the bar reloads from the file either way.
**Click the bar.** Opens an editor with a searchable timezone picker: add,
rename, retime and remove clocks, and set the working-hours window. Each row
shows that zone's current offset so you can see the effect before saving.
**Edit the file.** It hot-reloads on save, no restart:
```json
{
"version": 1,
"clocks": [
{ "label": "LA", "timezone": "America/Los_Angeles" },
{ "label": "NYC", "timezone": "America/New_York" },
{ "label": "SG", "timezone": "Asia/Singapore" },
{ "label": "AUS", "timezone": "Australia/Sydney" },
{ "label": "NZ", "timezone": "Pacific/Auckland" }
],
"workingHours": { "start": 8, "end": 18, "workdays": [1, 2, 3, 4, 5] },
"dimOutsideWorkingHours": true,
"showSeconds": false,
"barSize": 0
}
```
| Key | Meaning |
|---|---|
| `clocks[].label` | Shown left of the time. Defaults to the last part of the zone name. |
| `clocks[].timezone` | Any IANA zone (`timedatectl list-timezones`). |
| `workingHours.start` / `.end` | Hours, 0–24, in each clock's own zone. A window that wraps midnight (`22`–`6`) is understood. |
| `workingHours.workdays` | Days counted as working. `0` = Sunday. |
| `dimOutsideWorkingHours` | Mute clocks outside that window. |
| `showSeconds` | `HH:MM:SS` and a per-second tick, instead of `HH:MM`. |
| `barSize` | Bar height in px. `0` follows the theme's bar height. |
There is no `left` / `right` setting: which side a clock lands on is a fact
about its offset, not a preference.
**When something is wrong**, the bar says so rather than lying. A zone tzdata
cannot resolve renders as `LABEL --:--` in the theme's urgent colour instead of
silently falling back to UTC. A `clockbar.json` that will not parse turns the
bar's top hairline red and puts `config error` beside your local time, while
the last good config keeps ticking — and the broken file is never overwritten.
## IPC
```bash
omarchy-shell nerdnation.clockbar edit # open the editor
omarchy-shell nerdnation.clockbar toggle
omarchy-shell nerdnation.clockbar refresh # re-probe timezone offsets
omarchy-shell nerdnation.clockbar reload # re-read clockbar.json
```
Bind the editor to a key in `~/.config/hypr/bindings.lua` if you want one.
## How it works, and why
Qt's QML engine ships **no `Intl`**, so there is no way to render an IANA
timezone from inside QML — the usual `Intl.DateTimeFormat({ timeZone })` route
does not exist. Rather than bundle a copy of the tz database, the plugin asks
the system's own tzdata for each zone's current UTC offset (`TZ=<zone> date +%z`)
and then does the arithmetic itself. That keeps it correct across DST changes
and tzdata updates without knowing any of the rules.
The probe is one short-lived shell, and its answer stays valid until a
transition, so it runs on a slow timer — every ten minutes, on each hour
boundary (where transitions land), and whenever the config changes. In between,
the clocks advance by arithmetic on the minute tick.
`ClockModel.js` holds all of that logic and touches no QML types, so it runs
unchanged under node:
```bash
node tests/model.test.js
```
## Layout
| File | Role |
|---|---|
| `manifest.json` | Plugin manifest — `kind: panel`, `keepLoaded` |
| `Clockbar.qml` | Entry point: the bottom layer-shell surface, one per screen |
| `Editor.qml` | The click-to-edit popup |
| `Config.qml` | `clockbar.json` — read, watch, write |
| `Zones.qml` | Timezone offset probe and the zone list for the picker |
| `ClockModel.js` | Ordering, formatting, day offsets, working hours (pure JS) |
| `tests/model.test.js` | Unit tests for the above |
+112
View File
@@ -0,0 +1,112 @@
import QtQuick
import Quickshell.Io
import "ClockModel.js" as Model
// Supplies the two things QML cannot work out for itself: the current UTC
// offset of each configured zone, and the list of zones the picker can offer.
//
// Qt's QML engine ships no `Intl`, so there is no in-process way to render an
// IANA zone. Rather than bundle a copy of the tz database, this asks the
// system one through `date`, which keeps the bar correct across DST changes
// and tzdata updates without any code of ours needing to know the rules.
//
// The probe is cheap (one short-lived shell, a few `date` calls) and its
// result stays valid until a transition, so it runs on a slow timer instead of
// on every tick — the clocks themselves are advanced by arithmetic in between.
QtObject {
id: root
// zone name -> "+0200". A zone absent from here failed to resolve and is
// rendered as unknown rather than being silently shown as UTC.
property var offsets: ({})
property string localOffset: ""
property var allZones: []
property bool ready: false
property var zones: []
signal updated()
// Skipping zones tzdata does not have keeps a typo out of the output
// entirely, which is what lets the model tell "unknown zone" apart from
// "genuinely UTC". Zone names arrive as argv, never interpolated into the
// script, and the model has already filtered them to the tzdata name shape.
readonly property string probeScript:
'printf "%s\\t%s\\n" LOCAL "$(date +%z)"\n' +
'for z in "$@"; do\n' +
' [ -e "/usr/share/zoneinfo/$z" ] || continue\n' +
' printf "%s\\t%s\\n" "$z" "$(TZ="$z" date +%z)"\n' +
'done'
// A probe requested while one is in flight is remembered, not dropped.
// Config changes arrive as a burst of binding updates, so the request that
// actually carries the newly added zone is usually not the first one — and
// dropping it left that clock reading "--:--" until the ten-minute timer
// came round.
property bool probePending: false
function probe() {
if (probeProc.running) {
root.probePending = true
return
}
root.probePending = false
probeProc.command = ["sh", "-c", root.probeScript, "sh"].concat(root.zones)
probeProc.running = true
}
onZonesChanged: probe()
property Process probeProc: Process {
// Deferred so `running` has settled to false before the next probe starts.
onExited: if (root.probePending) Qt.callLater(root.probe)
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var report = Model.parseOffsetReport(text)
var local = report["LOCAL"]
delete report["LOCAL"]
if (local) root.localOffset = local
root.offsets = report
root.ready = true
root.updated()
}
}
}
property Process zoneListProc: Process {
command: ["sh", "-c",
// timedatectl is the canonical list; the zoneinfo walk is the fallback
// for a system where systemd is not answering.
"timedatectl list-timezones 2>/dev/null || " +
"find /usr/share/zoneinfo -type f -printf '%P\\n' 2>/dev/null | grep -E '^[A-Z][A-Za-z_+-]*/' | sort"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var out = []
var lines = String(text || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].replace(/^\s+|\s+$/g, "")
if (line) out.push(line)
}
root.allZones = out
}
}
}
// DST transitions land on the hour, so a re-probe shortly after every hour
// boundary catches one within a minute; the ten-minute tick is the safety
// net for tzdata updates and suspend/resume.
property Timer refreshTimer: Timer {
interval: 10 * 60 * 1000
repeat: true
running: true
onTriggered: root.probe()
}
Component.onCompleted: {
zoneListProc.running = true
probe()
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"id": "nerdnation.clockbar",
"name": "Clock bar",
"version": "1.0.0",
"author": "Harvey",
"description": "A second bar along the bottom of every screen showing world clocks, laid out as one timeline around your local time.",
"kinds": [
"panel"
],
"keepLoaded": true,
"entryPoints": {
"panel": "Clockbar.qml"
}
}
+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" : ""))