feat(alarms): schedule an alarm on a date, pause a repeating one

A one-shot alarm can now ring on a chosen date, and a repeating alarm
can be paused for a range of days. Both are local dates: the date is a
floor and the pause a hole in the occurrence sequence, so DST and zone
changes resolve as for any other alarm. A missed date behaves like a
missed one-shot, and each clears itself once it can no longer change a
candidate. Picking a repeat day clears the date, as in Google Clock;
"skip next" stays the quick version of a pause.

The editor uses the M3 date and date-range pickers, which offer no past
day. The list, the next-alarm widget and the self-check read the
resolver, so they follow. The schema goes to v4 with a migration, and
the backup format to v2, which still imports v1 files. SET_ALARM no
longer re-uses an alarm that carries a date or a pause.

Fixes #21
This commit is contained in:
makiolaj committed 2026-10-21 20:16:34 +02:00
1 parent 3f431a7f32
commit b1a45db54a
43 files changed
+2488 -74

No files matched your search

+61 -6
View File
@@ -48,7 +48,7 @@ through.
│ entities │ typed prefs
┌──────────────┴─────────────┐ ┌─────────┴───────────────┐
│ DAOs + mappers (Room) │ │ PrefStore (DataStore) │
│ ClockulaDatabase v3 │ │ clockula_prefs │
│ ClockulaDatabase v4 │ │ clockula_prefs │
└──────────────┬─────────────┘ └─────────┬───────────────┘
│ │
SQLite preferences_pb
@@ -99,7 +99,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `domain/backup/` | `BackupContent` (M10): the plain Kotlin model between `data/backup`'s JSON codec and the repository/engine; decoupled from the wire DTOs the same way the data layer is decoupled from Room |
| `domain/selfcheck/` | `SelfCheck` (M10): the "why might my alarm not ring?" rule table: pure functions over a `DeviceState` snapshot, each producing a `CheckResult` with a status and an optional deep-link `SelfCheckFix` |
| `domain/widget/` | the three widget presenters (M10), `NextAlarmWidgetModel`/`TimerWidgetModel`/`ClockWidgetModel`: selection and formatting only, so a widget's content is unit-tested without Glance |
| `data/db/` | `ClockulaDatabase` (`@Database` v3, `exportSchema = true`) and `Migrations` |
| `data/db/` | `ClockulaDatabase` (`@Database` v4, `exportSchema = true`) and `Migrations` |
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)`, `TimerRingStateStore` (the ring session's one DataStore record) |
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)`, unchanged since M2; M8 is the milestone that finally calls all of it |
@@ -145,10 +145,11 @@ kit module: the kit's roadmap keeps schedulers app-local.
## 4. The data model
`ClockulaDatabase` is at **version 3** with five tables. Every column is a
`ClockulaDatabase` is at **version 4** with five tables. Every column is a
primitive: `Long`, `Int`, `String` or `Boolean`. There are **no Room
`TypeConverter`s**. Enums are stored as `Enum.name` in a `TEXT` column,
instants and durations as milliseconds, the repeat set as an `INTEGER` bitmask.
instants and durations as milliseconds, local dates as epoch days, the repeat
set as an `INTEGER` bitmask.
That puts the whole entity↔domain translation inside the mappers, which are
plain JVM objects that a unit test can feed a corrupt row. A converter would
move the same translation into generated code, where an unknown enum name throws
@@ -169,6 +170,8 @@ JSON and M10's JSON backup all read the same vocabulary in a diff.
| `skip_next_occurrence` | INTEGER | |
| `ringtone_uri`, `vibrate`, `snooze_minutes`, `snooze_limit`, `volume_ramp_seconds`, `dismiss_challenge` | nullable | **overrides**; `NULL` = inherit `ClockDefaults`. For `ringtone_uri`: `NULL` = inherit, `clockula://silent` = deliberate silence (§13), anything else a content URI |
| `delete_after_use` | INTEGER | v3 (M9): a transient alarm the `AlarmClock` contract asked for. Deleted rather than disabled when its cycle closes (§17) |
| `scheduled_date` | INTEGER? | v4: a one-shot's date, as `LocalDate.toEpochDay()`. A local date, never an instant, so no zone is baked in (§11, "A date and a pause") |
| `pause_start`, `pause_end` | INTEGER? | v4: a repeating alarm's pause, both ends inclusive epoch days. One without the other reads as no pause |
| `created_at`, `updated_at` | INTEGER | wall-clock epoch millis |
Per-alarm settings are nullable *overrides*, never concrete copies of the
@@ -389,6 +392,14 @@ table that *has* one, and the migration test fails for a reason nobody enjoys
finding. Every row written before M9 therefore reads as the permanent alarm or
timer it was.
**v3 → v4 (#21).** `MIGRATION_3_4` adds three nullable `INTEGER` columns to
`alarms`: `scheduled_date`, `pause_start` and `pause_end`, with no default, as
the entity declares them, so every alarm written before v4 reads as unscheduled
and unpaused. A date is only ever stored on a non-repeating alarm and a pause
only on a repeating one; `AlarmMapper` reads the other combination, or an epoch
day outside `AlarmDates` (2000-01-01 to 9999-12-31), as absent rather than
throwing.
---
## 5. The two clocks
@@ -751,7 +762,7 @@ and a TalkBack-shaped accessibility click completing the hold challenge.
zones with awkward transitions.
- **Instrumentation-only** is the half a fake cannot prove: generated SQL,
unique indices, real `@Transaction` behaviour, the foreign key cascading, the
v1 → v2 and v2 → v3 migrations validated against the exported schema, and the
v1 → v2, v2 → v3 and v3 → v4 migrations validated against the exported schema, and the
database opening at its current version.
- **`ArchitectureRulesTest`** greps the main source set for Room leakage above
`data/` and Android imports inside `domain/`, `alarm/AlarmEngine.kt` and
@@ -1289,6 +1300,46 @@ one audio stream.
---
### A date and a pause
Two optional fields shape the occurrence sequence without touching the
resolver's precedence (#21). Both are **local dates**, so "Thursday the 28th"
is the 28th of whatever zone the phone is in when it arrives, and both are
applied by `AlarmOccurrences.sequence`, the one place that knows about DST:
- **`scheduledDate`** (one-shot only) is a *floor*: no candidate on an earlier
local date. It is not a filter, so the sequence stays infinite. A date missed
while the phone was off leaves the alarm behaving exactly as a missed plain
one-shot does, ringing at its next time of day, and the snooze and skip
paths, which call `.first` on the sequence, can never run dry. Ringing on the
date closes the cycle like any one-shot's: the alarm switches off.
- **`pause`** (repeating only) is a *hole*: candidates whose local date falls
inside it are dropped. It is jumped rather than walked, so a corrupt pause
centuries long costs one step. "Skip next" still works on a paused alarm and
skips the first ring after the pause; it stays the quick version of a pause.
Both clear themselves, and the rule is exact rather than approximate: a date or
a pause is cleared once it lies wholly before `firstDateFor(now - fireGrace)`,
the earliest local date the sequence looks at. At that point it cannot change a
single candidate, so clearing it changes nothing but the row. Measuring from
`now - fireGrace` rather than from today is load-bearing: a pause ending at
midnight would otherwise be cleared at 00:00 and let its last day's 23:59
occurrence ring late through the grace window. The resolver reports
`clearScheduledDate` / `clearPause` on every branch, a disabled alarm included,
and the engine writes them through `AlarmRepository.edit`, conditional on the
stored value still being the one it resolved, so a pause the editor has just
picked is never cleared by a resolution of the old one.
The surfaces never show a stale one in the meantime: `AlarmSchedule` shows a
date only while its occurrence is still ahead and a pause only while its last
day has not passed. The editor's date picker starts at `AlarmSchedule.earliestDate`,
the date of the alarm's next plain occurrence, so neither a past day nor
today-after-the-time-has-gone can be picked; moving the time earlier than now
on a date set for today drops the date. Picking a repeat day clears the date
and clearing the last one clears the pause, as in Google Clock. The
`AlarmClock` contract cannot express either field, so `SET_ALARM` never
re-uses an alarm that carries one (§17).
## 12. The app shell
Four tabs, one host, and one surface that follows whatever is running.
@@ -2605,7 +2656,11 @@ the extras can set, so a snooze override the user set by hand does not prevent i
Ties go to the **lower id**, the same rule the alarm engine's precedence
already uses. A reused alarm is enabled *and* has any pending skip cleared,
because leaving one armed would silently eat the occurrence just asked for, and
it is never made transient, as the contract's own parenthesis says. A timer is
it is never made transient, as the contract's own parenthesis says. An alarm
with a scheduled date or a pause is never identical: the extras cannot express
either, and re-using it would either keep the caller's alarm quiet until the
pause ends or throw away a holiday the user set, so a new alarm is created
instead. A timer is
identical only if it is **IDLE** with the same duration and label: restarting
somebody's running countdown is destructive.
+19 -5
View File
@@ -11,7 +11,7 @@ written here.
```json
{
"format": "clockula-backup",
"version": 1,
"version": 2,
"exportedAt": 1700000000000,
"alarms": [ … ],
"timers": [ … ],
@@ -21,8 +21,14 @@ written here.
```
- `format`: must be exactly `"clockula-backup"`.
- `version`: must be exactly `1`. Any other value rejects the whole file:
Clockula does not guess at a schema it has not implemented.
- `version`: `2` on export. Import accepts `1` and `2`; any other value
rejects the whole file, since Clockula does not guess at a schema it has not
implemented. Version 2 adds an alarm's `scheduled_date`, `pause_start` and
`pause_end`; a version 1 file is a version 2 file without them, and imports
with every alarm unscheduled and unpaused. The bump is what keeps an older
Clockula from importing a v2 file and silently turning a dated alarm into
tomorrow's, or a paused one back on. The file name stays
`clockula-backup-v1.json` so an existing backup folder keeps working.
- `exportedAt`: epoch milliseconds, informational only (not re-imported).
## Parsing rules
@@ -30,8 +36,8 @@ written here.
- **Tolerant of unknown fields, at any level.** An older or newer client's
extra field is ignored, never an error.
- **Strict about known fields.** A malformed value in a field this schema
defines (`format`, `version`, an alarm's `hour`/`minute`, a world clock's
`zone_id`, a timer's `duration_millis`) rejects the **whole file** with a
defines (`format`, `version`, an alarm's `hour`/`minute` or dates, a world
clock's `zone_id`, a timer's `duration_millis`) rejects the **whole file** with a
specific reason. Nothing is imported half-way.
- Settings values (snooze minutes, volume ramp, …) are
**not** range-checked at parse time; they pass through the same clamps
@@ -92,6 +98,14 @@ One object per row of `alarms`, config only:
| `volume_ramp_seconds` | Int? | `null` = inherit. |
| `dismiss_challenge` | String? | `NONE`/`MATH`/`HOLD`, or `null` = inherit. |
| `delete_after_use` | Boolean | A transient, contract-created alarm. |
| `scheduled_date` | String? | v2. ISO-8601 local date (`2026-10-28`) a one-shot alarm rings on. Ignored on a repeating alarm. |
| `pause_start` | String? | v2. First day of a repeating alarm's pause, ISO-8601. Ignored on a one-shot alarm. |
| `pause_end` | String? | v2. Last day of the pause, inclusive. Present exactly when `pause_start` is. |
A date that does not parse, lies outside 2000-01-01..9999-12-31, a pause with
only one end, or a pause ending before it starts rejects the whole file, like
an out-of-range `hour`. A date on a repeating alarm or a pause on a one-shot is
dropped rather than rejected, which is what the editor would do with it.
**Not exported:** the `alarm_states` ring-state table (snooze/ringing/handled-
occurrence bookkeeping). It is volatile, per-device state, not configuration,