Files
OmarchyClockBar/ClockModel.js
T
HarveyandClaude Opus 5 460217d059 Let the timezone picker be searched by country
Finding a zone meant already knowing its name, which is the wrong way round
when the thing you know is the country -- Ghana's zone is named after Accra,
and nothing in the picker said so.

Zones now carry the countries they cover, drawn from tzdata's own tables:
zone1970.tab and zone.tab for the zone-to-country-code rows, iso3166.tab for
the names. The picker already matched on description as well as label, so
putting the countries there makes them searchable and shows them in the list.

Those tables only name about 420 of the ~600 zones; the rest are
backward-compatibility aliases like America/Buenos_Aires and US/Pacific, which
tzdata ships as hard links to their canonical zone. Matching an alias to its
canonical name by inode lifts coverage to 553, and the 45 left are the Etc/GMT
pseudo-zones, which have no country by definition.

The inherited countries are the union of every zone sharing the inode, not the
first one found: those siblings are one clock under several countries' names,
and each table names a different subset, so first-match made Africa/Asmera
findable as Ethiopia but not as Eritrea.

Joining is done in ClockModel against a fixture, so it does not depend on the
host's tzdata version; the shell side only reads the files.

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

330 lines
12 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
}
// ---------------------------------------------------------- zone catalogue
//
// Builds the picker's list: every valid zone, tagged with the countries it
// covers, so a search for "Ghana" finds Africa/Accra without the user knowing
// the zone is named after a city.
//
// The country data is tzdata's own. `zone1970.tab` lists one canonical zone
// per region and `zone.tab` the older per-country set; together they name
// about 420 of the ~600 zones. The rest are backward-compatibility aliases
// (America/Buenos_Aires, Africa/Asmera, US/Pacific), which tzdata ships as
// hard links to their canonical zone — so an alias is matched to its canonical
// name by inode and inherits its countries. Without that, searching a country
// would silently miss its legacy spellings.
function parseZoneCatalog(raw) {
var section = ""
var countryName = {} // "ZA" -> "South Africa"
var zoneCountries = {} // "Africa/Johannesburg" -> ["South Africa", ...]
var zoneInode = {} // zone -> inode
var inodeZones = {} // inode -> [zones]
var listed = []
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].replace(/\s+$/, "")
if (!line) continue
if (line.charAt(0) === "@") { section = line; continue }
if (line.charAt(0) === "#") continue
var f = line.split("\t")
if (section === "@ISO") {
if (f.length >= 2 && f[0]) countryName[f[0]] = f[1]
} else if (section === "@ZONE") {
// codes \t coordinates \t zone [\t comment]
if (f.length < 3 || !f[2]) continue
var zone = f[2]
var codes = f[0].split(",")
var acc = zoneCountries[zone] || []
for (var c = 0; c < codes.length; c++) {
var name = countryName[codes[c]]
// Union across both tab files, in first-seen order: a zone can appear
// in each with a different country set (Europe/Zurich is CH,DE,LI).
if (name && acc.indexOf(name) === -1) acc.push(name)
}
zoneCountries[zone] = acc
} else if (section === "@INODE") {
if (f.length < 2 || !f[1]) continue
zoneInode[f[1]] = f[0]
if (!inodeZones[f[0]]) inodeZones[f[0]] = []
inodeZones[f[0]].push(f[1])
} else if (section === "@LIST") {
listed.push(line)
}
}
var out = []
for (var j = 0; j < listed.length; j++) {
var z = listed[j]
var countries = zoneCountries[z]
if (!countries || !countries.length) {
// An alias: borrow from the zones sharing its inode. Union rather than
// first-match, because those siblings are the same clock under several
// countries' names and each tab file may name a different subset —
// taking only the first made Africa/Asmera searchable as Ethiopia but
// not as Eritrea.
var siblings = inodeZones[zoneInode[z]] || []
var merged = []
for (var k = 0; k < siblings.length; k++) {
var sc = zoneCountries[siblings[k]] || []
for (var m = 0; m < sc.length; m++) if (merged.indexOf(sc[m]) === -1) merged.push(sc[m])
}
countries = merged
}
out.push({ zone: z, countries: (countries || []).join(", ") })
}
out.sort(function (a, b) { return a.zone < b.zone ? -1 : (a.zone > b.zone ? 1 : 0) })
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,
parseZoneCatalog: parseZoneCatalog,
parseOffsetReport: parseOffsetReport
}
}