Add right-click popup to sketchybar meetingbar item
Swift script now outputs JSON with all remaining events for the day,
enabling a popup on right-click without a separate query mode.
Left click still opens the primary meeting URL.

Assisted-by: Claude Opus 4.6 via pi
change oxqkrzzzmywztoupkrkturrortktzovq
commit c35d244adec36962e00b67384f00bdb4c9be42e0
author Alpha Chen <alpha@kejadlen.dev>
date
parent xoopnylv
diff --git a/.config/sketchybar/meetingbar.swift b/.config/sketchybar/meetingbar.swift
index 226f100b..c0194365 100755
--- a/.config/sketchybar/meetingbar.swift
+++ b/.config/sketchybar/meetingbar.swift
@@ -1,23 +1,18 @@
 #!/usr/bin/swift
 
-/* Usage:
-
-In ~/.config/sketchybar/sketchybarrc:
-
-    --add item meetingbar right \
-    --set meetingbar \
-          icon=􀉉 \
-          icon.padding_right=8 \
-          label.y_offset=2 \
-          update_freq=120 \
-          script="$PLUGIN_DIR/meetingbar.sh"
-
-In ~/.config/sketchybar/plugins/meetingbar.sh:
-
-    CALENDAR="alice@example.com"
-    MEETING=$("$CONFIG_DIR/meetingbar.swift" "$CALENDAR")
-    sketchybar --set "$NAME" label="$MEETING"
+/*
+Outputs JSON with the primary event (for bar label) and all remaining events today (for popup).
+
+Output format:
+{
+  "primary": { "display": "Standup ends in 5 minutes", "url": "https://..." },
+  "events": [
+    { "time": "9:00 - 9:30 AM", "title": "Standup", "url": "https://..." },
+    { "time": "10:00 - 11:00 AM", "title": "Design Review", "url": null }
+  ]
+}
 
+When there are no meetings: { "primary": null, "events": [] }
 */
 
 import Foundation
@@ -82,32 +77,38 @@ class MeetingBar {
         }
     }
 
