Files
OmarchyClockBar/ClockModel.js
T
HarveySaaymanandClaude Opus 5 b3123ecb3b Seconds per clock, and type that scales with the bar height
Seconds move from one bar-wide switch to a per-clock setting. A clock's own
`showSeconds` wins, the top-level one is the default for clocks with no
opinion, and the centre clock gets `local.showSeconds` since it has no entry in
`clocks[]`. An explicit false on a clock beats a true default, so the check is
for the property's presence rather than its truthiness.

The tick rate follows what is actually displayed: `anyShowsSeconds()` decides
between a per-second and a per-minute SystemClock, so a bar showing no seconds
does not wake the shell sixty times as often to redraw an identical minute.

`barSize` is now editable from the popup, and everything drawn on the bar is
sized against the theme's own bar height rather than pinned to the theme's
12px type. A taller bar carries proportionally larger text, gaps and rule; at
the theme's own height the scale is exactly 1, so nothing moves. Heights are
clamped to 12-120 rather than rejected, so a value the editor can produce can
never be one the config layer silently discards.

Fixes a latent bug this surfaced: updateRow() rebuilt a draft row from a fixed
pair of fields, so editing a clock's label would have thrown away its own
seconds setting. It now copies every key the row had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MhxtL7WzL5XmuTd8GHbcTY
2026-09-04 18:02:36 +02:00

247 lines
9.2 KiB
JavaScript

// 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
}
// Seconds are decided per clock, falling back to the config-wide default.
// An explicit `false` on a clock has to beat a `true` default, so the check is
// for the property's presence, not its truthiness.
function showsSeconds(entry, fallback) {
if (entry && typeof entry.showSeconds === "boolean") return entry.showSeconds
return fallback === true
}
// Whether anything on the bar is showing seconds, which is what decides
// between a per-second and a per-minute tick. Ticking every second when
// nothing displays them wakes the whole shell sixty times as often for no
// visible change.
function anyShowsSeconds(config) {
var cfg = config || {}
var fallback = cfg.showSeconds === true
if (showsSeconds(cfg.local, fallback)) return true
var entries = Array.isArray(cfg.clocks) ? cfg.clocks : []
for (var i = 0; i < entries.length; i++) {
if (showsSeconds(entries[i], fallback)) return true
}
return false
}
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 withSeconds = showsSeconds(entry, opts.showSeconds)
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: withSeconds ? "--:--:--" : "--:--",
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, withSeconds),
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, showsSeconds(cfg.local, 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,
showsSeconds: showsSeconds,
anyShowsSeconds: anyShowsSeconds,
buildClock: buildClock,
buildBar: buildBar,
zonesToProbe: zonesToProbe,
parseOffsetReport: parseOffsetReport
}
}