Both are pure functions so they can be tested without a device. The ramp in
particular: a fade-in is the kind of thing that is easy to get subtly wrong and
impossible to notice until an alarm opens at full volume or never reaches it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The resolver is pure: it reads an alarm and its ring state and returns what
should happen, and the engine does every write. Two rules keep re-resolution
honest, and both are load-bearing enough to be worth naming.
The grace window lets a fire that arrives a couple of minutes late still count,
so a device powered on at 07:01 rings its 07:00 alarm. The handled-occurrence
watermark suppresses a candidate only when it is both at-or-before the
watermark and at-or-before now — the second conjunct is what stops a user who
set the clock forward, let an alarm fire, then set it back from having every
future occurrence silenced forever.
Skip needs a watermark of its own, because a bare flag eats a second alarm once
the skipped occurrence has passed. On a one-shot alarm, skipping the only
occurrence is dismissing it, so it disables the alarm instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Occurrences are generated by walking local dates forward and mapping each
through ZonedDateTime.of, never by adding 24 hours to an instant. That is the
whole DST story: java.time's default resolver shifts a 02:30 alarm forward to
03:30 on a spring-forward night rather than dropping it, and takes the earlier
of the two 02:30s on a fall-back night rather than ringing twice.
Asserted for Berlin and New York, and across all 128 repeat masks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Snooze, the handled-occurrence watermark and the skip watermark need somewhere
to live that survives a process death. They go in their own table rather than
as columns on alarms: a missing row is the initial record, and the FK cascade
means deleting an alarm cannot leave orphaned ring state behind.
Schema v2 is exported and committed alongside v1, so MIGRATION_1_2 is
reviewable and testable rather than taken on faith.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The seam is only worth having if something checks it. This fails the
build if androidx.room, an entity or a query string appears outside
data/, rather than leaving it to review to notice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four interfaces the rest of the app will talk to. They speak domain
types and Flows only — no entity and no query string crosses this seam.
Every read-modify-write goes through a DAO transaction rather than a
find-then-update, so the ringing service marking a timer expired cannot
silently lose the minute a user just added. Adding a world clock that is
already there returns the existing row instead of the insert sentinel.
Tested on the JVM against fake DAOs over MutableStateFlow with injected
fake clocks, so repository behaviour needs no device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Alarm and timer defaults, world-clock preferences, and the stopwatch's
running state — the things that are settings rather than rows.
The run record is read and written as a whole in one DataStore
transaction, so a reader never sees a half-applied record and a process
killed mid-write cannot persist one.
Values are clamped on read: a preferences file someone has edited by hand
degrades to the default instead of propagating nonsense upwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The translation either way, including the repeat mask's bit order and the
nullable per-alarm overrides that mean "inherit the app default" rather
than a concrete value.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alarms, timers, world_clocks and stopwatch_laps, with the schema exported
to app/schemas and committed so migrations can be tested from v1 onwards.
The DAOs return Flows for reads and keep every multi-step write inside a
@Transaction, so a read-modify-write cannot lose a concurrent one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The aggregates Clockula stores — alarms, timers, world clocks and the
stopwatch run — as ordinary Kotlin, with no Room or Android type anywhere
near them.
Time is taken as a parameter, never read ambiently: WallClock and
ElapsedRealtimeClock are separate types so a call site has to name which
one it means. A running timer resolves against the monotonic clock and
keeps a wall-clock value only as a post-reboot fallback; the stopwatch
gets no wall-clock value at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SettingsPrefs exposes the appearance slice; DataModule builds the DataStore on
the kit's @IoDispatcher. The store is created with a corruption handler that
replaces an unreadable file with empty preferences, so a truncated
clockula_prefs.preferences_pb cannot leave the app crashing on every launch
with no recovery short of clearing app data.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>