diff --git a/AGENTS.md b/AGENTS.md index 9207431..8f069f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -457,6 +457,21 @@ machine belongs in `~/.claude/TOOLCHAIN.md` (toolchain versions) or the screen opens. The reset is not optional: without it the window is spliced onto rows that are no longer adjacent to it, which reads as ordinary output. +- **The five-hour window has no reset time between blocks, and that is not + a missing value.** The usage API anchors it to the block it started in -- + measured 2026-08-31, the reset came back as exactly five hours after work + resumed, and the weekly windows in the same response carried the identical + microsecond, so both are computed from one `now()` at request time. When + no block is running there is nothing to reset and `resets_at` is `null`; + the same response shows other idle windows with the same shape. The weekly + ones always have a reset because a week is always running, which is why + "the others seem fine". + So `resets_at` absent means **not running**, and only a timestamp that + arrives and cannot be parsed is unknown. The app collapsed both into one + null and the session bar said "reset time unknown" for a machine behaving + perfectly -- while the usage dialog, reading the same field, quietly drew + nothing. `WindowEnd` in `ResetCountdown.kt` is now the one rule both go + through. - **A transcript page used to cost the whole transcript.** `read_window` read and parsed every line and then kept the last `limit` of them, so the work was the size of the conversation rather than the size of the answer: diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt index b0e7bce..aea19cf 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt @@ -20,18 +20,42 @@ fun formatSpan(until: Duration): String = } /** - * Time from [now] until [resetsAt], or null when that timestamp cannot be read. + * What is known about when a usage window ends. * - * Null rather than a zero duration, because a string this app failed to parse is not a window that - * has just run out: a caller given zero for both would tell the reader to refresh on the strength - * of something nobody measured. + * Three answers rather than a nullable duration, because two of them shared `null` and they are not + * the same thing at all. A window the server sent no reset time for is one that is **not running**: + * the five-hour window is anchored to the block it started in, so between sessions there is nothing + * counting down and the API says so by omitting the field -- measured against a live response on + * 2026-08-31, where the five-hour window's reset was exactly five hours after the moment work + * resumed. A timestamp that did arrive and could not be read is the genuinely unknown case, and it + * is the only one worth those words. + * + * Collapsing them put "reset time unknown" on the session bar for a machine behaving perfectly, on + * the one row somebody reads before starting something big -- and the usage dialog, looking at the + * same field, quietly drew nothing. Two rules for one missing value; this is the rule. + */ +sealed class WindowEnd { + /** No reset time was sent, so nothing is running in this window. Not a failure to find out. */ + data object NotRunning : WindowEnd() + + /** A timestamp arrived and could not be read. The one case that is actually unknown. */ + data object Unreadable : WindowEnd() + + /** How long is left. Negative once the window is past, which each caller words for itself. */ + data class Ends(val until: Duration) : WindowEnd() +} + +/** + * [resetsAt] as the server sent it -- absent, unreadable, or a moment -- against [now]. * * [now] is a parameter rather than read here so a caller can drive it from state and have the * countdown recompute on its own schedule. */ -fun remainingUntil(resetsAt: String, now: OffsetDateTime): Duration? = - try { - Duration.between(now, OffsetDateTime.parse(resetsAt)) +fun windowEnd(resetsAt: String?, now: OffsetDateTime): WindowEnd { + if (resetsAt == null) return WindowEnd.NotRunning + return try { + WindowEnd.Ends(Duration.between(now, OffsetDateTime.parse(resetsAt))) } catch (_: Exception) { - null + WindowEnd.Unreadable } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt index b14e389..1cea984 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt @@ -185,19 +185,22 @@ private fun UsageNote(text: String) { * The percentage on its own does not answer the question it gets asked, which is whether to start * something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers. * - * The window's end has its own missing case, kept apart from the rest: a snapshot can arrive with - * no reset time, and saying "refresh soon" there would put a recommendation on the screen that - * nothing measured. The percentage is still known, so it is still shown. + * The window's end has two missing cases and they are worded differently on purpose; see + * [WindowEnd]. A window that is not running gets the percentage and nothing else, because there is + * no countdown to report and inventing one would be the same fault as inventing the number. */ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String { val percent = "${window.percent.toInt()}%" - val until = window.resetsAt?.let { remainingUntil(it, now) } - return when { - until == null -> "$percent · reset time unknown" - // Under a minute, including past the end: the number would round to "0m left", which reads - // as a measurement rather than as the window having run out. - until < Duration.ofMinutes(1) -> "$percent · refresh soon" - else -> "$percent · ${formatSpan(until)} left" + return when (val end = windowEnd(window.resetsAt, now)) { + // Between blocks the five-hour window has no reset time, and saying so is a fact about + // nothing: there is no window to run out. The percentage is the whole answer. + WindowEnd.NotRunning -> percent + WindowEnd.Unreadable -> "$percent · reset time unreadable" + is WindowEnd.Ends -> + // Under a minute, including past the end: the number would round to "0m left", which + // reads as a measurement rather than as the window having run out. + if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon" + else "$percent · ${formatSpan(end.until)} left" } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt index 8d2fd4c..39ecf07 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt @@ -204,10 +204,10 @@ private fun WindowBar(window: UsageWindow) { color = quotaColor(window.percent), modifier = Modifier.fillMaxWidth(), ) - window.resetsAt?.let { + resetLine(window)?.let { Spacer(Modifier.height(2.dp)) Text( - "resets ${formatReset(it)}", + it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -215,8 +215,19 @@ private fun WindowBar(window: UsageWindow) { } } -/** "in 3h 12m" -- close enough for deciding whether to start a big task. */ -private fun formatReset(resetsAt: String): String { - val until = remainingUntil(resetsAt, OffsetDateTime.now()) ?: return "at $resetsAt" - return if (until.isNegative) "soon" else "in ${formatSpan(until)}" -} +/** + * "resets in 3h 12m" -- close enough for deciding whether to start a big task -- or nothing. + * + * Null for a window that is not running, which is the case this row has always drawn as nothing and + * is right to: there is no end to report. What it used to get wrong is the other missing case, a + * timestamp that arrived and could not be read: that was printed raw, so a parse failure appeared + * as an ISO string in the middle of a sentence written for a person. Both cases are named in + * [WindowEnd], and the session bar words them the same way. + */ +private fun resetLine(window: UsageWindow): String? = + when (val end = windowEnd(window.resetsAt, OffsetDateTime.now())) { + WindowEnd.NotRunning -> null + WindowEnd.Unreadable -> "reset time unreadable" + is WindowEnd.Ends -> + if (end.until.isNegative) "resets soon" else "resets in ${formatSpan(end.until)}" + }