diff --git a/timezone-hub/README.md b/timezone-hub/README.md new file mode 100644 index 000000000..4058a05bc --- /dev/null +++ b/timezone-hub/README.md @@ -0,0 +1,85 @@ +# Timezone Hub + +Timezone Hub is a multi-timezone clock for comparing cities, working hours, +dates, and daylight at a glance. It can also change the device timezone from +the Noctalia panel. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `ahmedhossamdev/timezone-hub` | +| Entries | Bar widget: `bar`; panel: `panel`; service: `service` | + +## Requirements + +- Install `timedatectl` on `PATH` to detect, list, and change timezones. +- Install `pkexec` on `PATH` and run a polkit authentication agent to authorize + device-wide timezone changes. +- `/usr/share/zoneinfo/zone1970.tab` or `/usr/share/zoneinfo/zone.tab` is + optional and supplies representative coordinates for sunrise and sunset. + +## Usage + +Add the **Timezone Hub** bar widget in **Settings → Bar**. Click the widget to +open the comparison panel, or use: + +```sh +noctalia msg panel-toggle ahmedhossamdev/timezone-hub:panel +``` + +The panel always shows the device timezone first. Use **Add a timezone** to +search the IANA timezone database and add comparison cities. Drag the grip to +reorder a city, use its eye button to show or hide it in the bar widget, use +the pin button to make it the device timezone, and use the trash button to +remove it. Up to three timezones can appear in the bar. + +The pencil button changes the device timezone directly. This launches a polkit +authentication prompt because the setting applies to the whole system. The +settings button opens the plugin's settings page. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `time_format` | `select` | `24h` | Uses 24-hour or 12-hour times. | +| `show_date_in_bar` | `bool` | `true` | Shows each selected timezone's date in the bar widget. | +| `show_date_in_panel` | `bool` | `true` | Shows the local date in every panel row. | +| `show_relative_time` | `bool` | `true` | Shows ahead/behind, day, and working-hours descriptions. | +| `show_sun_times` | `bool` | `false` | Shows locally calculated sunrise and sunset times when timezone coordinate data is available. | +| `date_format` | `select` | `weekday` | Formats dates as weekday, short, or ISO. | +| `hours_before` | `int` | `2` | Number of hours before the selected time shown in each hour strip, from 0 to 6. | +| `hours_after` | `int` | `9` | Number of hours after the selected time shown in each hour strip, from 3 to 16. | +| `highlight_work_hours` | `bool` | `true` | Highlights the configured working-hour range. | +| `work_hour_start` | `int` | `9` | Working day start hour, from 0 to 23. | +| `work_hour_end` | `int` | `17` | Working day end hour, from 1 to 24. | + +## IPC + +Open the panel: + +```sh +noctalia msg panel-toggle ahmedhossamdev/timezone-hub:panel +``` + +Manage comparison timezones through the `service` entry: + +```sh +noctalia msg plugin ahmedhossamdev/timezone-hub:service all add "America/Los_Angeles" +noctalia msg plugin ahmedhossamdev/timezone-hub:service all remove "America/Los_Angeles" +noctalia msg plugin ahmedhossamdev/timezone-hub:service all list +``` + +`add` and `remove` accept one exact IANA timezone. `list` returns the saved +comparison timezones. + +## Notes + +- The plugin writes comparison timezones and bar selections to its Noctalia + plugin data directory so they survive shell and plugin restarts. +- It makes no network requests. +- Sunrise and sunset are calculated locally from representative coordinates in + the system timezone database. Times can vary within large timezones, and no + value is shown for polar dates on which the sun does not rise or set. +- Changing the device timezone spawns `pkexec timedatectl set-timezone` and may + show an authentication prompt. diff --git a/timezone-hub/bar.luau b/timezone-hub/bar.luau new file mode 100644 index 000000000..4cd26c3b5 --- /dev/null +++ b/timezone-hub/bar.luau @@ -0,0 +1,70 @@ +--!nonstrict +-- Timezone Hub bar widget — shows the device's local time (or, if chosen +-- in the panel, a comparison city's time instead) with its zone +-- abbreviation. Click opens the panel. + +local rows = {} +local DEVICE_BAR_KEY = "__device__" +local barTzs = { DEVICE_BAR_KEY } + +local function findRow(tz) + if tz == DEVICE_BAR_KEY then return rows[1] end + for _, row in ipairs(rows) do + if row.tz == tz then + return row + end + end + return nil +end + +local function render() + local selectedRows = {} + for _, zone in ipairs(barTzs) do + local row = findRow(zone) + if row ~= nil then table.insert(selectedRows, row) end + end + if #selectedRows == 0 and rows[1] ~= nil then + table.insert(selectedRows, rows[1]) + end + if #selectedRows == 0 then + barWidget.setGlyph("world") + barWidget.setText("--:--") + return + end + + barWidget.setGlyph("world") + local segments = {} + for _, row in ipairs(selectedRows) do + local label = row.tz and row.tz:match(".*/(.*)$") or nil + label = (label or row.label or "Time"):gsub("_", " ") + local segment = label + if noctalia.getConfig("show_date_in_bar") ~= false + and row.dateText and row.dateText ~= "" + then + segment = segment .. " " .. row.dateText + end + segment = segment .. " " .. (row.timeText or "--:--") + table.insert(segments, segment) + end + barWidget.setText(table.concat(segments, " · ")) +end + +function update() + render() +end + +function onClick() + noctalia.togglePanel("ahmedhossamdev/timezone-hub:panel") +end + +noctalia.state.watch("timezone_hub.rows", function(value) + rows = type(value) == "table" and value or {} + render() +end) + +noctalia.state.watch("timezone_hub.bar_tzs", function(value) + barTzs = type(value) == "table" and value or { DEVICE_BAR_KEY } + render() +end) + +render() diff --git a/timezone-hub/panel.luau b/timezone-hub/panel.luau new file mode 100644 index 000000000..71d205ff2 --- /dev/null +++ b/timezone-hub/panel.luau @@ -0,0 +1,765 @@ +--!nonstrict +-- Timezone Hub panel — device timezone pinned first, then every comparison +-- city: label, live time, UTC offset, delta vs device, and a compact +-- (non-scrolling) hour-offset strip with the current hour highlighted and +-- work hours shaded. A second view is a shared searchable IANA-zone picker, +-- used both to add a comparison city and to change the device timezone. + +local DRAG_TYPE = "timezone-hub-city" +local WEEKDAY_NAMES = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" } + +local rows = {} +local comparisons = {} +local deviceTz = "" +local changingDeviceTz = false +local DEVICE_BAR_KEY = "__device__" +local barTzs = { DEVICE_BAR_KEY } +local allZones = {} +local allZonesLoaded = false + +local view = "list" -- "list" | "picker" +local pickerMode = "add" -- "add" | "device" +local query = "" +local queryKey = 0 +local pendingDelete = nil -- tz pending trash confirmation +local hourOffset = 0 -- hours the strip window is shifted from "now" (paging, since ui.scroll has no horizontal mode) + +-- --------------------------------------------------------------------- +-- Helpers +-- --------------------------------------------------------------------- +local function rowFor(tz) + for _, row in ipairs(rows) do + if row.tz == tz then + return row + end + end + return nil +end + +local function shortLabel(tz) + local slash = tz:match(".*/(.*)$") + if slash ~= nil then + return (slash:gsub("_", " ")) + end + return tz +end + +local function roundHalf(x) + if x < 0 then + return -roundHalf(-x) + end + return math.floor(x * 2 + 0.5) / 2 +end + +local function diffLabel(offsetMinutes, deviceOffsetMinutes) + if offsetMinutes == nil or deviceOffsetMinutes == nil then + return "" + end + local diff = roundHalf((offsetMinutes - deviceOffsetMinutes) / 60) + if diff == 0 then + return noctalia.tr("row.same-time") + end + return (diff > 0 and "+" or "") .. tostring(diff) .. "h" +end + +local function barZoneSelected(key) + for _, selected in ipairs(barTzs) do + if selected == key then return true end + end + return false +end + +local function daysBeforeYear(year) + local previous = year - 1 + return 365 * previous + + math.floor(previous / 4) + - math.floor(previous / 100) + + math.floor(previous / 400) +end + +local function localDateInfo(zone) + local raw = noctalia.formatTime("%Y|%j|%H", nil, zone) + local year, day, hour = raw:match("(%d+)|(%d+)|(%d+)") + if year == nil then return nil end + return { + ordinal = daysBeforeYear(tonumber(year)) + tonumber(day), + hour = tonumber(hour), + } +end + +local function offsetDescription(offsetMinutes, deviceOffsetMinutes) + if offsetMinutes == nil or deviceOffsetMinutes == nil then return nil end + local difference = offsetMinutes - deviceOffsetMinutes + if difference == 0 then return noctalia.tr("relative.same-time") end + local absolute = math.abs(difference) + local hours = math.floor(absolute / 60) + local minutes = absolute % 60 + local amount + if hours == 0 then + amount = tostring(minutes) .. "m" + elseif minutes == 0 then + amount = tostring(hours) .. "h" + else + amount = tostring(hours) .. "h " .. tostring(minutes) .. "m" + end + return noctalia.tr(difference > 0 and "relative.ahead" or "relative.behind", { offset = amount }) +end + +local function relativeDescriptions(row, deviceRow, workStart, workEnd) + if noctalia.getConfig("show_relative_time") == false then + local fallback = row.isDevice and "" or diffLabel(row.offsetMinutes, deviceRow and deviceRow.offsetMinutes or nil) + return fallback ~= "" and { fallback } or {} + end + + local parts = {} + if not row.isDevice then + local offset = offsetDescription(row.offsetMinutes, deviceRow and deviceRow.offsetMinutes or nil) + if offset ~= nil then table.insert(parts, offset) end + end + + local here = localDateInfo(row.tz) + local device = deviceRow and localDateInfo(deviceRow.tz) or nil + if here ~= nil and device ~= nil then + local dayDifference = here.ordinal - device.ordinal + if dayDifference ~= 0 then + local day + if dayDifference == -1 then + day = noctalia.tr("relative.yesterday") + elseif dayDifference == 1 then + day = noctalia.tr("relative.tomorrow") + else + day = noctalia.tr("relative.day-offset", { days = (dayDifference > 0 and "+" or "") .. tostring(dayDifference) }) + end + local period + if here.hour < 5 then + period = noctalia.tr("relative.night") + elseif here.hour < 12 then + period = noctalia.tr("relative.morning") + elseif here.hour < 17 then + period = noctalia.tr("relative.afternoon") + elseif here.hour < 21 then + period = noctalia.tr("relative.evening") + else + period = noctalia.tr("relative.night") + end + table.insert(parts, day .. " " .. period) + end + table.insert(parts, noctalia.tr( + here.hour >= workStart and here.hour < workEnd + and "relative.inside-work-hours" + or "relative.outside-work-hours" + )) + end + return parts +end + +local function flooredHourEpoch() + return math.floor(math.floor(noctalia.nowMs() / 1000) / 3600) * 3600 + hourOffset * 3600 +end + +local function cellInfo(zone, colEpoch) + local raw = noctalia.formatTime("%w|%d|%H", colEpoch, zone) + local w, d, h = raw:match("(%d+)|(%d+)|(%d+)") + if w == nil then + return nil + end + return { weekday = tonumber(w), day = tonumber(d), hour = tonumber(h) } +end + +local function cellLabel(zone, colEpoch, use24h) + local info = cellInfo(zone, colEpoch) + if info == nil then + return "…" + end + if info.hour == 0 then + return WEEKDAY_NAMES[info.weekday + 1] .. " " .. info.day + end + if use24h then + return tostring(info.hour) + end + local h12 = info.hour % 12 + if h12 == 0 then + h12 = 12 + end + return tostring(h12) +end + +local function cellIsWork(zone, colEpoch, workStart, workEnd) + local info = cellInfo(zone, colEpoch) + return info ~= nil and info.hour >= workStart and info.hour < workEnd +end + +-- --------------------------------------------------------------------- +-- Drag & drop (comparison rows only) +-- --------------------------------------------------------------------- +local function insertionZone(index) + return ui.dropZone({ + key = "gap-" .. index, + accepts = { DRAG_TYPE }, + value = tostring(index), + onDrop = "onZoneDropped", + height = 3, + radius = 4, + expandOnDrag = true, + hitSlop = 20, + }) +end + +function onZoneDropped(payload, value) + local insertAt = tonumber(value) + if type(payload) ~= "string" or payload == "" or insertAt == nil then + return + end + noctalia.state.set("timezone_hub.cmd", { op = "reorder", tz = payload, index = insertAt }) +end + +-- --------------------------------------------------------------------- +-- Row rendering +-- --------------------------------------------------------------------- +-- The strip gets an explicit pixel budget rather than flexGrow: flexGrow +-- competing against fixed-width siblings (grip/info/actions) in the same +-- row was not reliably claiming its full share, leaving a dead gap before +-- the action buttons. An explicit width, divided evenly among however many +-- columns are configured, is deterministic instead. +local STRIP_WIDTH = 320 +local CELL_GAP = 1 + +local function hourStrip(zone, hoursBefore, hoursAfter, use24h, highlightWork, workStart, workEnd) + local floored = flooredHourEpoch() + local columnCount = hoursBefore + hoursAfter + 1 + local cellWidth = (STRIP_WIDTH - (columnCount - 1) * CELL_GAP) / columnCount + local cells = {} + for col = -hoursBefore, hoursAfter do + local isNow = col == -hourOffset + local cellProps = { + key = "cell-" .. tostring(col), + width = cellWidth, + align = "center", + justify = "center", + radius = 3, + } + if isNow then + cellProps.fill = "primary/0.18" + elseif highlightWork and cellIsWork(zone, floored + col * 3600, workStart, workEnd) then + cellProps.fill = "on_surface/0.05" + end + table.insert( + cells, + ui.column(cellProps, { + ui.label({ + text = cellLabel(zone, floored + col * 3600, use24h), + fontSize = isNow and 11 or 9, + fontWeight = isNow and "bold" or "normal", + color = isNow and "primary" or "on_surface_variant", + textAlign = "center", + }), + }) + ) + end + return ui.row({ width = STRIP_WIDTH, gap = CELL_GAP }, cells) +end + +local ACTION_BUTTON_SIZE = 28 + +local function rowActions(row, isDeviceRow) + if not isDeviceRow and pendingDelete == row.tz then + return { + ui.button({ + glyph = "check", + glyphSize = 14, + variant = "destructive", + width = ACTION_BUTTON_SIZE, + height = ACTION_BUTTON_SIZE, + paddingH = 0, + paddingV = 0, + tooltip = noctalia.tr("row.confirm-remove"), + onClick = function() + noctalia.state.set("timezone_hub.cmd", { op = "remove", tz = row.tz }) + pendingDelete = nil + end, + }), + ui.button({ + glyph = "close", + glyphSize = 14, + variant = "ghost", + width = ACTION_BUTTON_SIZE, + height = ACTION_BUTTON_SIZE, + paddingH = 0, + paddingV = 0, + tooltip = noctalia.tr("row.cancel-remove"), + onClick = function() + pendingDelete = nil + render() + end, + }), + } + end + + local barKey = isDeviceRow and DEVICE_BAR_KEY or row.tz + local starSelected = barZoneSelected(barKey) + local actions = { + ui.button({ + glyph = starSelected and "eye" or "eye-off", + glyphSize = 14, + variant = "ghost", + width = ACTION_BUTTON_SIZE, + height = ACTION_BUTTON_SIZE, + paddingH = 0, + paddingV = 0, + selected = starSelected, + tooltip = noctalia.tr(starSelected and "row.hide-from-bar" or "row.show-in-bar"), + onClick = function() + noctalia.state.set("timezone_hub.cmd", { op = "toggle_bar_zone", tz = isDeviceRow and "" or row.tz }) + end, + }), + } + + if not isDeviceRow then + table.insert( + actions, + ui.button({ + glyph = "pin-filled", + glyphSize = 14, + variant = "ghost", + width = ACTION_BUTTON_SIZE, + height = ACTION_BUTTON_SIZE, + paddingH = 0, + paddingV = 0, + tooltip = noctalia.tr("row.set-device"), + enabled = not changingDeviceTz, + onClick = function() + noctalia.state.set("timezone_hub.cmd", { op = "set_device", tz = row.tz }) + end, + }) + ) + + table.insert( + actions, + ui.button({ + glyph = "trash", + glyphSize = 14, + variant = "ghost", + width = ACTION_BUTTON_SIZE, + height = ACTION_BUTTON_SIZE, + paddingH = 0, + paddingV = 0, + tooltip = noctalia.tr("row.remove"), + onClick = function() + pendingDelete = row.tz + render() + end, + }) + ) + end + + return actions +end + +-- Every row shares the same fixed-width leading (grip) and trailing +-- (actions) slots regardless of how many action buttons or whether a grip +-- is shown, so the strip's flexGrow space is identical across rows and the +-- hour columns line up between the device row and every comparison row. +local GRIP_SLOT_WIDTH = 20 +-- Three explicit 28px controls plus 8px inset on either side. Keeping the +-- hitboxes fixed prevents the final action from painting beyond the card. +local ACTIONS_SLOT_WIDTH = 100 +local SCROLLBAR_GUTTER = 16 + +local function cityRow(row, isDeviceRow, hoursBefore, hoursAfter, use24h, highlightWork, workStart, workEnd) + local gripSlot + if isDeviceRow then + gripSlot = ui.column({ width = GRIP_SLOT_WIDTH }, {}) + else + gripSlot = ui.dragSource({ + key = "grip-" .. row.tz, + dragType = DRAG_TYPE, + payload = row.tz, + previewAncestor = 1, + liftFromLayout = true, + width = GRIP_SLOT_WIDTH, + height = 20, + align = "center", + justify = "center", + tooltip = noctalia.tr("row.drag-tooltip"), + }, { + ui.glyph({ name = "menu-2", size = 12, color = "on_surface_variant" }), + }) + end + + local infoItems = { + ui.label({ text = row.label, fontWeight = "bold", fontSize = 12, maxLines = 1 }), + ui.label({ text = row.tz, fontSize = 9, color = "on_surface_variant", maxLines = 1 }), + ui.row({ gap = 4, align = "center", justify = "start" }, { + ui.label({ text = row.timeText or "--:--", fontWeight = "bold", fontSize = 14, color = isDeviceRow and "primary" or "on_surface" }), + ui.label({ text = row.abbrev or "", fontSize = 9, color = "on_surface_variant" }), + }), + } + if noctalia.getConfig("show_date_in_panel") ~= false + and row.dateText and row.dateText ~= "" + then + table.insert(infoItems, ui.label({ + text = row.dateText, + fontSize = 9, + color = "on_surface_variant", + maxLines = 1, + })) + end + if row.sunriseText and row.sunsetText then + table.insert(infoItems, ui.row({ gap = 3, align = "center", justify = "start" }, { + ui.glyph({ name = "sunrise", size = 10, color = "on_surface_variant" }), + ui.label({ text = row.sunriseText, fontSize = 9, color = "on_surface_variant" }), + ui.glyph({ name = "sunset", size = 10, color = "on_surface_variant" }), + ui.label({ text = row.sunsetText, fontSize = 9, color = "on_surface_variant" }), + })) + end + local relativeLines = relativeDescriptions(row, rows[1], workStart, workEnd) + for _, relative in ipairs(relativeLines) do + table.insert(infoItems, ui.label({ + text = relative, + fontSize = 9, + color = "primary", + maxLines = 1, + })) + end + local info = ui.column({ width = 132, gap = 1, justify = "start" }, infoItems) + + local strip = hourStrip(row.tz, hoursBefore, hoursAfter, use24h, highlightWork, workStart, workEnd) + + local actionsSlot = ui.row( + { + width = ACTIONS_SLOT_WIDTH, + gap = 0, + paddingH = 8, + align = "center", + justify = "end", + }, + rowActions(row, isDeviceRow) + ) + + return ui.row({ + key = "row-" .. row.key, + gap = 6, + align = "center", + justify = "start", + paddingV = 4, + paddingH = 6, + fill = "surface_variant/0.25", + radius = 6, + }, { gripSlot, info, strip, actionsSlot }) +end + +-- --------------------------------------------------------------------- +-- List view +-- --------------------------------------------------------------------- +local function listView() + local hoursBefore = noctalia.getConfig("hours_before") or 1 + local hoursAfter = noctalia.getConfig("hours_after") or 5 + local use24h = (noctalia.getConfig("time_format") or "24h") == "24h" + local highlightWork = noctalia.getConfig("highlight_work_hours") + if highlightWork == nil then + highlightWork = true + end + local workStart = noctalia.getConfig("work_hour_start") or 9 + local workEnd = noctalia.getConfig("work_hour_end") or 17 + + local body = {} + + table.insert( + body, + ui.row({ align = "center", justify = "space_between" }, { + ui.row({ gap = 8, align = "center", justify = "start", flexGrow = 1 }, { + ui.glyph({ name = "world", size = 20, color = "primary" }), + ui.column({ gap = 0, justify = "start" }, { + ui.label({ text = noctalia.tr("panel.title"), fontSize = 14, fontWeight = "bold" }), + ui.label({ + text = deviceTz ~= "" and (noctalia.tr("panel.subtitle") .. " " .. deviceTz) or noctalia.tr("panel.detecting"), + fontSize = 10, + color = "on_surface_variant", + }), + }), + }), + ui.row({ gap = 2, justify = "start" }, { + ui.button({ + glyph = "pencil", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("row.change-device"), + enabled = not changingDeviceTz, + onClick = function() + openPicker("device") + end, + }), + ui.button({ + glyph = "plus", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.add"), + enabled = #comparisons < 6, + onClick = function() + openPicker("add") + end, + }), + ui.button({ + glyph = "settings", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.open-settings"), + onClick = function() + noctalia.openSettings() + end, + }), + ui.button({ glyph = "close", variant = "ghost", controlSize = "sm", onClick = function() + panel.close() + end }), + }), + }) + ) + + table.insert(body, ui.separator({})) + + local windowSpan = hoursBefore + hoursAfter + 1 + table.insert( + body, + ui.row({ gap = 4, align = "center", justify = "center" }, { + ui.button({ + glyph = "chevron-left", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.earlier"), + onClick = function() + hourOffset -= windowSpan + render() + end, + }), + ui.button({ + text = hourOffset == 0 and noctalia.tr("panel.now") or ((hourOffset > 0 and "+" or "") .. tostring(hourOffset) .. "h"), + variant = hourOffset == 0 and "ghost" or "outline", + controlSize = "sm", + enabled = hourOffset ~= 0, + tooltip = noctalia.tr("panel.jump-to-now"), + onClick = function() + hourOffset = 0 + render() + end, + }), + ui.button({ + glyph = "chevron-right", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.later"), + onClick = function() + hourOffset += windowSpan + render() + end, + }), + }) + ) + + if rows[1] ~= nil then + -- Comparison rows live in a scroll viewport, which reserves space for its + -- scrollbar. Give the fixed device row the same right gutter so both card + -- widths and every fixed-width child column line up exactly. + table.insert(body, ui.column({ + paddingRight = SCROLLBAR_GUTTER, + align = "stretch", + }, { + cityRow(rows[1], true, hoursBefore, hoursAfter, use24h, highlightWork, workStart, workEnd), + })) + end + + local list = {} + for i, entry in ipairs(comparisons) do + local row = rowFor(entry.tz) + if row ~= nil then + table.insert(list, insertionZone(i)) + table.insert(list, cityRow(row, false, hoursBefore, hoursAfter, use24h, highlightWork, workStart, workEnd)) + end + end + if #comparisons > 0 then + table.insert(list, insertionZone(#comparisons + 1)) + end + + if #list == 0 then + table.insert(body, ui.label({ + text = noctalia.tr("settings.no-comparisons"), + fontSize = 11, + color = "on_surface_variant", + })) + else + table.insert(body, ui.scroll({ + flexGrow = 1, + gap = 4, + padding = 0, + align = "stretch", + justify = "start", + }, list)) + end + + return ui.column({ flexGrow = 1, gap = 6, padding = 8, justify = "start" }, body) +end + +-- --------------------------------------------------------------------- +-- Picker view +-- --------------------------------------------------------------------- +local function filteredZones() + local q = noctalia.string.trim(query):lower() + local out = {} + for _, z in ipairs(allZones) do + if q == "" or z:lower():find(q, 1, true) ~= nil then + table.insert(out, z) + if #out >= 100 then + break + end + end + end + return out +end + +local function pick(tz) + if pickerMode == "device" then + noctalia.state.set("timezone_hub.cmd", { op = "set_device", tz = tz }) + else + noctalia.state.set("timezone_hub.cmd", { op = "add", tz = tz, label = shortLabel(tz) }) + end + view = "list" + render() +end + +function onPickerSubmit(_value) + local matches = filteredZones() + if #matches == 1 then + pick(matches[1]) + end +end + +local function pickerView() + local body = { + ui.row({ gap = 6, align = "center", justify = "start" }, { + ui.button({ + glyph = "arrow-left", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("picker.back"), + onClick = function() + view = "list" + render() + end, + }), + ui.label({ + text = pickerMode == "device" and noctalia.tr("picker.title-device") or noctalia.tr("picker.title-add"), + fontSize = 13, + fontWeight = "bold", + flexGrow = 1, + }), + }), + ui.input({ + key = "search-" .. tostring(queryKey), + value = query, + placeholder = noctalia.tr("picker.search-placeholder"), + submitOnEnter = true, + onChange = function(value) + query = value + render() + end, + onSubmit = "onPickerSubmit", + }), + } + + if not allZonesLoaded then + table.insert(body, ui.label({ text = noctalia.tr("picker.loading"), fontSize = 10, color = "on_surface_variant" })) + end + + local matches = filteredZones() + if allZonesLoaded and #matches == 0 then + table.insert(body, ui.label({ text = noctalia.tr("picker.empty"), fontSize = 11, color = "on_surface_variant" })) + else + local buttons = {} + for _, z in ipairs(matches) do + table.insert( + buttons, + ui.button({ + key = "zone-" .. z, + text = (z:gsub("_", " ")), + glyph = "pin-filled", + variant = "ghost", + contentAlign = "start", + onClick = function() + pick(z) + end, + }) + ) + end + table.insert(body, ui.scroll({ flexGrow = 1, gap = 2, align = "stretch", justify = "start" }, buttons)) + end + + return ui.column({ flexGrow = 1, gap = 6, padding = 8, justify = "start" }, body) +end + +-- --------------------------------------------------------------------- +-- Render dispatch +-- --------------------------------------------------------------------- +function render() + if view == "picker" then + panel.render(pickerView()) + else + panel.render(listView()) + end +end + +function openPicker(mode) + pickerMode = mode + view = "picker" + query = "" + queryKey += 1 + pendingDelete = nil + if not allZonesLoaded then + noctalia.state.set("timezone_hub.cmd", { op = "load_zones" }) + end + render() +end + +-- --------------------------------------------------------------------- +-- Lifecycle +-- --------------------------------------------------------------------- +function onOpen(_context) + view = "list" + pendingDelete = nil + hourOffset = 0 + panel.setWantsSecondTicks(true) + render() +end + +noctalia.state.watch("timezone_hub.rows", function(value) + rows = type(value) == "table" and value or {} + render() +end) + +noctalia.state.watch("timezone_hub.comparisons", function(value) + comparisons = type(value) == "table" and value or {} + render() +end) + +noctalia.state.watch("timezone_hub.device_tz", function(value) + deviceTz = type(value) == "string" and value or "" + render() +end) + +noctalia.state.watch("timezone_hub.changing_device_tz", function(value) + changingDeviceTz = value == true + render() +end) + +noctalia.state.watch("timezone_hub.bar_tzs", function(value) + barTzs = type(value) == "table" and value or { DEVICE_BAR_KEY } + render() +end) + +noctalia.state.watch("timezone_hub.all_zones", function(value) + allZones = type(value) == "table" and value or {} + render() +end) + +noctalia.state.watch("timezone_hub.all_zones_loaded", function(value) + allZonesLoaded = value == true + render() +end) diff --git a/timezone-hub/plugin.toml b/timezone-hub/plugin.toml new file mode 100644 index 000000000..6fe6a3259 --- /dev/null +++ b/timezone-hub/plugin.toml @@ -0,0 +1,121 @@ +# Timezone Hub — compare your device timezone against other cities and +# change your device timezone, from the bar and a panel. + +id = "ahmedhossamdev/timezone-hub" +name = "Timezone Hub" +version = "2.1.0" +plugin_api = 24 +author = "ahmedhossamdev" +license = "MIT" +icon = "world" +description = "Change your device timezone and compare it against other cities, with live times and an hour-offset strip for each." +tags = ["bar", "clock", "time", "utility"] +dependencies = ["timedatectl", "pkexec"] + +[[widget]] +id = "bar" +entry = "bar.luau" + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 660 +height = 560 +placement = "attached" +position = "auto" +open_near_click = true + +[[service]] +id = "service" +entry = "service.luau" + +[[setting]] +key = "time_format" +type = "select" +label_key = "settings.time-format" +default = "24h" +options = [ + { value = "24h", label_key = "settings.format-24h" }, + { value = "12h", label_key = "settings.format-12h" }, +] + +[[setting]] +key = "show_date_in_bar" +type = "bool" +label_key = "settings.show-date-in-bar" +description_key = "settings.show-date-in-bar-desc" +default = true + +[[setting]] +key = "show_date_in_panel" +type = "bool" +label_key = "settings.show-date-in-panel" +description_key = "settings.show-date-in-panel-desc" +default = true + +[[setting]] +key = "show_relative_time" +type = "bool" +label_key = "settings.show-relative-time" +description_key = "settings.show-relative-time-desc" +default = true + +[[setting]] +key = "show_sun_times" +type = "bool" +label_key = "settings.show-sun-times" +description_key = "settings.show-sun-times-desc" +default = false + +[[setting]] +key = "date_format" +type = "select" +label_key = "settings.date-format" +default = "weekday" +options = [ + { value = "weekday", label_key = "settings.date-format-weekday" }, + { value = "short", label_key = "settings.date-format-short" }, + { value = "iso", label_key = "settings.date-format-iso" }, +] + +[[setting]] +key = "hours_before" +type = "int" +label_key = "settings.hours-before" +default = 2 +min = 0 +max = 6 + +[[setting]] +key = "hours_after" +type = "int" +label_key = "settings.hours-after" +default = 9 +min = 3 +max = 16 + +[[setting]] +key = "highlight_work_hours" +type = "bool" +label_key = "settings.highlight-work-hours" +default = true + +[[setting]] +key = "work_hour_start" +type = "int" +label_key = "settings.work-hours-range" +description_key = "settings.work-hour-start-desc" +default = 9 +min = 0 +max = 23 +visible_when = { key = "highlight_work_hours", values = ["true"] } + +[[setting]] +key = "work_hour_end" +type = "int" +label_key = "settings.work-hours-range" +description_key = "settings.work-hour-end-desc" +default = 17 +min = 1 +max = 24 +visible_when = { key = "highlight_work_hours", values = ["true"] } diff --git a/timezone-hub/service.luau b/timezone-hub/service.luau new file mode 100644 index 000000000..08ff128fb --- /dev/null +++ b/timezone-hub/service.luau @@ -0,0 +1,557 @@ +--!nonstrict +-- Timezone Hub service — owns the device timezone, the comparison-city +-- list, and the bar-widget zone choices. Publishes computed rows every +-- second so the bar widget and panel stay thin clients of this state. +-- +-- Persistence: pluginDataDir()/data.json -> +-- { comparisons = {...}, barTzs = { "__device__", "Area/City", ... } } +-- State: +-- timezone_hub.device_tz -> string +-- timezone_hub.changing_device_tz -> bool +-- timezone_hub.rows -> { { key, label, tz, isDevice, index, +-- timeText, dateText, offsetMinutes, +-- abbrev }, ... } +-- timezone_hub.bar_tzs -> up to three zone keys; "__device__" follows the device zone +-- timezone_hub.all_zones -> { "Area/City", ... } +-- timezone_hub.all_zones_loaded -> bool +-- Commands (timezone_hub.cmd): +-- { op = "add"|"remove"|"reorder"|"set_device"|"toggle_bar_zone"|"load_zones", ... } + +noctalia.setUpdateInterval(1000) + +local FALLBACK_ZONES = { + "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", + "America/Sao_Paulo", "America/Mexico_City", "America/Toronto", "America/Vancouver", + "America/Bogota", "America/Lima", "America/Argentina/Buenos_Aires", + "Europe/London", "Europe/Paris", "Europe/Berlin", "Europe/Madrid", "Europe/Rome", + "Europe/Amsterdam", "Europe/Zurich", "Europe/Stockholm", "Europe/Athens", + "Europe/Istanbul", "Europe/Moscow", "Europe/Warsaw", "Europe/Dublin", + "Africa/Cairo", "Africa/Johannesburg", "Africa/Lagos", "Africa/Nairobi", + "Asia/Dubai", "Asia/Karachi", "Asia/Kolkata", "Asia/Dhaka", "Asia/Bangkok", + "Asia/Jakarta", "Asia/Singapore", "Asia/Hong_Kong", "Asia/Shanghai", + "Asia/Manila", "Asia/Seoul", "Asia/Tokyo", + "Australia/Perth", "Australia/Brisbane", "Australia/Sydney", "Australia/Melbourne", + "Pacific/Auckland", "Pacific/Honolulu", "UTC", +} + +local deviceTz = "" +local changingDeviceTz = false +local comparisons = {} -- ordered { { tz, label }, ... } +local DEVICE_BAR_KEY = "__device__" +local MAX_BAR_ZONES = 3 +local barTzs = { DEVICE_BAR_KEY } +local allZones = {} +local allZonesLoaded = false +local allZonesLoading = false +local allZonesLoadStartedMs = nil +local ZONES_LOAD_TIMEOUT_MS = 8000 + +-- --------------------------------------------------------------------- +-- Persistence +-- --------------------------------------------------------------------- +local function dataPath() + local dir = noctalia.pluginDataDir() + if dir == nil then + return nil + end + return dir .. "/data.json" +end + +local function saveData() + local path = dataPath() + if path == nil then + return + end + local encoded = noctalia.json.encode({ comparisons = comparisons, barTzs = barTzs }) + if encoded ~= nil then + noctalia.writeFile(path, encoded) + end +end + +local function loadData() + comparisons = {} + barTzs = { DEVICE_BAR_KEY } + local path = dataPath() + if path ~= nil then + local raw = noctalia.readFile(path) + if raw ~= nil then + local decoded = noctalia.json.decode(raw) + if type(decoded) == "table" then + if type(decoded.comparisons) == "table" then + for _, entry in ipairs(decoded.comparisons) do + if type(entry) == "table" and type(entry.tz) == "string" and entry.tz ~= "" then + table.insert(comparisons, { tz = entry.tz, label = entry.label or entry.tz }) + end + end + end + if type(decoded.barTzs) == "table" then + local restored = {} + local seen = {} + for _, zone in ipairs(decoded.barTzs) do + if type(zone) == "string" and zone ~= "" and not seen[zone] and #restored < MAX_BAR_ZONES then + table.insert(restored, zone) + seen[zone] = true + end + end + if #restored > 0 then barTzs = restored end + elseif type(decoded.barTz) == "string" then + -- Migrate the v2.0 single-zone selection. An empty string used to + -- mean "follow the device timezone". + barTzs = { decoded.barTz == "" and DEVICE_BAR_KEY or decoded.barTz } + end + end + end + end +end + +-- --------------------------------------------------------------------- +-- Helpers +-- --------------------------------------------------------------------- +local function shortLabel(zone) + local slash = zone:match(".*/(.*)$") + if slash ~= nil then + return (slash:gsub("_", " ")) + end + return zone +end + +local function indexOfComparison(zone) + for i, entry in ipairs(comparisons) do + if entry.tz == zone then + return i + end + end + return nil +end + +-- "+0200" -> 120, "-0930" -> -570 +local function parseOffsetMinutes(z) + if type(z) ~= "string" or #z < 5 then + return nil + end + local sign = z:sub(1, 1) == "-" and -1 or 1 + local hh = tonumber(z:sub(2, 3)) or 0 + local mm = tonumber(z:sub(4, 5)) or 0 + return sign * (hh * 60 + mm) +end + +local function timeText(zone) + local raw = noctalia.formatTime("%H|%M", nil, zone) + local hh, mm = raw:match("(%d+)|(%d+)") + if hh == nil then + return "--:--" + end + if noctalia.getConfig("time_format") == "12h" then + local h12 = tonumber(hh) % 12 + if h12 == 0 then + h12 = 12 + end + return string.format("%d:%s %s", h12, mm, tonumber(hh) < 12 and "AM" or "PM") + end + return hh .. ":" .. mm +end + +local function clockTextFromHours(hours) + local totalMinutes = math.floor(hours * 60 + 0.5) % (24 * 60) + local hh = math.floor(totalMinutes / 60) + local mm = totalMinutes % 60 + if noctalia.getConfig("time_format") == "12h" then + local h12 = hh % 12 + if h12 == 0 then h12 = 12 end + return string.format("%d:%02d %s", h12, mm, hh < 12 and "AM" or "PM") + end + return string.format("%02d:%02d", hh, mm) +end + +local function dateText(zone) + local style = noctalia.getConfig("date_format") or "weekday" + local format = "%a, %b %d" + if style == "short" then + format = "%b %d" + elseif style == "iso" then + format = "%Y-%m-%d" + end + return noctalia.formatTime(format, nil, zone) +end + +-- --------------------------------------------------------------------- +-- Sunrise/sunset (local calculation; no network) +-- --------------------------------------------------------------------- +local zoneCoordinates = {} + +local function decodeCoordinate(value, degreeDigits) + if type(value) ~= "string" or #value < degreeDigits + 3 then return nil end + local sign = value:sub(1, 1) == "-" and -1 or 1 + local digits = value:sub(2) + local degrees = tonumber(digits:sub(1, degreeDigits)) + local minutes = tonumber(digits:sub(degreeDigits + 1, degreeDigits + 2)) + local seconds = #digits >= degreeDigits + 4 and tonumber(digits:sub(degreeDigits + 3, degreeDigits + 4)) or 0 + if degrees == nil or minutes == nil or seconds == nil then return nil end + return sign * (degrees + minutes / 60 + seconds / 3600) +end + +local function loadZoneCoordinates(path) + local raw = noctalia.readFile(path) + if type(raw) ~= "string" then return end + for line in raw:gmatch("[^\r\n]+") do + if line:sub(1, 1) ~= "#" then + local coordinate, zone = line:match("^[^\t]+\t([^\t]+)\t([^\t]+)") + if coordinate ~= nil and zone ~= nil and zoneCoordinates[zone] == nil then + local latitudePart, longitudePart = coordinate:match("^([%+%-]%d+)([%+%-]%d+)$") + local latitude = decodeCoordinate(latitudePart, 2) + local longitude = decodeCoordinate(longitudePart, 3) + if latitude ~= nil and longitude ~= nil then + zoneCoordinates[zone] = { latitude = latitude, longitude = longitude } + end + end + end + end +end + +local function normalizeDegrees(value) + return ((value % 360) + 360) % 360 +end + +local function radians(value) + return value * math.pi / 180 +end + +local function degrees(value) + return value * 180 / math.pi +end + +-- NOAA's sunrise equation, returning UTC decimal hours. The standard +-- 90.833-degree zenith accounts for atmospheric refraction and the sun's +-- apparent radius. +local function sunUtcHour(dayOfYear, latitude, longitude, sunrise) + local longitudeHour = longitude / 15 + local approximate = dayOfYear + ((sunrise and 6 or 18) - longitudeHour) / 24 + local meanAnomaly = 0.9856 * approximate - 3.289 + local trueLongitude = normalizeDegrees( + meanAnomaly + + 1.916 * math.sin(radians(meanAnomaly)) + + 0.020 * math.sin(radians(2 * meanAnomaly)) + + 282.634 + ) + local rightAscension = normalizeDegrees(degrees(math.atan(0.91764 * math.tan(radians(trueLongitude))))) + local longitudeQuadrant = math.floor(trueLongitude / 90) * 90 + local ascensionQuadrant = math.floor(rightAscension / 90) * 90 + rightAscension = (rightAscension + longitudeQuadrant - ascensionQuadrant) / 15 + + local sinDeclination = 0.39782 * math.sin(radians(trueLongitude)) + local cosDeclination = math.cos(math.asin(sinDeclination)) + local cosHour = ( + math.cos(radians(90.833)) - sinDeclination * math.sin(radians(latitude)) + ) / (cosDeclination * math.cos(radians(latitude))) + if cosHour < -1 or cosHour > 1 then return nil end + + local hourAngle = degrees(math.acos(cosHour)) + if sunrise then hourAngle = 360 - hourAngle end + hourAngle /= 15 + local localMeanTime = hourAngle + rightAscension - 0.06571 * approximate - 6.622 + return normalizeDegrees((localMeanTime - longitudeHour) * 15) / 15 +end + +local function sunTimes(zone, offsetMinutes) + if noctalia.getConfig("show_sun_times") ~= true then return nil, nil end + local coordinate = zoneCoordinates[zone] + if coordinate == nil or offsetMinutes == nil then return nil, nil end + local dayOfYear = tonumber(noctalia.formatTime("%j", nil, zone)) + if dayOfYear == nil then return nil, nil end + local sunriseUtc = sunUtcHour(dayOfYear, coordinate.latitude, coordinate.longitude, true) + local sunsetUtc = sunUtcHour(dayOfYear, coordinate.latitude, coordinate.longitude, false) + if sunriseUtc == nil or sunsetUtc == nil then return nil, nil end + local offsetHours = offsetMinutes / 60 + return clockTextFromHours(sunriseUtc + offsetHours), clockTextFromHours(sunsetUtc + offsetHours) +end + +-- --------------------------------------------------------------------- +-- Row computation + publish +-- --------------------------------------------------------------------- +local function computeRows() + local rows = {} + local deviceOffset = deviceTz ~= "" and parseOffsetMinutes(noctalia.formatTime("%z", nil, deviceTz)) or nil + local deviceSunrise, deviceSunset = sunTimes(deviceTz, deviceOffset) + table.insert(rows, { + key = "__device__", + label = noctalia.tr("device.label"), + tz = deviceTz, + isDevice = true, + index = -1, + timeText = deviceTz ~= "" and timeText(deviceTz) or "--:--", + dateText = deviceTz ~= "" and dateText(deviceTz) or "", + offsetMinutes = deviceOffset, + abbrev = deviceTz ~= "" and noctalia.formatTime("%Z", nil, deviceTz) or "", + sunriseText = deviceSunrise, + sunsetText = deviceSunset, + }) + + for i, entry in ipairs(comparisons) do + if entry.tz ~= deviceTz then + local offset = parseOffsetMinutes(noctalia.formatTime("%z", nil, entry.tz)) + local sunrise, sunset = sunTimes(entry.tz, offset) + table.insert(rows, { + key = entry.tz .. "#" .. i, + label = entry.label or entry.tz, + tz = entry.tz, + isDevice = false, + index = i, + timeText = timeText(entry.tz), + dateText = dateText(entry.tz), + offsetMinutes = offset, + abbrev = noctalia.formatTime("%Z", nil, entry.tz), + sunriseText = sunrise, + sunsetText = sunset, + }) + end + end + return rows +end + +local function publish() + noctalia.state.set("timezone_hub.device_tz", deviceTz) + noctalia.state.set("timezone_hub.changing_device_tz", changingDeviceTz) + noctalia.state.set("timezone_hub.bar_tzs", barTzs) + noctalia.state.set("timezone_hub.rows", computeRows()) + -- Raw ordered comparison list (distinct from `rows`, which hides an + -- entry that currently matches the device zone) so the panel can index + -- drag-reorder positions and dedupe against the real underlying list. + noctalia.state.set("timezone_hub.comparisons", comparisons) +end + +-- --------------------------------------------------------------------- +-- Device timezone: detect + change +-- --------------------------------------------------------------------- +local function detectDeviceTimezone() + noctalia.runAsync({ "timedatectl", "show", "-p", "Timezone", "--value" }, function(result) + local tz = result.stdout and noctalia.string.trim(result.stdout) or "" + if tz == "" then + noctalia.runAsync( + { "sh", "-c", "readlink -f /etc/localtime | sed 's#.*/zoneinfo/##'" }, + function(fallback) + local fbTz = fallback.stdout and noctalia.string.trim(fallback.stdout) or "" + if fbTz ~= "" then + deviceTz = fbTz + publish() + else + noctalia.log("TimezoneHub: could not detect the device timezone") + end + end + ) + return + end + deviceTz = tz + publish() + end) +end + +local function setDeviceTimezone(tz) + if tz == nil or tz == "" or changingDeviceTz or tz == deviceTz then + return + end + changingDeviceTz = true + publish() + noctalia.runAsync({ "pkexec", "timedatectl", "set-timezone", tz }, function(result) + changingDeviceTz = false + if result.exitCode == 0 then + -- Swap, don't just overwrite: drop the newly-promoted zone out of the + -- comparison list (it's about to be the device row, not a comparison + -- row) and put the outgoing device zone back into the list in its + -- place, so switching devices never silently loses or duplicates a + -- zone. + local oldDeviceTz = deviceTz + local promotedIdx = indexOfComparison(tz) + if promotedIdx ~= nil then + table.remove(comparisons, promotedIdx) + if oldDeviceTz ~= "" then + table.insert(comparisons, promotedIdx, { tz = oldDeviceTz, label = shortLabel(oldDeviceTz) }) + end + elseif oldDeviceTz ~= "" and indexOfComparison(oldDeviceTz) == nil then + table.insert(comparisons, 1, { tz = oldDeviceTz, label = shortLabel(oldDeviceTz) }) + end + -- Keep both selected clocks when the device zone and a comparison zone + -- trade places. The special device key follows the promoted zone while + -- the old device zone becomes an ordinary comparison selection. + for i, selected in ipairs(barTzs) do + if selected == tz then + barTzs[i] = DEVICE_BAR_KEY + elseif selected == DEVICE_BAR_KEY and oldDeviceTz ~= "" then + barTzs[i] = oldDeviceTz + end + end + deviceTz = tz + saveData() + publish() + noctalia.notify(noctalia.tr("toast.title"), noctalia.tr("toast.device-changed") .. " " .. tz) + else + publish() + noctalia.notifyError(noctalia.tr("toast.title"), noctalia.tr("toast.device-change-failed")) + end + end) +end + +-- --------------------------------------------------------------------- +-- Full IANA zone list (lazy, for the picker) +-- --------------------------------------------------------------------- +local function loadAllZones() + if allZonesLoaded or allZonesLoading then + return + end + allZonesLoading = true + allZonesLoadStartedMs = noctalia.nowMs() + noctalia.runAsync({ "timedatectl", "list-timezones" }, function(result) + allZonesLoading = false + allZonesLoadStartedMs = nil + local list = {} + if result.exitCode == 0 and result.stdout then + for line in result.stdout:gmatch("[^\r\n]+") do + local trimmed = noctalia.string.trim(line) + if trimmed ~= "" then + table.insert(list, trimmed) + end + end + end + allZones = #list > 0 and list or FALLBACK_ZONES + allZonesLoaded = true + noctalia.state.set("timezone_hub.all_zones", allZones) + noctalia.state.set("timezone_hub.all_zones_loaded", true) + end) +end + +-- --------------------------------------------------------------------- +-- Comparison list mutations +-- --------------------------------------------------------------------- +local function addComparison(tz, label) + tz = noctalia.string.trim(tz or "") + if tz == "" or tz == deviceTz then + return false, "invalid" + end + if not noctalia.isValidTimezone(tz) then + return false, "invalid" + end + if indexOfComparison(tz) ~= nil then + return false, "duplicate" + end + table.insert(comparisons, { tz = tz, label = (label and label ~= "") and label or shortLabel(tz) }) + saveData() + publish() + return true +end + +local function removeComparison(tz) + local idx = indexOfComparison(tz) + if idx == nil then + return false + end + table.remove(comparisons, idx) + for i = #barTzs, 1, -1 do + if barTzs[i] == tz then table.remove(barTzs, i) end + end + if #barTzs == 0 then table.insert(barTzs, DEVICE_BAR_KEY) end + saveData() + publish() + return true +end + +-- Move `tz` so it ends up at 1-based `index` in the final list. +local function reorderComparison(tz, index) + local fromIdx = indexOfComparison(tz) + local insertAt = tonumber(index) + if fromIdx == nil or insertAt == nil then + return false + end + local entry = table.remove(comparisons, fromIdx) + if fromIdx < insertAt then + insertAt -= 1 + end + insertAt = math.max(1, math.min(insertAt, #comparisons + 1)) + table.insert(comparisons, insertAt, entry) + saveData() + publish() + return true +end + +local function toggleBarZone(tz) + local key = (tz == nil or tz == "") and DEVICE_BAR_KEY or tz + for i, selected in ipairs(barTzs) do + if selected == key then + if #barTzs > 1 then table.remove(barTzs, i) end + saveData() + publish() + return + end + end + if #barTzs >= MAX_BAR_ZONES then + noctalia.notifyError(noctalia.tr("toast.title"), noctalia.tr("toast.max-bar-zones")) + return + end + table.insert(barTzs, key) + saveData() + publish() +end + +-- --------------------------------------------------------------------- +-- Commands from the panel +-- --------------------------------------------------------------------- +noctalia.state.watch("timezone_hub.cmd", function(cmd) + if type(cmd) ~= "table" then + return + end + local op = cmd.op + if op == "add" then + local ok, reason = addComparison(cmd.tz, cmd.label) + if not ok then + if reason == "invalid" then + noctalia.notifyError(noctalia.tr("toast.title"), noctalia.tr("ipc.invalid-zone", { zone = cmd.tz or "" })) + elseif reason == "duplicate" then + noctalia.notifyError(noctalia.tr("toast.title"), noctalia.tr("ipc.duplicate-zone", { zone = cmd.tz or "" })) + end + end + elseif op == "remove" then + removeComparison(cmd.tz) + elseif op == "reorder" then + reorderComparison(cmd.tz, cmd.index) + elseif op == "set_device" then + setDeviceTimezone(cmd.tz) + elseif op == "toggle_bar_zone" or op == "set_bar_zone" then + toggleBarZone(cmd.tz) + elseif op == "load_zones" then + loadAllZones() + end + noctalia.state.set("timezone_hub.cmd", nil) +end) + +function onIpc(event, payload) + if event == "add" then + addComparison(payload or "") + elseif event == "remove" then + removeComparison(payload or "") + elseif event == "list" then + local names = {} + for _, entry in ipairs(comparisons) do + table.insert(names, entry.tz) + end + local body = table.concat(names, "\n") + noctalia.notify(noctalia.tr("toast.title"), body ~= "" and body or noctalia.tr("ipc.empty-list")) + end +end + +function update() + -- Self-heal: if the zone-list fetch never came back (its runAsync + -- callback silently never fired), don't stay stuck forever - clear the + -- in-flight flag and retry so a picker left open on "Loading..." recovers + -- on its own instead of needing the plugin restarted. + if allZonesLoading and allZonesLoadStartedMs ~= nil and (noctalia.nowMs() - allZonesLoadStartedMs) > ZONES_LOAD_TIMEOUT_MS then + noctalia.log("TimezoneHub: zone list load timed out, retrying") + allZonesLoading = false + allZonesLoadStartedMs = nil + loadAllZones() + end + publish() +end + +loadZoneCoordinates("/usr/share/zoneinfo/zone1970.tab") +loadZoneCoordinates("/usr/share/zoneinfo/zone.tab") +loadData() +detectDeviceTimezone() +publish() diff --git a/timezone-hub/thumbnail.webp b/timezone-hub/thumbnail.webp new file mode 100644 index 000000000..7459542e0 Binary files /dev/null and b/timezone-hub/thumbnail.webp differ diff --git a/timezone-hub/translations/en.json b/timezone-hub/translations/en.json new file mode 100644 index 000000000..63c1db170 --- /dev/null +++ b/timezone-hub/translations/en.json @@ -0,0 +1,84 @@ +{ + "device": { + "label": "This Device" + }, + "panel": { + "title": "Timezone Hub", + "subtitle": "Device timezone:", + "detecting": "Detecting device timezone…", + "add": "Add a timezone", + "open-settings": "Open plugin settings", + "earlier": "Earlier hours", + "later": "Later hours", + "now": "Now", + "jump-to-now": "Jump back to now" + }, + "row": { + "change-device": "Change device timezone", + "set-device": "Set as device timezone", + "show-in-bar": "Show in bar widget", + "hide-from-bar": "Hide from bar widget", + "remove": "Remove", + "confirm-remove": "Confirm removal", + "cancel-remove": "Cancel removal", + "drag-tooltip": "Drag to reorder", + "same-time": "same time" + }, + "picker": { + "back": "Back", + "title-device": "Set device timezone", + "title-add": "Add a timezone", + "search-placeholder": "Search city or region…", + "loading": "Loading timezone database…", + "empty": "No matching timezones" + }, + "toast": { + "title": "Timezone Hub", + "device-changed": "Device timezone set to", + "device-change-failed": "Failed to change the device timezone. Authorization was denied or timedatectl is unavailable.", + "max-bar-zones": "You can show up to three timezones in the bar." + }, + "ipc": { + "invalid-zone": "\"{zone}\" is not a valid IANA timezone", + "duplicate-zone": "\"{zone}\" is already in the comparison list", + "empty-list": "No comparison timezones configured" + }, + "settings": { + "time-format": "Time format", + "format-24h": "24-hour", + "format-12h": "12-hour", + "show-date-in-bar": "Show date in bar", + "show-date-in-bar-desc": "Show the selected timezone's local date beside the bar clock.", + "show-date-in-panel": "Show dates in panel", + "show-date-in-panel-desc": "Show each timezone's local date in its comparison row.", + "show-relative-time": "Show relative descriptions", + "show-relative-time-desc": "Describe whether a city is ahead or behind, on another day, and inside working hours.", + "show-sun-times": "Show sunrise and sunset", + "show-sun-times-desc": "Calculate local sunrise and sunset from the system timezone database without network access.", + "date-format": "Date format", + "date-format-weekday": "Weekday · Tue, Sep 09", + "date-format-short": "Short · Sep 09", + "date-format-iso": "ISO · 2026-09-09", + "hours-before": "Hours shown before now", + "hours-after": "Hours shown after now", + "highlight-work-hours": "Highlight work hours", + "work-hours-range": "Work hours", + "work-hour-start-desc": "Hour the work day starts (0-23)", + "work-hour-end-desc": "Hour the work day ends (1-24)", + "no-comparisons": "No comparison timezones yet — add one above." + }, + "relative": { + "same-time": "Same time", + "ahead": "{offset} ahead", + "behind": "{offset} behind", + "yesterday": "Yesterday", + "tomorrow": "Tomorrow", + "day-offset": "{days} days", + "morning": "morning", + "afternoon": "afternoon", + "evening": "evening", + "night": "night", + "inside-work-hours": "Within working hours", + "outside-work-hours": "Outside working hours" + } +}