{
  "format" : 1,
  "manifest" : {
    "accent" : "red",
    "capabilities" : [
      {
        "calendar" : {

        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/UpNext",
    "hotkey" : "ctrl+option+j",
    "id" : "io.navbytes.stackbar.up-next",
    "minimumStackbar" : "0.17.0",
    "name" : "Up Next",
    "summary" : "Your next meeting in the menu bar, with a countdown and one-click join.",
    "surfaces" : [
      "menuBar",
      "panel",
      "window",
      "widget",
      "settings"
    ],
    "symbol" : "calendar",
    "triggers" : [
      {
        "launch" : {

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

        }
      }
    ],
    "version" : "1.4.1"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "GI7puPKrwJ9hn7kWD4Zbfz0kfH/Rb0CTVG/itl89vsDMumYGbHD+oYNSGEZ8PZ9bF/M9YUm4Jw+Y7zbkU/h1Dg==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// Your next meeting in the menu bar, with a one-click join.\n///\n/// It reads the calendars you have in Calendar.app (through the host, which\n/// asks for access once), finds the video link in the location, URL or notes\n/// (Zoom, Meet, Teams, Webex and a dozen more), counts down to it, reminds\n/// you a few minutes before, and can open the call for you when it starts.\n@main\nfinal class UpNext: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.up-next\",\n        name: \"Up Next\",\n        version: \"1.4.1\",\n        summary: \"Your next meeting in the menu bar, with a countdown and one-click join.\",\n        surfaces: [.menuBar, .panel, .window, .widget, .settings],\n        triggers: [.launch, .every(.seconds(30)), .wake],\n        capabilities: [.calendar],\n        hotkey: \"ctrl+option+j\",\n        minimumStackbar: \"0.17.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/UpNext\",\n        accent: .red,\n        symbol: \"calendar\"\n    )\n\n    // MARK: Previews\n\n    /// The simulated calendar outside the app is a sample working day.\n    static let previews: [PreviewScene<UpNext>] = [\n        PreviewScene(\"hero\", \"Your next meeting, one click from joining\") { try await $0.refresh() },\n        PreviewScene(\"week\", \"The day and the week's meeting load\", surface: .window) { try await $0.refresh() },\n        PreviewScene(\"widget\", \"What's next, on your desktop\", surface: .widget) { try await $0.refresh() },\n    ]\n\n    // MARK: Meeting links\n\n    struct Service {\n        let name: String\n        /// Lowercased fragments a link to this service contains.\n        let patterns: [String]\n    }\n\n    /// Known video services, most specific first.\n    static let services: [Service] = [\n        Service(name: \"Zoom\", patterns: [\"zoom.us/j/\", \"zoom.us/my/\", \"zoom.us/w/\", \"zoomgov.com/j/\", \"zoom.us/s/\"]),\n        Service(name: \"Google Meet\", patterns: [\"meet.google.com/\"]),\n        Service(name: \"Teams\", patterns: [\"teams.microsoft.com/l/meetup-join\", \"teams.live.com/meet\", \"teams.microsoft.com/meet\"]),\n        Service(name: \"Webex\", patterns: [\".webex.com/meet\", \".webex.com/join\", \".webex.com/wbxmjs\", \".webex.com/\"]),\n        Service(name: \"Slack huddle\", patterns: [\"app.slack.com/huddle\"]),\n        Service(name: \"FaceTime\", patterns: [\"facetime.apple.com/join\"]),\n        Service(name: \"Chime\", patterns: [\"chime.aws/\"]),\n        Service(name: \"GoTo Meeting\", patterns: [\"gotomeeting.com/join\", \"gotomeet.me/\", \"meet.goto.com/\"]),\n        Service(name: \"Whereby\", patterns: [\"whereby.com/\"]),\n        Service(name: \"Jitsi\", patterns: [\"meet.jit.si/\"]),\n        Service(name: \"BlueJeans\", patterns: [\"bluejeans.com/\"]),\n        Service(name: \"Around\", patterns: [\"around.co/\"]),\n        Service(name: \"Tuple\", patterns: [\"tuple.app/c/\"]),\n        Service(name: \"Gather\", patterns: [\"app.gather.town/\"]),\n        Service(name: \"Skype\", patterns: [\"join.skype.com/\"]),\n        Service(name: \"Discord\", patterns: [\"discord.gg/\", \"discord.com/channels/\"]),\n        Service(name: \"Zoho Meeting\", patterns: [\"meeting.zoho.com/\", \"meeting.zoho.eu/\"]),\n        Service(name: \"Livestorm\", patterns: [\"app.livestorm.co/\"]),\n    ]\n\n    struct MeetingLink: Equatable {\n        let url: String\n        let service: String\n    }\n\n    /// The first video-call link in the event's location, URL or notes.\n    static func meetingLink(for event: CalendarEvent) -> MeetingLink? {\n        for text in [event.location, event.url, event.notes].compactMap({ $0 }) {\n            for url in links(in: text) {\n                let lower = url.lowercased()\n                if let service = services.first(where: { service in service.patterns.contains { lower.contains($0) } }) {\n                    return MeetingLink(url: url, service: service.name)\n                }\n            }\n        }\n        return nil\n    }\n\n    /// Every http(s) URL in a piece of text, without trailing punctuation.\n    static func links(in text: String) -> [String] {\n        var result: [String] = []\n        var rest = Substring(text)\n        while let start = rest.range(of: \"https://\", options: .caseInsensitive) ?? rest.range(of: \"http://\", options: .caseInsensitive) {\n            let tail = rest[start.lowerBound...]\n            let end = tail.firstIndex(where: { $0.isWhitespace || \"<>\\\"'()[]{}\".contains($0) }) ?? tail.endIndex\n            var url = String(tail[..<end])\n            while let last = url.last, \".,;:!?\".contains(last) { url.removeLast() }\n            if url.count > 10 { result.append(url) }\n            rest = tail[end...]\n        }\n        return result\n    }\n\n    // MARK: State\n\n    @Stored(\"hiddenCalendars\") var hiddenCalendars: [String] = []\n    @Stored(\"showAllDay\") var showAllDay = true\n    @Stored(\"showDeclined\") var showDeclined = false\n    @Stored(\"onlyWithLinks\") var onlyWithLinks = false\n    @Stored(\"remindMinutes\") var remindMinutes = 2.0\n    @Stored(\"autoJoin\") var autoJoin = false\n    /// Cover the screen when a meeting starts, so it can't be missed.\n    @Stored(\"fullScreenAlert\") var fullScreenAlert = false\n    @Stored(\"titleLength\") var titleLength = 24.0\n    @Stored(\"showTitle\") var showTitle = true\n    @Stored(\"openInApps\") var openInApps = true\n    @Stored(\"onlyWithPeople\") var onlyWithPeople = false\n    @Stored(\"showFinished\") var showFinished = false\n    /// Show the next meeting in the menu bar only this many hours ahead (0: any time today).\n    @Stored(\"menuBarHours\") var menuBarHours = 0.0\n\n    var events: [CalendarEvent] = []\n    var calendars: [String] = []\n    var lastError: String?\n    var loaded = false\n    private var reminded: Set<String> = []\n    private var joined: Set<String> = []\n    private var alerted: Set<String> = []\n\n    func refresh() async throws {\n        let now = Date()\n        let start = Calendar.current.startOfDay(for: now)\n        guard let end = Calendar.current.date(byAdding: .day, value: 7, to: start) else { return }\n        do {\n            let all = try await Stackbar.calendarEvents(from: start, to: end)\n            calendars = Array(Set(all.map(\\.calendar))).sorted()\n            events = all\n            lastError = nil\n            loaded = true\n        } catch {\n            lastError = \"\\(error)\"\n            return\n        }\n        remindAndJoin(now: now)\n    }\n\n    /// The events the settings let through.\n    var visible: [CalendarEvent] {\n        events.filter { event in\n            !hiddenCalendars.contains(event.calendar)\n                && (showAllDay || !event.isAllDay)\n                && (showDeclined || event.participation != .declined)\n                && (!onlyWithLinks || event.isAllDay || Self.meetingLink(for: event) != nil)\n                && (!onlyWithPeople || event.isAllDay || event.attendees > 1)\n        }\n    }\n\n    /// Timed events that haven't ended, soonest first.\n    func upcoming(now: Date = Date()) -> [CalendarEvent] {\n        visible.filter { !$0.isAllDay && $0.end > now && $0.participation != .declined }.sorted { $0.start < $1.start }\n    }\n\n    /// What's on now (the latest-started one), else what's next today.\n    func focus(now: Date = Date()) -> CalendarEvent? {\n        let soon = upcoming(now: now)\n        if let current = soon.filter({ $0.start <= now }).max(by: { $0.start < $1.start }) {\n            // A meeting about to start beats one that is ending.\n            if let next = soon.first(where: { $0.start > now }), next.start.timeIntervalSince(now) <= 5 * 60, current.end.timeIntervalSince(now) <= 5 * 60 { return next }\n            return current\n        }\n        // Today's next one, or one soon after midnight (a late-night call\n        // shouldn't vanish because the date changes first).\n        return soon.first { Calendar.current.isDate($0.start, inSameDayAs: now) || $0.start.timeIntervalSince(now) <= 3 * 3600 }\n    }\n\n    func remindAndJoin(now: Date) {\n        for event in upcoming(now: now) {\n            let until = event.start.timeIntervalSince(now)\n            if remindMinutes > 0, until > 0, until <= remindMinutes * 60 + 15, !reminded.contains(event.id) {\n                reminded.insert(event.id)\n                let link = Self.meetingLink(for: event)\n                Stackbar.notify(\n                    \"\\(event.title) in \\(max(1, Int((until / 60).rounded()))) min\",\n                    body: link.map { \"On \\($0.service)\" } ?? event.location,\n                    actions: link.map { [NotificationAction(\"Join\", url: openInApps ? Self.appURL(for: $0) : $0.url)] } ?? []\n                )\n            }\n            if fullScreenAlert, until <= 60, until > -60, !alerted.contains(event.id) {\n                alerted.insert(event.id)\n                let link = Self.meetingLink(for: event)\n                var actions = link.map { [NotificationAction(\"Join on \\($0.service)\", url: openInApps ? Self.appURL(for: $0) : $0.url)] } ?? []\n                if let dial = Self.dialIn(for: event) { actions.append(NotificationAction(\"Call in\", url: dial.tel)) }\n                Stackbar.alert(event.title, body: until > 0 ? \"Starts at \\(Self.time(event.start))\" + (event.organizer.map { \" · \\($0)\" } ?? \"\") : \"Started at \\(Self.time(event.start))\", actions: actions)\n            }\n            if autoJoin, until <= 30, now < event.start.addingTimeInterval(120), !joined.contains(event.id), let link = Self.meetingLink(for: event) {\n                joined.insert(event.id)\n                Stackbar.open(link.url)\n            }\n        }\n        if reminded.count > 200 { reminded = Set(reminded.suffix(50)) }\n        if joined.count > 200 { joined = Set(joined.suffix(50)) }\n        if alerted.count > 200 { alerted = Set(alerted.suffix(50)) }\n    }\n\n    func join(_ event: CalendarEvent) {\n        if let link = Self.meetingLink(for: event) {\n            joined.insert(event.id)\n            Stackbar.open(openInApps ? Self.appURL(for: link) : link.url)\n        } else if let url = event.url {\n            Stackbar.open(url)\n        }\n    }\n\n    /// A phone dial-in from the event: a `tel:` link, or a number starting\n    /// with + (Zoom's \"+16465588656,,123456789#\" included). `tel` keeps the\n    /// pauses and PIN so the call dials straight in.\n    static func dialIn(for event: CalendarEvent) -> (display: String, tel: String)? {\n        for text in [event.location, event.notes].compactMap({ $0 }) {\n            if let match = text.firstMatch(of: /tel:(\\+?[0-9,;#*]{7,})/) {\n                let number = String(match.1)\n                return (number.replacingOccurrences(of: \",\", with: \" \").trimmingCharacters(in: .whitespaces), \"tel:\" + number.replacingOccurrences(of: \"#\", with: \"%23\"))\n            }\n            if let match = text.firstMatch(of: /\\+\\d[\\d \\-().]{6,}\\d(?:,,\\d+#?)?/) {\n                let raw = String(match.0)\n                let digits = raw.filter { $0.isNumber || $0 == \"+\" || $0 == \",\" || $0 == \"#\" }\n                guard digits.filter(\\.isNumber).count >= 8 else { continue }\n                return (raw.components(separatedBy: \",,\").first ?? raw, \"tel:\" + digits.replacingOccurrences(of: \"#\", with: \"%23\"))\n            }\n        }\n        return nil\n    }\n\n    /// The meeting app's own URL for Zoom and Teams links, so joining opens\n    /// the app without leaving a browser tab behind. Other links stay https.\n    static func appURL(for link: MeetingLink) -> String {\n        guard let url = URLComponents(string: link.url), let host = url.host?.lowercased() else { return link.url }\n        if link.service == \"Zoom\", url.path.hasPrefix(\"/j/\") {\n            let number = url.path.dropFirst(3).prefix { $0.isNumber }\n            guard !number.isEmpty else { return link.url }\n            var app = URLComponents()\n            app.scheme = \"zoommtg\"\n            app.host = host\n            app.path = \"/join\"\n            app.queryItems = [URLQueryItem(name: \"action\", value: \"join\"), URLQueryItem(name: \"confno\", value: String(number))]\n                + (url.queryItems ?? []).filter { $0.name == \"pwd\" }\n            return app.string ?? link.url\n        }\n        if link.service == \"Teams\", link.url.lowercased().hasPrefix(\"https://teams.microsoft.com/\") {\n            return \"msteams:\" + link.url.dropFirst(\"https://teams.microsoft.com\".count)\n        }\n        return link.url\n    }\n\n    static let instantMeetings: [(name: String, url: String)] = [\n        (\"Google Meet\", \"https://meet.google.com/new\"),\n        (\"Zoom\", \"https://zoom.us/start/videomeeting\"),\n        (\"Teams\", \"https://teams.microsoft.com/l/meeting/new\"),\n    ]\n\n    // MARK: Words\n\n    static func time(_ date: Date) -> String { date.formatted(date: .omitted, time: .shortened) }\n\n    static func span(_ seconds: Double) -> String {\n        let minutes = Int((seconds / 60).rounded(.up))\n        if minutes < 60 { return \"\\(max(1, minutes))m\" }\n        let hours = minutes / 60\n        return minutes % 60 == 0 ? \"\\(hours)h\" : \"\\(hours)h \\(minutes % 60)m\"\n    }\n\n    func countdown(_ event: CalendarEvent, now: Date = Date()) -> String {\n        if event.start <= now { return \"\\(Self.span(event.end.timeIntervalSince(now))) left\" }\n        return \"in \\(Self.span(event.start.timeIntervalSince(now)))\"\n    }\n\n    func shortTitle(_ title: String) -> String {\n        let limit = max(4, Int(titleLength))\n        return title.count > limit ? String(title.prefix(limit - 1)).trimmingCharacters(in: .whitespaces) + \"…\" : title\n    }\n\n    func subtitle(_ event: CalendarEvent) -> String {\n        var parts: [String] = []\n        parts.append(event.isAllDay ? \"All day\" : \"\\(Self.time(event.start))–\\(Self.time(event.end))\")\n        if let link = Self.meetingLink(for: event) {\n            parts.append(link.service)\n        } else if let location = event.location, !location.isEmpty {\n            parts.append(location.count > 40 ? String(location.prefix(39)) + \"…\" : location)\n        }\n        if event.attendees > 1 { parts.append(\"\\(event.attendees) people\") }\n        parts.append(event.calendar)\n        return parts.joined(separator: \" · \")\n    }\n\n    // MARK: Surfaces\n\n    let theme = Theme.red\n\n    var menuBar: ViewContent {\n        if lastError != nil && !loaded {\n            Label(\"\", symbol: \"calendar.badge.exclamationmark\").role(.warning)\n        } else if let event = focus(), menuBarHours == 0 || event.start.timeIntervalSinceNow <= menuBarHours * 3600 {\n            let symbol = Self.meetingLink(for: event) != nil ? \"video\" : \"calendar\"\n            let until = event.start.timeIntervalSinceNow\n            if until > 0, until <= 10 * 60 {\n                // The host counts down, so it never says \"in 1m\" as the call starts.\n                HStack(spacing: Space.xs) {\n                    Image(systemName: symbol)\n                    if showTitle { Text(shortTitle(event.title) + \" ·\") }\n                    TimerText(TimeRange(start: Date(), end: event.start))\n                }\n                .foreground(.role(.warning))\n            } else {\n                Label(showTitle ? \"\\(shortTitle(event.title)) · \\(countdown(event))\" : countdown(event), symbol: symbol).role(.primary)\n            }\n        } else {\n            Label(\"\", symbol: \"calendar\").role(.secondary)\n        }\n    }\n\n    var panel: ViewContent {\n        let now = Date()\n        if let lastError, !loaded {\n            EmptyState(\"Allow calendar access\", symbol: \"calendar.badge.exclamationmark\", message: lastError, theme: theme) {\n                ActionButton(\"Try Again\", symbol: \"arrow.clockwise\", theme: theme, prominent: true) { try await self.refresh() }\n            }\n        } else {\n            header(now: now)\n            if let lastError { Banner(lastError) }\n            hero(now: now)\n            if loaded, !meetings(on: now).isEmpty { dayCard(now: now) }\n            List(searchable: true) {\n                day(\"Today\", now, now: now)\n                if let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: now) {\n                    day(\"Tomorrow\", tomorrow, now: now)\n                }\n            }\n        }\n    }\n\n    @ViewBuilder func header(now: Date) -> ViewContent {\n        let count = meetings(on: now).count\n        let hours = meetingHours(on: now)\n        PanelHeader(now.formatted(.dateTime.weekday(.wide).day().month(.wide)),\n                    subtitle: !loaded ? \"Reading your calendars…\" : count == 0 ? \"No meetings today\" : \"\\(count) meeting\\(count == 1 ? \"\" : \"s\") today · \\(Self.hours(hours))\",\n                    symbol: \"calendar\", theme: theme)\n    }\n\n    /// What's on now or next, as big as the panel gets.\n    @ViewBuilder func hero(now: Date) -> ViewContent {\n        if let event = focus(now: now) {\n            let link = Self.meetingLink(for: event)\n            let dial = Self.dialIn(for: event)\n            let started = event.start <= now\n            // Sized by its content, unlike a Backdrop, so there's no empty band.\n            VStack(spacing: Space.m) {\n                HStack(alignment: .end, spacing: Space.m) {\n                    VStack(spacing: Space.xxs) {\n                        HStack(spacing: Space.xs) {\n                            Image(systemName: started ? \"record.circle.fill\" : \"clock.fill\").font(size: 10, weight: .bold).symbolEffect(started ? .pulse : .breathe)\n                            Text(started ? \"NOW\" : \"NEXT\").typography(.overline)\n                        }\n                        Text(Self.clipped(event.title, 30)).typography(Typography(size: 18, weight: .bold))\n                        Text(heroDetail(event)).typography(.caption).opacity(0.85)\n                    }\n                    .frame(fillWidth: true, alignment: .leading)\n                    clock(event, now: now)\n                }\n                if link != nil || dial != nil {\n                    HStack(spacing: Space.s) {\n                        if let link {\n                            ActionButton(\"Join \\(link.service)\", symbol: \"video.fill\", theme: theme, prominent: true, fillWidth: true, key: \"join-focus\") { self.join(event) }\n                        }\n                        if let dial {\n                            Button(key: \"call-focus\", action: { Stackbar.open(dial.tel) }) {\n                                HStack(spacing: Space.xs) {\n                                    Image(systemName: \"phone.fill\").font(size: 12, weight: .semibold)\n                                    Text(\"Call in\").typography(.headline)\n                                }\n                                .padding(horizontal: Space.m, vertical: Space.s)\n                                .frame(fillWidth: link == nil)\n                                .background(.material(.glass), in: .capsule)\n                            }\n                        }\n                    }\n                }\n            }\n            .foreground(.onMedia)\n            .padding(Space.l)\n            .frame(fillWidth: true, alignment: .leading)\n            .background(.scrim, in: .roundedRectangle(cornerRadius: Radius.large + 2))\n            .background(stage(for: event), in: .roundedRectangle(cornerRadius: Radius.large + 2))\n        } else if loaded {\n            HeroCard(theme: theme, spacing: Space.s) {\n                HStack(spacing: Space.m) {\n                    IconTile(\"sun.max.fill\", theme: .amber, size: 40)\n                    VStack(spacing: Space.xxs) {\n                        Text(\"Nothing else today\").typography(.title)\n                        Text(\"Enjoy the focus time\").role(.secondary).typography(.caption)\n                    }\n                    .frame(fillWidth: true, alignment: .leading)\n                }\n                startChips\n            }\n        }\n        if focus(now: now) != nil || !loaded {\n            startChips\n        }\n    }\n\n    /// Start an instant call: chips, so picking the same one twice works.\n    @ViewBuilder var startChips: ViewContent {\n        HStack(spacing: Space.xs + 2) {\n            Text(\"Start a call\").role(.secondary).typography(.caption)\n            ForEach(Self.instantMeetings) { meeting in\n                Chip(meeting.name == \"Google Meet\" ? \"Meet\" : meeting.name,\n                     theme: theme, key: \"start-\\(meeting.name)\") { Stackbar.open(meeting.url) }\n            }\n            Spacer()\n        }\n    }\n\n    /// The stage behind the next meeting: its calendar's color, deepening\n    /// to night, so the hero says which calendar at a glance.\n    func stage(for event: CalendarEvent) -> Paint {\n        guard let color = event.calendarColor, HexColor.components(color) != nil else { return theme.vivid }\n        return .gradient([color, \"#1B1530\"], angle: 45)\n    }\n\n    /// Time left as a ring once it has started, else a countdown.\n    @ViewBuilder func clock(_ event: CalendarEvent, now: Date) -> ViewContent {\n        if event.start <= now {\n            ZStack {\n                Circle().stroke(.hex(\"#FFFFFF40\"), lineWidth: 5)\n                ProgressBar(timing: TimeRange(start: event.start, end: event.end), style: .ring(lineWidth: 5)).foreground(.onMedia)\n                VStack(alignment: .center, spacing: 0) {\n                    Text(Self.span(event.end.timeIntervalSince(now))).typography(Typography(size: 14, weight: .bold, design: .rounded, monospacedDigit: true)).numericTransition()\n                    Text(\"left\").typography(.caption).opacity(0.8)\n                }\n            }\n            .frame(width: 62, height: 62)\n        } else if event.start.timeIntervalSince(now) < 3600 {\n            VStack(alignment: .end, spacing: 0) {\n                TimerText(TimeRange(start: now, end: event.start)).typography(Typography(size: 26, weight: .heavy, design: .rounded, monospacedDigit: true))\n                Text(\"until it starts\").typography(.caption).opacity(0.8)\n            }\n        } else {\n            Text(countdown(event, now: now)).typography(Typography(size: 26, weight: .heavy, design: .rounded, monospacedDigit: true)).numericTransition()\n        }\n    }\n\n    static func clipped(_ text: String, _ limit: Int) -> String {\n        text.count > limit ? String(text.prefix(limit - 1)) + \"…\" : text\n    }\n\n    func heroDetail(_ event: CalendarEvent) -> String {\n        var parts = [\"\\(Self.time(event.start))–\\(Self.time(event.end))\"]\n        if let link = Self.meetingLink(for: event) {\n            parts.append(link.service)\n        } else if let location = event.location, !location.isEmpty {\n            parts.append(location.count > 30 ? String(location.prefix(29)) + \"…\" : location)\n        }\n        if event.attendees > 1 { parts.append(\"\\(event.attendees) people\") }\n        return parts.joined(separator: \" · \")\n    }\n\n    /// Today's meetings on a bar from the morning to the evening, with now marked.\n    @ViewBuilder func dayCard(now: Date) -> ViewContent {\n        let start = Calendar.current.startOfDay(for: now)\n        let todays = meetings(on: now)\n        let first = min(8, todays.map { Int($0.start.timeIntervalSince(start) / 3600) }.min() ?? 8)\n        let last = max(20, todays.map { Int(($0.end.timeIntervalSince(start) / 3600).rounded(.up)) }.max() ?? 20)\n        let from = start.addingTimeInterval(Double(max(0, first)) * 3600)\n        let span = Double(min(24, last) - max(0, first)) * 3600\n        let fraction = { (date: Date) in date.timeIntervalSince(from) / span }\n        Card(spacing: Space.xs + 2) {\n            DayBar(todays.map { fraction($0.start)...max(fraction($0.start), fraction($0.end)) }, now: (0...1).contains(fraction(now)) ? fraction(now) : nil, theme: theme)\n            HStack {\n                ForEach(Array(stride(from: max(0, first), through: min(24, last), by: max(1, (min(24, last) - max(0, first)) / 3)).enumerated())) { index, hour in\n                    if index > 0 { Spacer() }\n                    Text(\"\\(hour)\").role(.secondary).typography(.captionNumber)\n                }\n            }\n            .frame(width: 332)\n        }\n    }\n\n    /// The window adds the week's meeting load.\n    var window: ViewContent {\n        panel\n        if loaded {\n            let calendar = Calendar.current\n            let days = (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: Date()) }\n            let load = days.map { (meetingHours(on: $0) * 10).rounded() / 10 }\n            Card(\"Meeting load\", detail: \"\\(Self.hours(load.reduce(0, +))) in the next 7 days\", spacing: Space.s) {\n                Chart(values: load, kind: .bar, labels: days.map { calendar.shortWeekdaySymbols[calendar.component(.weekday, from: $0) - 1] }, unit: \"h\", goal: 4, highlight: 0)\n                    .foreground(theme.solidPaint)\n            }\n            let today = byCalendar(on: Date())\n            if today.count > 1 {\n                Card(\"Today by calendar\", spacing: Space.s) {\n                    Chart(values: today.map(\\.hours), kind: .donut, labels: today.map(\\.name), unit: \"h\")\n                }\n            }\n        }\n    }\n\n    /// A working day, for the \"in meetings today\" gauge.\n    static let workday = 8.0\n\n    static func hours(_ value: Double) -> String {\n        let minutes = Int((value * 60).rounded())\n        return minutes >= 60 ? \"\\(minutes / 60)h\" + (minutes % 60 > 0 ? \" \\(minutes % 60)m\" : \"\") : \"\\(minutes)m\"\n    }\n\n    /// Timed events you go to (not declined) that overlap the day.\n    func meetings(on date: Date) -> [CalendarEvent] {\n        let start = Calendar.current.startOfDay(for: date)\n        let end = start.addingTimeInterval(86400)\n        return visible.filter { !$0.isAllDay && $0.participation != .declined && $0.start < end && $0.end > start }\n    }\n\n    /// Hours of the day in meetings, overlaps counted once.\n    func meetingHours(on date: Date) -> Double {\n        let start = Calendar.current.startOfDay(for: date)\n        let end = start.addingTimeInterval(86400)\n        let spans = meetings(on: date).map { (max($0.start, start), min($0.end, end)) }.sorted { $0.0 < $1.0 }\n        var total = 0.0\n        var cursor = start\n        for (from, to) in spans where to > cursor {\n            total += to.timeIntervalSince(max(from, cursor))\n            cursor = to\n        }\n        return total / 3600\n    }\n\n    func byCalendar(on date: Date) -> [(name: String, hours: Double)] {\n        let start = Calendar.current.startOfDay(for: date)\n        let end = start.addingTimeInterval(86400)\n        let grouped = Dictionary(grouping: meetings(on: date), by: \\.calendar).mapValues { events in\n            events.reduce(0.0) { $0 + min($1.end, end).timeIntervalSince(max($1.start, start)) / 3600 }\n        }\n        return grouped.map { (name: $0.key, hours: ($0.value * 10).rounded() / 10) }.sorted { $0.hours > $1.hours }\n    }\n\n    @ViewBuilder func day(_ title: String, _ date: Date, now: Date) -> ViewContent {\n        let dayStart = Calendar.current.startOfDay(for: date)\n        let dayEnd = Calendar.current.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart.addingTimeInterval(86400)\n        // Timed events first (soonest on top, so Return joins it), all-day ones after.\n        let items = visible.filter { $0.start < dayEnd && $0.end > dayStart && (showFinished || $0.isAllDay || $0.end > now) }\n            .sorted { ($0.isAllDay ? 1 : 0, $0.start) < ($1.isAllDay ? 1 : 0, $1.start) }\n        if !items.isEmpty {\n            Section(title) {\n                ForEach(items) { event in row(event, now: now) }\n            }\n        }\n    }\n\n    func row(_ event: CalendarEvent, now: Date) -> Row {\n        let link = Self.meetingLink(for: event)\n        let past = !event.isAllDay && event.end <= now\n        var row = Row(event.title, subtitle: subtitle(event), symbol: event.isAllDay ? \"sun.max\" : link != nil ? \"video\" : \"calendar\", key: event.id)\n        switch event.participation {\n        case .declined: row = row.badge(\"Declined\", role: .critical)\n        case .tentative: row = row.badge(\"Maybe\", role: .warning)\n        case .pending: row = row.badge(\"Invited\", role: .warning)\n        default:\n            if past {\n                break\n            } else if !event.isAllDay, event.start <= now {\n                row = row.badge(\"Now\", role: .accent)\n            } else if !event.isAllDay, event.start.timeIntervalSince(now) < 3600 {\n                row = row.badge(countdown(event, now: now), role: .primary)\n            }\n        }\n        if let link {\n            row = row.action(\"Join on \\(link.service)\", shortcut: \"cmd+return\") { self.join(event) }\n                .action(\"Copy meeting link\") { Stackbar.copy(link.url) }\n        }\n        if let url = event.url, url != link?.url {\n            row = row.action(\"Open event link\") { Stackbar.open(url) }\n        }\n        if let dial = Self.dialIn(for: event) {\n            row = row.action(\"Call in (\\(dial.display))\") { Stackbar.open(dial.tel) }\n                .action(\"Copy dial-in number\") { Stackbar.copy(dial.display) }\n        }\n        return row.action(\"Copy details\") {\n            Stackbar.copy([event.title, self.subtitle(event), link?.url].compactMap { $0 }.joined(separator: \"\\n\"))\n        }\n    }\n\n    static let remindChoices: [(title: String, minutes: Double)] = [(\"Off\", 0), (\"1 min before\", 1), (\"2 min before\", 2), (\"5 min before\", 5), (\"10 min before\", 10)]\n    static let titleChoices: [(title: String, length: Double)] = [(\"Hidden\", 0), (\"Short\", 16), (\"Medium\", 24), (\"Long\", 32)]\n\n    /// Stackbar's Settings window, so the panel stays about the day.\n    var settings: ViewContent {\n        Section(\"Reminders and joining\") {\n            Picker(\"Remind me\", options: Self.remindChoices.map(\\.title), selection: Self.remindChoices.first { $0.minutes == remindMinutes }?.title ?? \"\\(Int(remindMinutes)) min before\", key: \"remind\") { title in\n                self.remindMinutes = Self.remindChoices.first { $0.title == title }?.minutes ?? self.remindMinutes\n            }\n            Toggle(\"Open the call when it starts\", isOn: autoJoin) { self.autoJoin = $0 }\n            Toggle(\"Open Zoom and Teams in their apps\", isOn: openInApps) { self.openInApps = $0 }\n            Toggle(\"Cover the screen when a meeting starts\", isOn: fullScreenAlert) { self.fullScreenAlert = $0 }\n        }\n        Section(\"Menu bar\") {\n            Picker(\"Title\", options: Self.titleChoices.map(\\.title), selection: showTitle ? Self.titleChoices.first { $0.length == titleLength }?.title ?? \"\\(Int(titleLength))\" : \"Hidden\", style: .segmented, key: \"title-length\") { title in\n                guard let choice = Self.titleChoices.first(where: { $0.title == title }) else { return }\n                self.showTitle = choice.length > 0\n                if choice.length > 0 { self.titleLength = choice.length }\n            }\n            Slider(menuBarHours == 0 ? \"Menu bar: any meeting today\" : \"Menu bar: meetings within \\(Int(menuBarHours))h\", value: menuBarHours, in: 0...8, step: 1, key: \"menu-hours\") { self.menuBarHours = $0 }\n        }\n        Section(\"Which events\") {\n            Toggle(\"Show all-day events\", isOn: showAllDay) { self.showAllDay = $0 }\n            Toggle(\"Show declined events\", isOn: showDeclined) { self.showDeclined = $0 }\n            Toggle(\"Only meetings with a call link\", isOn: onlyWithLinks) { self.onlyWithLinks = $0 }\n            Toggle(\"Only events with other people\", isOn: onlyWithPeople) { self.onlyWithPeople = $0 }\n            Toggle(\"Show finished events\", isOn: showFinished) { self.showFinished = $0 }\n        }\n        Section(\"Calendars\") {\n            if calendars.isEmpty { Text(\"No calendars yet.\").role(.secondary) }\n            ForEach(calendars) { name in\n                Toggle(name, isOn: !hiddenCalendars.contains(name), key: \"calendar-\\(name)\") { on in\n                    if on { self.hiddenCalendars.removeAll { $0 == name } } else if !self.hiddenCalendars.contains(name) { self.hiddenCalendars.append(name) }\n                }\n            }\n        }\n    }\n\n    var widget: ViewContent {\n        let now = Date()\n        if let event = focus(now: now) {\n            Backdrop(paint: stage(for: event), theme: theme, height: 131) {\n                HStack(alignment: .end, spacing: Space.m) {\n                    VStack(spacing: Space.xxs) {\n                        Text(\"\\(event.start <= now ? \"NOW\" : \"NEXT\") · \\(Self.time(event.start))\").typography(.overline)\n                        Text(Self.clipped(event.title, 32)).typography(.title)\n                        if let next = upcoming(now: now).first(where: { $0.id != event.id }) {\n                            Text(\"Then \\(Self.time(next.start))  \\(Self.clipped(next.title, 24))\").typography(.caption).opacity(0.8)\n                        }\n                    }\n                    .frame(fillWidth: true, alignment: .leading)\n                    clock(event, now: now)\n                }\n            }\n        } else {\n            HStack(spacing: Space.m) {\n                IconTile(\"sun.max.fill\", theme: .amber, size: 36)\n                VStack(spacing: Space.xxs) {\n                    Text(\"Nothing else today\").typography(.headline)\n                    Text(\"Enjoy the focus time\").role(.secondary).typography(.caption)\n                }\n                .frame(fillWidth: true, alignment: .leading)\n            }\n        }\n    }\n\n    // MARK: Commands\n\n    var commands: [Command] {\n        Command(\"join-next\", title: \"Join Next Meeting\", symbol: \"video\", keywords: [\"meeting\", \"zoom\", \"meet\", \"teams\", \"call\", \"join\"]) { _ in\n            guard let event = self.focus() else { return .message(\"No more meetings today\") }\n            guard Self.meetingLink(for: event) != nil || event.url != nil else { return .failure(\"\\(event.title) has no call link\") }\n            self.join(event)\n            return .message(\"Joining \\(event.title)\")\n        }\n        Command(\"copy-next-link\", title: \"Copy Next Meeting Link\", symbol: \"link\", keywords: [\"meeting\", \"zoom\", \"meet\", \"teams\", \"call\", \"link\"]) { _ in\n            guard let event = self.focus() else { return .message(\"No more meetings today\") }\n            guard let link = Self.meetingLink(for: event) else { return .failure(\"\\(event.title) has no call link\") }\n            return .copy(link.url)\n        }\n        Command(\"agenda\", title: \"Today's Meetings\", symbol: \"calendar\", keywords: [\"meeting\", \"agenda\", \"calendar\", \"today\", \"schedule\"])\n            .view(search: { _ in try await self.refresh() }) { invocation in\n                let now = Date()\n                let query = invocation.text.lowercased()\n                let today = self.visible\n                    .filter { Calendar.current.isDate($0.start, inSameDayAs: now) && (self.showFinished || $0.isAllDay || $0.end > now) }\n                    .filter { query.isEmpty || $0.title.lowercased().contains(query) }\n                    .sorted { ($0.isAllDay ? 0 : 1, $0.start) < ($1.isAllDay ? 0 : 1, $1.start) }\n                if today.isEmpty {\n                    ContentUnavailableView(query.isEmpty ? \"Nothing else today\" : \"No meetings match\", symbol: \"sun.max\")\n                } else {\n                    List(today) { event in self.row(event, now: now) }\n                }\n            }\n    }\n\n    // MARK: Agents\n\n    var tools: [AgentTool] {\n        AgentTool(\"next_meeting\", description: \"The meeting on now or next, with its time and call link\") { _ in\n            guard let event = self.focus() else { return \"No more meetings today.\" }\n            return \"\\(event.title), \\(self.subtitle(event)), \\(self.countdown(event))\" + (Self.meetingLink(for: event).map { \"\\nJoin: \\($0.url)\" } ?? \"\")\n        }\n        AgentTool(\"agenda\", description: \"Meetings for today or tomorrow, one per line\", parameters: [ToolParameter(\"day\", description: \"today or tomorrow (default today)\", required: false)]) { arguments in\n            let tomorrow = arguments.string(\"day\")?.lowercased() == \"tomorrow\"\n            let date = tomorrow ? Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date() : Date()\n            let items = self.visible.filter { Calendar.current.isDate($0.start, inSameDayAs: date) }.sorted { $0.start < $1.start }\n            return items.isEmpty ? \"Nothing on the calendar \\(tomorrow ? \"tomorrow\" : \"today\").\" : items.map { \"\\($0.isAllDay ? \"All day\" : Self.time($0.start)) \\($0.title)\" }.joined(separator: \"\\n\")\n        }\n        AgentTool(\"join_next\", description: \"Open the call link of the meeting on now or next\") { _ in\n            guard let event = self.focus(), let link = Self.meetingLink(for: event) else { return \"The next meeting has no call link.\" }\n            self.join(event)\n            return \"Opening \\(event.title) on \\(link.service).\"\n        }\n    }\n}\n",
  "sourceDigest" : "55f18e34b685f331fd26206be59334002c7bc998d641dca1be02abdf014de828"
}