diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-13 07:11:04 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-13 07:11:04 +0000 |
| commit | 44abf42ed45aa8f285e7ce031cbb9ef1ade667ea (patch) | |
| tree | dbc21cadc2750c6e39e7f7613be0b3f73790c445 /docs/superpowers/specs | |
| parent | 8310f802dd9fc6ef5dff0be7f640f79c5b39987f (diff) | |
| parent | 4126fe4f56a6eb9703a084d4793a597f37bf2867 (diff) | |
Merge github/master: reconcile with parallel widget work
Another session pushed 27 commits in parallel covering quick-add, event
detail popups, recurrence display, overdue badges, a manual refresh button,
and its own fix for the same overdue-tasks bug (via a separate
GetOverdueNativeTasks fetch folded into BuildTimeline, rather than widening
GetNativeTasksByDateRange's bound directly). Reconciled rather than blindly
taking one side:
- Reverted GetNativeTasksByDateRange to its original bounded query and kept
upstream's GetOverdueNativeTasks + BuildTimeline fold-in as the sole
overdue mechanism for native tasks, to avoid double-counting overdue
items (my widened query + their separate fetch would have both returned
them). Re-pointed the regression test at the now-correct contract and
added a store-level test for GetOverdueNativeTasks directly.
- Kept my GetGoogleTasksByDateRange fix as-is (single unbounded query) --
upstream never touched Google Tasks overdue handling, so there's no
duplication risk there.
- Rewove WidgetRoot's LazyColumn structure (added for scrolling) around
upstream's new header buttons, pinned all-day event rows, and the
enhanced TomorrowSection, none of which were written LazyColumn-aware
since that work landed on this side only.
- Combined both sides' additions to TaskDetailActivity/TaskDetailSheet
(description-edit detail popup + due-date reschedule label) and
WidgetRepository/Actions (optimistic local removal + refresh button
wiring) -- these were independent, non-overlapping features that both
needed to survive.
- Renumbered the migration collision: both sides independently added a
migration numbered 022. Card-description was already applied to the live
production DB under that filename earlier this session (migrations are
tracked by filename), so it keeps 022; the recurring-event-id migration,
never deployed under any name here, moves to 023.
Verified: go build clean, full test suite passes (only the two
pre-existing agent-handler failures and the pre-existing models package
build error remain, both confirmed unrelated via git stash before this
session began), and a dry run against a copy of the live production
database applies both migrations cleanly with no re-run conflicts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'docs/superpowers/specs')
5 files changed, 200 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-07-12-widget-clickable-reschedule-design.md b/docs/superpowers/specs/2026-07-12-widget-clickable-reschedule-design.md new file mode 100644 index 0000000..c456908 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-widget-clickable-reschedule-design.md @@ -0,0 +1,41 @@ +# Widget Clickable Date/Time Reschedule — Design + +## Context + +Third of five widget features (refresh → overdue badge → **clickable reschedule** → quick add → recurrence). Today `TaskDetailSheet` (the bottom-sheet popup opened by tapping a task row) shows a separate "Reschedule" `OutlinedButton` for doot tasks, which opens a `DatePickerDialog` — but the popup never displays the task's *current* due date anywhere. The user wants the due-date value itself to be the clickable element, replacing the standalone button. + +Decided without a user Q&A round (per explicit instruction to proceed autonomously). + +## Key finding from grounding + +`WidgetItem.start` is deliberately `null` for every doot task regardless of whether it has a real due date — this is intentional, existing behavior from the 2026-07-12 all-day-pinning fix (`TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior`): doot tasks are "floating" items positioned by the client's `SlotPacker`, and `start` is reserved for that scheduling/positioning role. There is currently **no field carrying a doot task's raw due date to the Android client at all** — `TaskRow`'s `Intent` extras don't include it either. This must be added; it doesn't already exist under a different name. + +## Design + +**Server (Go):** add a new field, independent of `Start`/`IsAllDay` semantics, so this change cannot regress the floating-task positioning behavior the prior fix protects: +- `models.WidgetItem` gains `DueDate *time.Time `json:"due_date,omitempty"``. +- `TimelineItemToWidgetItem`: when `item.Type == TimelineItemTypeTask && item.Source == "doot" && !item.Time.IsZero()`, set `wi.DueDate = &item.Time`. This is the *only* new condition — it does not touch the existing `Start`/`End` logic at all. + +**Android — data layer:** +- `WidgetItem.kt` gains `@SerialName("due_date") val dueDate: String? = null`. + +**Android — passing the value into the popup:** +- `TaskRow`'s `detailIntent` (`DootWidget.kt`) gains `putExtra(TaskDetailActivity.EXTRA_DUE_DATE, task.dueDate)` (nullable string extra). +- `TaskDetailActivity.onCreate` reads it: `val dueDate = intent.getStringExtra(EXTRA_DUE_DATE)`, passes to `TaskDetailSheet`. + +**Android — UI:** +- `TaskDetailSheet` gains a `dueDate: String?` parameter. +- The existing "Reschedule" `OutlinedButton` (only ever shown for `source == "doot"`) is replaced by a tappable row: an outlined `Row` styled like the current button (same border/shape/padding) containing formatted text — + - If `dueDate != null`: parse and format as `"Due " + MMM d` (e.g. "Due Jul 15"), using `java.time.LocalDate`/`DateTimeFormatter` (already available; `TaskDetailActivity.kt` already imports `java.util.Calendar`/`TimeZone` for the existing picker, this adds the modern `java.time` formatter alongside it). + - If `dueDate == null`: show `"No due date · tap to schedule"`. + - Tapping the row opens the same `DatePickerDialog` that exists today (unchanged picker logic) — only the trigger element changes from a button labeled "Reschedule" to this date-display row. +- No time-of-day editing: the server's reschedule endpoint (`HandleWidgetReschedule`) only accepts a `YYYY-MM-DD` date and sets the task to midnight — this was already true before this feature and stays true. "date/time" in the request is read as "the current due-date value that's displayed," not a request for new time-granularity editing, since doot tasks don't carry time-of-day today (confirmed: every existing `due_date` in the production DB is midnight-valued). + +## Testing + +- Server: unit test for `TimelineItemToWidgetItem` confirming `DueDate` is set for a doot task with a real due date, and confirming it's still `nil` for (a) a doot task with no due date and (b) a non-doot item (e.g. a calendar event), so this can't leak into other item types. +- Android: no unit-test surface (consistent with the rest of this session's widget UI work) — verified by build + manual on-device check. + +## Out of scope + +Quick add and recurrence display — each gets its own spec. diff --git a/docs/superpowers/specs/2026-07-12-widget-overdue-badge-design.md b/docs/superpowers/specs/2026-07-12-widget-overdue-badge-design.md new file mode 100644 index 0000000..db8cdce --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-widget-overdue-badge-design.md @@ -0,0 +1,30 @@ +# Widget Overdue Badge — Design + +## Context + +Second of five widget features (refresh → **overdue badge** → clickable reschedule → quick add → recurrence). Direct follow-on from the 2026-07-12 fix (commit `f01b292`) that made overdue native tasks appear in the widget at all: `TimelineItem.IsOverdue` is computed server-side but never reaches the client. Today all 10 previously-invisible overdue tasks render identically to a normal task due today — same color, same style, no way to tell "this is 12 days late" from "this is due today." + +Decided without a user Q&A round (per explicit instruction to proceed autonomously); the choices below are the minimal, lowest-risk options consistent with existing patterns in the codebase. + +## Goal + +Visually distinguish overdue tasks from normal tasks in the widget. + +## Design + +**Server (Go):** +- `models.WidgetItem` (`internal/models/widget.go`) gains `IsOverdue bool `json:"is_overdue"``. +- `TimelineItemToWidgetItem` (`internal/handlers/widget.go`) sets `wi.IsOverdue = item.IsOverdue` — `TimelineItem.IsOverdue` is already computed correctly by `ComputeDaySection` (confirmed by the prior fix), this just forwards the existing field. + +**Android (Kotlin):** +- `WidgetItem.kt` gains `@SerialName("is_overdue") val isOverdue: Boolean = false`. +- `TaskRow` (`DootWidget.kt`) is the single rendering path for every task (today's floating queue, tomorrow's section, and now overdue tasks all flow through it — confirmed by grounding: `TomorrowSection` and the floating-task fragments both call `TaskRow`). When `task.isOverdue == true`, the title `Text` uses a warning color (`Color(0xFFF87171)` — a soft red, distinct from the existing grey `0xFFDDDDDD` and from every `sourceColor` value) instead of the default grey. No other layout change — same row height, same checkbox, same tap targets. + +**Why title-color instead of a new badge/icon/label:** the widget is already dense (hour grid + floating queue + tomorrow section); every existing "this is special" signal in this file is done via color (see `sourceColor`, `AllDayRow`'s colored bar, `EventBlock`'s past-event alpha dimming) rather than added text or icons. A color change is the lowest-risk, most consistent option and needs no new layout space. + +**Out of scope:** No "N days overdue" text, no sort-order change (overdue tasks already interleave into the floating queue via `SlotPacker` same as any other floating task — leaving that alone, this feature is purely visual). + +## Testing + +- Server: unit test for `TimelineItemToWidgetItem` asserting `IsOverdue` is forwarded (mirrors the existing `TestTimelineItemToWidgetItem_AllDayEvent` pattern in `widget_test.go`). +- Android: no unit-test surface (consistent with all other `DootWidget.kt` changes this session) — verified by build + manual on-device check, deferred to the controller same as Feature 1's Step 8. diff --git a/docs/superpowers/specs/2026-07-12-widget-quick-add-design.md b/docs/superpowers/specs/2026-07-12-widget-quick-add-design.md new file mode 100644 index 0000000..a025554 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-widget-quick-add-design.md @@ -0,0 +1,35 @@ +# Widget Quick Add — Design + +## Context + +Fourth of five widget features (refresh → overdue badge → clickable reschedule → **quick add** → recurrence). Decided without a user Q&A round (per explicit instruction to proceed autonomously). + +## Key constraint from grounding + +Jetpack Glance / RemoteViews widgets do not support a reliable in-widget text-entry field across launchers and Android versions. Every existing interactive flow in this widget that needs more than a tap (reschedule's date picker) already follows the same pattern: tap a widget element → launch a full `ComponentActivity` with a Compose `ModalBottomSheet` → do the real work there → close. Quick add follows the identical pattern rather than attempting in-widget text input. + +A reusable server-side creation path already exists for the web UI: `HandleUnifiedAdd` (`internal/handlers/handlers.go:608`) creates a `models.Task` via `h.store.CreateNativeTask`. It's form-encoded and behind session/cookie auth, not the widget's bearer-token JSON API, so it isn't directly reusable from the widget client — but the underlying `CreateNativeTask` call is the same one this feature will use, just from a new bearer-token-protected JSON endpoint that mirrors the existing `/api/widget/complete` and `/api/widget/reschedule` handlers. + +## Design + +**Server (Go):** +- New handler `HandleWidgetAdd` in `internal/handlers/widget.go`, matching the shape of `HandleWidgetComplete`: decode a JSON body `{"title": "..."}`, reject empty/whitespace-only titles with 400, create an undated doot task (`models.Task{ID: newID(), Content: title, Priority: 1}` — no due date; a quick-add task starts in the same "undated/floating" bucket as any other undated doot task), call `h.store.CreateNativeTask`, return 200 on success or 500 on a store error. +- New route in `cmd/dashboard/main.go`, alongside the other widget routes: `r.With(widgetAuth).Post("/api/widget/add", h.HandleWidgetAdd)`. + +**Android — data layer:** +- `WidgetRepository` gains `suspend fun addTask(title: String): Result<Unit>`, POSTing to `/api/widget/add`. +- Unlike `reschedule`/`complete` (which manually string-interpolate their JSON bodies from internal IDs that can't contain special characters), `addTask` takes arbitrary free-text user input, so it must not use manual string interpolation — a title containing a `"` or `\` would produce invalid or unsafe JSON. It uses a small `@Serializable data class WidgetAddRequest(val title: String)` encoded via the file's existing `json` (`kotlinx.serialization.json.Json`) instance instead. + +**Android — UI:** +- New composable `QuickAddButton()` in `DootWidget.kt`, same 24dp tap-target pattern as `RefreshButton`, placed in the "TODAY" header row next to the refresh button (order: `Text("TODAY")` — `defaultWeight()` — `QuickAddButton()` — `RefreshButton(isRefreshing)`). Uses a new `ic_add.xml` drawable (24dp plus-sign vector, same stroke style as the other icons). +- Tapping it launches a new `QuickAddActivity : ComponentActivity`, structurally a near-twin of `TaskDetailActivity`: a `ModalBottomSheet` containing a `TextField` (title input, autofocus) and an "Add" `Button`. Submitting: calls `WidgetRepository.addTask(title)`, and on success, `fetchAndPersist` + `DootWidget().updateAll()` + `finish()`, matching the existing reschedule success flow exactly. Empty/blank titles disable the "Add" button (no server round-trip for obviously-invalid input — the server still validates independently as the source of truth). +- `QuickAddActivity` is a separate class from `TaskDetailActivity` (not a mode flag on the existing one) — they have different triggers (widget button vs. task row tap), different required inputs (no id/source/completable for creation), and keeping them separate avoids a sprawling "does five different things" activity, consistent with this codebase's existing per-purpose-Activity pattern. + +## Testing + +- Server: unit test for `HandleWidgetAdd` covering (a) success — valid title creates a task, verified via the store, (b) empty title → 400, matching the existing `TestHandleWidgetComplete_*` test style in `widget_test.go` (uses `setupTestDB`). +- Android: no unit-test surface (consistent with the rest of this session's widget UI work) — verified by build + manual on-device check. + +## Out of scope + +Recurrence display gets its own spec (the fifth and last feature). diff --git a/docs/superpowers/specs/2026-07-12-widget-recurrence-design.md b/docs/superpowers/specs/2026-07-12-widget-recurrence-design.md new file mode 100644 index 0000000..0f241cf --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-widget-recurrence-design.md @@ -0,0 +1,51 @@ +# Widget Recurrence Display — Design + +## Context + +Fifth and last of five widget features. Decided without a user Q&A round (per explicit instruction to proceed autonomously) — this one has more judgment calls than the other four, documented below with reasoning. + +## Key findings from grounding + +1. **No recurrence data exists anywhere in this codebase today.** `models.CalendarEvent` has no recurrence field, `GoogleCalendarClient` never reads `item.Recurrence` or `item.RecurringEventId` from the Google API response, and there's no DB column for it. This is new plumbing end-to-end, not a forward of an existing value (unlike the overdue-badge and due-date features). + +2. **`GoogleCalendarClient` calls `.SingleEvents(true)`** (`internal/api/google_calendar.go:124,157`), which makes Google's API expand recurring series into individual instances. Each instance has `RecurringEventId` (pointing at the master event) but does **not** carry the `Recurrence` field (the RRULE strings) — that only lives on the master event. Getting the actual schedule therefore requires a second API call: `Events.Get(calendarID, recurringEventId)`. + +3. **No per-event calendar attribution exists.** Events from all 3 configured calendars are merged into one deduplicated list with no record of which calendar each came from (`calendar_events` table has no `calendar_id` column). Looking up a master event by ID needs to know which calendar to query. Rather than add calendar attribution (a bigger change touching the DB schema, the dedup logic, and every caller), this design tries each configured calendar ID in turn — there are only ~3, and this only happens on-demand (see point 5), not during the bulk fetch. + +4. **Calendar events currently never open an in-app popup at all.** Tapping a calendar event (`EventBlock`, `TomorrowEventRow`, or `AllDayRow` in `DootWidget.kt`) launches `ACTION_VIEW` directly to the Google Calendar app/website. Only tasks (`TaskRow`) open the in-app `TaskDetailActivity` bottom sheet. Since the user asked for recurrence to show "in the popup," and there is no existing popup for events, this design adds one — a new `EventDetailActivity`, structurally a sibling of `TaskDetailActivity`/`QuickAddActivity` (same one-Activity-per-purpose pattern used by quick add). The existing "jump straight to Google Calendar" behavior is preserved as a button inside the new popup, not removed. + +5. **Recurrence lookup is lazy (on-demand), not part of the bulk `/api/widget` fetch.** Pre-fetching every recurring event's master record during each timeline build would mean N extra Google API calls per fetch cycle (every 15 minutes, or on-demand) even when nobody looks at any of them. Instead, a new endpoint is queried only when the user actually opens a recurring event's popup — matching how reschedule already does its own live network call from `TaskDetailActivity` rather than being pre-computed into the widget JSON blob. + +## Design + +**Server — data layer (Go):** +- `models.CalendarEvent` gains `RecurringEventID string` (empty = not a recurring instance). +- Migration `022_calendar_events_recurring_id.sql`: `ALTER TABLE calendar_events ADD COLUMN recurring_event_id TEXT DEFAULT ''`. +- `internal/store/sqlite.go`'s `SaveCalendarEvents`/`GetCalendarEventsByDateRange` thread the new column through (the table is fully replaced on every save — `DELETE FROM calendar_events` then re-insert — so no backfill logic is needed, the next fetch cycle repopulates it). +- `GoogleCalendarClient.parseEventTime`'s callers (`GetUpcomingEvents`, `GetEventsByDateRange`) capture `item.RecurringEventId` into `models.CalendarEvent.RecurringEventID`. +- `TimelineItem` gains `RecurringEventID string`; `BuildTimeline`'s event-mapping section forwards it from `models.CalendarEvent`. +- `models.WidgetItem` gains `RecurringEventID string `json:"recurring_event_id,omitempty"``; `TimelineItemToWidgetItem` forwards it only for `Type == "event"`. + +**Server — recurrence lookup (Go):** +- `GoogleCalendarAPI` interface gains `GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error)`. +- `GoogleCalendarClient.GetRecurrenceRule` tries `Events.Get(calendarID, recurringEventID)` against each configured calendar ID in turn, returns the first success's formatted recurrence text; returns an error if the event isn't found on any configured calendar. +- New pure function `formatRecurrence(rrules []string) string` turns Google's RRULE strings into short English (e.g. `RRULE:FREQ=WEEKLY;BYDAY=MO` → `"Repeats weekly on Monday"`). Covers `FREQ` (DAILY/WEEKLY/MONTHLY/YEARLY), `INTERVAL` (e.g. "every 2 weeks"), and `BYDAY` (weekday names, comma-joined for multiple days). Anything it can't parse falls back to the generic `"Recurring event"` rather than showing nothing or crashing — this is intentionally NOT a full RFC 5545 parser, just common-case coverage (every recurring event actually seen in this calendar during grounding was a simple weekly repeat). +- New handler `HandleWidgetRecurrence` (`GET /api/widget/recurrence?recurring_event_id=X`), bearer-token protected like the other widget endpoints: calls `GetRecurrenceRule`, returns `{"recurrence": "..."}` on success or 404 if not found on any calendar. +- New route: `r.With(widgetAuth).Get("/api/widget/recurrence", h.HandleWidgetRecurrence)`. + +**Android — data layer:** +- `WidgetItem.kt` gains `@SerialName("recurring_event_id") val recurringEventId: String? = null`. +- `WidgetRepository` gains `suspend fun getRecurrence(recurringEventId: String): Result<String>` — a GET request with the id as a query parameter, parsing `{"recurrence": "..."}` from the response. + +**Android — UI:** +- New `EventDetailActivity.kt` (separate class, not a mode flag on `TaskDetailActivity` — different trigger, different data, same reasoning as keeping `QuickAddActivity` separate): a `ModalBottomSheet` showing the event title, formatted start time, a recurrence line that shows "Loading…" then the fetched text (only rendered at all if `recurringEventId != null` — a non-recurring event shows no recurrence line, not an empty one), and an "Open in Calendar" button that does the existing `ACTION_VIEW` behavior. +- `EventBlock`, `TomorrowEventRow`, and `AllDayRow` (`DootWidget.kt`) change their `clickable` action from directly launching `ACTION_VIEW` to instead launching `EventDetailActivity` with the event's id/title/start/url/recurringEventId as extras. + +## Testing + +- Server: unit tests for `formatRecurrence` (the pure function) covering the cases in the design above — this is the highest-value test in this feature since it's the one piece of real logic; table-driven, matching this codebase's existing style where a table is natural (see `TestCalcCalendarBounds` in `timeline_logic_test.go` for the established table-driven pattern in this repo). Unit test for `TimelineItemToWidgetItem` confirming `RecurringEventID` forwards only for events. `HandleWidgetRecurrence` tested via the mock calendar client (`MockCalendarClient` in `timeline_logic_test.go`, which needs a new `GetRecurrenceRule` mock method added alongside its existing mocked methods). +- Android: no unit-test surface (consistent with the rest of this session's widget UI work) — verified by build + manual on-device check. + +## Out of scope + +Editing/creating recurrence rules (this is read-only display). Showing recurrence for doot tasks (doot tasks have no recurrence concept at all — confirmed during earlier grounding that `models.Task.IsRecurring` exists but is never set or used anywhere in the Go codebase; wiring that up is a different, unscoped feature). diff --git a/docs/superpowers/specs/2026-07-12-widget-refresh-button-design.md b/docs/superpowers/specs/2026-07-12-widget-refresh-button-design.md new file mode 100644 index 0000000..4a3ad34 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-widget-refresh-button-design.md @@ -0,0 +1,43 @@ +# Widget Refresh Button — Design + +## Context + +First of five widget feature requests (refresh button, quick add, clickable-date reschedule, recurrence in the popup, overdue badge), being designed and shipped as separate small cycles rather than one batch. This spec covers only the refresh button. + +`RefreshWorker` already exists and does the right thing on completion: `fetchAndPersist()` then `DootWidget().updateAll()`. Today it only runs on a 15-minute periodic schedule (`RefreshWorker.schedule()`) or via `RefreshWorker.runOnce()`, which nothing currently calls. There is no manual trigger in the widget UI. + +## Goal + +A tappable refresh icon on the widget that triggers an immediate fetch, with visual feedback while the fetch is in flight. + +## Constraint + +Android AppWidgets render via `RemoteViews`, which does not support arbitrary View animation (no rotating-icon spinner). The realistic version of "spinner" is a static icon swap: refresh glyph ↔ a distinct "loading" glyph, swapped synchronously on tap and swapped back when the worker finishes. Confirmed acceptable with the user. + +## Design + +**Flow:** +1. User taps the refresh icon. +2. `RefreshTaskAction.onAction()` (new `ActionCallback`, mirrors the existing `CompleteTaskAction`) sets `Keys.IS_REFRESHING = true` in the widget's DataStore and calls `DootWidget().updateAll(context)` immediately — the icon flips to the loading glyph before any network call happens. +3. The same action enqueues `RefreshWorker.runOnce(context)`. +4. `RefreshWorker.doWork()` is extended so that, on **both** the success and failure branches, it clears `Keys.IS_REFRESHING` and calls `DootWidget().updateAll(context)` again — the icon always reverts, even if the fetch errors out and `Result.retry()` is returned (a retry is still "not actively refreshing" from the user's point of view between attempts). + +**Components:** + +- `data/DataStore.kt` — add `val IS_REFRESHING = booleanPreferencesKey("is_refreshing")` to `Keys`. +- `res/drawable/ic_refresh.xml` — new 24dp vector icon, same style/stroke convention as the existing `ic_checkbox_empty.xml`. +- `res/drawable/ic_refresh_loading.xml` — new 24dp vector icon, visually distinct (e.g. hourglass or three dots) from the idle refresh icon. +- `widget/ui/Actions.kt` — new `RefreshTaskAction : ActionCallback`, no parameters needed. +- `widget/ui/DootWidget.kt`: + - `provideGlance()` reads `prefs[Keys.IS_REFRESHING] ?: false` and passes it into `WidgetRoot(items, now, isRefreshing)`. + - `WidgetRoot`'s existing "TODAY" header `Row` gains a second child: a new `RefreshButton(isRefreshing: Boolean)` composable, right-aligned (the `Row` needs `horizontalArrangement`/a spacer or `defaultWeight()` on the "TODAY" text so the icon lands on the right edge). + - `RefreshButton` renders `ic_refresh_loading` if `isRefreshing`, else `ic_refresh`; both wrapped in a `Box` with `clickable(actionRunCallback<RefreshTaskAction>())`, sized to match the existing checkbox tap target (24dp box, matching `TaskRow`'s checkbox pattern). +- `widget/work/RefreshWorker.kt` — `doWork()`'s `fold` branches both gain the clear-flag-and-update step before returning their `Result`. + +**Error handling:** No new error states. `RefreshWorker.doWork()` already returns `Result.retry()` on failure; WorkManager's existing backoff handles the retry timing. The icon simply reverts to idle between attempts rather than staying in a stuck loading state. + +**Testing:** No new unit-test surface — this is Glance/RemoteViews composition and WorkManager wiring, consistent with how the rest of `DootWidget.kt` and the `*Worker.kt` classes are (not) unit tested today. Verified by building the APK, installing on device, and confirming the tap → loading-icon → fetch → idle-icon cycle live, the same verification approach used for every other widget fix this session. + +## Out of scope + +The other four widget features (quick add, clickable-date reschedule, recurrence display, overdue badge) — each gets its own spec. |
