{
  "format" : 1,
  "manifest" : {
    "accent" : "violet",
    "capabilities" : [

    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/WorldClock",
    "id" : "io.navbytes.stackbar.plugins.world-clock",
    "minimumStackbar" : "0.17.0",
    "name" : "World Clock",
    "summary" : "The time in the cities you work with, with offsets and working hours.",
    "surfaces" : [
      "menuBar",
      "panel",
      "settings",
      "widget"
    ],
    "symbol" : "globe",
    "triggers" : [
      {
        "launch" : {

        }
      },
      {
        "every" : {
          "seconds" : 30
        }
      },
      {
        "wake" : {

        }
      }
    ],
    "version" : "1.4.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "DcNb2xq7QXlhZ3xwU83voTi65ertRv4OnEhV/pY6UBFS6WKBoPPmDnhAQ+/1ZA/okmeoS1uOwBJrFLaRzguTDA==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// The time in the places you work with. The menu bar shows one city; the\n/// panel shows them all with the offset from you and whether it is working\n/// hours there, drawn on your own day so the overlap shows at a glance.\n/// Cities are chosen in Settings. A slider in the panel moves every clock\n/// forward or back, to answer \"what time is it there when it's 3pm here\".\n/// Needs nothing from the host.\n@main\nfinal class WorldClock: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.world-clock\",\n        name: \"World Clock\",\n        version: \"1.4.0\",\n        summary: \"The time in the cities you work with, with offsets and working hours.\",\n        surfaces: [.menuBar, .panel, .settings, .widget],\n        triggers: [.launch, .every(.seconds(30)), .wake],\n        minimumStackbar: \"0.17.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/WorldClock\",\n        accent: .violet,\n        symbol: \"globe\"\n    )\n\n    // MARK: Previews\n\n    static let previews: [PreviewScene<WorldClock>] = [\n        PreviewScene(\"hero\", \"Every city you work with, and their working hours\") { plugin in\n            plugin.zones = [\"America/Los_Angeles\", \"America/New_York\", \"Europe/London\", \"Asia/Kolkata\", \"Asia/Tokyo\"]\n        },\n        PreviewScene(\"plan\", \"Slide to find a time that works everywhere\") { plugin in\n            plugin.zones = [\"America/Los_Angeles\", \"America/New_York\", \"Europe/London\", \"Asia/Kolkata\", \"Asia/Tokyo\"]\n            plugin.offsetHours = 3\n        },\n        PreviewScene(\"widget\", \"Your cities on the desktop\", surface: .widget) { _ in },\n    ]\n\n    /// The real time, from the last refresh.\n    var realNow = Date()\n    /// How far the panel's time-travel slider has moved every clock, in hours.\n    var offsetHours = 0.0\n    /// The moment every clock shows: now, or where the slider points.\n    var now: Date { realNow.addingTimeInterval(offsetHours * 3600) }\n    @Stored(\"zones\") var zones: [String] = [\"America/Los_Angeles\", \"America/New_York\", \"Europe/London\", \"Asia/Kolkata\"]\n    @Stored(\"menuBarZone\") var menuBarZone = \"Europe/London\"\n    @Stored(\"use24Hour\") var use24Hour = true\n    @Stored(\"workStart\") var workStart = 9.0\n    @Stored(\"workEnd\") var workEnd = 18.0\n\n    func refresh() async throws {\n        realNow = Date()\n    }\n\n    let theme = Theme.violet\n\n    var menuBar: ViewContent {\n        if let zone = TimeZone(identifier: menuBarZone) {\n            Label(\"\\(city(menuBarZone)) \\(time(realNow, in: zone))\", symbol: \"globe\").role(.primary)\n        } else {\n            Label(\"\", symbol: \"globe\").role(.secondary)\n        }\n    }\n\n    var panel: ViewContent {\n        PanelHeader(\"\\(time(now, in: .current)) here\", subtitle: (offsetHours == 0 ? \"\" : Self.shift(offsetHours) + \" · \") + \"\\(now.formatted(.dateTime.weekday(.wide).day().month(.abbreviated))) · \\(validZones.count) \\(validZones.count == 1 ? \"city\" : \"cities\")\",\n                    symbol: \"globe\", theme: theme) {\n            Picker(\"Clock\", options: [\"12h\", \"24h\"], selection: use24Hour ? \"24h\" : \"12h\", style: .segmented) { self.use24Hour = $0 == \"24h\" }\n                .frame(width: 96)\n        }\n        if validZones.isEmpty {\n            EmptyState(\"Add a city\", symbol: \"globe\", message: \"Type a city or a time zone. The menu bar shows the one you tap.\", theme: theme) {\n                TextField(\"City or time zone, like Tokyo\") { submitted in self.add(submitted) }\n            }\n        } else {\n            VStack(spacing: Space.s) {\n                ForEach(validZones) { zone in card(zone) }\n            }\n            timeTravel\n            Text(\"Bars show each city's working hours across your day, and the tick is your time\\(offsetHours == 0 ? \" now\" : \" on the slider\"). Tap a city to show it in the menu bar.\").role(.secondary).typography(.caption)\n        }\n    }\n\n    /// A slider that moves every clock, so you can find a time that works\n    /// everywhere. Steps are half an hour; Now puts it back.\n    @ViewBuilder var timeTravel: ViewContent {\n        HStack(spacing: Space.s) {\n            Image(systemName: \"clock.arrow.2.circlepath\").opacity(0.6)\n            Slider(offsetHours == 0 ? \"Slide to see another time\" : Self.shift(offsetHours) + \" · \\(time(now, in: .current)) here\", value: offsetHours, in: -12...12, step: 0.5, key: \"time-travel\") { value in\n                self.offsetHours = value\n            }\n            if offsetHours != 0 {\n                ActionButton(\"Now\", theme: theme) { self.offsetHours = 0 }\n            }\n        }\n    }\n\n    /// `+3h`, `−1h 30m`.\n    static func shift(_ hours: Double) -> String {\n        let minutes = Int((abs(hours) * 60).rounded())\n        let text = minutes % 60 == 0 ? \"\\(minutes / 60)h\" : minutes < 60 ? \"\\(minutes)m\" : \"\\(minutes / 60)h \\(minutes % 60)m\"\n        return (hours > 0 ? \"+\" : \"−\") + text + \" from now\"\n    }\n\n    /// One city as a band of its sky: dawn, day, dusk or night, with the\n    /// sun or moon, a big time, and its working hours on your day.\n    @ViewBuilder func card(_ zone: TimeZone) -> ViewContent {\n        let working = isWorking(zone)\n        let sky = Sky(hour: hour(in: zone))\n        let shown = zone.identifier == menuBarZone\n        Button(key: \"city-\\(zone.identifier)\", action: { self.menuBarZone = zone.identifier }) {\n            VStack(spacing: Space.s + 2) {\n                HStack(spacing: Space.s + 2) {\n                    Image(systemName: sky.symbol).font(size: 20, weight: .semibold).symbolRendering(.multicolor)\n                    VStack(spacing: 1) {\n                        HStack(spacing: Space.xs) {\n                            Text(city(zone.identifier)).typography(.headline)\n                            if shown { Image(systemName: \"menubar.rectangle\").font(size: 10, weight: .semibold).opacity(0.8).accessibilityLabel(\"Shown in the menu bar\") }\n                        }\n                        Text(subtitle(for: zone) + (working ? \" · working\" : \"\")).typography(.caption).opacity(0.8)\n                    }\n                    Spacer()\n                    Text(time(now, in: zone)).typography(Typography(size: 28, weight: .bold, design: .rounded, monospacedDigit: true)).numericTransition()\n                }\n                .frame(fillWidth: true)\n                hoursBar(workingHoursOnYourDay(zone), now: hour(in: .current) / 24, width: 332)\n            }\n            .foreground(.onMedia)\n            .padding(Space.m)\n            .frame(fillWidth: true)\n            .background(sky.paint, in: .roundedRectangle(cornerRadius: Radius.large + 2))\n            .hover(scale: 1.015, highlight: .hex(\"#FFFFFF14\"))\n        }\n        .accessibilityLabel(\"\\(city(zone.identifier)), \\(time(now, in: zone)), \\(working ? \"working hours\" : \"outside working hours\")\")\n    }\n\n    /// Your day as a faint line on the sky, the city's working hours bright\n    /// on it, and a tick at your now.\n    func hoursBar(_ spans: [ClosedRange<Double>], now: Double, width: Double) -> some StackbarView {\n        let tick = 2.0, height = 4.0\n        var pieces: [(width: Double, lit: Bool)] = []\n        var at = 0.0\n        for span in spans.sorted(by: { $0.lowerBound < $1.lowerBound }) {\n            let from = min(max(span.lowerBound, at), 1), to = min(max(span.upperBound, from), 1)\n            if from > at { pieces.append(((from - at) * width, false)) }\n            if to > from { pieces.append(((to - from) * width, true)) }\n            at = to\n        }\n        if at < 1 { pieces.append(((1 - at) * width, false)) }\n        return ZStack(alignment: .start) {\n            HStack(spacing: 0) {\n                ForEach(Array(pieces.enumerated())) { _, piece in\n                    Rectangle().fill(piece.lit ? .hex(\"#FFFFFFD9\") : .hex(\"#FFFFFF33\")).frame(width: max(piece.width, 0), height: height)\n                }\n            }\n            .clipShape(.capsule)\n            HStack(spacing: 0) {\n                Rectangle().fill(.clear).frame(width: max(0, min(now, 1) * width - tick / 2), height: 10)\n                Capsule().fill(.onMedia).frame(width: tick, height: 10).shadow(radius: 2, color: HexColor(\"#00000066\"))\n            }\n        }\n        .frame(width: width, height: 10)\n        .accessibilityLabel(\"Working hours on your day\")\n    }\n\n    /// The city's working hours, moved onto your clock, as fractions of your day.\n    func workingHoursOnYourDay(_ zone: TimeZone) -> [ClosedRange<Double>] {\n        let offset = Double(zone.secondsFromGMT(for: now) - TimeZone.current.secondsFromGMT(for: now)) / 3600\n        let start = (workStart - offset).truncatingRemainder(dividingBy: 24)\n        let from = start < 0 ? start + 24 : start\n        let to = from + max(0, workEnd - workStart)\n        return to <= 24 ? [from / 24...to / 24] : [from / 24...1, 0...(to - 24) / 24]\n    }\n\n    var widget: ViewContent {\n        Grid(columns: 2, spacing: Space.s) {\n            ForEach(Array(validZones.prefix(4))) { zone in\n                let sky = Sky(hour: hour(in: zone, at: realNow))\n                VStack(spacing: Space.xxs) {\n                    HStack(spacing: Space.xs) {\n                        Image(systemName: sky.symbol).font(size: 11).symbolRendering(.multicolor)\n                        Text(city(zone.identifier)).typography(.caption).opacity(0.85)\n                    }\n                    Text(time(realNow, in: zone)).typography(Typography(size: 22, weight: .bold, design: .rounded, monospacedDigit: true))\n                }\n                .foreground(.onMedia)\n                .padding(horizontal: Space.m - 2, vertical: Space.s)\n                .frame(fillWidth: true, alignment: .leading)\n                .background(sky.paint, in: .roundedRectangle(cornerRadius: Radius.large))\n            }\n        }\n    }\n\n    static let hours = (0...24).map { String(format: \"%02d:00\", $0) }\n\n    var settings: ViewContent {\n        Section(\"Cities\") {\n            ForEach(zones) { identifier in\n                Row(city(identifier), subtitle: TimeZone(identifier: identifier) == nil ? \"Unknown time zone\" : identifier, symbol: \"mappin\", key: \"zone-\\(identifier)\")\n                    .action(\"Remove\") { self.zones.removeAll { $0 == identifier } }\n            }\n            TextField(\"Add a city or time zone, like Tokyo\", text: \"\") { submitted in self.add(submitted) }\n            Text(\"Type a city name (Tokyo, New York) or a zone (Asia/Tokyo).\").style(.caption).role(.secondary)\n        }\n        Section(\"Menu bar\") {\n            Picker(\"City\", options: validZones.map(\\.identifier), selection: menuBarZone) { self.menuBarZone = $0 }\n        }\n        Section(\"Working hours\") {\n            Picker(\"Start\", options: Array(Self.hours.dropLast()), selection: Self.hours[Int(workStart)], key: \"work-start\") { self.workStart = Double(Self.hours.firstIndex(of: $0) ?? 9) }\n            Picker(\"End\", options: Array(Self.hours.dropFirst()), selection: Self.hours[min(24, Int(workEnd))], key: \"work-end\") { self.workEnd = Double(Self.hours.firstIndex(of: $0) ?? 18) }\n        }\n    }\n\n    var tools: [AgentTool] {\n        AgentTool(\"times\", description: \"The current time in each configured city\") { _ in\n            self.validZones.map { \"\\(self.city($0.identifier)): \\(self.time(self.realNow, in: $0))\" }.joined(separator: \"\\n\")\n        }\n    }\n\n    // MARK: Commands\n\n    var commands: [Command] {\n        Command(\"times\", title: \"World Clock\", subtitle: \"The time in your cities\", symbol: \"globe\", keywords: [\"time\", \"zone\", \"clock\", \"city\", \"timezone\"])\n            .view(search: { _ in try await self.refresh() }) { invocation in\n                let query = invocation.text.lowercased()\n                let zones = self.validZones.filter { query.isEmpty || self.city($0.identifier).lowercased().contains(query) || $0.identifier.lowercased().contains(query) }\n                if zones.isEmpty {\n                    ContentUnavailableView(self.validZones.isEmpty ? \"No cities yet\" : \"No city matches\", symbol: \"globe\", message: self.validZones.isEmpty ? \"Add cities in World Clock's settings.\" : nil)\n                } else {\n                    List(zones) { zone in\n                        Row(self.city(zone.identifier), subtitle: self.subtitle(for: zone, at: self.realNow), symbol: Sky(hour: self.hour(in: zone, at: self.realNow)).symbol, key: zone.identifier)\n                            .badge(self.time(self.realNow, in: zone), role: self.isWorking(zone, at: self.realNow) ? .accent : .secondary)\n                            .action(\"Copy Time\") { Stackbar.copy(\"\\(self.time(self.realNow, in: zone)) in \\(self.city(zone.identifier))\") }\n                            .action(\"Show in Menu Bar\") { self.menuBarZone = zone.identifier }\n                    }\n                }\n            }\n    }\n\n    // MARK: -\n\n    var validZones: [TimeZone] { zones.compactMap(TimeZone.init(identifier:)) }\n\n    func add(_ text: String) {\n        let trimmed = text.trimmingCharacters(in: .whitespaces)\n        let match = TimeZone.knownTimeZoneIdentifiers.first { $0.caseInsensitiveCompare(trimmed) == .orderedSame }\n            ?? TimeZone.knownTimeZoneIdentifiers.first { city($0).caseInsensitiveCompare(trimmed) == .orderedSame }\n        guard let match, !zones.contains(match) else { return }\n        zones.append(match)\n    }\n\n    func city(_ identifier: String) -> String {\n        (identifier.split(separator: \"/\").last.map(String.init) ?? identifier).replacingOccurrences(of: \"_\", with: \" \")\n    }\n\n    func time(_ date: Date, in zone: TimeZone) -> String {\n        let formatter = DateFormatter()\n        formatter.locale = Locale(identifier: \"en_US_POSIX\")\n        formatter.timeZone = zone\n        formatter.dateFormat = use24Hour ? \"HH:mm\" : \"h:mm a\"\n        return formatter.string(from: date)\n    }\n\n    func hour(in zone: TimeZone, at date: Date? = nil) -> Double {\n        var calendar = Calendar(identifier: .gregorian)\n        calendar.timeZone = zone\n        let parts = calendar.dateComponents([.hour, .minute], from: date ?? now)\n        return Double(parts.hour ?? 0) + Double(parts.minute ?? 0) / 60\n    }\n\n    func isWorking(_ zone: TimeZone, at date: Date? = nil) -> Bool {\n        let date = date ?? now\n        let weekday = { () -> Int in\n            var calendar = Calendar(identifier: .gregorian)\n            calendar.timeZone = zone\n            return calendar.component(.weekday, from: date)\n        }()\n        return (2...6).contains(weekday) && hour(in: zone, at: date) >= workStart && hour(in: zone, at: date) < workEnd\n    }\n\n    private func subtitle(for zone: TimeZone, at date: Date? = nil) -> String {\n        let now = date ?? self.now\n        let difference = zone.secondsFromGMT(for: now) - TimeZone.current.secondsFromGMT(for: now)\n        let hours = Double(difference) / 3600\n        let offset = difference == 0 ? \"Same time\" : String(format: \"%@%gh\", hours > 0 ? \"+\" : \"−\", abs(hours))\n        // Compare the calendar dates in each zone, not the sign of the offset.\n        let theirs = day(in: zone, at: now), ours = day(in: .current, at: now)\n        return offset + (theirs == ours ? \"\" : theirs > ours ? \" · tomorrow\" : \" · yesterday\")\n    }\n\n    private func day(in zone: TimeZone, at now: Date) -> String {\n        let formatter = DateFormatter()\n        formatter.locale = Locale(identifier: \"en_US_POSIX\")\n        formatter.timeZone = zone\n        formatter.dateFormat = \"yyyy-MM-dd\"\n        return formatter.string(from: now)\n    }\n}\n",
  "sourceDigest" : "2ed3146a4ebe71b8aba65dea74743c7d57e20a60712f1368384abbc098143c32"
}