diff --git a/MeetingBar/Core/Models/MBEvent+Helpers.swift b/MeetingBar/Core/Models/MBEvent+Helpers.swift index 7c647ac7..19f2512d 100644 --- a/MeetingBar/Core/Models/MBEvent+Helpers.swift +++ b/MeetingBar/Core/Models/MBEvent+Helpers.swift @@ -84,6 +84,39 @@ public extension Array where Element == MBEvent { return result } + /// When there is an ongoing event and a subsequent event starts within + /// `gapThreshold` seconds of the current event's end, returns both. + /// Returns `(current, upcoming)` — either or both may be nil. + func currentAndUpcomingEvent(gapThreshold: TimeInterval = 900) -> (current: MBEvent?, upcoming: MBEvent?) { + let now = Date() + + // Find the ongoing event (started but not ended) + let current = first(where: { + !$0.isAllDay + && $0.startDate <= now + && $0.endDate > now + && $0.participationStatus != .declined + && $0.status != .canceled + }) + + guard let current = current else { + return (nil, nil) + } + + // Find the next event that starts after now but within + // `gapThreshold` seconds of the current event's end. + let upcoming = first(where: { + !$0.isAllDay + && $0.id != current.id + && $0.startDate > now + && $0.startDate <= current.endDate.addingTimeInterval(gapThreshold) + && $0.participationStatus != .declined + && $0.status != .canceled + }) + + return (current, upcoming) + } + /// From a pre-filtered, sorted array, find the nearest upcoming MBEvent. func nextEvent(linkRequired: Bool = false) -> MBEvent? { var nextEvent: MBEvent? diff --git a/MeetingBar/Extensions/DefaultsKeys.swift b/MeetingBar/Extensions/DefaultsKeys.swift index f8bb88b9..ac1eeb1e 100644 --- a/MeetingBar/Extensions/DefaultsKeys.swift +++ b/MeetingBar/Extensions/DefaultsKeys.swift @@ -46,6 +46,10 @@ extension Defaults.Keys { static let ongoingEventVisibility = Key("ongoingEventVisibility", default: .showTenMinBeforeNext) + // Next Event Flip (alternating current/upcoming in status bar) + static let nextEventFlipMode = Key("nextEventFlipMode", default: .showAfterTenMin) + static let nextEventFlipIntervalSeconds = Key("nextEventFlipIntervalSeconds", default: 5) + // Menu Appearance static let showTimelineInMenu = Key("showTimelineInMenu", default: true) // if the event title in the menu should be shortened or not -> the length will be stored in field menuEventTitleLength diff --git a/MeetingBar/UI/StatusBar/StatusBarItemController.swift b/MeetingBar/UI/StatusBar/StatusBarItemController.swift index f663446a..9c84c7bb 100644 --- a/MeetingBar/UI/StatusBar/StatusBarItemController.swift +++ b/MeetingBar/UI/StatusBar/StatusBarItemController.swift @@ -38,6 +38,14 @@ final class StatusBarItemController { private var cancellables = Set() + // MARK: - Flip timer for current/upcoming event alternation + + /// When true, the status bar shows the upcoming event; otherwise the current event. + private var showingUpcomingEvent = false + + /// Task that drives the flip between current and upcoming event. + private var flipTask: Task? + init() { statusItem = NSStatusBar.system.statusItem( withLength: NSStatusItem.variableLength @@ -166,6 +174,211 @@ final class StatusBarItemController { } func updateTitle() { + let flipMode = Defaults[.nextEventFlipMode] + + // Flip feature requires ongoing event visibility to be "hide 10 min before next" + guard flipMode != .disabled, + Defaults[.ongoingEventVisibility] == .showTenMinBeforeNext else { + stopFlipTimer() + renderNormalTitle() + return + } + + let pair = events.currentAndUpcomingEvent() + if let current = pair.current, let upcoming = pair.upcoming { + let now = Date() + let minutesIntoCurrent = now.timeIntervalSince(current.startDate) / 60 + + let minMinutesIn: Double = (flipMode == .showAfterTenMin) ? 10.0 : 0.0 + + // Only start flipping when the current meeting is far enough in. + guard minutesIntoCurrent >= minMinutesIn else { + stopFlipTimer() + renderNormalTitle() + return + } + + // We have both a current and upcoming event — start the flip timer + // and render whichever side we're currently showing. + startFlipTimer() + if showingUpcomingEvent { + renderUpcomingEvent(upcoming, whileCurrent: current) + } else { + renderEvent(current) + } + return + } + + // No flip scenario — stop the timer and render normally + stopFlipTimer() + renderNormalTitle() + } + + // MARK: - Flip timer management + + private func startFlipTimer() { + // Don't start a new timer if one is already running + guard flipTask == nil else { return } + flipTask = Task { [weak self] in + while let self, !Task.isCancelled { + let interval = TimeInterval(Defaults[.nextEventFlipIntervalSeconds]) + try? await Task.sleep(nanoseconds: UInt64(interval * Double(NSEC_PER_SEC))) + guard !Task.isCancelled else { break } + await MainActor.run { + self.showingUpcomingEvent.toggle() + self.updateTitle() + } + } + } + } + + private func stopFlipTimer() { + flipTask?.cancel() + flipTask = nil + showingUpcomingEvent = false + } + + // MARK: - Render the "upcoming event" side of the flip + + private func renderUpcomingEvent(_ upcoming: MBEvent, whileCurrent current: MBEvent) { + guard let button = statusItem.button else { return } + button.image = nil + button.title = "" + + let (upcomingTitle, upcomingTime) = createEventStatusString( + title: upcoming.title, + startDate: upcoming.startDate, + endDate: upcoming.endDate + ) + + // Set the icon + if Defaults[.eventTitleIconFormat] != .none { + let image: NSImage + if Defaults[.eventTitleIconFormat] == .eventtype { + image = getIconForMeetingService(upcoming.meetingLink?.service) + } else { + image = NSImage(named: Defaults[.eventTitleIconFormat].rawValue)! + } + button.image = image + button.image?.size = MenuStyleConstants.iconSize + } + + if button.image?.name() == "no_online_session" { + button.imagePosition = .noImage + } else { + button.imagePosition = .imageLeft + } + + let menuTitle = NSMutableAttributedString() + + // Bold "Next: " prefix + let boldFont = NSFont.boldSystemFont(ofSize: MenuStyleConstants.defaultFontSize) + let regularFont = NSFont.systemFont(ofSize: MenuStyleConstants.defaultFontSize) + + menuTitle.append(NSAttributedString( + string: "Next: ", + attributes: [.font: boldFont] + )) + + var eventText = upcomingTitle + if Defaults[.eventTimeFormat] == .show { + eventText += " " + upcomingTime + } + + menuTitle.append(NSAttributedString( + string: eventText, + attributes: [.font: regularFont] + )) + + button.attributedTitle = menuTitle + button.toolTip = "Now: \(current.title)\nNext: \(upcoming.title)" + } + + // MARK: - Normal title rendering (original logic) + + private func renderEvent(_ event: MBEvent) { + guard let button = statusItem.button else { return } + button.image = nil + button.title = "" + button.toolTip = nil + + let (title, time) = createEventStatusString( + title: event.title, + startDate: event.startDate, + endDate: event.endDate + ) + + if Defaults[.eventTitleIconFormat] != .none { + let image: NSImage + if Defaults[.eventTitleIconFormat] == .eventtype { + image = getIconForMeetingService(event.meetingLink?.service) + } else { + image = NSImage(named: Defaults[.eventTitleIconFormat].rawValue)! + } + button.image = image + button.image?.size = MenuStyleConstants.iconSize + } + + if button.image?.name() == "no_online_session" { + button.imagePosition = .noImage + } else { + button.imagePosition = .imageLeft + } + + let menuTitle = NSMutableAttributedString() + + if Defaults[.eventTimeFormat] != .show_under_title || Defaults[.eventTitleFormat] == .none { + var eventTitle = title + if Defaults[.eventTimeFormat] == .show { + eventTitle += " " + time + } + + var styles = [NSAttributedString.Key: Any]() + styles[.font] = NSFont.systemFont(ofSize: MenuStyleConstants.defaultFontSize) + + if event.participationStatus == .pending, Defaults[.showPendingEvents] == .show_underlined { + styles[.underlineStyle] = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue | NSUnderlineStyle.byWord.rawValue + } + if event.participationStatus == .tentative, Defaults[.showTentativeEvents] == .show_underlined { + styles[.underlineStyle] = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue | NSUnderlineStyle.byWord.rawValue + } + + menuTitle.append(NSAttributedString(string: eventTitle, attributes: styles)) + } else { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineHeightMultiple = 0.7 + paragraphStyle.alignment = .center + + var styles = [NSAttributedString.Key: Any]() + styles[.font] = NSFont.systemFont(ofSize: 12) + styles[.baselineOffset] = -3 + + if event.participationStatus == .pending, Defaults[.showPendingEvents] == .show_inactive { + styles[.foregroundColor] = NSColor.disabledControlTextColor + } else if event.participationStatus == .pending, Defaults[.showPendingEvents] == .show_underlined { + styles[.underlineStyle] = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue | NSUnderlineStyle.byWord.rawValue + } + if event.participationStatus == .tentative, Defaults[.showTentativeEvents] == .show_inactive { + styles[.foregroundColor] = NSColor.disabledControlTextColor + } else if event.participationStatus == .tentative, Defaults[.showTentativeEvents] == .show_underlined { + styles[.underlineStyle] = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue | NSUnderlineStyle.byWord.rawValue + } + + menuTitle.append(NSAttributedString(string: title, attributes: styles)) + + let timeAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 9), + .foregroundColor: NSColor.lightGray + ] + menuTitle.append(NSAttributedString(string: "\n" + time, attributes: timeAttributes)) + menuTitle.addAttributes([.paragraphStyle: paragraphStyle], range: NSRange(location: 0, length: menuTitle.length)) + } + + button.attributedTitle = menuTitle + button.toolTip = event.title + } + + private func renderNormalTitle() { var title = "MeetingBar" var time = "" var nextEvent: MBEvent! @@ -179,8 +392,6 @@ final class StatusBarItemController { guard Defaults[.showEventMaxTimeUntilEventEnabled] else { return .nextEvent(nextEvent) } - // Positive, if in the future. Negative, if already started. - // Current or past events therefore don't get ignored. let timeUntilStart = nextEvent.startDate.timeIntervalSinceNow let thresholdInSeconds = TimeInterval(Defaults[.showEventMaxTimeUntilEventThreshold] * 60) return timeUntilStart < thresholdInSeconds ? .nextEvent(nextEvent) : .afterThreshold(nextEvent) @@ -200,7 +411,6 @@ final class StatusBarItemController { } } case let .afterThreshold(event): - // Not sure, what the title should be in this case. title = "⏰" if Defaults[.joinEventNotification] { Task { @@ -236,81 +446,7 @@ final class StatusBarItemController { } if button.image == nil { - if Defaults[.eventTitleIconFormat] != .none { - let image: NSImage - if Defaults[.eventTitleIconFormat] == .eventtype { - image = getIconForMeetingService(nextEvent.meetingLink?.service) - } else { - image = NSImage(named: Defaults[.eventTitleIconFormat].rawValue)! - } - - button.image = image - button.image?.size = MenuStyleConstants.iconSize - } - - if button.image?.name() == "no_online_session" { - button.imagePosition = .noImage - } else { - button.imagePosition = .imageLeft - } - - // create an NSMutableAttributedString that we'll append everything to - let menuTitle = NSMutableAttributedString() - - if Defaults[.eventTimeFormat] != .show_under_title || Defaults[.eventTitleFormat] == .none { - var eventTitle = title - if Defaults[.eventTimeFormat] == .show { - eventTitle += " " + time - } - - var styles = [NSAttributedString.Key: Any]() - styles[NSAttributedString.Key.font] = NSFont.systemFont(ofSize: MenuStyleConstants.defaultFontSize) - - if nextEvent.participationStatus == .pending, Defaults[.showPendingEvents] == .show_underlined { - styles[NSAttributedString.Key.underlineStyle] = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue | NSUnderlineStyle.byWord.rawValue - } - - if nextEvent.participationStatus == .tentative, Defaults[.showTentativeEvents] == .show_underlined { - styles[NSAttributedString.Key.underlineStyle] = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue | NSUnderlineStyle.byWord.rawValue - } - - menuTitle.append(NSAttributedString(string: eventTitle, attributes: styles)) - } else { - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineHeightMultiple = 0.7 - paragraphStyle.alignment = .center - - var styles = [NSAttributedString.Key: Any]() - styles[NSAttributedString.Key.font] = NSFont.systemFont(ofSize: 12) - styles[NSAttributedString.Key.baselineOffset] = -3 - - if nextEvent.participationStatus == .pending, Defaults[.showPendingEvents] == .show_inactive { - styles[NSAttributedString.Key.foregroundColor] = NSColor.disabledControlTextColor - } else if nextEvent.participationStatus == .pending, Defaults[.showPendingEvents] == .show_underlined { - styles[NSAttributedString.Key.underlineStyle] = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue | NSUnderlineStyle.byWord.rawValue - } - - if nextEvent.participationStatus == .tentative, Defaults[.showTentativeEvents] == .show_inactive { - styles[NSAttributedString.Key.foregroundColor] = NSColor.disabledControlTextColor - } else if nextEvent.participationStatus == .tentative, Defaults[.showTentativeEvents] == .show_underlined { - styles[NSAttributedString.Key.underlineStyle] = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue | NSUnderlineStyle.byWord.rawValue - } - - menuTitle.append(NSAttributedString(string: title, attributes: styles)) - - let timeAttributes = [ - NSAttributedString.Key.font: NSFont.systemFont(ofSize: 9), - NSAttributedString.Key.foregroundColor: NSColor.lightGray - ] - menuTitle.append(NSAttributedString(string: "\n" + time, attributes: timeAttributes)) - - menuTitle.addAttributes([NSAttributedString.Key.paragraphStyle: paragraphStyle], range: NSRange(location: 0, length: menuTitle.length)) - } - - button.attributedTitle = menuTitle - if nextEvent != nil { - button.toolTip = nextEvent.title - } + renderEvent(nextEvent) } } } diff --git a/MeetingBar/UI/Views/Preferences/AppearanceTab.swift b/MeetingBar/UI/Views/Preferences/AppearanceTab.swift index 38c25073..de359332 100644 --- a/MeetingBar/UI/Views/Preferences/AppearanceTab.swift +++ b/MeetingBar/UI/Views/Preferences/AppearanceTab.swift @@ -149,6 +149,8 @@ struct StatusBarSection: View { @Default(.showEventMaxTimeUntilEventThreshold) var showEventMaxTimeUntilEventThreshold @Default(.showEventMaxTimeUntilEventEnabled) var showEventMaxTimeUntilEventEnabled @Default(.ongoingEventVisibility) var ongoingEventVisibility + @Default(.nextEventFlipMode) var nextEventFlipMode + @Default(.nextEventFlipIntervalSeconds) var nextEventFlipIntervalSeconds var body: some View { GroupBox(label: Label("preferences_appearance_status_bar_title".loco(), systemImage: "menubar.rectangle")) { @@ -244,6 +246,29 @@ struct StatusBarSection: View { Text("preferences_appearance_status_bar_ongoing_time_ten_before_next_value".loco()).tag( OngoingEventVisibility.showTenMinBeforeNext) }.frame(width: 325) + + HStack { + Picker("Next event preview:", selection: $nextEventFlipMode) { + Text("Disabled").tag(NextEventFlipMode.disabled) + Text("Show after start").tag(NextEventFlipMode.showAfterStart) + Text("Show after 10 min").tag(NextEventFlipMode.showAfterTenMin) + } + .fixedSize() + .disabled(ongoingEventVisibility != .showTenMinBeforeNext) + .help(ongoingEventVisibility != .showTenMinBeforeNext + ? "Requires \"Ongoing event visibility\" to be set to \"hide 10 min before next event\"" + : "Alternate between showing the current and upcoming event in the status bar") + + if nextEventFlipMode != .disabled && ongoingEventVisibility == .showTenMinBeforeNext { + Picker("Flip every", selection: $nextEventFlipIntervalSeconds) { + ForEach([2, 3, 5, 7, 10, 15], id: \.self) { sec in + Text("\(sec)s").tag(sec) + } + } + .fixedSize() + .help("How often to alternate between current and upcoming event") + } + } }.frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/MeetingBar/Utilities/Constants.swift b/MeetingBar/Utilities/Constants.swift index e79d6775..e5796755 100644 --- a/MeetingBar/Utilities/Constants.swift +++ b/MeetingBar/Utilities/Constants.swift @@ -105,6 +105,12 @@ enum ShowEventsForPeriod: String, Defaults.Serializable, Codable, CaseIterable { case today_n_tomorrow } +enum NextEventFlipMode: String, Defaults.Serializable, Codable, CaseIterable { + case disabled + case showAfterStart + case showAfterTenMin +} + enum OngoingEventVisibility: String, Defaults.Serializable, Codable, CaseIterable { case hideImmediateAfter case showTenMinAfter diff --git a/MeetingBarTests/CurrentAndUpcomingEventTests.swift b/MeetingBarTests/CurrentAndUpcomingEventTests.swift new file mode 100644 index 00000000..5ed02ea1 --- /dev/null +++ b/MeetingBarTests/CurrentAndUpcomingEventTests.swift @@ -0,0 +1,293 @@ +// +// CurrentAndUpcomingEventTests.swift +// MeetingBar +// +// Tests for the currentAndUpcomingEvent() method and flip activation logic. +// + +@testable import MeetingBar +import Defaults +import XCTest + +// MARK: - currentAndUpcomingEvent() Tests + +class CurrentAndUpcomingEventTests: BaseTestCase { + private let now = Date() + + override func setUp() { + super.setUp() + Defaults[.allDayEvents] = .show + Defaults[.nonAllDayEvents] = .show + Defaults[.showPendingEvents] = .show + Defaults[.showTentativeEvents] = .show + Defaults[.declinedEventsAppereance] = .show_inactive + Defaults[.personalEventsAppereance] = .show_active + Defaults[.filterEventRegexes] = [] + Defaults[.dismissedEvents] = [] + Defaults[.showEventsForPeriod] = .today_n_tomorrow + } + + // MARK: - Basic pairing + + func test_returnsNilWhenNoEvents() { + let events: [MBEvent] = [] + let pair = events.currentAndUpcomingEvent() + XCTAssertNil(pair.current) + XCTAssertNil(pair.upcoming) + } + + func test_returnsNilWhenNoCurrentEvent() { + // Only a future event, nothing ongoing + let future = makeFakeEvent( + id: "F1", + start: now.addingTimeInterval(1800), + end: now.addingTimeInterval(3600) + ) + let pair = [future].currentAndUpcomingEvent() + XCTAssertNil(pair.current) + XCTAssertNil(pair.upcoming) + } + + func test_returnsCurrentButNoUpcoming_whenNoNextEvent() { + // Ongoing event, no next event + let current = makeFakeEvent( + id: "C1", + start: now.addingTimeInterval(-1800), + end: now.addingTimeInterval(1800) + ) + let pair = [current].currentAndUpcomingEvent() + XCTAssertEqual(pair.current?.id, "C1") + XCTAssertNil(pair.upcoming) + } + + func test_returnsBoth_whenNextStartsWithinGap() { + // Current meeting: started 30 min ago, ends in 30 min + let current = makeFakeEvent( + id: "C1", + start: now.addingTimeInterval(-1800), + end: now.addingTimeInterval(1800) + ) + // Next meeting: starts in 35 min (5 min after current ends) + // which is within the default 15-min gap threshold + let upcoming = makeFakeEvent( + id: "U1", + start: now.addingTimeInterval(2100), + end: now.addingTimeInterval(3900) + ) + let pair = [current, upcoming].currentAndUpcomingEvent() + XCTAssertEqual(pair.current?.id, "C1") + XCTAssertEqual(pair.upcoming?.id, "U1") + } + + func test_returnsNoUpcoming_whenNextStartsBeyondGap() { + // Current meeting: started 30 min ago, ends in 30 min + let current = makeFakeEvent( + id: "C1", + start: now.addingTimeInterval(-1800), + end: now.addingTimeInterval(1800) + ) + // Next meeting: starts in 60 min (30 min after current ends) + // which is beyond the default 15-min gap threshold + let farAway = makeFakeEvent( + id: "F1", + start: now.addingTimeInterval(3600), + end: now.addingTimeInterval(5400) + ) + let pair = [current, farAway].currentAndUpcomingEvent() + XCTAssertEqual(pair.current?.id, "C1") + XCTAssertNil(pair.upcoming) + } + + func test_customGapThreshold() { + let current = makeFakeEvent( + id: "C1", + start: now.addingTimeInterval(-1800), + end: now.addingTimeInterval(1800) + ) + // Next meeting starts 25 min after current ends (beyond 15-min default) + let upcoming = makeFakeEvent( + id: "U1", + start: now.addingTimeInterval(3300), + end: now.addingTimeInterval(5100) + ) + + // Default threshold (15 min = 900s): should NOT find upcoming + let pair1 = [current, upcoming].currentAndUpcomingEvent(gapThreshold: 900) + XCTAssertNil(pair1.upcoming) + + // Custom threshold (30 min = 1800s): SHOULD find upcoming + let pair2 = [current, upcoming].currentAndUpcomingEvent(gapThreshold: 1800) + XCTAssertEqual(pair2.upcoming?.id, "U1") + } + + // MARK: - Filtering (declined, canceled, all-day) + + func test_skipsDeclinedCurrentEvent() { + let declined = makeFakeEvent( + id: "D1", + start: now.addingTimeInterval(-1800), + end: now.addingTimeInterval(1800), + participationStatus: .declined + ) + let pair = [declined].currentAndUpcomingEvent() + XCTAssertNil(pair.current) + } + + func test_skipsCanceledCurrentEvent() { + let canceled = makeFakeEvent( + id: "X1", + start: now.addingTimeInterval(-1800), + end: now.addingTimeInterval(1800), + status: .canceled + ) + let pair = [canceled].currentAndUpcomingEvent() + XCTAssertNil(pair.current) + } + + func test_skipsAllDayCurrentEvent() { + let allDay = makeFakeEvent( + id: "AD1", + start: now.addingTimeInterval(-43200), + end: now.addingTimeInterval(43200), + isAllDay: true + ) + let pair = [allDay].currentAndUpcomingEvent() + XCTAssertNil(pair.current) + } + + func test_skipsDeclinedUpcomingEvent() { + let current = makeFakeEvent( + id: "C1", + start: now.addingTimeInterval(-1800), + end: now.addingTimeInterval(1800) + ) + let declined = makeFakeEvent( + id: "D1", + start: now.addingTimeInterval(2100), + end: now.addingTimeInterval(3900), + participationStatus: .declined + ) + let pair = [current, declined].currentAndUpcomingEvent() + XCTAssertEqual(pair.current?.id, "C1") + XCTAssertNil(pair.upcoming) + } + + func test_skipsCanceledUpcomingEvent() { + let current = makeFakeEvent( + id: "C1", + start: now.addingTimeInterval(-1800), + end: now.addingTimeInterval(1800) + ) + let canceled = makeFakeEvent( + id: "X1", + start: now.addingTimeInterval(2100), + end: now.addingTimeInterval(3900), + status: .canceled + ) + let pair = [current, canceled].currentAndUpcomingEvent() + XCTAssertEqual(pair.current?.id, "C1") + XCTAssertNil(pair.upcoming) + } + + // MARK: - Back-to-back meetings (next starts at or before current ends) + + func test_backToBackMeetings() { + let current = makeFakeEvent( + id: "C1", + start: now.addingTimeInterval(-1800), + end: now.addingTimeInterval(600) + ) + // Next meeting starts exactly when current ends + let upcoming = makeFakeEvent( + id: "U1", + start: now.addingTimeInterval(600), + end: now.addingTimeInterval(2400) + ) + let pair = [current, upcoming].currentAndUpcomingEvent() + XCTAssertEqual(pair.current?.id, "C1") + XCTAssertEqual(pair.upcoming?.id, "U1") + } + + // MARK: - Picks first valid upcoming (not a later one) + + func test_picksFirstValidUpcoming() { + let current = makeFakeEvent( + id: "C1", + start: now.addingTimeInterval(-1800), + end: now.addingTimeInterval(1800) + ) + let soon = makeFakeEvent( + id: "U1", + start: now.addingTimeInterval(2100), + end: now.addingTimeInterval(3900) + ) + let later = makeFakeEvent( + id: "U2", + start: now.addingTimeInterval(2400), + end: now.addingTimeInterval(4200) + ) + let pair = [current, soon, later].currentAndUpcomingEvent() + XCTAssertEqual(pair.upcoming?.id, "U1") + } +} + +// MARK: - Flip Activation Logic Tests + +class FlipActivationTests: BaseTestCase { + private let now = Date() + + /// Simulates the flip activation guard from StatusBarItemController. + /// Returns true if the flip should be active for the given mode. + private func shouldFlip(mode: NextEventFlipMode, currentStart: Date) -> Bool { + guard mode != .disabled else { return false } + let minutesIntoCurrent = now.timeIntervalSince(currentStart) / 60 + let minMinutesIn: Double = (mode == .showAfterTenMin) ? 10.0 : 0.0 + return minutesIntoCurrent >= minMinutesIn + } + + // MARK: - Disabled mode + + func test_noFlip_whenDisabled() { + let currentStart = now.addingTimeInterval(-900) // 15 min in + XCTAssertFalse(shouldFlip(mode: .disabled, currentStart: currentStart)) + } + + // MARK: - Show after start + + func test_showAfterStart_flipsImmediately() { + // Just started, 1 minute in + let currentStart = now.addingTimeInterval(-60) + XCTAssertTrue(shouldFlip(mode: .showAfterStart, currentStart: currentStart)) + } + + func test_showAfterStart_flipsAtZero() { + let currentStart = now + XCTAssertTrue(shouldFlip(mode: .showAfterStart, currentStart: currentStart)) + } + + // MARK: - Show after 10 min + + func test_showAfterTenMin_noFlipEarlyInMeeting() { + // 2 minutes into current + let currentStart = now.addingTimeInterval(-120) + XCTAssertFalse(shouldFlip(mode: .showAfterTenMin, currentStart: currentStart)) + } + + func test_showAfterTenMin_flipsAfterTenMin() { + // 12 minutes into current + let currentStart = now.addingTimeInterval(-720) + XCTAssertTrue(shouldFlip(mode: .showAfterTenMin, currentStart: currentStart)) + } + + func test_showAfterTenMin_flipsAtExactlyTenMin() { + // Exactly 10 minutes into current + let currentStart = now.addingTimeInterval(-600) + XCTAssertTrue(shouldFlip(mode: .showAfterTenMin, currentStart: currentStart)) + } + + func test_showAfterTenMin_noFlipAt9Min() { + // 9 minutes into current + let currentStart = now.addingTimeInterval(-540) + XCTAssertFalse(shouldFlip(mode: .showAfterTenMin, currentStart: currentStart)) + } +} diff --git a/MeetingBarTests/TimelineLogicTests.swift b/MeetingBarTests/TimelineLogicTests.swift index 7adcaaa2..f45f24e0 100644 --- a/MeetingBarTests/TimelineLogicTests.swift +++ b/MeetingBarTests/TimelineLogicTests.swift @@ -8,7 +8,7 @@ import XCTest @testable import MeetingBar -import SwiftUICore +import SwiftUI final class TimelineLogicTests: XCTestCase {