diff --git a/.github/e2e/gateway.py b/.github/e2e/gateway.py
index 2f0d1d9..e5c3f37 100644
--- a/.github/e2e/gateway.py
+++ b/.github/e2e/gateway.py
@@ -142,6 +142,8 @@ def __init__(self, name):
# Froyo's open items and the chat messages sent to its thread.
self.froyo = froyo_items()
self.chats = []
+ # True while a test wants Froyo's dashboard read to fail (the widget keeps its list).
+ self.froyo_down = False
def now_ts(self):
# Strictly increasing timestamps, so two notes pushed back to back never tie.
@@ -297,11 +299,14 @@ def do_GET(self):
state.gw_update = {"latest": q.get("latest", ""), "status": q.get("status", "succeeded")}
return self.send(200, "ok")
if p == "/test/froyo":
- # mode=reset (the made-up list), many (8 items, more than fit) or empty.
+ # 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).
+ mode = q.get("mode")
with state.lock:
- mode = q.get("mode")
- state.froyo = [] if mode == "empty" else froyo_items(many=mode == "many")
- state.chats = []
+ state.froyo_down = mode == "down"
+ if mode != "down":
+ state.froyo = [] if mode == "empty" else froyo_items(many=mode == "many")
+ state.chats = []
return self.send(200, "ok")
if p == "/test/chats":
with state.lock:
@@ -312,7 +317,9 @@ 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 = list(state.froyo)
+ items, down = list(state.froyo), state.froyo_down
+ if down:
+ return self.send(503, json.dumps({"error": "down"}), "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 718197f..91426d2 100755
--- a/.github/e2e/run.sh
+++ b/.github/e2e/run.sh
@@ -1073,6 +1073,40 @@ f_froyo_widget() {
widget_home
wait_text "7 open" 30
check "the count goes down to 7"
+
+ # The refresh button reads the list now; the header says when.
+ curl -s "$GW/test/froyo?mode=reset" >/dev/null
+ widget_home
+ tap_now "Refresh"
+ wait_text "5 open · Updated" 30
+ local now shown
+ now=$(adb shell date +%H:%M | tr -d '\r')
+ dump
+ shown=$(grep -oE 'Updated [0-9]{2}:[0-9]{2}' "$OUT/ui.xml" | head -1 | cut -d' ' -f2)
+ [[ -n $shown ]] || fail "no 'Updated HH:mm' (24 h) in the header"
+ python3 - "$shown" "$now" <<'PY' || fail "Updated $shown is not the phone's time now ($now)"
+import sys
+m = lambda t: int(t[:2]) * 60 + int(t[3:])
+d = (m(sys.argv[2]) - m(sys.argv[1])) % 1440
+sys.exit(0 if d <= 2 else 1)
+PY
+ check "the refresh button reads the list now and says 'Updated HH:mm' (24 h)"
+ rows=$(widget_rows) || fail "rows overlap with the Updated note: $rows"
+ ((rows == 5)) || fail "the Updated note cost a row: $rows of 5 show"
+ check "the note shares the header: all 5 rows still show"
+ adb exec-out screencap -p > "$OUT/shots/froyo-widget-refresh.png" 2>/dev/null || true
+
+ # A failed read keeps the rows and says "as of HH:mm".
+ curl -s "$GW/test/froyo?mode=down" >/dev/null
+ tap_now "Refresh"
+ wait_text "5 open · as of" 30
+ 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'"
+ curl -s "$GW/test/froyo?mode=reset" >/dev/null
+ tap_now "Refresh"
+ wait_text "5 open · Updated" 30
+ check "the next good read says Updated again"
# Ends on the home screen for the screenshot.
}
diff --git a/README.md b/README.md
index 0320646..2d7c059 100644
--- a/README.md
+++ b/README.md
@@ -96,7 +96,7 @@ 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, and after a Done. 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 the server cannot be reached, the last list stays, with **as of HH:mm**.
- 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.
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 a58c083..fff8edd 100644
--- a/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt
+++ b/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt
@@ -28,12 +28,12 @@ import androidx.work.WorkerParameters
import androidx.work.workDataOf
import io.github.rayvader.kirocrew.core.DueState
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 java.io.IOException
import java.time.LocalDateTime
import java.time.ZoneId
-import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.concurrent.TimeUnit
@@ -52,6 +52,14 @@ class FroyoWidgetProvider : AppWidgetProvider() {
override fun onAppWidgetOptionsChanged(context: Context, manager: AppWidgetManager, id: Int, options: Bundle) {
FroyoWidgets.render(context)
}
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == FroyoWidgets.ACTION_REFRESH) {
+ FroyoWidgets.refreshNow(context)
+ return
+ }
+ super.onReceive(context, intent)
+ }
}
/** The widget's saved list, what it shows, and when it refreshes. Android calls only at run time. */
@@ -61,6 +69,10 @@ object FroyoWidgets {
private const val SP_ITEMS = "froyo_items"
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"
+
+ /** 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"
private const val SP_HIDDEN = "froyo_hidden"
private const val SP_SLOT = "froyo_slot"
private const val SP_MEMBER = "froyo_member"
@@ -91,6 +103,21 @@ object FroyoWidgets {
WorkManager.getInstance(ctx).enqueueUniqueWork(REFRESH_NOW, ExistingWorkPolicy.REPLACE, request)
}
+ /** The refresh button: show "Updating…" at once, then read the list now. */
+ fun refreshNow(ctx: Context) {
+ sp(ctx).edit { putLong(SP_REFRESHING_AT, System.currentTimeMillis()) }
+ render(ctx)
+ // No network wait: offline, the read fails at once and the header says "as of HH:mm"
+ // instead of spinning until the network comes back.
+ val request = OneTimeWorkRequestBuilder().build()
+ WorkManager.getInstance(ctx).enqueueUniqueWork(REFRESH_NOW, ExistingWorkPolicy.REPLACE, request)
+ }
+
+ /** A read finished, good or not: stop showing "Updating…". */
+ fun refreshDone(ctx: Context) {
+ sp(ctx).edit { remove(SP_REFRESHING_AT) }
+ }
+
/** From the app's onResume: only when a widget is on the home screen. */
fun refreshIfShown(ctx: Context) {
if (hasWidgets(ctx)) refresh(ctx)
@@ -228,15 +255,23 @@ object FroyoWidgets {
val profile = profile(ctx)
v.setOnClickPendingIntent(R.id.froyo_root, openApp(ctx, profile, Upcoming.chatPagePath(slotKey(ctx)), 0))
- // Header: name and count, plus "as of HH:mm" when the last read failed.
- var count = ctx.getString(R.string.froyo_open, items.size)
- if (error != Error.NONE && loaded && at > 0) {
- val time = java.time.Instant.ofEpochMilli(at).atZone(ZoneId.systemDefault())
- .format(DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT))
- count = ctx.getString(R.string.froyo_as_of, count, time)
+ // Header: name and count, then "Updated HH:mm" after a good read, "as of HH:mm" after a
+ // failed one, or "Updating…" while the refresh button's read runs. It shares the header
+ // 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 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.UPDATING -> ctx.getString(R.string.froyo_updating_count, open)
+ SyncNote.NONE -> open
}
v.setViewVisibility(R.id.froyo_header, if (fit.header) View.VISIBLE else View.GONE)
v.setTextViewText(R.id.froyo_count, count)
+ v.setViewVisibility(R.id.froyo_refresh, if (refreshing) View.GONE else View.VISIBLE)
+ v.setViewVisibility(R.id.froyo_spinner, if (refreshing) View.VISIBLE else View.GONE)
+ v.setOnClickPendingIntent(R.id.froyo_refresh, refreshIntent(ctx))
val message = when {
loaded && items.isEmpty() -> ctx.getString(R.string.froyo_empty)
@@ -336,6 +371,11 @@ object FroyoWidgets {
private const val PI_FLAGS = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ private fun refreshIntent(ctx: Context): PendingIntent {
+ val intent = Intent(ctx, FroyoWidgetProvider::class.java).setAction(ACTION_REFRESH)
+ return PendingIntent.getBroadcast(ctx, 7200, intent, PI_FLAGS)
+ }
+
/** Settings > Add Froyo widget: asks the launcher to place it. False when the launcher cannot. */
fun requestPin(ctx: Context): Boolean {
val manager = AppWidgetManager.getInstance(ctx)
@@ -347,8 +387,12 @@ object FroyoWidgets {
/** Reads Froyo's list and redraws the widgets. */
class FroyoRefreshWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) {
override fun doWork(): Result {
- FroyoWidgets.fetch(applicationContext)
- FroyoWidgets.render(applicationContext)
+ try {
+ FroyoWidgets.fetch(applicationContext)
+ } finally {
+ FroyoWidgets.refreshDone(applicationContext)
+ FroyoWidgets.render(applicationContext)
+ }
return Result.success()
}
}
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 617a7f1..9289667 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
@@ -34,6 +34,12 @@ enum class DueState { NONE, LATER, SOON, LATE }
*/
data class WidgetFit(val rows: Int, val header: Boolean, val listHeightDp: Float)
+/** What the header says after the count: when the list was read, or that it is being read. */
+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)
+
/** 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)
@@ -236,6 +242,26 @@ object Upcoming {
fun used(fit: WidgetFit, fontScale: Float): Float =
PAD_DP + (if (fit.header) headerHeight(fontScale) else 0f) + fit.listHeightDp
+ /** A tap on the refresh button counts as running for at most this long, so a lost run never spins forever. */
+ const val REFRESH_SPIN_MS = 2L * 60 * 1000
+
+ /** True while a refresh the person started is still running: started, and not older than [REFRESH_SPIN_MS]. */
+ fun isRefreshing(startedMs: Long, nowMs: Long): Boolean =
+ startedMs > 0 && nowMs >= startedMs && nowMs - startedMs < REFRESH_SPIN_MS
+
+ /**
+ * The header's sync note. While a refresh runs: "Updating…". After a good read: "Updated HH:mm".
+ * After a failed read with a saved list: "as of HH:mm", the time of the list still shown.
+ * 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 {
+ 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)
+ }
+
/**
* 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/layout/widget_froyo.xml b/app/src/main/res/layout/widget_froyo.xml
index 8add465..6738110 100644
--- a/app/src/main/res/layout/widget_froyo.xml
+++ b/app/src/main/res/layout/widget_froyo.xml
@@ -40,6 +40,29 @@
android:maxLines="1"
android:textColor="@color/widget_muted"
android:textSize="12sp" />
+
+
+
+
+
Froyo\'s open items, soonest first. Tap Done to mark one done.
%1$d open
%1$s · as of %2$s
+ %1$s · Updated %2$s
+ %1$s · Updating…
+ Updating…
+ Refresh
Nothing coming up
Loading…
Add a server in the app first
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 0048b7a..599dd87 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,55 @@ class UpcomingTest {
assertEquals("•", Upcoming.icon("something-new"))
}
+ @Test
+ fun aGoodReadSaysUpdatedInLocal24HourTime() {
+ val at = java.time.ZonedDateTime.of(2026, 11, 5, 14, 7, 0, 0, zone).toInstant().toEpochMilli()
+ assertEquals(SyncLine(SyncNote.UPDATED, "14:07"), Upcoming.syncLine(false, false, true, at, zone))
+ // Another zone, same instant: local time of that zone.
+ assertEquals(SyncLine(SyncNote.UPDATED, "06:07"), Upcoming.syncLine(false, false, true, at, ZoneId.of("UTC")))
+ val night = java.time.ZonedDateTime.of(2026, 11, 5, 23, 5, 0, 0, zone).toInstant().toEpochMilli()
+ assertEquals("23:05", Upcoming.syncLine(false, false, true, night, zone).time)
+ }
+
+ @Test
+ fun aFailedReadKeepsTheListAndSaysAsOf() {
+ val at = java.time.ZonedDateTime.of(2026, 11, 5, 9, 30, 0, 0, zone).toInstant().toEpochMilli()
+ assertEquals(SyncLine(SyncNote.AS_OF, "09:30"), Upcoming.syncLine(false, true, true, at, zone))
+ }
+
+ @Test
+ fun nothingReadYetSaysNothing() {
+ assertEquals(SyncNote.NONE, Upcoming.syncLine(false, false, false, 0L, zone).note)
+ assertEquals(SyncNote.NONE, Upcoming.syncLine(false, true, false, 0L, zone).note)
+ assertEquals(SyncNote.NONE, Upcoming.syncLine(false, false, true, 0L, zone).note)
+ }
+
+ @Test
+ fun aRunningRefreshSaysUpdating() {
+ assertEquals(SyncLine(SyncNote.UPDATING, ""), Upcoming.syncLine(true, false, true, 1_000L, zone))
+ assertEquals(SyncNote.UPDATING, Upcoming.syncLine(true, true, false, 0L, zone).note)
+ }
+
+ @Test
+ fun refreshSpinnerStopsWhenDoneOrStale() {
+ val t = 10_000_000L
+ assertTrue(Upcoming.isRefreshing(t, t))
+ assertTrue(Upcoming.isRefreshing(t, t + 30_000))
+ // A run that never reported back stops spinning after two minutes.
+ assertFalse(Upcoming.isRefreshing(t, t + Upcoming.REFRESH_SPIN_MS))
+ // Cleared (0) or a clock that went back: not refreshing.
+ assertFalse(Upcoming.isRefreshing(0L, t))
+ assertFalse(Upcoming.isRefreshing(t, t - 1))
+ }
+
+ @Test
+ fun theSyncNoteSitsInTheHeaderSoNoRowIsLost() {
+ // Rows per height are unchanged by the note: it shares the header line.
+ assertEquals(3, Upcoming.fit(139f, 1f).rows)
+ assertEquals(3, Upcoming.fit(139f, 1.15f).rows)
+ assertEquals(2, Upcoming.fit(110f, 1.15f).rows)
+ }
+
@Test
fun listShowsWholeRowsAndScrollsTheRest() {
// Raymond's Wide on Smart Launcher is about 139 dp; the emulator's 4x2 is about 223 dp.