feat(domain): the live pill's subject, clock formatting and the challenge gate

Which of a running timer, a running stopwatch and a snoozed alarm the pill
speaks for is a pure selection over the repositories' flows, ordered so the
thing closest to needing attention wins. Countdown text truncates where a
stopwatch rounds up, because a timer reading 0:01 must still have a second
left in it.

The dismiss challenge is monotone in both escape routes — neither a wrong
answer nor a re-roll can make the way out shorter — so a half-awake user is
never stranded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
2026-09-12 13:07:04 +02:00
parent a1bf6170d2
commit 6dee033aba
8 changed files with 1071 additions and 0 deletions
@@ -0,0 +1,93 @@
package de.jeanlucmakiola.clockula.domain.alarm
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/** A two-term addition the user must answer to dismiss. */
data class MathProblem(val left: Int, val right: Int) {
val answer: Int get() = left + right
val prompt: String get() = "$left + $right"
/** Lenient about whitespace and leading zeros, strict about everything else. */
fun accepts(input: String): Boolean = input.trim().toIntOrNull() == answer
}
object MathProblems {
const val MIN_TERM: Int = 10
const val MAX_TERM: Int = 99
/** Deterministic for a given [seed]. Both terms in [MIN_TERM]..[MAX_TERM]. */
fun generate(seed: Long): MathProblem {
val random = Random(seed)
return MathProblem(
left = random.nextInt(MIN_TERM, MAX_TERM + 1),
right = random.nextInt(MIN_TERM, MAX_TERM + 1),
)
}
}
/**
* The barrier between a ringing alarm and dismissing it — and the promise that
* it always opens. Immutable; every transition returns a new gate.
*
* Two independent escape routes, because one that can fail is not an escape:
* [MAX_WRONG_ATTEMPTS] wrong answers, or [ESCAPE_AFTER] of ringing, whichever
* comes first. Neither is free, and neither can be taken away once earned.
*/
data class ChallengeGate(
val kind: DismissChallenge,
val problem: MathProblem?,
val wrongAttempts: Int,
val holdProgress: Float,
val escapeAvailable: Boolean,
val open: Boolean,
) {
companion object {
const val MAX_WRONG_ATTEMPTS: Int = 3
val ESCAPE_AFTER: Duration = 60.seconds
val HOLD_DURATION: Duration = 2.seconds
/** NONE starts open. MATH starts with a problem from [seed]. HOLD starts at zero. */
fun start(kind: DismissChallenge, seed: Long): ChallengeGate = ChallengeGate(
kind = kind,
problem = if (kind == DismissChallenge.MATH) MathProblems.generate(seed) else null,
wrongAttempts = 0,
holdProgress = 0f,
escapeAvailable = false,
open = kind == DismissChallenge.NONE,
)
}
/** MATH only; other kinds return `this`. Correct opens; wrong re-rolls from [nextSeed]. */
fun answer(input: String, nextSeed: Long): ChallengeGate {
if (kind != DismissChallenge.MATH) return this
val asked = problem ?: return this
if (asked.accepts(input)) return copy(open = true)
// No lockout and no penalty timer: a wrong answer replaces the problem,
// and that is all it does.
val attempts = wrongAttempts + 1
return copy(
problem = MathProblems.generate(nextSeed),
wrongAttempts = attempts,
escapeAvailable = escapeAvailable || attempts >= MAX_WRONG_ATTEMPTS,
)
}
/** HOLD only; other kinds return `this`. Clamped to 0f..1f; 1f opens. */
fun hold(progress: Float): ChallengeGate {
if (kind != DismissChallenge.HOLD) return this
val clamped = progress.coerceIn(0f, 1f)
return copy(holdProgress = clamped, open = open || clamped >= 1f)
}
/** How long the alarm has been ringing. Makes the escape available at [ESCAPE_AFTER]; never revokes it. */
fun ringElapsed(sinceRingStart: Duration): ChallengeGate =
copy(escapeAvailable = escapeAvailable || sinceRingStart >= ESCAPE_AFTER)
/** Opens the gate iff [escapeAvailable]; otherwise returns `this` unchanged. */
fun escape(): ChallengeGate = if (escapeAvailable) copy(open = true) else this
}
@@ -0,0 +1,41 @@
package de.jeanlucmakiola.clockula.domain.format
import java.util.Locale
import kotlin.math.ceil
import kotlin.time.Duration
/**
* The one place a duration becomes a readout: `M:SS` under an hour, `H:MM:SS`
* at or above it, ASCII digits through `Locale.ROOT` so the glyph columns do
* not jitter as the numbers change.
*/
object ClockFormat {
/** Counts *down*: rounds up to the next whole second. Negative reads as zero. */
fun countdown(remaining: Duration): String {
if (remaining <= Duration.ZERO) return format(0L)
// Rounded up, so a timer with a millisecond left reads 0:01 and only
// reads 0:00 when it is genuinely done.
return format(ceil(remaining.inWholeMilliseconds / 1000.0).toLong())
}
/** Counts *up*: truncates to whole seconds. Negative reads as zero. */
fun elapsed(elapsed: Duration): String {
if (elapsed <= Duration.ZERO) return format(0L)
// Truncated, so a stopwatch readout never runs ahead of reality.
return format(elapsed.inWholeSeconds)
}
private fun format(totalSeconds: Long): String {
val hours = totalSeconds / 3_600
val minutes = (totalSeconds % 3_600) / 60
val seconds = totalSeconds % 60
// Locale.ROOT, not the default: the readout is a column of glyphs, and
// a locale with its own numerals would change their width mid-count.
return if (hours > 0) {
String.format(Locale.ROOT, "%d:%02d:%02d", hours, minutes, seconds)
} else {
String.format(Locale.ROOT, "%d:%02d", minutes, seconds)
}
}
}
@@ -0,0 +1,102 @@
package de.jeanlucmakiola.clockula.domain.live
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.snapshotAt
import kotlin.time.Duration
import kotlin.time.Instant
/** What the live pill is currently about. */
sealed interface LivePillSubject {
data class TimerSubject(val timerId: Long, val label: String) : LivePillSubject
data object StopwatchSubject : LivePillSubject
}
enum class LivePillMode { RUNNING, PAUSED, EXPIRED }
data class LivePillState(
val subject: LivePillSubject,
val mode: LivePillMode,
/** Remaining for a timer, elapsed for the stopwatch. Never negative. */
val value: Duration,
/** Other *active* timers — active timers minus this one, floored at zero. */
val otherActiveTimers: Int,
) {
/** False for [LivePillMode.EXPIRED]: there is nothing left to pause. */
val canPause: Boolean get() = mode != LivePillMode.EXPIRED
}
/** The pill's whole decision: which subject the user needs to see, and how. */
object LivePillSelector {
/** Null when nothing is active. Pure: never writes, never throws. */
fun select(
timers: List<Timer>,
stopwatch: StopwatchRun,
elapsedRealtime: Duration,
wallClock: Instant,
): LivePillState? {
// Active, not running: a timer the user paused from the pill must keep
// the pill, or the control vanishes under the thumb that used it.
val active = timers.filter { it.state != TimerState.IDLE }
val winner = active
.map { it.read(elapsedRealtime, wallClock) }
// A deadline missed costs something; a stopwatch tick missed costs
// nothing — so expiry outranks running, which outranks paused, and
// every timer outranks the stopwatch. Ties break the way the alarm
// engine's do: the lower sort order, then the lower id.
.minWithOrNull(
compareBy<Reading> { it.mode.rank }
.thenBy { if (it.mode == LivePillMode.RUNNING) it.value else Duration.ZERO }
.thenBy { it.timer.sortOrder }
.thenBy { it.timer.id },
)
if (winner != null) {
return LivePillState(
subject = LivePillSubject.TimerSubject(winner.timer.id, winner.timer.label),
mode = winner.mode,
value = winner.value,
otherActiveTimers = active.size - 1,
)
}
if (stopwatch.state == StopwatchState.IDLE) return null
// The snapshot, not the stored row: a run whose anchor did not survive a
// reboot reads as paused at what it banked rather than as running.
val snapshot = stopwatch.snapshotAt(elapsedRealtime)
return LivePillState(
subject = LivePillSubject.StopwatchSubject,
mode = if (snapshot.isRunning) LivePillMode.RUNNING else LivePillMode.PAUSED,
value = snapshot.elapsed,
otherActiveTimers = 0,
)
}
/** One timer as the pill would show it, read through the snapshot. */
private data class Reading(val timer: Timer, val mode: LivePillMode, val value: Duration)
private val LivePillMode.rank: Int
get() = when (this) {
LivePillMode.EXPIRED -> 0
LivePillMode.RUNNING -> 1
LivePillMode.PAUSED -> 2
}
private fun Timer.read(elapsedRealtime: Duration, wallClock: Instant): Reading {
if (state == TimerState.PAUSED) return Reading(this, LivePillMode.PAUSED, remaining)
val snapshot = snapshotAt(elapsedRealtime, wallClock)
return when {
// Reached zero: the pill flips before M6's expiry write lands, so it
// never shows a live timer counting into negative time.
snapshot.hasExpired -> Reading(this, LivePillMode.EXPIRED, Duration.ZERO)
snapshot.isRunning -> Reading(this, LivePillMode.RUNNING, snapshot.remaining)
// Neither: a corrupt row. Show what it last banked, and let the user
// start it again rather than throwing inside a list.
else -> Reading(this, LivePillMode.PAUSED, snapshot.remaining)
}
}
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.clockula.domain.time
import kotlinx.coroutines.flow.Flow
import kotlin.time.Duration
import kotlin.time.Instant
@@ -28,3 +29,12 @@ interface ElapsedRealtimeClock {
interface ZoneProvider {
fun current(): java.time.ZoneId
}
/**
* Emits on a fixed cadence so a readout can advance without a repository write.
* Injected, never a raw `delay` at a call site, so a test drives the tick
* instead of waiting for it. Emits once immediately, then every [period].
*/
interface Ticker {
fun ticks(period: Duration): Flow<Unit>
}