Skip to content
Merged
14 changes: 10 additions & 4 deletions apps/threshold/src-tauri/src/alarm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,17 +237,23 @@ impl AlarmCoordinator {
///
/// - `app`: app handle for event emission.
/// - `id`: alarm identifier.
/// - `minutes`: snooze duration in minutes.
/// - `snoozed_until`: absolute epoch-millisecond timestamp for the new trigger.
/// The TS layer is responsible for computing the anchor (now + N for ringing,
/// original_trigger + N for upcoming) and enforcing a minimum-in-future floor.
pub async fn snooze_alarm<R: Runtime>(
&self,
app: &AppHandle<R>,
id: i32,
minutes: i64,
snoozed_until: i64,
) -> Result<()> {
let alarm = self.db.get_by_id(id).await?;
let now = chrono::Utc::now().timestamp_millis();
if snoozed_until <= now {
return Err(Error::Validation(
"snoozed_until must be in the future".into(),
));
}
let alarm = self.db.get_by_id(id).await?;
let original_trigger = alarm.next_trigger.unwrap_or(now);
let snoozed_until = now + minutes * 60 * 1000;

let revision = self.db.next_revision().await?;
let updated = self.db.update_next_trigger(id, Some(snoozed_until), revision).await?;
Expand Down
6 changes: 3 additions & 3 deletions apps/threshold/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,15 +121,15 @@ pub async fn dismiss_alarm<R: Runtime>(
/// - `app`: app handle for command context.
/// - `coordinator`: alarm coordinator state.
/// - `id`: alarm identifier.
/// - `minutes`: snooze duration in minutes.
/// - `snoozed_until`: absolute epoch-millisecond timestamp for the new trigger.
pub async fn snooze_alarm<R: Runtime>(
app: AppHandle<R>,
coordinator: State<'_, AlarmCoordinator>,
id: i32,
minutes: i64,
snoozed_until: i64,
) -> Result<(), String> {
coordinator
.snooze_alarm(&app, id, minutes)
.snooze_alarm(&app, id, snoozed_until)
.await
.map_err(|e| e.to_string())
}
Expand Down
5 changes: 4 additions & 1 deletion apps/threshold/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,10 @@ pub fn run() {
}

if let Some(coord) = handle.try_state::<AlarmCoordinator>() {
match coord.snooze_alarm(&handle, cmd.alarm_id, cmd.snooze_length_minutes).await {
// Watch snooze is always now-anchored (ringing alarm)
let snoozed_until = chrono::Utc::now().timestamp_millis()
+ cmd.snooze_length_minutes * 60 * 1000;
match coord.snooze_alarm(&handle, cmd.alarm_id, snoozed_until).await {
Ok(_) => log::info!("watch: snoozed alarm {} for {} min", cmd.alarm_id, cmd.snooze_length_minutes),
Err(e) => log::error!("watch: failed to snooze alarm {}: {e}", cmd.alarm_id),
}
Expand Down
6 changes: 3 additions & 3 deletions apps/threshold/src/screens/Ringing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ vi.mock('../services/AlarmManagerService', () => ({
isInitialized: vi.fn(() => true),
loadAlarms: vi.fn(),
stopRinging: vi.fn(),
snoozeAlarm: vi.fn(),
snoozeRinging: vi.fn(),
},
}));

Expand Down Expand Up @@ -237,7 +237,7 @@ describe('Ringing Screen Logic', () => {

// Assert
await waitFor(() => {
expect(alarmManagerService.snoozeAlarm).toHaveBeenCalledWith(1, 10);
expect(alarmManagerService.snoozeRinging).toHaveBeenCalledWith(1, 10);
expect(mockWindow.close).toHaveBeenCalled();
});
expect(appManagementService.minimizeApp).not.toHaveBeenCalled();
Expand All @@ -256,7 +256,7 @@ describe('Ringing Screen Logic', () => {

// Assert
await waitFor(() => {
expect(alarmManagerService.snoozeAlarm).toHaveBeenCalledWith(1, 10);
expect(alarmManagerService.snoozeRinging).toHaveBeenCalledWith(1, 10);
expect(appManagementService.minimizeApp).toHaveBeenCalled();
});
expect(mockWindow.close).not.toHaveBeenCalled();
Expand Down
2 changes: 1 addition & 1 deletion apps/threshold/src/screens/Ringing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ const Ringing: React.FC = () => {

const handleSnooze = async () => {
console.log('Snoozing Alarm', alarmId, 'for', snoozeLength, 'minutes');
await alarmManagerService.snoozeAlarm(alarmId, snoozeLength);
await alarmManagerService.snoozeRinging(alarmId, snoozeLength);
await closeRingingWindow();
};

Expand Down
132 changes: 128 additions & 4 deletions apps/threshold/src/services/AlarmManagerService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,15 +320,34 @@ describe('AlarmManagerService', () => {
expect(scheduleCalls).toHaveLength(2);
});

it('snoozes alarms and stops the current ring by default', async () => {
it('snoozes ringing alarm with now-anchored timestamp and stops ringing', async () => {
const service = new AlarmManagerService();
const before = Date.now();

await service.snoozeAlarm(42, 10);
await service.snoozeRinging(42, 10);

expect(AlarmService.snooze).toHaveBeenCalledWith(42, 10);
const after = Date.now();
const [calledId, calledTimestamp] = (AlarmService.snooze as any).mock.calls[0];
expect(calledId).toBe(42);
expect(calledTimestamp).toBeGreaterThanOrEqual(before + 10 * 60_000);
expect(calledTimestamp).toBeLessThanOrEqual(after + 10 * 60_000);
expect(invoke).toHaveBeenCalledWith('plugin:alarm-manager|stop_ringing');
});

it('snoozes upcoming alarm with trigger-anchored timestamp without stopping ringing', async () => {
const service = new AlarmManagerService();
const nextTrigger = Date.now() + 5 * 60_000;
(AlarmService.get as any).mockResolvedValue({ id: 42, nextTrigger });

await service.snoozeUpcoming(42, 10);

const [calledId, calledTimestamp] = (AlarmService.snooze as any).mock.calls[0];
expect(calledId).toBe(42);
// Anchored to nextTrigger + 10 min, with a floor of now + 60s
expect(calledTimestamp).toBe(nextTrigger + 10 * 60_000);
expect(invoke).not.toHaveBeenCalledWith('plugin:alarm-manager|stop_ringing');
});

it('dismisses upcoming actions by mapping notification ID to alarm ID', async () => {
const service = new AlarmManagerService();
let actionCallback: ((notification: any) => Promise<void>) | null = null;
Expand Down Expand Up @@ -386,18 +405,21 @@ describe('AlarmManagerService', () => {
it('snoozes upcoming actions without stopping active ringing', async () => {
const service = new AlarmManagerService();
let actionCallback: ((notification: any) => Promise<void>) | null = null;
const nextTrigger = Date.now() + 10 * 60_000;

(PlatformUtils.isMobile as any).mockReturnValue(true);
(PlatformUtils.getPlatform as any).mockReturnValue('android');
(onAction as any).mockImplementation(async (cb: (notification: any) => Promise<void>) => {
actionCallback = cb;
return undefined;
});
(AlarmService.get as any).mockResolvedValue({ id: 11, nextTrigger });

await service.init();
expect(actionCallback).not.toBeNull();

(invoke as any).mockClear();
const before = Date.now();
await actionCallback!({
actionId: 'snooze_alarm',
notification: {
Expand All @@ -406,9 +428,13 @@ describe('AlarmManagerService', () => {
},
});

expect(AlarmService.snooze).toHaveBeenCalledWith(11, 10);
// Upcoming snooze anchors to nextTrigger + snoozeLength
const [calledId, calledTimestamp] = (AlarmService.snooze as any).mock.calls[0];
expect(calledId).toBe(11);
expect(calledTimestamp).toBe(nextTrigger + 10 * 60_000);
expect(invoke).not.toHaveBeenCalledWith('plugin:alarm-manager|stop_ringing');
expect(showToast).toHaveBeenCalled();
void before;
});

it('clears upcoming notification when alarm starts ringing', async () => {
Expand Down Expand Up @@ -515,4 +541,102 @@ describe('AlarmManagerService', () => {
}),
);
});

it('cancels native alarm and upcoming notification when alarm:cancelled fires', async () => {
const service = new AlarmManagerService();
(PlatformUtils.isMobile as any).mockReturnValue(true);

// Pre-populate the signature map as if the alarm had been scheduled
(service as any).scheduledSignatures.set(5, '12345|');

await service.init();

const cancelledHandlers = eventListeners.get('alarm:cancelled') ?? [];
expect(cancelledHandlers.length).toBe(1);

for (const handler of cancelledHandlers) {
await handler({ payload: { id: 5, reason: 'DELETED' } });
}

expect(invoke).toHaveBeenCalledWith('plugin:alarm-manager|cancel', { payload: { id: 5 } });
expect(cancel).toHaveBeenCalledWith([1_000_005]);
expect(removeActive).toHaveBeenCalledWith([{ id: 1_000_005 }]);
expect((service as any).scheduledSignatures.has(5)).toBe(false);
});

it('deleteAlarm only calls AlarmService.delete and relies on alarm:cancelled listener', async () => {
const service = new AlarmManagerService();
await service.deleteAlarm(99);

expect(AlarmService.delete).toHaveBeenCalledWith(99);
// cancelNativeAlarm is NOT called directly — handled by alarm:cancelled event
expect(invoke).not.toHaveBeenCalledWith('plugin:alarm-manager|cancel', expect.anything());
});

it('snoozeUpcoming floors snoozedUntil to now+60s when nextTrigger+N is in the past', async () => {
const service = new AlarmManagerService();
// Simulate: alarm originally at T-5m, snooze 3 minutes → T-2m = past
const nextTrigger = Date.now() - 5 * 60_000;
(AlarmService.get as any).mockResolvedValue({ id: 7, nextTrigger });

const before = Date.now();
await service.snoozeUpcoming(7, 3);
const after = Date.now();

const [, calledTimestamp] = (AlarmService.snooze as any).mock.calls[0];
// Floor: must be at least now + 60s
expect(calledTimestamp).toBeGreaterThanOrEqual(before + 60_000);
expect(calledTimestamp).toBeLessThanOrEqual(after + 60_000 + 100);
});

it('snooze-from-ringing-notification calls snoozeRinging with the alarm ID', async () => {
const service = new AlarmManagerService();
(PlatformUtils.isMobile as any).mockReturnValue(true);

localStorageState.set('threshold_snooze_length', '10');
await service.init();

// Simulate the alarm-manager:snooze-requested event from the Android bridge
const snoozeRequestHandlers = eventListeners.get('alarm-manager:snooze-requested') ?? [];
expect(snoozeRequestHandlers.length).toBe(1);

const before = Date.now();
for (const handler of snoozeRequestHandlers) {
await handler({ payload: { id: 15 } });
}
const after = Date.now();

const [calledId, calledTimestamp] = (AlarmService.snooze as any).mock.calls[0];
expect(calledId).toBe(15);
expect(calledTimestamp).toBeGreaterThanOrEqual(before + 10 * 60_000);
expect(calledTimestamp).toBeLessThanOrEqual(after + 10 * 60_000);
expect(invoke).toHaveBeenCalledWith('plugin:alarm-manager|stop_ringing');
});

it('falls back to stopping ringing when the alarm_trigger snooze action has no alarm ID', async () => {
const service = new AlarmManagerService();
let actionCallback: ((notification: any) => Promise<void>) | null = null;

(PlatformUtils.isMobile as any).mockReturnValue(true);
(onAction as any).mockImplementation(async (cb: (notification: any) => Promise<void>) => {
actionCallback = cb;
return undefined;
});

await service.init();
expect(actionCallback).not.toBeNull();

(invoke as any).mockClear();
// The JS-driven 'alarm_trigger' notification carries no alarm ID (unlike the
// native AlarmRingingService channel bridge, tested above).
await actionCallback!({
actionId: 'snooze',
notification: {
actionTypeId: 'alarm_trigger',
},
});

expect(AlarmService.snooze).not.toHaveBeenCalled();
expect(invoke).toHaveBeenCalledWith('plugin:alarm-manager|stop_ringing');
});
});
64 changes: 47 additions & 17 deletions apps/threshold/src/services/AlarmManagerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export class AlarmManagerService {
});
console.log('[AlarmManager] Event listener 1/4 registered.');

console.log('[AlarmManager] Setting up event listener 2/4: alarms:batch:updated...');
console.log('[AlarmManager] Setting up event listener 2/5: alarms:batch:updated...');
// Listen for batch events and refresh native schedule
await listen('alarms:batch:updated', async () => {
console.log('[AlarmManager] Received alarms:batch:updated event');
Expand All @@ -123,9 +123,22 @@ export class AlarmManagerService {
reason: 'alarm-batch-updated',
});
});
console.log('[AlarmManager] Event listener 2/4 registered.');
console.log('[AlarmManager] Event listener 2/5 registered.');

console.log('[AlarmManager] Setting up event listener 3/5: alarm:cancelled...');
// Eagerly cancel native alarm and upcoming notification when Rust emits alarm:cancelled.
// This fires before alarms:batch:updated and closes the race window where a cancelled
// alarm could still fire if the BroadcastReceiver was already dispatched.
await listen<{ id: number; reason: string }>('alarm:cancelled', async (event) => {
const { id } = event.payload;
console.log(`[AlarmManager] Received alarm:cancelled for id=${id}, reason=${event.payload.reason}`);
await this.cancelNativeAlarm(id);
await alarmNotificationService.cancelUpcomingNotification(id);
this.scheduledSignatures.delete(id);
});
console.log('[AlarmManager] Event listener 3/5 registered.');

console.log('[AlarmManager] Setting up event listener 3/4: settings-changed...');
console.log('[AlarmManager] Setting up event listener 4/5: settings-changed...');
await listen<{ key?: string; value?: unknown }>('settings-changed', async (event) => {
if (event.payload?.key !== 'is24h') return;
if (!PlatformUtils.isMobile()) return;
Expand All @@ -135,16 +148,16 @@ export class AlarmManagerService {
reason: 'settings-24h-changed',
});
});
console.log('[AlarmManager] Event listener 3/4 registered.');
console.log('[AlarmManager] Event listener 4/5 registered.');

console.log('[AlarmManager] Setting up event listener 4/4: notifications:upcoming:resync...');
console.log('[AlarmManager] Setting up event listener 5/5: notifications:upcoming:resync...');
await listen<NotificationUpcomingResyncEvent>(
'notifications:upcoming:resync',
async (event) => {
await this.resyncUpcomingNotifications(event.payload);
},
);
console.log('[AlarmManager] Event listener 4/4 registered.');
console.log('[AlarmManager] Event listener 5/5 registered.');

console.log('[AlarmManager] Checking for native imports...');
await this.checkImports();
Expand All @@ -169,18 +182,26 @@ export class AlarmManagerService {
console.log('[AlarmManager] Action: Dismiss');
await this.stopRinging();
},
onSnoozeRinging: async () => {
console.log('[AlarmManager] Action: Snooze');
// Keep existing behaviour until ringing notifications include alarm IDs.
await this.stopRinging();
onSnoozeRinging: async (alarmId) => {
console.log('[AlarmManager] Action: Snooze ringing', alarmId);
if (alarmId == null) {
// The JS-driven ringing notification action has no alarm ID to
// recalculate against yet — fall back to stopping ringing only,
// same as before this alarm ID plumbing existed.
await this.stopRinging();
return;
}
const snoozeLength = SettingsService.getSnoozeLength();
await this.snoozeRinging(alarmId, snoozeLength);
await this.emitUpcomingSnoozeToast(alarmId, snoozeLength);
},
onDismissUpcoming: async (alarmId) => {
console.log('[AlarmManager] Action: Dismiss upcoming alarm', alarmId);
await this.dismissNextOccurrence(alarmId);
},
onSnoozeUpcoming: async (alarmId, snoozeLength) => {
console.log('[AlarmManager] Action: Snooze upcoming alarm', alarmId);
await this.snoozeAlarm(alarmId, snoozeLength, false);
await this.snoozeUpcoming(alarmId, snoozeLength);
await this.emitUpcomingSnoozeToast(alarmId, snoozeLength);
},
});
Expand Down Expand Up @@ -347,17 +368,26 @@ export class AlarmManagerService {
}

async deleteAlarm(id: number) {
// Cancellation is handled by the alarm:cancelled listener registered in init().
await AlarmService.delete(id);
}

async snoozeRinging(id: number, minutes: number) {
console.log(`[AlarmManager] Snoozing ringing alarm ${id} for ${minutes} minutes`);
const snoozedUntil = Date.now() + minutes * 60_000;
await alarmNotificationService.cancelUpcomingNotification(id);
await AlarmService.snooze(id, snoozedUntil);
await this.stopRinging();
}

async snoozeAlarm(id: number, minutes: number, stopCurrentRinging: boolean = true) {
console.log(`[AlarmManager] Snoozing alarm ${id} for ${minutes} minutes`);
async snoozeUpcoming(id: number, minutes: number) {
console.log(`[AlarmManager] Snoozing upcoming alarm ${id} for ${minutes} minutes`);
const alarm = await AlarmService.get(id);
const anchor = alarm?.nextTrigger ?? Date.now();
// Floor ensures the new trigger is always in the future even if the alarm was slow to dismiss.
const snoozedUntil = Math.max(Date.now() + 60_000, anchor + minutes * 60_000);
await alarmNotificationService.cancelUpcomingNotification(id);
await AlarmService.snooze(id, minutes);
if (stopCurrentRinging) {
await this.stopRinging();
}
await AlarmService.snooze(id, snoozedUntil);
}

private async scheduleNativeAlarm(
Expand Down
Loading
Loading