Skip to content

Alarm history: per-alarm event log and usage metrics #272

Description

@ScottMorris

Summary

Record alarm lifecycle events (fired, dismissed, snoozed) in a local, append-only history log, and surface them through a single summary view — an aggregate stats/insights screen across all alarms, with the raw metrics exportable so users can take the data wherever they want.

A per-alarm history section (recent activity shown on each alarm's edit screen) was considered and is deliberately deferred — see "Deferred", below. The capture side still records per-alarm rows, so adding that surface later is purely a UI change.

Refined from the original idea capture: "Each time an alarm is triggered or changed, there should be a little history section… maybe one summary section, but per-alarm history also seems tantalizing… metrics that the user could use wherever they want could be interesting."

Motivation

  • Users have no visibility into how they actually use their alarms — how often one fires, how often they snooze it, how long they take to dismiss it.
  • For Random Window mode (the signature feature), history is uniquely interesting: the app already knows both the scheduled trigger and the actual fire time, so it can show the distribution of where in the window alarms actually landed.
  • Raw metrics export gives users ownership of their own data without Threshold needing any analytics/cloud component — everything stays local.

What exists today (why this is cheap to capture)

All lifecycle events already flow through AlarmCoordinator (apps/threshold/src-tauri/src/alarm/mod.rs), the single SQLite writer:

  • report_alarm_fired receives actual_fired_at alongside the scheduled next_trigger, and both platforms converge on it — Android via the alarm-manager:native-fired channel (lib.rs), desktop via alarm-ringAlarmService.reportAlarmFired. Today it only emits alarm:fired and persists nothing.
  • dismiss_alarm and snooze_alarm are likewise the single choke points for every dismiss/snooze source: in-app, native notification actions, upcoming-alarm notification, and watch (wear:alarm:dismiss / wear:alarm:snooze).
  • Persistence infrastructure (tauri-plugin-sql migrations, revision counter, tombstones) is already in place in alarm/database.rs.

So history capture is: one new table plus inserts at three existing points in the coordinator. No new native code and no new event plumbing is required for the capture side.

Proposed approach

Storage. New alarm_history table (new migration in alarm/database.rs), append-only, written by AlarmCoordinator only — consistent with "Rust owns alarm state". Sketch:

column notes
id autoincrement
alarm_id not a strict FK — see deletion note below
event_type fired | dismissed | snoozed (| skipped?)
scheduled_at the trigger the event relates to (epoch ms)
occurred_at actual event time (epoch ms)
source app | notification | watch | … (optional but cheap, and makes "dismissed from watch vs phone" a possible metric)
label, mode denormalized snapshot so history stays readable after the alarm is deleted (alarms are hard-deleted with tombstones)

Capture points. report_alarm_fired, dismiss_alarm, snooze_alarm. Optionally record dismiss-of-upcoming as skipped since that path already goes through dismiss_alarm with different semantics.

Retention. Cap history (e.g. N rows per alarm or a time horizon) and prune on insert, so the DB doesn't grow unbounded on a years-old install.

Display. A "History & stats" row in Settings navigating to a new screen (e.g. /history) — not a new top-level toolbar destination, so Home stays a pure alarm list.

Layout direction: a key-metrics card grid (inspired by Google Health/Fitbit's dashboard). Each card follows one anatomy: title → headline number → 7-day dot/bar strip (S S M T W T F) → qualitative status chip. The chip carries the interpretation ("Snooze-free week", "Drifting later") so the graphics stay tiny — dots and chips render with plain SVG/divs, no chart library (the repo doesn't have one and shouldn't gain one for this).

Candidate v1 cards:

  • Wake time — average actual dismiss time this week, dot per day, chip: "Consistent" / "Drifting later"
  • Snoozes — average per wake, bar per day, with a per-day snooze-free badge (Fitbit "goal met" pattern) that yields streaks ("6-day no-snooze streak") without scores or points
  • Time to dismiss — how long the alarm rings before dismissal, chip: "Quick riser"
  • Window landing — the Random Window signature card: a thin horizontal band per alarm (window_start → window_end) with a dot per actual fire — shows the randomness working

Other patterns borrowed from that dashboard:

  • "Set up" empty cards as feature discovery: if the user has no Window alarms, the Window card renders as "Try a window alarm" — better than a dash, and advertises the signature feature.
  • One long-horizon card: wake-time trend over months with a delta callout ("23 min earlier than April") — alarm history is one of the few datasets where a 3-month trend is meaningful.
  • Customize: let users pick/reorder which cards show (can land after v1).
  • History rows for deleted alarms still appear via the denormalized label snapshot ("Old work alarm (deleted)"); this screen is their only home.
  • Screen-level empty state: a single quiet line ("No history yet") until the first fire, not a frame of zeroes.
  • The export button lives on this screen.

Since history is read-mostly and the screen is entered on demand, the UI can simply query on view — no alarm:history:updated event needed for v1.

Export. An "export history" action producing CSV or JSON (share sheet on Android, file save on desktop) covers the "metrics the user could use wherever they want" part without building a stats dashboard first.

Candidate metrics

  • Times fired (per alarm / per week)
  • Snooze rate and average snoozes per wake
  • Time-to-dismiss (needs the fix noted below)
  • Window mode: distribution of actual fire time within the configured window
  • Dismiss source breakdown (phone vs watch vs notification)

Future directions (post-v1, all derivable from the same schema)

Insight cards that only an alarm app can render — each is "just a query" over alarm_history, no schema changes:

  • Social jetlag — weekday vs weekend wake-time gap, a real sleep-science metric computable purely from dismiss timestamps: "Your weekend wake is 1h 40m later than weekdays."
  • Does the window work? — compare snooze rate / time-to-dismiss between Window and Fixed alarms, per user: "You snooze 40% less on window alarms." Threshold testing its own product hypothesis on the user's own data.
  • Best spot in the window — correlate landing position with dismiss speed: "You wake easiest in the first third of your window." (The far-future closed loop — a "smart window" biasing randomness toward the easy-wake zone — is a separate feature; the insight card is cheap.)
  • Randomness receipts — accumulated histogram of landings across the window; part trust feature, part satisfying.

Health Connect integration (new plugin)

For people who like interconnected health tracking, write wake data to Health Connect on Android — even a simple alarm contributes a solid morning anchor next to sleep data from other apps. Health Connect is on-device, so this respects the local-only ethos; it's the export idea taken to its natural conclusion (data for other apps, not just CSV for humans).

Shape: a new plugins/health-connect following the house plugin rules — Android permissions injected via update_android_manifest() in build.rs (Health Connect needs manifest-declared permissions + a privacy-policy intent filter), Kotlin side using androidx.health.connect client, registered in the root Cargo.toml workspace and lib.rs. Writing is likely one-way Rust→Kotlin (run_mobile_plugin), so the channel/queue pattern may not be needed. Opt-in via a Settings toggle.

Key design question: Health Connect has no native "alarm" or "wake event" record type. The nearest mapping is contributing to sleep-session data (e.g. session end = dismiss time), but Threshold doesn't know when the user fell asleep, so writing a full SleepSessionRecord would fabricate data. Options to investigate: whether HC accepts open-ended/partial session semantics, whether a future record type fits better, or whether this should wait. This question gates the plugin.

Design decisions to settle

  • History must not bump the alarm revision. Revision drives wear FullSync; a history insert changing it would cause sync churn on every fire. History rows should be versioned independently (or not at all).
  • Accurate fired_at on dismiss: dismiss_alarm currently approximates fired_at = dismissed_at (alarm/mod.rs:182-183). Time-to-dismiss metrics need the real fire time threaded through — the history table itself can provide it (correlate the preceding fired row) instead of changing the dismiss call signature.
  • Watch exposure: phone-only to start; the watch does not need history in FullSync payloads.
  • Scope of "changed": the original idea mentions logging alarm changes too. CRUD history (created/edited/toggled) is a possible event_type extension, but firing/snooze/dismiss is where the user value is — suggest treating config-change history as out of scope for v1.

Deferred

  • Per-alarm history section on EditAlarm — a collapsed "History" accordion showing recent wakes for that alarm. Deliberately skipped for now; the summary screen covers the need and the capture schema already stores per-alarm rows, so this can be added later without data-model changes.
  • Card customization (pick/reorder) — pattern noted above; not needed for a first cut.

Out of scope (v1)

  • Any cloud/analytics component — all data stays on device
  • Chart libraries or visualizations beyond the dot/bar strips and the window-distribution band (export unblocks power users)
  • Incremental wear sync of history
  • Health Connect plugin (tracked above as a future direction; may deserve its own issue when picked up)

Implementation notes

  • Keep docs/architecture/event-architecture.md in lockstep if any new event is added.
  • New TS-facing types (history record) should follow the ts-rs generated-bindings pattern in alarm/models.rs rather than hand-written types.
  • Desktop and Android both already report fires through the same coordinator path, so history parity comes for free; verify the desktop dismiss path tags its source correctly.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions