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:
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user