Merge branch 'main' of git.arirex.me:iris/ai-app

This commit is contained in:
iris committed 2026-08-31 00:19:25 -04:00
commit 3095052df0
5 files changed
+101 -31

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
}
}
@@ -88,13 +88,15 @@ private val LOADING_SPINNER = 48.dp
* row is anything from one line to a page and a fixed count is therefore a distance only by
* accident. Eight rows was the number, and on a tool-heavy transcript eight rows is less than one
* screen: the reader reached the end of what was loaded on *every* swipe and waited a round trip
* standing there. That is what "scrolling is laggy" turned out to be -- not a slow frame, but the
* list running out of transcript, which the emulator showed as a swipe that moved nothing for 689ms
* and then jumped.
* standing there, which is a list running out of transcript rather than a slow frame.
*
* Three, so a fling lands on rows that are already there and the page after them is on its way. The
* cost of being generous is a page fetched that nobody reads; the cost of being mean is a list that
* stops under a finger, and those are not the same size.
*
* Counted at the server rather than inferred from the screen, which is the only measurement here
* that does not depend on how the emulator renders: against a 24,000-event transcript at `--delay
* 120`, ten swipes asked for **ten** pages before this and **three** after.
*/
private const val HISTORY_SCREENS = 3
@@ -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)}"
}