From fc32ff3bfa021f2406a1a26648a12f52bb61cec6 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Mon, 30 Mar 2026 13:10:07 -0700 Subject: [PATCH] fix: persist event reminder notifications Remove the auto-dismiss timeout from calendar event reminders and add a regression test for persistent notifications. --- .../services/event-notification/index.test.ts | 75 +++++++++++++++++++ .../src/services/event-notification/index.ts | 2 +- .../swift-lib/src/NotificationInstance.swift | 49 ++++++++---- .../src/NotificationManager+Animation.swift | 2 +- .../src/NotificationManager+CompactView.swift | 4 + .../NotificationManager+ExpandedView.swift | 2 +- 6 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/services/event-notification/index.test.ts diff --git a/apps/desktop/src/services/event-notification/index.test.ts b/apps/desktop/src/services/event-notification/index.test.ts new file mode 100644 index 0000000000..dd77188118 --- /dev/null +++ b/apps/desktop/src/services/event-notification/index.test.ts @@ -0,0 +1,75 @@ +import { createMergeableStore } from "tinybase/with-schemas"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { SCHEMA } from "@hypr/store"; + +const pluginNotification = vi.hoisted(() => ({ + showNotification: vi.fn().mockResolvedValue({ status: "ok", data: null }), +})); + +vi.mock("@hypr/plugin-notification", async () => { + const actual = await vi.importActual< + typeof import("@hypr/plugin-notification") + >("@hypr/plugin-notification"); + + return { + ...actual, + commands: { + ...actual.commands, + showNotification: pluginNotification.showNotification, + }, + }; +}); + +import { checkEventNotifications } from "./index"; + +import { SCHEMA as SETTINGS_SCHEMA } from "~/store/tinybase/store/settings"; + +function createMainStore() { + return createMergeableStore() + .setTablesSchema(SCHEMA.table) + .setValuesSchema(SCHEMA.value); +} + +function createSettingsStore() { + return createMergeableStore() + .setTablesSchema(SETTINGS_SCHEMA.table) + .setValuesSchema(SETTINGS_SCHEMA.value); +} + +describe("checkEventNotifications", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-30T16:00:00.000Z")); + pluginNotification.showNotification.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("shows persistent notifications for upcoming events", () => { + const store = createMainStore(); + const settingsStore = createSettingsStore(); + const startTime = "2026-03-30T16:02:00.000Z"; + + settingsStore.setValue("notification_event", true); + store.setRow("events", "event-1", { + title: "Design review", + started_at: startTime, + }); + + checkEventNotifications(store, settingsStore, new Map()); + + expect(pluginNotification.showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + key: `event-event-1-${new Date(startTime).getTime()}`, + title: "Design review", + message: "Starting in 2 minutes", + timeout: null, + source: { type: "calendar_event", event_id: "event-1" }, + action_label: "Start listening", + }), + ); + }); +}); diff --git a/apps/desktop/src/services/event-notification/index.ts b/apps/desktop/src/services/event-notification/index.ts index ba1c42f001..976d317b85 100644 --- a/apps/desktop/src/services/event-notification/index.ts +++ b/apps/desktop/src/services/event-notification/index.ts @@ -132,7 +132,7 @@ export function checkEventNotifications( key: notificationKey, title: title, message: `Starting in ${minutesUntil} minute${minutesUntil !== 1 ? "s" : ""}`, - timeout: { secs: 30, nanos: 0 }, + timeout: null, source: { type: "calendar_event", event_id: eventId }, start_time: Math.floor(startTime.getTime() / 1000), participants: participants, diff --git a/crates/notification-macos/swift-lib/src/NotificationInstance.swift b/crates/notification-macos/swift-lib/src/NotificationInstance.swift index 0a1d113cde..a9671ca7db 100644 --- a/crates/notification-macos/swift-lib/src/NotificationInstance.swift +++ b/crates/notification-macos/swift-lib/src/NotificationInstance.swift @@ -16,7 +16,8 @@ class NotificationInstance { var countdownTimer: Timer? var meetingStartTime: Date? - weak var timerLabel: NSTextField? + var compactTimerLabel: NSTextField? + var expandedTimerLabel: NSTextField? weak var progressBar: NotificationBackgroundView? { didSet { progressBar?.onProgressComplete = { [weak self] in @@ -45,28 +46,46 @@ class NotificationInstance { NotificationManager.shared.animateExpansion(notification: self, isExpanded: isExpanded) } - func startCountdown(label: NSTextField) { - timerLabel = label + func stopCountdown() { + countdownTimer?.invalidate() + countdownTimer = nil + } + + func setCompactCountdownLabel(_ label: NSTextField) { + compactTimerLabel = label + startCountdownIfNeeded() updateCountdown() + } + + func setExpandedCountdownLabel(_ label: NSTextField) { + expandedTimerLabel = label + startCountdownIfNeeded() + updateCountdown() + } + + func clearExpandedCountdownLabel() { + expandedTimerLabel = nil + } + + private func startCountdownIfNeeded() { + guard meetingStartTime != nil else { + stopCountdown() + return + } + guard countdownTimer == nil else { return } - countdownTimer?.invalidate() countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.updateCountdown() } } - func stopCountdown() { - countdownTimer?.invalidate() - countdownTimer = nil - timerLabel = nil - } - private func updateCountdown() { - guard let startTime = meetingStartTime, let label = timerLabel else { return } + guard let startTime = meetingStartTime else { return } let remaining = startTime.timeIntervalSinceNow if remaining <= 0 { - label.stringValue = "Started" + compactTimerLabel?.stringValue = "Started" + expandedTimerLabel?.stringValue = "Started" countdownTimer?.invalidate() countdownTimer = nil @@ -77,7 +96,9 @@ class NotificationInstance { } else { let minutes = Int(remaining) / 60 let seconds = Int(remaining) % 60 - label.stringValue = "Begins in \(minutes):\(String(format: "%02d", seconds))" + let countdownText = "Begins in \(minutes):\(String(format: "%02d", seconds))" + compactTimerLabel?.stringValue = countdownText + expandedTimerLabel?.stringValue = countdownText } } @@ -103,6 +124,8 @@ class NotificationInstance { progressBar?.onProgressComplete = nil progressBar?.resetProgress() stopCountdown() + compactTimerLabel = nil + expandedTimerLabel = nil NSAnimationContext.runAnimationGroup({ context in context.duration = Timing.dismiss diff --git a/crates/notification-macos/swift-lib/src/NotificationManager+Animation.swift b/crates/notification-macos/swift-lib/src/NotificationManager+Animation.swift index a4e569b3b8..6644d979cc 100644 --- a/crates/notification-macos/swift-lib/src/NotificationManager+Animation.swift +++ b/crates/notification-macos/swift-lib/src/NotificationManager+Animation.swift @@ -84,7 +84,7 @@ extension NotificationManager { } private func animateToCompact(notification: NotificationInstance, frame: NSRect) { - notification.stopCountdown() + notification.clearExpandedCountdownLabel() notification.expandedContentView?.removeFromSuperview() notification.expandedContentView = nil notification.compactContentView?.alphaValue = 0 diff --git a/crates/notification-macos/swift-lib/src/NotificationManager+CompactView.swift b/crates/notification-macos/swift-lib/src/NotificationManager+CompactView.swift index 4f2fc1eaa9..2d31169474 100644 --- a/crates/notification-macos/swift-lib/src/NotificationManager+CompactView.swift +++ b/crates/notification-macos/swift-lib/src/NotificationManager+CompactView.swift @@ -57,6 +57,10 @@ extension NotificationManager { textStack.addArrangedSubview(titleLabel) textStack.addArrangedSubview(bodyLabel) + if notification.meetingStartTime != nil { + notification.setCompactCountdownLabel(bodyLabel) + } + container.addArrangedSubview(iconContainer) container.addArrangedSubview(textStack) diff --git a/crates/notification-macos/swift-lib/src/NotificationManager+ExpandedView.swift b/crates/notification-macos/swift-lib/src/NotificationManager+ExpandedView.swift index c58dc87de7..d2c42a60a1 100644 --- a/crates/notification-macos/swift-lib/src/NotificationManager+ExpandedView.swift +++ b/crates/notification-macos/swift-lib/src/NotificationManager+ExpandedView.swift @@ -55,7 +55,7 @@ extension NotificationManager { container.addArrangedSubview(actionStack) actionStack.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true - notification.startCountdown(label: timerLabel) + notification.setExpandedCountdownLabel(timerLabel) return container }