Tell "this window is not running" from "we could not read the time"

The five-hour window's reset time is absent between blocks, because the
window is anchored to the block it started in and there is nothing to reset
until one is running. Measured against a live response: 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. The weeklies always have a reset because a week is
always running -- which is why only the five-hour row looked wrong.

`remainingUntil` returned null for that and for a timestamp it could not
parse, so the session bar announced "reset time unknown" about a machine
behaving perfectly, on the one row somebody reads before starting something
big. The usage dialog, looking at the same field, drew nothing at all and
printed a raw ISO string when a parse did fail. One missing value, two
rules, and neither of them right.

`WindowEnd` names the three answers and both callers go through it. A window
that is not running shows its percentage and no countdown, in the bar as
well as the dialog; an unreadable timestamp says so in words rather than
showing itself.

Looked at both on the emulator, the second by making the server drop the
field: 15% with "4h 43m left" when a block is running, "13%" alone when none
is, and the dialog's five-hour row with no reset line beside weeklies that
have one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-31 00:17:03 -04:00
1 parent 3da0f2e2f6
commit 4edc96de09
4 files changed
+78 -25

No files matched your search

@@ -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
}
}
@@ -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"
}
}
@@ -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)}"
}