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: <title>`. 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.<br><br> <string name="froyo_widget_description">Froyo\'s open items, soonest first. Tap Done to mark one done.</string> <string name="froyo_open">%1$d open</string> <string name="froyo_as_of">%1$s · as of %2$s</string> + <string name="froyo_as_of_why">%1$s · as of %2$s · %3$s</string> + <string name="froyo_failed_why">Couldn\'t update · %1$s</string> <string name="froyo_updated">%1$s · Updated %2$s</string> <string name="froyo_updating_count">%1$s · Updating…</string> <string name="froyo_updating">Updating…</string> 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 = "<!doctype html><html><head><style>.x{}</style></head><body>" + + "<script type=\"application/json\" id=\"kirocrew-dashboard-data\">$island</script>" + + "<script>/* header time drawn by script */ document.getElementById('kirocrew-dashboard-data');</script>" + + "</body></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()