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