{
  "format" : 1,
  "manifest" : {
    "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.11.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"
    ],
    "triggers" : [
      {
        "launch" : {

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

        }
      }
    ],
    "version" : "1.0.1"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "b0KaRXhPv83B1emQAUjXfuxTUYO1SaOzRyVPFBkzF+nxacAqb76J1VZR8AwYTgtI2uLdJFjsfrLhclPZRD56CA==",
  "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.0.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.11.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/UpNext\"\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    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 soon = event.start.timeIntervalSinceNow < 5 * 60\n            let title = showTitle ? \"\\(shortTitle(event.title)) · \\(countdown(event))\" : countdown(event)\n            Label(title, symbol: Self.meetingLink(for: event) != nil ? \"video\" : \"calendar\").role(event.start <= Date() ? .accent : soon ? .warning : .primary)\n        } else {\n            Label(\"\", symbol: \"calendar\").role(.secondary)\n        }\n    }\n\n    var panel: ViewContent {\n        let now = Date()\n        if let lastError {\n            Text(lastError).role(.warning)\n        }\n        if let event = focus(now: now) {\n            Text(event.start <= now ? \"Now\" : \"Next\").style(.caption).role(.secondary)\n            Text(event.title).style(.headline)\n            Text(\"\\(subtitle(event)) · \\(countdown(event, now: now))\").style(.caption).role(.secondary)\n            if event.start <= now, event.end > event.start {\n                ProgressBar(\"\\(Int((event.end.timeIntervalSince(now) / 60).rounded(.up))) min left\", value: now.timeIntervalSince(event.start), total: event.end.timeIntervalSince(event.start), role: event.end.timeIntervalSince(now) < 300 ? .warning : .accent)\n            }\n            if let link = Self.meetingLink(for: event) {\n                Button(\"Join on \\(link.service)\", symbol: \"video.fill\", role: .accent, key: \"join-focus\") { self.join(event) }\n            }\n        } else if loaded {\n            ContentUnavailableView(\"No more meetings today\", symbol: \"calendar\", message: \"Enjoy the focus time.\")\n        }\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        if loaded {\n            let hours = meetingHours(on: now)\n            ProgressBar(\"\\(Self.hours(hours)) in meetings today\", value: min(hours, Self.workday), total: Self.workday, role: hours > 6 ? .warning : .accent)\n        }\n        Picker(\"Start a meeting now\", options: Self.instantMeetings.map(\\.name), selection: nil) { name in\n            if let meeting = Self.instantMeetings.first(where: { $0.name == name }) { Stackbar.open(meeting.url) }\n        }\n    }\n\n    /// The window adds the week's meeting load.\n    var window: ViewContent {\n        panel\n        if loaded {\n            Section(\"Meeting load\") {\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                Chart(\"Hours in meetings, next 7 days\", values: load, kind: .bar, labels: days.map { calendar.shortWeekdaySymbols[calendar.component(.weekday, from: $0) - 1] }, unit: \"h\", goal: 4, highlight: 0)\n                LabeledContent(\"This week\", value: Self.hours(load.reduce(0, +)))\n                let today = byCalendar(on: Date())\n                if today.count > 1 {\n                    Chart(\"Today by calendar\", 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                row = row.badge(\"Done\", role: .secondary)\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    /// Stackbar's Settings window, so the panel stays about the day.\n    var settings: ViewContent {\n        Section(\"Reminders and joining\") {\n            Slider(remindMinutes > 0 ? \"Remind \\(Int(remindMinutes)) min before\" : \"No reminders\", value: remindMinutes, in: 0...15, step: 1, key: \"remind\") { self.remindMinutes = $0 }\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            Toggle(\"Show the title in the menu bar\", isOn: showTitle) { self.showTitle = $0 }\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            if showTitle {\n                Slider(\"Menu bar title: \\(Int(titleLength)) characters\", value: titleLength, in: 8...60, step: 4, key: \"title-length\") { self.titleLength = $0 }\n            }\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        if let event = focus() {\n            Text(event.title).style(.headline)\n            Text(\"\\(Self.time(event.start)) · \\(countdown(event))\").style(.caption)\n            if let link = Self.meetingLink(for: event) { Label(link.service, symbol: \"video\") }\n        } else {\n            Label(\"No more meetings today\", symbol: \"calendar\")\n        }\n        ForEach(upcoming().dropFirst().prefix(3)) { event in\n            LabeledContent(Self.time(event.start), value: event.title)\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" : "08c8f4a294b57c2a6ef12d71a63a33b17a192762c53422e6f68a8a8f2c851f09"
}