Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions apps/desktop/src/services/event-notification/index.test.ts
Original file line number Diff line number Diff line change
@@ -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",
}),
);
});
});
2 changes: 1 addition & 1 deletion apps/desktop/src/services/event-notification/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
49 changes: 36 additions & 13 deletions crates/notification-macos/swift-lib/src/NotificationInstance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
}
}

Expand All @@ -103,6 +124,8 @@ class NotificationInstance {
progressBar?.onProgressComplete = nil
progressBar?.resetProgress()
stopCountdown()
compactTimerLabel = nil
expandedTimerLabel = nil

NSAnimationContext.runAnimationGroup({ context in
context.duration = Timing.dismiss
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading