From 5dcb2df81302b8346fab916c96dace2045a16b91 Mon Sep 17 00:00:00 2001
From: rayvader <42436531+rayvader@users.noreply.github.com>
Date: Thu, 5 Nov 2026 09:11:33 +0000
Subject: [PATCH] fix: Froyo widget says why a read failed, reads
open_items_live
The header now shows the reason next to 'as of HH:mm' (sign-in, network,
timeout, server, format), and logs it under the FroyoWidget tag. Reads
prefer Froyo's open_items_live (kept current by its sync job) and fall
back to open_items. A 400/404/409 asks for Froyo's thread again once,
and the dashboard read waits up to 45 s.
---
.github/e2e/gateway.py | 25 ++++--
.github/e2e/run.sh | 12 +++
README.md | 4 +-
.../github/rayvader/kirocrew/AlertsClient.kt | 15 +++-
.../github/rayvader/kirocrew/FroyoWidget.kt | 90 +++++++++++++++----
.../github/rayvader/kirocrew/core/Upcoming.kt | 56 +++++++++++-
app/src/main/res/values/strings.xml | 2 +
.../rayvader/kirocrew/core/UpcomingTest.kt | 77 ++++++++++++++++
8 files changed, 250 insertions(+), 31 deletions(-)
diff --git a/.github/e2e/gateway.py b/.github/e2e/gateway.py
index e5c3f37..da5a62b 100644
--- a/.github/e2e/gateway.py
+++ b/.github/e2e/gateway.py
@@ -144,6 +144,8 @@ def __init__(self, name):
self.chats = []
# True while a test wants Froyo's dashboard read to fail (the widget keeps its list).
self.froyo_down = False
+ # True while a test wants the dashboard to answer 200 with no readable list.
+ self.froyo_bad = False
def now_ts(self):
# Strictly increasing timestamps, so two notes pushed back to back never tie.
@@ -186,13 +188,20 @@ def froyo_items(many=False):
def froyo_dashboard(items):
- """Like GET /api/members/{slug}/dashboard: the page's values sit in a JSON data island."""
- island = json.dumps({"fields": {"open_items": items, "items_as_of": "now"}, "agentic": ["open_items"]})
+ """Like GET /api/members/{slug}/dashboard for Froyo's page v4: the values sit in a JSON data
+ island. open_items_live (the sync job's list, an object) is current; the agentic open_items
+ lags behind and carries one stale row, which the widget must never show."""
+ stale = items + [{"n": 99, "title": "E2E stale row", "due": None, "kind": "deadline", "flag": None}]
+ island = json.dumps({
+ "fields": {"open_items": stale, "open_items_live": {"items": items, "as_of": "now"}, "items_as_of": "now"},
+ "agentic": ["open_items", "items_as_of"],
+ })
island = island.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
html = ('
Froyo'
''
'')
- return json.dumps({"state": "live", "template": "e2e", "rendered_html": html})
+ return json.dumps({"instance_version": 4, "dashboard": "e2e", "state": "live", "scripts": True,
+ "fields": ["items_as_of", "open_items", "open_items_live"], "rendered_html": html})
def make_handler(state):
@@ -300,11 +309,13 @@ def do_GET(self):
return self.send(200, "ok")
if p == "/test/froyo":
# mode=reset (the made-up list), many (8 items, more than fit), empty,
- # or down (reads fail, the list is kept; any other mode brings it back up).
+ # down (503) or bad (200, no list): reads fail and the list is kept; any other
+ # mode brings it back up.
mode = q.get("mode")
with state.lock:
state.froyo_down = mode == "down"
- if mode != "down":
+ state.froyo_bad = mode == "bad"
+ if mode not in ("down", "bad"):
state.froyo = [] if mode == "empty" else froyo_items(many=mode == "many")
state.chats = []
return self.send(200, "ok")
@@ -317,9 +328,11 @@ def do_GET(self):
if q.get("member") != FROYO_MEMBER:
return self.send(400, json.dumps({"code": "missing_member"}), "application/json")
with state.lock:
- items, down = list(state.froyo), state.froyo_down
+ items, down, bad = list(state.froyo), state.froyo_down, state.froyo_bad
if down:
return self.send(503, json.dumps({"error": "down"}), "application/json")
+ if bad:
+ return self.send(200, json.dumps({"state": "empty"}), "application/json")
return self.send(200, froyo_dashboard(items), "application/json")
if p == "/api/update/check":
if not self.signed_in():
diff --git a/.github/e2e/run.sh b/.github/e2e/run.sh
index 91426d2..e6c25fa 100755
--- a/.github/e2e/run.sh
+++ b/.github/e2e/run.sh
@@ -1103,10 +1103,22 @@ PY
has_text "E2E test parcel" || fail "a failed read hid the rows"
! has_text "Updated" || fail "a failed read still says Updated"
check "a failed refresh keeps the rows and says 'as of HH:mm'"
+ has_text "· network" || fail "the header does not say why the read failed (503 -> network)"
+ check "the header says why: 'as of HH:mm · network'"
+
+ # A 200 with no readable list is a format problem, and says so.
+ curl -s "$GW/test/froyo?mode=bad" >/dev/null
+ tap_now "Refresh"
+ wait_text "· format" 30
+ has_text "E2E test parcel" || fail "a format failure hid the rows"
+ check "a page with no list says 'as of HH:mm · format' and keeps the rows"
curl -s "$GW/test/froyo?mode=reset" >/dev/null
tap_now "Refresh"
wait_text "5 open · Updated" 30
+ ! has_text "· network" || fail "the reason stayed after a good read"
check "the next good read says Updated again"
+ ! has_text "E2E stale row" || fail "the lagging open_items field was shown instead of open_items_live"
+ check "the live list (open_items_live) wins over the lagging open_items"
# Ends on the home screen for the screenshot.
}
diff --git a/README.md b/README.md
index 2d7c059..87e5b8e 100644
--- a/README.md
+++ b/README.md
@@ -96,10 +96,10 @@ A home-screen widget, **Froyo · Upcoming**, lists the open items of the crewmat
- Due within 24 hours is orange. Overdue is red and says **late**.
- Tap a row to open Froyo's chat in the app.
- Tap **Done** on a row. The app asks first. Say yes and the row goes, with **Undo** for 5 seconds. Then the app sends `Done: ` to Froyo's chat, and Froyo marks it done. Undo after that sends `Undo done: `. With app lock on, Done asks you to unlock first.
-- It refreshes about every 30 minutes, when you open the app, after a Done, and when you tap **⟳** in the header (it shows **Updating…** while it reads). The header says **Updated HH:mm** after a good read. Nothing open shows **Nothing coming up**. When the server cannot be reached, the last list stays, with **as of HH:mm**.
+- It refreshes about every 30 minutes, when you open the app, after a Done, and when you tap **⟳** in the header (it shows **Updating…** while it reads). The header says **Updated HH:mm** after a good read. Nothing open shows **Nothing coming up**. When a read fails, the last list stays, with **as of HH:mm** and why: **sign-in** (open the app to sign in), **network**, **timeout**, **server** or **format** (the page held no list the widget can read).
- Resize it from 4×1 to 4×4. A taller widget shows more rows at once; the list shows whole rows and scrolls to the rest. Rows are never squeezed or overlapped.
-How it works: the app reads Froyo's thread with `POST /api/members/froyo-personal-butler/thread` and its open items from `GET /api/members/froyo-personal-butler/dashboard` (the `open_items` field of Froyo's dashboard), with the app's own login cookie. Done is a chat message sent with `POST /api/chat`, the same call the dashboard's chat box makes.
+How it works: the app reads Froyo's thread with `POST /api/members/froyo-personal-butler/thread` and its open items from `GET /api/members/froyo-personal-butler/dashboard` (Froyo's `open_items_live` field, kept current by its sync job, else `open_items`), with the app's own login cookie. Done is a chat message sent with `POST /api/chat`, the same call the dashboard's chat box makes.
The widget shows item titles on the home screen, also while app lock is on.
diff --git a/app/src/main/java/io/github/rayvader/kirocrew/AlertsClient.kt b/app/src/main/java/io/github/rayvader/kirocrew/AlertsClient.kt
index 1d90656..35614c4 100644
--- a/app/src/main/java/io/github/rayvader/kirocrew/AlertsClient.kt
+++ b/app/src/main/java/io/github/rayvader/kirocrew/AlertsClient.kt
@@ -97,14 +97,21 @@ class AlertsClient(private val ctx: Context) {
* the dashboard's Origin, which the gateway's CSRF check asks for, and [json] as its body.
* Throws IOException on network errors.
*/
- fun call(profile: Profile, method: String, path: String, json: String? = null): Response {
- var res = callOnce(profile, method, path, json)
- if (res.authRequired && refresh(profile)) res = callOnce(profile, method, path, json)
+ fun call(
+ profile: Profile,
+ method: String,
+ path: String,
+ json: String? = null,
+ readTimeoutMs: Int = READ_TIMEOUT_MS,
+ ): Response {
+ var res = callOnce(profile, method, path, json, readTimeoutMs)
+ if (res.authRequired && refresh(profile)) res = callOnce(profile, method, path, json, readTimeoutMs)
return res
}
- private fun callOnce(profile: Profile, method: String, path: String, json: String?): Response {
+ private fun callOnce(profile: Profile, method: String, path: String, json: String?, readTimeoutMs: Int): Response {
val conn = open(profile, method, path)
+ conn.readTimeout = readTimeoutMs
try {
conn.setRequestProperty("Accept", "application/json")
if (method != "GET") {
diff --git a/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt b/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt
index fff8edd..25bfc3f 100644
--- a/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt
+++ b/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt
@@ -27,11 +27,14 @@ import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import io.github.rayvader.kirocrew.core.DueState
+import io.github.rayvader.kirocrew.core.FailReason
import io.github.rayvader.kirocrew.core.Profile
import io.github.rayvader.kirocrew.core.SyncNote
import io.github.rayvader.kirocrew.core.Upcoming
import io.github.rayvader.kirocrew.core.UpcomingItem
+import android.util.Log
import java.io.IOException
+import java.net.SocketTimeoutException
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.Locale
@@ -70,6 +73,7 @@ object FroyoWidgets {
private const val SP_ITEMS_AT = "froyo_items_at"
private const val SP_ERROR = "froyo_error"
private const val SP_REFRESHING_AT = "froyo_refreshing_at"
+ private const val SP_REASON = "froyo_fail_reason"
/** The header's refresh button. Only this app can send it: the button's intent is explicit. */
const val ACTION_REFRESH = "io.github.rayvader.kirocrew.FROYO_REFRESH"
@@ -133,33 +137,69 @@ object FroyoWidgets {
/** Reads Froyo's thread and open items from the active server. Off the main thread. */
fun fetch(ctx: Context): Error {
- val profile = profile(ctx) ?: return saveError(ctx, Error.NO_SERVER)
+ val profile = profile(ctx) ?: return saveError(ctx, Error.NO_SERVER, null, "no server")
val client = AlertsClient(ctx)
return try {
- val thread = thread(ctx, client, profile) ?: return saveError(ctx, lastAuth ?: Error.FAILED)
- val res = client.call(profile, "GET", Upcoming.dashboardPath(thread.member))
- if (res.authRequired) return saveError(ctx, Error.SIGNED_OUT)
- if (res.code != 200) return saveError(ctx, Error.FAILED)
- val items = Upcoming.extractItems(res.body) ?: return saveError(ctx, Error.FAILED)
+ var thread = thread(ctx, client, profile)
+ ?: return failThread(ctx)
+ var res = client.call(profile, "GET", Upcoming.dashboardPath(thread.member), readTimeoutMs = DASHBOARD_READ_TIMEOUT_MS)
+ if (!res.authRequired && Upcoming.threadMayBeStale(res.code)) {
+ // The remembered member or slot may be gone: ask for the thread again, once.
+ forgetThread(ctx)
+ thread = thread(ctx, client, profile) ?: return failThread(ctx)
+ res = client.call(profile, "GET", Upcoming.dashboardPath(thread.member), readTimeoutMs = DASHBOARD_READ_TIMEOUT_MS)
+ }
+ val items = if (res.code == 200 && !res.authRequired) Upcoming.extractItems(res.body) else null
+ val reason = Upcoming.failReason(res.code, res.authRequired, items != null)
+ if (reason != null || items == null) {
+ val error = if (reason == FailReason.SIGN_IN) Error.SIGNED_OUT else Error.FAILED
+ return saveError(ctx, error, reason ?: FailReason.FORMAT, "HTTP ${res.code}")
+ }
sp(ctx).edit {
putString(SP_ITEMS, items)
putLong(SP_ITEMS_AT, System.currentTimeMillis())
putString(SP_ERROR, Error.NONE.name)
+ remove(SP_REASON)
}
Error.NONE
+ } catch (e: SocketTimeoutException) {
+ saveError(ctx, Error.FAILED, FailReason.TIMEOUT, e.javaClass.simpleName)
} catch (e: IOException) {
- saveError(ctx, Error.FAILED)
+ saveError(ctx, Error.FAILED, FailReason.NETWORK, e.javaClass.simpleName)
} catch (e: RuntimeException) {
// CookieManager throws while the WebView package is being updated.
- saveError(ctx, Error.FAILED)
+ saveError(ctx, Error.FAILED, FailReason.NETWORK, e.javaClass.simpleName)
+ }
+ }
+
+ private fun failThread(ctx: Context): Error {
+ val auth = lastAuth
+ return if (auth == Error.SIGNED_OUT) {
+ saveError(ctx, Error.SIGNED_OUT, FailReason.SIGN_IN, "thread: sign-in")
+ } else {
+ saveError(ctx, Error.FAILED, lastThreadReason ?: FailReason.SERVER, "thread")
+ }
+ }
+
+ private fun forgetThread(ctx: Context) {
+ sp(ctx).edit {
+ remove(SP_SLOT)
+ remove(SP_MEMBER)
+ remove(SP_PROFILE)
}
}
+ /** The dashboard composes Froyo's whole page per read, which can be slow over a far link. */
+ private const val DASHBOARD_READ_TIMEOUT_MS = 45_000
+
+ @Volatile private var lastThreadReason: FailReason? = null
+
@Volatile private var lastAuth: Error? = null
/** Froyo's pinned chat, asked once per server and then remembered. */
fun thread(ctx: Context, client: AlertsClient, profile: Profile): io.github.rayvader.kirocrew.core.FroyoThread? {
lastAuth = null
+ lastThreadReason = null
val sp = sp(ctx)
val slot = sp.getString(SP_SLOT, null)
val member = sp.getString(SP_MEMBER, null)
@@ -168,8 +208,15 @@ object FroyoWidgets {
}
val res = client.call(profile, "POST", Upcoming.THREAD_PATH)
if (res.authRequired) lastAuth = Error.SIGNED_OUT
- if (res.code != 200) return null
- val thread = Upcoming.parseThread(res.body) ?: return null
+ if (res.code != 200) {
+ lastThreadReason = Upcoming.failReason(res.code, res.authRequired, false)
+ return null
+ }
+ val thread = Upcoming.parseThread(res.body)
+ if (thread == null) {
+ lastThreadReason = FailReason.FORMAT
+ return null
+ }
sp.edit {
putString(SP_SLOT, thread.slotKey)
putString(SP_MEMBER, thread.member)
@@ -188,15 +235,26 @@ object FroyoWidgets {
remove(SP_MEMBER)
remove(SP_PROFILE)
putString(SP_ERROR, Error.SIGNED_OUT.name)
+ putString(SP_REASON, FailReason.SIGN_IN.name)
}
render(ctx)
}
- private fun saveError(ctx: Context, error: Error): Error {
- sp(ctx).edit { putString(SP_ERROR, error.name) }
+ private fun saveError(ctx: Context, error: Error, reason: FailReason? = null, detail: String = ""): Error {
+ // In logcat too, so a stuck widget can be traced: adb logcat -s FroyoWidget
+ Log.w(TAG, "read failed: ${error.name} ${reason?.word ?: "-"} $detail")
+ sp(ctx).edit {
+ putString(SP_ERROR, error.name)
+ if (reason != null) putString(SP_REASON, reason.name) else remove(SP_REASON)
+ }
return error
}
+ private const val TAG = "FroyoWidget"
+
+ private fun savedReason(ctx: Context): FailReason? =
+ sp(ctx).getString(SP_REASON, null)?.let { runCatching { FailReason.valueOf(it) }.getOrNull() }
+
fun hide(ctx: Context, title: String) {
val hidden = Upcoming.decodeHidden(sp(ctx).getString(SP_HIDDEN, null)).toMutableMap()
hidden[title] = System.currentTimeMillis()
@@ -260,10 +318,11 @@ object FroyoWidgets {
// line, so no row is given up for it.
val open = ctx.getString(R.string.froyo_open, items.size)
val refreshing = Upcoming.isRefreshing(sp(ctx).getLong(SP_REFRESHING_AT, 0L), System.currentTimeMillis())
- val line = Upcoming.syncLine(refreshing, error != Error.NONE, loaded, at, ZoneId.systemDefault())
+ val line = Upcoming.syncLine(refreshing, error != Error.NONE, loaded, at, ZoneId.systemDefault(), savedReason(ctx))
val count = when (line.note) {
SyncNote.UPDATED -> ctx.getString(R.string.froyo_updated, open, line.time)
- SyncNote.AS_OF -> ctx.getString(R.string.froyo_as_of, open, line.time)
+ SyncNote.AS_OF -> line.reason?.let { ctx.getString(R.string.froyo_as_of_why, open, line.time, it.word) }
+ ?: ctx.getString(R.string.froyo_as_of, open, line.time)
SyncNote.UPDATING -> ctx.getString(R.string.froyo_updating_count, open)
SyncNote.NONE -> open
}
@@ -278,7 +337,8 @@ object FroyoWidgets {
loaded -> null
error == Error.NO_SERVER -> ctx.getString(R.string.froyo_no_server)
error == Error.SIGNED_OUT -> ctx.getString(R.string.froyo_signed_out)
- error == Error.FAILED -> ctx.getString(R.string.froyo_failed)
+ error == Error.FAILED -> savedReason(ctx)?.let { ctx.getString(R.string.froyo_failed_why, it.word) }
+ ?: ctx.getString(R.string.froyo_failed)
else -> ctx.getString(R.string.froyo_loading)
}
v.setViewVisibility(R.id.froyo_message, if (message != null) View.VISIBLE else View.GONE)
diff --git a/app/src/main/java/io/github/rayvader/kirocrew/core/Upcoming.kt b/app/src/main/java/io/github/rayvader/kirocrew/core/Upcoming.kt
index 9289667..475808b 100644
--- a/app/src/main/java/io/github/rayvader/kirocrew/core/Upcoming.kt
+++ b/app/src/main/java/io/github/rayvader/kirocrew/core/Upcoming.kt
@@ -38,7 +38,21 @@ data class WidgetFit(val rows: Int, val header: Boolean, val listHeightDp: Float
enum class SyncNote { NONE, UPDATED, AS_OF, UPDATING }
/** [note] plus the 24 h "HH:mm" it names (empty for [SyncNote.NONE] and [SyncNote.UPDATING]). */
-data class SyncLine(val note: SyncNote, val time: String)
+data class SyncLine(val note: SyncNote, val time: String, val reason: FailReason? = null)
+
+/** Why the last read failed, in a word the header can show next to "as of HH:mm". */
+enum class FailReason(val word: String) {
+ /** The login ran out and could not be renewed: open the app to sign in. */
+ SIGN_IN("sign-in"),
+ /** No connection, or the server could not be reached. */
+ NETWORK("network"),
+ /** The server took too long to answer. */
+ TIMEOUT("timeout"),
+ /** The server answered with an error code. */
+ SERVER("server"),
+ /** The server answered, but not with a list the widget can read. */
+ FORMAT("format"),
+}
/** Froyo's pinned chat: the slot to send to and the member name the dashboard read needs. */
data class FroyoThread(val slotKey: String, val member: String)
@@ -55,6 +69,12 @@ object Upcoming {
/** The dashboard field Froyo writes its open items to. */
const val FIELD = "open_items"
+
+ /**
+ * The field Froyo's sync job keeps current every 15 min: an object whose `items` is the
+ * same rows as [FIELD]. Preferred when it holds a list; [FIELD] is the fallback.
+ */
+ const val LIVE_FIELD = "open_items_live"
private const val MAX_TITLE = 200
private const val DATA_ID = "kirocrew-dashboard-data"
@@ -106,7 +126,8 @@ object Upcoming {
val html = JSONObject(body).optString("rendered_html")
val island = island(html) ?: return null
val fields = JSONObject(island).optJSONObject("fields") ?: return null
- fields.optJSONArray(FIELD)?.toString()
+ val live = fields.optJSONObject(LIVE_FIELD)?.optJSONArray("items")
+ (live ?: fields.optJSONArray(FIELD))?.toString()
} catch (e: JSONException) {
null
}
@@ -255,13 +276,40 @@ object Upcoming {
* Nothing when no list was ever read. [atMs] is when the shown list was read; the time is
* 24 h in [zone].
*/
- fun syncLine(refreshing: Boolean, failed: Boolean, loaded: Boolean, atMs: Long, zone: ZoneId): SyncLine {
+ fun syncLine(
+ refreshing: Boolean,
+ failed: Boolean,
+ loaded: Boolean,
+ atMs: Long,
+ zone: ZoneId,
+ reason: FailReason? = null,
+ ): SyncLine {
if (refreshing) return SyncLine(SyncNote.UPDATING, "")
if (!loaded || atMs <= 0) return SyncLine(SyncNote.NONE, "")
val time = java.time.Instant.ofEpochMilli(atMs).atZone(zone).format(DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT))
- return SyncLine(if (failed) SyncNote.AS_OF else SyncNote.UPDATED, time)
+ return if (failed) SyncLine(SyncNote.AS_OF, time, reason) else SyncLine(SyncNote.UPDATED, time)
}
+ /**
+ * Why a read failed, from what the server answered. [authRequired] is the login check
+ * after one renewal try; [code] is the HTTP status; [parsed] is false when a 200 held no
+ * readable list. Null when the read worked.
+ */
+ fun failReason(code: Int, authRequired: Boolean, parsed: Boolean): FailReason? = when {
+ authRequired -> FailReason.SIGN_IN
+ code == 200 && parsed -> null
+ code == 200 -> FailReason.FORMAT
+ code == 408 || code == 504 -> FailReason.TIMEOUT
+ code == 502 || code == 503 -> FailReason.NETWORK
+ else -> FailReason.SERVER
+ }
+
+ /**
+ * True when the remembered thread may be the reason a read failed: the server no longer
+ * knows that member name or slot (400 / 404 / 409). The widget then asks for the thread again.
+ */
+ fun threadMayBeStale(code: Int): Boolean = code == 400 || code == 404 || code == 409
+
/**
* Items marked done on the phone stay hidden until Froyo's list drops them, or for
* [HIDE_MS] if it never does (then the row comes back, which shows the done did not land).
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ffb6fb3..2ef7adf 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -184,6 +184,8 @@ The app saves the server and signs you in.
Froyo\'s open items, soonest first. Tap Done to mark one done.
%1$d open
%1$s · as of %2$s
+ %1$s · as of %2$s · %3$s
+ Couldn\'t update · %1$s
%1$s · Updated %2$s
%1$s · Updating…
Updating…
diff --git a/app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingTest.kt b/app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingTest.kt
index 599dd87..82df2f6 100644
--- a/app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingTest.kt
+++ b/app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingTest.kt
@@ -105,6 +105,83 @@ class UpcomingTest {
assertEquals("•", Upcoming.icon("something-new"))
}
+ /** Like Froyo's dashboard from page v4: open_items_live (sync job, object) beside open_items. */
+ private fun v4Dashboard(live: String?, items: String): String {
+ val liveField = live?.let { ",\"open_items_live\":$it" } ?: ""
+ val island = """{"fields":{"cleared_items":[],"coming_up":[],"items_as_of":"Thu 5 Nov, 17:01 SGT",""" +
+ """"milestones":[],"open_items":$items$liveField,"waiting_count":0,"waiting_items":[]},""" +
+ """"agentic":["cleared_items","coming_up","items_as_of","open_items"],"seq":42,"missing":[],""" +
+ """"written_at":{"open_items":"2026-11-05T09:00:37Z"},"locale":"en","subject":"crewmate","last_user_at":""}"""
+ val html = "" +
+ "" +
+ "" +
+ ""
+ return JSONObject()
+ .put("instance_version", 4)
+ .put("template", JSONObject().put("id", "froyo-dashboard").put("version", 4))
+ .put("dashboard", "froyo-dashboard")
+ .put("state", "live")
+ .put("fields", org.json.JSONArray(listOf("open_items", "open_items_live")))
+ .put("scripts", true)
+ .put("rendered_html", html)
+ .toString()
+ }
+
+ @Test
+ fun aV4PageIsReadAndPrefersTheLiveList() {
+ val old = """[{"n":1,"id":"a","title":"Old item","due":"2026-11-04","kind":"booking","flag":null}]"""
+ val live = """{"items":[{"n":1,"id":"b","title":"Test dinner 18:30","due":"2026-11-05","kind":"booking"},""" +
+ """{"n":2,"id":"c","title":"Test flight","due":"2026-11-09","kind":"flight"}],"as_of":"Thu 5 Nov, 17:01 SGT · 2 open"}"""
+ val items = Upcoming.parseItems(Upcoming.extractItems(v4Dashboard(live, old))!!, zone)
+ assertEquals(listOf("Test dinner 18:30", "Test flight"), items.map { it.title })
+ }
+
+ @Test
+ fun aV4PageWithoutTheLiveListFallsBackToOpenItems() {
+ val old = """[{"n":1,"title":"Only item","due":null,"kind":"bill","flag":"x"}]"""
+ assertEquals(listOf("Only item"), Upcoming.parseItems(Upcoming.extractItems(v4Dashboard(null, old))!!, zone).map { it.title })
+ // A live field that is not the expected object falls back too.
+ assertEquals(listOf("Only item"), Upcoming.parseItems(Upcoming.extractItems(v4Dashboard("\"soon\"", old))!!, zone).map { it.title })
+ assertEquals(listOf("Only item"), Upcoming.parseItems(Upcoming.extractItems(v4Dashboard("{\"as_of\":\"x\"}", old))!!, zone).map { it.title })
+ // An empty live list is a real answer: nothing open.
+ assertEquals("[]", Upcoming.extractItems(v4Dashboard("{\"items\":[]}", old)))
+ }
+
+ @Test
+ fun failureReasons() {
+ assertEquals(null, Upcoming.failReason(200, false, true))
+ assertEquals(FailReason.FORMAT, Upcoming.failReason(200, false, false))
+ assertEquals(FailReason.SIGN_IN, Upcoming.failReason(401, true, false))
+ assertEquals(FailReason.SIGN_IN, Upcoming.failReason(403, true, false))
+ assertEquals(FailReason.SERVER, Upcoming.failReason(403, false, false))
+ assertEquals(FailReason.SERVER, Upcoming.failReason(500, false, false))
+ assertEquals(FailReason.SERVER, Upcoming.failReason(409, false, false))
+ assertEquals(FailReason.NETWORK, Upcoming.failReason(502, false, false))
+ assertEquals(FailReason.TIMEOUT, Upcoming.failReason(504, false, false))
+ assertEquals("sign-in", FailReason.SIGN_IN.word)
+ assertEquals("network", FailReason.NETWORK.word)
+ assertEquals("format", FailReason.FORMAT.word)
+ }
+
+ @Test
+ fun aStaleThreadIsAskedForAgain() {
+ assertTrue(Upcoming.threadMayBeStale(400))
+ assertTrue(Upcoming.threadMayBeStale(404))
+ assertTrue(Upcoming.threadMayBeStale(409))
+ assertFalse(Upcoming.threadMayBeStale(200))
+ assertFalse(Upcoming.threadMayBeStale(401))
+ assertFalse(Upcoming.threadMayBeStale(500))
+ }
+
+ @Test
+ fun aFailedReadSaysWhyNextToAsOf() {
+ val at = java.time.ZonedDateTime.of(2026, 11, 5, 5, 48, 0, 0, ZoneId.of("Indian/Maldives")).toInstant().toEpochMilli()
+ val line = Upcoming.syncLine(false, true, true, at, ZoneId.of("Indian/Maldives"), FailReason.NETWORK)
+ assertEquals(SyncLine(SyncNote.AS_OF, "05:48", FailReason.NETWORK), line)
+ // A good read carries no reason, even if an old one was saved.
+ assertEquals(null, Upcoming.syncLine(false, false, true, at, zone, FailReason.NETWORK).reason)
+ }
+
@Test
fun aGoodReadSaysUpdatedInLocal24HourTime() {
val at = java.time.ZonedDateTime.of(2026, 11, 5, 14, 7, 0, 0, zone).toInstant().toEpochMilli()