029 — Timer expiry + unified NotificationSource interface
Parent PRD
PRD.md §FR7 (configuration) + §FR3 (pig UI)
What to build
Detect when a focus timer expires, introduce a composable notification interface that any subsystem implements, and migrate the existing cap notifications onto the same interface so the app has one notification system.
NotificationSource trait (crates/domain/src/notification.rs)
pub trait NotificationSource {
fn key(&self) -> &'static str; // stable identifier used in settings
fn label(&self) -> &'static str; // human-readable name for settings UI
}
pub struct NotificationSettings {
pub sources: std::collections::HashMap<String, bool>,
}
impl NotificationSettings {
pub fn is_enabled(&self, source: &dyn NotificationSource) -> bool {
*self.sources.get(source.key()).unwrap_or(&true)
}
}
pub fn all_sources() -> Vec<Box<dyn NotificationSource>> {
vec![
Box::new(TimerExpiredSource),
Box::new(FocusesOverCapSource),
Box::new(TasksOverCapSource),
]
}
Settings gains notifications: NotificationSettings
settings.yaml gains a notifications: section; missing keys default to true
Alerts struct is removed; Settings.alerts field deleted
notifications.sources is the only notification toggle surface
Concrete sources
pub struct TimerExpiredSource;
impl NotificationSource for TimerExpiredSource {
fn key(&self) -> &'static str { "timer_expired" }
fn label(&self) -> &'static str { "Timer expired" }
}
pub struct FocusesOverCapSource;
impl NotificationSource for FocusesOverCapSource {
fn key(&self) -> &'static str { "focuses_over_cap" }
fn label(&self) -> &'static str { "Too many focuses" }
}
pub struct TasksOverCapSource;
impl NotificationSource for TasksOverCapSource {
fn key(&self) -> &'static str { "tasks_over_cap" }
fn label(&self) -> &'static str { "Too many tasks in a focus" }
}
Cap notifier migration
CapEvaluator::new takes NotificationSettings instead of the full Settings.alerts
- In
evaluate(), gate each call:
focuses_over_cap / focuses_under_cap → is_enabled(&FocusesOverCapSource)
task_over_cap / task_under_cap → is_enabled(&TasksOverCapSource)
under_cap recovery notifications follow the same per-source toggle (mute the source, mute its recovery too — one switch, one subsystem)
Expiry detection
- Tokio interval task (1 Hz) in the composition root
- Each tick:
store.list(), for each focus where timer.is_some() && timer.status == Running && timer_remaining_secs(...) == None:
- Build new timer with
status: Expired
store.update_timer(focus_id, &timer) — single atomic write per timer lifetime
- Emit Tauri event
timer-expired { focus_id, focus_title }
- If
notifications.is_enabled(&TimerExpiredSource) → fire system notification
- Already-
Expired focuses skipped (persisted status is the dedup lock; survives restarts)
FocusStore trait extension
fn update_timer(&self, focus_id: &str, timer: &FocusTimer) -> Result<(), FocusStoreError>;
MarkdownFocusStore: atomic write of timer.json
- Test stubs in
commands crate: implement (mutate cloned focus list)
settings.yaml format
notifications:
timer_expired: true
focuses_over_cap: true
tasks_over_cap: true
Missing notifications: section → all sources default true. The old alerts: section is no longer parsed; existing users who had system_notifications: false re-toggle via tray (031). Clean break — single-user app, no migration shim.
Completion promise
When a focus timer reaches zero, TimerStatus transitions to Expired (persisted exactly once), a Tauri event fires, and a system notification appears (if enabled). All notifications — timer expiry and cap transitions — flow through one NotificationSource interface, controlled by one notifications.sources map.
Acceptance criteria
Blocked by
028 (FocusTimer domain types) — done.
029 — Timer expiry + unified NotificationSource interface
Parent PRD
PRD.md §FR7 (configuration) + §FR3 (pig UI)
What to build
Detect when a focus timer expires, introduce a composable notification interface that any subsystem implements, and migrate the existing cap notifications onto the same interface so the app has one notification system.
NotificationSourcetrait (crates/domain/src/notification.rs)Settingsgainsnotifications: NotificationSettingssettings.yamlgains anotifications:section; missing keys default totrueAlertsstruct is removed;Settings.alertsfield deletednotifications.sourcesis the only notification toggle surfaceConcrete sources
Cap notifier migration
CapEvaluator::newtakesNotificationSettingsinstead of the fullSettings.alertsevaluate(), gate each call:focuses_over_cap/focuses_under_cap→is_enabled(&FocusesOverCapSource)task_over_cap/task_under_cap→is_enabled(&TasksOverCapSource)under_caprecovery notifications follow the same per-source toggle (mute the source, mute its recovery too — one switch, one subsystem)Expiry detection
store.list(), for each focus wheretimer.is_some() && timer.status == Running && timer_remaining_secs(...) == None:status: Expiredstore.update_timer(focus_id, &timer)— single atomic write per timer lifetimetimer-expired { focus_id, focus_title }notifications.is_enabled(&TimerExpiredSource)→ fire system notificationExpiredfocuses skipped (persisted status is the dedup lock; survives restarts)FocusStoretrait extensionMarkdownFocusStore: atomic write oftimer.jsoncommandscrate: implement (mutate cloned focus list)settings.yamlformatMissing
notifications:section → all sources defaulttrue. The oldalerts:section is no longer parsed; existing users who hadsystem_notifications: falsere-toggle via tray (031). Clean break — single-user app, no migration shim.Completion promise
When a focus timer reaches zero,
TimerStatustransitions toExpired(persisted exactly once), a Tauri event fires, and a system notification appears (if enabled). All notifications — timer expiry and cap transitions — flow through oneNotificationSourceinterface, controlled by onenotifications.sourcesmap.Acceptance criteria
NotificationSourcetrait,NotificationSettings,all_sources()incrates/domain/src/notification.rsTimerExpiredSource,FocusesOverCapSource,TasksOverCapSourceimplementNotificationSourceAlertsstruct deleted;Settings.alertsfield removedSettings.notificationsfield; round-trips throughsettings.yamlFocusStore::update_timerdefined;MarkdownFocusStorewrites atomicallyCapEvaluatorconsultsNotificationSettings::is_enabledper source (focuses, tasks gated independently)TimerStatus::Expiredexactly once per timertimer-expiredemitted withfocus_idandfocus_titletimer_expiredsource is enabled; suppressed when disabledtask checkgreenBlocked by
028 (FocusTimer domain types) — done.