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 4f0cde4..8e332f4 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 @@ -71,8 +71,9 @@ object Upcoming { 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. + * The field Froyo's sync job keeps current every 15 min: the gateway's custom fold row + * {data: {items, as_of}, at, stale_after}, whose `data.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 @@ -126,7 +127,12 @@ object Upcoming { val html = JSONObject(body).optString("rendered_html") val island = island(html) ?: return null val fields = JSONObject(island).optJSONObject("fields") ?: return null - val live = fields.optJSONObject(LIVE_FIELD)?.optJSONArray("items") + // The gateway's custom fold row is {data: {items, as_of}, at, stale_after}: the + // sync job's record sits under `data`, which is where the page itself reads it. + // A bare {items} is still read, in case a page binds `.data` directly. + val liveObj = fields.optJSONObject(LIVE_FIELD) + val live = liveObj?.optJSONObject("data")?.optJSONArray("items") + ?: liveObj?.optJSONArray("items") (live ?: fields.optJSONArray(FIELD))?.toString() } catch (e: JSONException) { null 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 912f569..362383e 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 @@ -136,6 +136,27 @@ class UpcomingTest { assertEquals(listOf("Test dinner 18:30", "Test flight"), items.map { it.title }) } + /** The real shape: the gateway's custom fold row wraps the job's record in `data`. */ + @Test + fun theLiveListIsReadFromTheFoldRowsData() { + val old = """[{"n":1,"id":"a","title":"Old item","due":"2026-11-04","kind":"booking","flag":null}]""" + val live = """{"data":{"items":[{"n":1,"id":"b","title":"Test dinner 18:30","due":"2026-11-05","kind":"booking"},""" + + """{"n":2,"id":"c","title":"Test snorkel 14:30","due":"2026-11-06","kind":"booking"}],""" + + """"as_of":"Thu 5 Nov, 21:28 SGT ยท 2 open"},"at":"2026-11-05T13:28:55Z",""" + + """"writer":"4cbe3e0f","stale_after":"2026-11-05T14:28:55Z"}""" + val items = Upcoming.parseItems(Upcoming.extractItems(v4Dashboard(live, old))!!, zone) + assertEquals(listOf("Test dinner 18:30", "Test snorkel 14:30"), items.map { it.title }) + } + + @Test + fun aFoldRowWithNoListInItsDataFallsBackToOpenItems() { + val old = """[{"n":1,"title":"Only item","due":null,"kind":"bill","flag":"x"}]""" + val live = """{"data":{"as_of":"x"},"at":"2026-11-05T13:28:55Z"}""" + assertEquals(listOf("Only item"), Upcoming.parseItems(Upcoming.extractItems(v4Dashboard(live, old))!!, zone).map { it.title }) + // An empty list under data is a real answer: nothing open. + assertEquals("[]", Upcoming.extractItems(v4Dashboard("""{"data":{"items":[]}}""", old))) + } + @Test fun aV4PageWithoutTheLiveListFallsBackToOpenItems() { val old = """[{"n":1,"title":"Only item","due":null,"kind":"bill","flag":"x"}]"""