// 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+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 } }