From 35a84d319d926b57bae82578037ce5e68442cdb1 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 12 Jul 2026 08:39:36 +0000 Subject: docs: add widget refresh button design spec Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VTUSAEKfsPc6WGDq45yPHD --- .../2026-07-12-widget-refresh-button-design.md | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-12-widget-refresh-button-design.md (limited to 'docs/superpowers/specs') 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())`, 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. -- cgit v1.2.3 From 957549d8920423a1358e300f0848f5a0332cc48a Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 12 Jul 2026 09:43:48 +0000 Subject: docs: add widget overdue-badge design spec and implementation plan Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VTUSAEKfsPc6WGDq45yPHD --- .../plans/2026-07-12-widget-overdue-badge.md | 260 +++++++++++++++++++++ .../2026-07-12-widget-overdue-badge-design.md | 30 +++ 2 files changed, 290 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-12-widget-overdue-badge.md create mode 100644 docs/superpowers/specs/2026-07-12-widget-overdue-badge-design.md (limited to 'docs/superpowers/specs') diff --git a/docs/superpowers/plans/2026-07-12-widget-overdue-badge.md b/docs/superpowers/plans/2026-07-12-widget-overdue-badge.md new file mode 100644 index 0000000..c67cf72 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-widget-overdue-badge.md @@ -0,0 +1,260 @@ +# Widget Overdue Badge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Forward the already-computed `IsOverdue` flag from the server's `TimelineItem` through the widget API to the Android client, and render overdue task titles in a distinct warning color. + +**Architecture:** Add `IsOverdue` to `models.WidgetItem` (Go) and forward it in `TimelineItemToWidgetItem`. Add the matching `isOverdue` field to the Android `WidgetItem` data class. `TaskRow` (the single shared rendering path for every task row in the widget) reads the flag and colors the title text accordingly. + +**Tech Stack:** Go (`internal/models`, `internal/handlers`), Kotlin/Jetpack Glance (Android widget). + +## Global Constraints + +- Visual signal is a title-text color change only — no new icon, badge, or label, and no sort-order change. This matches every other "this is special" signal in `DootWidget.kt`, which is color-only (`sourceColor`, `AllDayRow`'s bar, `EventBlock`'s past-event dimming). +- The overdue color is `Color(0xFFF87171)` (soft red) — distinct from the default title color `Color(0xFFDDDDDD)` and from every value in `sourceColor()`. +- Server-side test follows the existing pattern in `internal/handlers/widget_test.go` (see `TestTimelineItemToWidgetItem_AllDayEvent` for the style: table-free, one behavior per test function, doc comment explaining the "why"). + +--- + +### Task 1: Server — forward IsOverdue through the widget API + +**Files:** +- Modify: `internal/models/widget.go` +- Modify: `internal/handlers/widget.go` +- Test: `internal/handlers/widget_test.go` + +**Interfaces:** +- Produces: `models.WidgetItem.IsOverdue bool` (JSON field `is_overdue`) +- Consumes: `models.TimelineItem.IsOverdue` (already exists, already correctly computed — see `internal/models/timeline.go:62`) + +- [ ] **Step 1: Write the failing test** + +Add to `internal/handlers/widget_test.go`, directly after `TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior`: + +```go +// TestTimelineItemToWidgetItem_ForwardsIsOverdue proves the 2026-07-12 +// overdue-badge fix: TimelineItem.IsOverdue (already computed correctly by +// ComputeDaySection, confirmed by the earlier fix that made overdue tasks +// appear in the timeline at all) must be forwarded onto WidgetItem so the +// Android client can render it distinctly -- previously it was silently +// dropped, so an overdue task looked identical to a normal one on the +// widget. +func TestTimelineItemToWidgetItem_ForwardsIsOverdue(t *testing.T) { + item := models.TimelineItem{ + ID: "overdue-1", + Title: "Pay the water bill", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: time.Now(), + IsOverdue: true, + } + + wi := TimelineItemToWidgetItem(item) + + if !wi.IsOverdue { + t.Error("expected IsOverdue to be forwarded as true") + } +} + +func TestTimelineItemToWidgetItem_NotOverdueByDefault(t *testing.T) { + item := models.TimelineItem{ + ID: "today-1", + Title: "Water the plants", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: time.Now(), + } + + wi := TimelineItemToWidgetItem(item) + + if wi.IsOverdue { + t.Error("expected IsOverdue to be false when the source item isn't overdue") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/handlers/ -run TestTimelineItemToWidgetItem_ForwardsIsOverdue -v` +Expected: FAIL — `wi.IsOverdue` is always `false` (the zero value), because `models.WidgetItem` has no such field yet (this will actually be a compile error first: `unknown field IsOverdue in struct literal` is not applicable here since the test only reads `wi.IsOverdue` — the compile error will be `wi.IsOverdue undefined (type models.WidgetItem has no field or method IsOverdue)`). + +- [ ] **Step 3: Add the field to `models.WidgetItem`** + +In `internal/models/widget.go`, add `IsOverdue` to the `WidgetItem` struct (after `IsAllDay`, before `URL`, to keep related boolean flags grouped): + +```go +type WidgetItem struct { + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + Type string `json:"type"` + Start *time.Time `json:"start,omitempty"` // nil = no specific time (floating task) + End *time.Time `json:"end,omitempty"` + IsAllDay bool `json:"is_all_day"` + IsOverdue bool `json:"is_overdue"` + URL string `json:"url,omitempty"` + Completable bool `json:"completable"` // true = doot task (checkbox shown) +} +``` + +- [ ] **Step 4: Forward the field in `TimelineItemToWidgetItem`** + +In `internal/handlers/widget.go`, find: + +```go + wi := models.WidgetItem{ + ID: item.ID, + Title: item.Title, + Source: item.Source, + IsAllDay: item.IsAllDay, + URL: item.URL, + } +``` + +Replace with: + +```go + wi := models.WidgetItem{ + ID: item.ID, + Title: item.Title, + Source: item.Source, + IsAllDay: item.IsAllDay, + IsOverdue: item.IsOverdue, + URL: item.URL, + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/handlers/ -run TestTimelineItemToWidgetItem -v` +Expected: PASS — all `TestTimelineItemToWidgetItem_*` tests pass, including the two new ones. + +- [ ] **Step 6: Run the full Go test suite and gofmt check** + +Run: `go build ./... && go test ./... 2>&1 | tail -20` +Expected: only the two pre-existing failures unrelated to this change (`TestHandleAgentTaskWriteOperations`, `TestHandleAgentCreateOperations` in `internal/handlers`, and the `internal/models` vet failure for `undefined: MealToAtom`) — confirm no new failures. + +Run: `gofmt -l internal/models/widget.go internal/handlers/widget.go internal/handlers/widget_test.go` +Expected: no output (clean). + +- [ ] **Step 7: Commit** + +```bash +git add internal/models/widget.go internal/handlers/widget.go internal/handlers/widget_test.go +git commit -m "feat(widget): forward IsOverdue from TimelineItem to WidgetItem API" +``` + +--- + +### Task 2: Android — render overdue tasks with a distinct title color + +**Depends on:** Task 1 must be deployed (or at least merged) first — the Android build needs `is_overdue` in the JSON response to have somewhere to come from, though the field itself is optional/defaults false so the build will compile fine either way. Sequencing is for a meaningful on-device test, not a compile dependency. + +**Files:** +- Modify: `android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt` +- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt` + +**Interfaces:** +- Consumes: JSON field `is_overdue` (produced by Task 1) +- Produces: `WidgetItem.isOverdue: Boolean` (default `false`) + +- [ ] **Step 1: Add the field to the Android `WidgetItem` data class** + +Replace the full contents of `android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt`: + +```kotlin +package org.terst.doot.widget.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class WidgetItem( + val id: String, + val title: String, + val source: String, // "todoist" | "trello" | "calendar" | "plantoeat" | "gtasks" + val type: String, // "task" | "event" + val start: String? = null, // ISO-8601 or null (floating task) + val end: String? = null, // ISO-8601 or null + @SerialName("is_all_day") val isAllDay: Boolean = false, + @SerialName("is_overdue") val isOverdue: Boolean = false, + val url: String = "", + val completable: Boolean = false +) + +@Serializable +data class WidgetResponse( + val now: String, + val items: List +) +``` + +- [ ] **Step 2: Color the title text for overdue tasks in `TaskRow`** + +In `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt`, find the `TaskRow` composable's title `Text` (currently around line 344-351): + +```kotlin + Box( + modifier = GlanceModifier + .defaultWeight() + .clickable(actionStartActivity(detailIntent)) + .padding(start = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = task.title, + style = TextStyle( + color = ColorProvider(Color(0xFFDDDDDD.toInt())), + fontSize = 14.sp + ), + maxLines = 1 + ) + } +``` + +Replace with: + +```kotlin + Box( + modifier = GlanceModifier + .defaultWeight() + .clickable(actionStartActivity(detailIntent)) + .padding(start = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = task.title, + style = TextStyle( + color = ColorProvider( + if (task.isOverdue) Color(0xFFF87171) else Color(0xFFDDDDDD.toInt()) + ), + fontSize = 14.sp + ), + maxLines = 1 + ) + } +``` + +- [ ] **Step 3: Build and confirm no compile errors** + +```bash +cd android && ./gradlew assembleDebug +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Commit** + +```bash +git add android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt \ + android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt +git commit -m "feat(widget): color overdue task titles distinctly" +``` + +Manual on-device verification (release APK build, deploy, visual check) is deferred to the controller, same as the refresh-button feature's Step 8. + +--- + +## Out of scope + +Clickable-date reschedule, quick add, and recurrence display — each gets its own spec and plan. 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. -- cgit v1.2.3 From aa896b6b38f425a5422baf73399620df1f842f3d Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 12 Jul 2026 09:47:42 +0000 Subject: docs: add widget clickable-reschedule design spec Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VTUSAEKfsPc6WGDq45yPHD --- ...026-07-12-widget-clickable-reschedule-design.md | 41 ++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-12-widget-clickable-reschedule-design.md (limited to 'docs/superpowers/specs') 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. -- cgit v1.2.3 From 5ed9f04225e69a0d1bdc23c9da5761b663c18271 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 12 Jul 2026 09:51:15 +0000 Subject: docs: add widget quick-add design spec Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VTUSAEKfsPc6WGDq45yPHD --- .../specs/2026-07-12-widget-quick-add-design.md | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-12-widget-quick-add-design.md (limited to 'docs/superpowers/specs') 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`, 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). -- cgit v1.2.3 From 72d9ee7bd547515afe6d09b35f6ef37bbc0baa78 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 12 Jul 2026 10:10:12 +0000 Subject: docs: add widget recurrence-display design spec Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VTUSAEKfsPc6WGDq45yPHD --- .../specs/2026-07-12-widget-recurrence-design.md | 51 ++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-12-widget-recurrence-design.md (limited to 'docs/superpowers/specs') 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` — 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). -- cgit v1.2.3