From 460217d059c8b5f2e793399c0400ca66cad65c07 Mon Sep 17 00:00:00 2001 From: Harvey Date: Fri, 4 Sep 2026 20:13:11 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01MhxtL7WzL5XmuTd8GHbcTY --- ClockModel.js | 83 +++++++++++++++++++++++++++++++++++++++++++++ Editor.qml | 9 +++-- README.md | 11 ++++-- Zones.qml | 36 ++++++++++++-------- tests/model.test.js | 71 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 18 deletions(-) diff --git a/ClockModel.js b/ClockModel.js index 0765576..484c669 100644 --- a/ClockModel.js +++ b/ClockModel.js @@ -214,6 +214,88 @@ function zonesToProbe(config) { 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 = {} @@ -241,6 +323,7 @@ if (typeof module !== "undefined" && module.exports) { buildClock: buildClock, buildBar: buildBar, zonesToProbe: zonesToProbe, + parseZoneCatalog: parseZoneCatalog, parseOffsetReport: parseOffsetReport } } diff --git a/Editor.qml b/Editor.qml index 7e1c105..3758e70 100644 --- a/Editor.qml +++ b/Editor.qml @@ -34,10 +34,15 @@ Item { readonly property color accent: Color.accent readonly property color urgent: Color.urgent + // The picker matches on label OR description, so putting the countries in + // the description makes a zone findable by country without knowing that, + // say, Ghana's zone is named after Accra. It shows there too, which is the + // other half of the problem — recognising the right zone once you see it. 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] }) + for (var i = 0; i < list.length; i++) + out.push({ value: list[i].zone, label: list[i].zone, description: list[i].countries }) return out } @@ -264,7 +269,7 @@ Item { showLabel: false value: row.modelData.timezone options: root.zoneOptions - placeholderText: "Search timezones..." + placeholderText: "Search by country or zone..." triggerLabel: row.modelData.timezone || "Choose a timezone" foreground: root.foreground accent: root.accent diff --git a/README.md b/README.md index 4f0029a..969e47f 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,15 @@ 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. +rename, retime and remove clocks, set seconds per clock, and set the +working-hours window and bar height. Each row shows that zone's current offset +so you can see the effect before saving. + +The picker searches **country names as well as zone names**, so you can find a +zone without knowing it is named after a city — typing `Ghana` finds +`Africa/Accra`. Country data is tzdata's own (`zone1970.tab`, `zone.tab`, +`iso3166.tab`); the legacy alias zones tzdata ships as hard links inherit the +countries of the canonical zone they point at. **Edit the file.** It hot-reloads on save, no restart: diff --git a/Zones.qml b/Zones.qml index 98b675f..6aea204 100644 --- a/Zones.qml +++ b/Zones.qml @@ -20,6 +20,8 @@ QtObject { // rendered as unknown rather than being silently shown as UTC. property var offsets: ({}) property string localOffset: "" + // [{ zone, countries }] for the picker: the zone name plus the countries it + // covers, so it can be found by country as well as by name. property var allZones: [] property bool ready: false @@ -75,23 +77,29 @@ QtObject { } } + // Emits tzdata's own country tables alongside the zone list, tagged into + // sections. The shell only reads files; the joining is done in ClockModel so + // it can be tested. `right/` and `posix/` are pruned because they duplicate + // every zone under a different inode, which would double the output and + // break the alias-by-inode match. + readonly property string catalogScript: + 'printf "@ISO\n"\n' + + 'cat /usr/share/zoneinfo/iso3166.tab 2>/dev/null\n' + + 'printf "@ZONE\n"\n' + + 'cat /usr/share/zoneinfo/zone1970.tab /usr/share/zoneinfo/zone.tab 2>/dev/null\n' + + 'printf "@INODE\n"\n' + + 'find /usr/share/zoneinfo -type f -not -path "*/right/*" -not -path "*/posix/*" ' + + '-printf "%i\\t%P\\n" 2>/dev/null\n' + + 'printf "@LIST\n"\n' + + 'timedatectl list-timezones 2>/dev/null || ' + + 'find /usr/share/zoneinfo -type f -not -path "*/right/*" -not -path "*/posix/*" ' + + '-printf "%P\\n" 2>/dev/null | grep -E "^[A-Z][A-Za-z_+-]*/" | sort' + 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"] + command: ["sh", "-c", root.catalogScript] 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 - } + onStreamFinished: root.allZones = Model.parseZoneCatalog(text) } } diff --git a/tests/model.test.js b/tests/model.test.js index d186b74..494a031 100644 --- a/tests/model.test.js +++ b/tests/model.test.js @@ -249,4 +249,75 @@ test("an unresolved clock keeps the width of the format it asked for", function assert.strictEqual(bar.right[1].time, "--:--:--") }) +// A miniature stand-in for the tzdata files, so these tests do not depend on +// the host's tzdata version. Africa/Asmera is a hard link to Africa/Nairobi +// (inode 100) and carries no country row of its own. +var CATALOG = [ + "@ISO", + "# a comment that must be skipped", + "ZA\tSouth Africa", + "LS\tLesotho", + "ER\tEritrea", + "ET\tEthiopia", + "CH\tSwitzerland", + "DE\tGermany", + "@ZONE", + "ZA,LS\t-2615+02800\tAfrica/Johannesburg", + "CH,DE\t+4723+00832\tEurope/Zurich\tBusingen", + "ER\t+1520+03853\tAfrica/Asmara", + "ET\t+0901+03842\tAfrica/Nairobi", + "@INODE", + "100\tAfrica/Nairobi", + "100\tAfrica/Asmara", + "100\tAfrica/Asmera", + "200\tAfrica/Johannesburg", + "300\tEtc/GMT+5", + "@LIST", + "Africa/Johannesburg", + "Europe/Zurich", + "Africa/Asmera", + "Etc/GMT+5" +].join("\n") + +test("the catalogue tags zones with their countries", function () { + var cat = M.parseZoneCatalog(CATALOG) + var by = {} + cat.forEach(function (c) { by[c.zone] = c.countries }) + assert.strictEqual(by["Africa/Johannesburg"], "South Africa, Lesotho", "all codes on the row") + assert.strictEqual(by["Europe/Zurich"], "Switzerland, Germany") +}) + +test("a legacy alias inherits countries from its hard-link siblings", function () { + var by = {} + M.parseZoneCatalog(CATALOG).forEach(function (c) { by[c.zone] = c.countries }) + // Asmera has no row of its own; it shares inode 100 with Asmara and Nairobi, + // so it must be findable by BOTH of their countries, not just the first. + assert.ok(by["Africa/Asmera"].indexOf("Eritrea") !== -1, "Eritrea, via Asmara") + assert.ok(by["Africa/Asmera"].indexOf("Ethiopia") !== -1, "Ethiopia, via Nairobi") +}) + +test("zones with no country are listed, not dropped", function () { + var cat = M.parseZoneCatalog(CATALOG) + var etc = cat.filter(function (c) { return c.zone === "Etc/GMT+5" })[0] + assert.ok(etc, "still offered in the picker") + assert.strictEqual(etc.countries, "", "just has nothing to match a country search") +}) + +test("the catalogue is sorted and only contains listed zones", function () { + var cat = M.parseZoneCatalog(CATALOG) + var names = cat.map(function (c) { return c.zone }) + assert.deepStrictEqual(names, names.slice().sort(), "sorted by zone") + assert.strictEqual(names.indexOf("Africa/Nairobi"), -1, + "Nairobi supplies country data but was not in @LIST, so it is not offered") + assert.strictEqual(names.length, 4) +}) + +test("a malformed or empty catalogue yields an empty list, not a crash", function () { + assert.deepStrictEqual(M.parseZoneCatalog(""), []) + assert.deepStrictEqual(M.parseZoneCatalog(null), []) + assert.deepStrictEqual(M.parseZoneCatalog("garbage\nwith no sections"), []) + assert.deepStrictEqual(M.parseZoneCatalog("@LIST\nAfrica/Cairo"), + [{ zone: "Africa/Cairo", countries: "" }], "a list with no country data still works") +}) + console.log(passed + " passed" + (process.exitCode ? ", some FAILED" : ""))