-    // Computed property to get the next meeting
-    var nextMeeting: EKEvent? {
-        get throws {
+    /// All non-all-day, non-declined events from now until end of day, sorted by start time.
+    func remainingEventsToday() throws -> [EKEvent] {
         let currentDate = Date()
+        let calendar = Calendar.current
 
-        // Get all calendars and filter for the specified calendar title
         let calendars = eventStore.calendars(for: .event).filter { $0.title.contains(self.calendarTitle) }
-
         guard !calendars.isEmpty else {
             throw MeetingBarError(message: "No calendar found for '\(self.calendarTitle)'")
         }
 
-        // Create a predicate for events starting from now until the end of the day
-        let startDate = currentDate
-
-        // Create a date for the end of tomorrow
-        let calendar = Calendar.current
         guard let tomorrow = calendar.date(byAdding: .day, value: 1, to: currentDate),
-              let endOfTomorrow = calendar.date(bySettingHour: 0, minute: 0, second: 0, of: tomorrow) else {
+              let endOfDay = calendar.date(bySettingHour: 0, minute: 0, second: 0, of: tomorrow) else {
             throw MeetingBarError(message: "Failed to calculate date")
         }
 
-        let endDate = endOfTomorrow
+        let predicate = eventStore.predicateForEvents(withStart: currentDate, end: endOfDay, calendars: calendars)
+        return eventStore.events(matching: predicate)
+            .filter { event in
+                guard !event.isAllDay else { return false }
+                if let attendees = event.attendees,
+                   let me = attendees.first(where: { $0.isCurrentUser }),
+                   me.participantStatus == .declined {
+                    return false
+                }
+                return true
+            }
+            .sorted { $0.startDate < $1.startDate }
+    }
 
-        let predicate = eventStore.predicateForEvents(withStart: startDate, end: endDate, calendars: calendars)
-        let events = eventStore.events(matching: predicate).filter { !$0.isAllDay }
+    /// Smart selection of which event to feature in the bar, using the full priority logic.
+    func primaryEvent(from events: [EKEvent]) -> EKEvent? {
+        let currentDate = Date()
 
         // Find current meetings, preferring accepted ones
         let currentMeetings = events.filter { currentDate >= $0.startDate && currentDate <= $0.endDate }
@@ -117,7 +118,6 @@ class MeetingBar {
             if firstPriority != secondPriority {
                 return firstPriority < secondPriority
             }
-            // Same status, prefer shorter meeting
             let firstDuration = first.endDate.timeIntervalSince(first.startDate)
             let secondDuration = second.endDate.timeIntervalSince(second.startDate)
             return firstDuration < secondDuration
@@ -125,39 +125,25 @@ class MeetingBar {
 
         // Find all events starting after current time
         let upcomingEvents = events.filter { $0.startDate > currentDate }
-        // Priority: earliest start > accepted status > shortest duration
         let nextMeeting = upcomingEvents.min { first, second in
             if first.startDate != second.startDate {
                 return first.startDate < second.startDate
             }
-            // Same start time, prefer accepted events
             let firstPriority = participationPriority(for: first)
             let secondPriority = participationPriority(for: second)
             if firstPriority != secondPriority {
                 return firstPriority < secondPriority
             }
-            // Same status, pick the shortest one
             let firstDuration = first.endDate.timeIntervalSince(first.startDate)
             let secondDuration = second.endDate.timeIntervalSince(second.startDate)
             return firstDuration < secondDuration
         }
 
-        // No current meeting, show next meeting if available
-        guard let current = currentMeeting else {
-            return nextMeeting
-        }
-
-        // No next meeting, show current meeting
-        guard let next = nextMeeting else {
-            return current
-        }
+        guard let current = currentMeeting else { return nextMeeting }
+        guard let next = nextMeeting else { return current }
 
-        // Decide which meeting to show based on both meetings
         let duration = current.endDate.timeIntervalSince(current.startDate)
         let timeUntilNext = next.startDate.timeIntervalSince(currentDate)
-
-        // The longer the current meeting, the more we prioritize showing the next one
-        // Threshold increases with meeting duration: 5 min base + 10 min per hour of meeting
         let threshold = 5 * 60 + (duration / 3600) * 10 * 60
 
         if timeUntilNext < threshold {
@@ -165,65 +151,41 @@ class MeetingBar {
         }
 
         return current
-        }
     }
 }
 
-// Get calendar title from command line arguments
-guard CommandLine.arguments.count > 1 else {
-    print("Error: Calendar title argument is required")
-    print("Usage: \(CommandLine.arguments[0]) <calendar-title>")
-    exit(1)
-}
-
-let calendarTitle = CommandLine.arguments[1]
+// MARK: - Helpers
 
 func extractMeetingURL(from event: EKEvent) -> String? {
-    // Check notes for meeting URL
-    if let notes = event.notes {
-        if let url = extractMeetingURL(from: notes) {
-            return url
-        }
+    if let notes = event.notes, let url = extractMeetingURL(from: notes) {
+        return url
     }
-
-    // Check location for meeting URL
-    if let location = event.location {
-        if let url = extractMeetingURL(from: location) {
-            return url
-        }
+    if let location = event.location, let url = extractMeetingURL(from: location) {
+        return url
     }
-
-    // Check URL property
     if let url = event.url?.absoluteString {
         if url.contains("zoom.us") || url.contains("vimeo.com") {
             return url
         }
     }
-
     return nil
 }
 
 func extractMeetingURL(from text: String) -> String? {
-    // Pattern to match Zoom URLs
-    let zoomPattern = "https://[a-zA-Z0-9.-]*\\.?zoom\\.us/[^\\s]+"
-    // Pattern to match Vimeo URLs
-    let vimeoPattern = "https://(?:www\\.)?vimeo\\.com/[^\\s]+"
-
-    for pattern in [zoomPattern, vimeoPattern] {
-        guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
-            continue
-        }
+    let patterns = [
+        "https://[a-zA-Z0-9.-]*\\.?zoom\\.us/[^\\s]+",
+        "https://(?:www\\.)?vimeo\\.com/[^\\s]+"
+    ]
 
+    for pattern in patterns {
+        guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { continue }
         let nsString = text as NSString
         let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsString.length))
-
         if let match = matches.first {
             let url = nsString.substring(with: match.range)
-            // Remove trailing quotes
             return url.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
         }
     }
-
     return nil
 }
 
@@ -232,28 +194,53 @@ func formatTimeUntil(_ date: Date, from currentDate: Date) -> String {
     if minutes <= 0 {
         return "now"
     } else if minutes < 60 {
-        if minutes == 1 {
-            return "in 1 minute"
-        } else {
-            return "in \(minutes) minutes"
-        }
+        return minutes == 1 ? "in 1 minute" : "in \(minutes) minutes"
     } else {
         let hours = minutes / 60
         let remainingMinutes = minutes % 60
-        if remainingMinutes == 0 {
-            return "in \(hours)h"
-        } else {
-            return "in \(hours)h\(remainingMinutes)m"
-        }
+        return remainingMinutes == 0 ? "in \(hours)h" : "in \(hours)h\(remainingMinutes)m"
     }
 }
 
+func jsonString(_ s: String) -> String {
+    // Escape backslashes, quotes, and control characters for JSON
+    var result = s
+    result = result.replacingOccurrences(of: "\\", with: "\\\\")
+    result = result.replacingOccurrences(of: "\"", with: "\\\"")
+    result = result.replacingOccurrences(of: "\n", with: "\\n")
+    result = result.replacingOccurrences(of: "\r", with: "\\r")
+    result = result.replacingOccurrences(of: "\t", with: "\\t")
+    return "\"\(result)\""
+}
+
+func jsonStringOrNull(_ s: String?) -> String {
+    guard let s = s else { return "null" }
+    return jsonString(s)
+}
+
+// MARK: - Main
+
+guard CommandLine.arguments.count > 1 else {
+    print("Error: Calendar title argument is required")
+    print("Usage: \(CommandLine.arguments[0]) <calendar-title>")
+    exit(1)
+}
+
+let calendarTitle = CommandLine.arguments[1]
+
 do {
     let manager = try MeetingBar(calendarTitle: calendarTitle)
-    if let event = try manager.nextMeeting {
-        let currentDate = Date()
-        let isCurrentMeeting = currentDate >= event.startDate && currentDate <= event.endDate
+    let events = try manager.remainingEventsToday()
+    let primary = manager.primaryEvent(from: events)
+    let currentDate = Date()
+
+    let timeFormatter = DateFormatter()
+    timeFormatter.dateFormat = "h:mm a"
 
+    // Build primary object
+    let primaryJSON: String
+    if let event = primary {
+        let isCurrentMeeting = currentDate >= event.startDate && currentDate <= event.endDate
         let displayText: String
         if isCurrentMeeting {
             let timeStr = formatTimeUntil(event.endDate, from: currentDate)
@@ -262,20 +249,30 @@ do {
             let timeStr = formatTimeUntil(event.startDate, from: currentDate)
             displayText = "\(event.title ?? "Untitled Event") \(timeStr)"
         }
-
-        // Output format: markdown link [DISPLAY_TEXT](URL) or just DISPLAY_TEXT if no URL
-        if let meetingURL = extractMeetingURL(from: event) {
-            print("[\(displayText)](\(meetingURL))")
-        } else {
-            print(displayText)
-        }
+        let url = extractMeetingURL(from: event)
+        primaryJSON = "{ \"display\": \(jsonString(displayText)), \"url\": \(jsonStringOrNull(url)) }"
     } else {
-        print("No meetings")
+        primaryJSON = "null"
     }
+
+    // Build events array
+    var eventJSONs: [String] = []
+    for event in events {
+        let start = timeFormatter.string(from: event.startDate)
+        let end = timeFormatter.string(from: event.endDate)
+        let time = "\(start) - \(end)"
+        let title = event.title ?? "Untitled Event"
+        let url = extractMeetingURL(from: event)
+        let isCurrent = currentDate >= event.startDate && currentDate <= event.endDate
+        eventJSONs.append("{ \"time\": \(jsonString(time)), \"title\": \(jsonString(title)), \"url\": \(jsonStringOrNull(url)), \"current\": \(isCurrent) }")
+    }
+
+    let eventsJSON = "[\(eventJSONs.joined(separator: ", "))]"
+    print("{ \"primary\": \(primaryJSON), \"events\": \(eventsJSON) }")
 } catch let error as MeetingBarError {
-    print("Error: \(error.message)")
+    fputs("Error: \(error.message)\n", stderr)
     exit(1)
 } catch {
-    print("Error: \(error)")
+    fputs("Error: \(error)\n", stderr)
     exit(1)
 }
diff --git a/.config/sketchybar/plugins/meetingbar.sh b/.config/sketchybar/plugins/meetingbar.sh
index 8c2bbe5e..697a1efd 100755
--- a/.config/sketchybar/plugins/meetingbar.sh
+++ b/.config/sketchybar/plugins/meetingbar.sh
@@ -1,23 +1,8 @@
 #!/bin/sh
 
 CALENDAR=
-OUTPUT=$("$CONFIG_DIR/meetingbar.swift" "$CALENDAR")
+JSON=$("$CONFIG_DIR/meetingbar.swift" "$CALENDAR")
 
-# Parse output: format is markdown link [DISPLAY_TEXT](URL) or just DISPLAY_TEXT
-if echo "$OUTPUT" | grep -q '\[.*\]('; then
-    # Extract display text and URL from markdown link format
-    MEETING=$(echo "$OUTPUT" | sed -E 's/\[(.*)\]\(.*/\1/')
-    ZOOM_URL=$(echo "$OUTPUT" | sed -E 's/.*\((.*)\)/\1/')
+DISPLAY=$(echo "$JSON" | jq -r '.primary.display // "No meetings"')
 
-    # Create a temporary script for clicking
-    CLICK_SCRIPT="open \"$ZOOM_URL\""
-
-    sketchybar --set "$NAME" \
-        label="$MEETING" \
-        click_script="$CLICK_SCRIPT"
-else
-    # No URL, just set the label and clear any click script
-    sketchybar --set "$NAME" \
-        label="$OUTPUT" \
-        click_script=""
-fi
+sketchybar --set "$NAME" label="$DISPLAY"
diff --git a/.config/sketchybar/plugins/meetingbar_click.sh b/.config/sketchybar/plugins/meetingbar_click.sh
new file mode 100755
index 00000000..64768010
--- /dev/null
+++ b/.config/sketchybar/plugins/meetingbar_click.sh
@@ -0,0 +1,70 @@
+#!/bin/sh
+
+CALENDAR=alpha.chen@gusto.com
+
+if [ "$BUTTON" = "right" ]; then
+    # Toggle popup off if already open
+    DRAWING=$(sketchybar --query meetingbar | jq -r '.popup.drawing')
+    if [ "$DRAWING" = "on" ]; then
+        sketchybar --set meetingbar popup.drawing=off
+        exit 0
+    fi
+
+    JSON=$("$CONFIG_DIR/meetingbar.swift" "$CALENDAR")
+
+    # Remove old popup items
+    sketchybar --remove '/meetingbar\.popup\./' 2>/dev/null
+
+    EVENT_COUNT=$(echo "$JSON" | jq '.events | length')
+
+    if [ "$EVENT_COUNT" -eq 0 ]; then
+        sketchybar --add item meetingbar.popup.0 popup.meetingbar \
+                   --set meetingbar.popup.0 \
+                         icon.drawing=off \
+                         label="No more meetings today"
+    else
+        INDEX=0
+        while IFS= read -r EVENT; do
+            TIME=$(echo "$EVENT" | jq -r '.time')
+            TITLE=$(echo "$EVENT" | jq -r '.title')
+            URL=$(echo "$EVENT" | jq -r '.url // empty')
+            CURRENT=$(echo "$EVENT" | jq -r '.current')
+            ITEM_NAME="meetingbar.popup.$INDEX"
+
+            LABEL="$TIME  $TITLE"
+            ICON=""
+            if [ "$CURRENT" = "true" ]; then
+                ICON="▶ "
+            fi
+
+            if [ -n "$URL" ]; then
+                sketchybar --add item "$ITEM_NAME" popup.meetingbar \
+                           --set "$ITEM_NAME" \
+                                 icon="$ICON" \
+                                 icon.padding_left=6 \
+                                 label="$LABEL" \
+                                 label.padding_right=6 \
+                                 click_script="open '$URL'; sketchybar --set meetingbar popup.drawing=off"
+            else
+                sketchybar --add item "$ITEM_NAME" popup.meetingbar \
+                           --set "$ITEM_NAME" \
+                                 icon="$ICON" \
+                                 icon.padding_left=6 \
+                                 label="$LABEL" \
+                                 label.padding_right=6
+            fi
+
+            INDEX=$((INDEX + 1))
+        done <<< "$(echo "$JSON" | jq -c '.events[]')"
+    fi
+
+    sketchybar --set meetingbar popup.drawing=on
+else
+    # Left click: open the primary event's meeting URL
+    JSON=$("$CONFIG_DIR/meetingbar.swift" "$CALENDAR")
+    URL=$(echo "$JSON" | jq -r '.primary.url // empty')
+
+    if [ -n "$URL" ]; then
+        open "$URL"
+    fi
+fi
diff --git a/.config/sketchybar/sketchybarrc b/.config/sketchybar/sketchybarrc
index 15f11b30..42741499 100755
--- a/.config/sketchybar/sketchybarrc
+++ b/.config/sketchybar/sketchybarrc
@@ -115,7 +115,12 @@ sketchybar --add item datetime right \
            #       icon=􀉉 \
            #       icon.padding_right=8 \
            #       update_freq=120 \
-           #       script="$PLUGIN_DIR/meetingbar.sh"
+           #       popup.background.color=0xff222222 \
+           #       popup.background.corner_radius=6 \
+           #       popup.background.border_color=0xff444444 \
+           #       popup.background.border_width=1 \
+           #       script="$PLUGIN_DIR/meetingbar.sh" \
+           #       click_script="$PLUGIN_DIR/meetingbar_click.sh"
 
 ##### Force all scripts to run the first time (never do this in a script) #####
 sketchybar --update