diff options
Diffstat (limited to 'docs/superpowers/plans')
5 files changed, 2785 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-12-widget-clickable-reschedule.md b/docs/superpowers/plans/2026-07-12-widget-clickable-reschedule.md new file mode 100644 index 0000000..7b2547c --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-widget-clickable-reschedule.md @@ -0,0 +1,498 @@ +# Widget Clickable Date/Time Reschedule 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:** Show a doot task's actual due date in the detail popup, and make that date display itself the tap target that opens the reschedule date picker — replacing the current separate "Reschedule" button, which shows no date at all today. + +**Architecture:** Add a new `DueDate` field to the widget API (independent of the existing `Start`/`IsAllDay` fields, which are reserved for the client's floating-task positioning and must not change). Thread it through the Android data model, the `Intent` extras `TaskRow` builds when opening the detail popup, and into `TaskDetailSheet`'s UI, where it replaces the existing button. + +**Tech Stack:** Go (`internal/models`, `internal/handlers`), Kotlin/Jetpack Glance + Compose (Android widget + detail activity). + +## Global Constraints + +- Do not touch `WidgetItem.Start`/`End`/`IsAllDay` logic in `TimelineItemToWidgetItem` — those are protected by the existing `TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior` test and drive the client's `SlotPacker` positioning. `DueDate` is a new, separate field. +- `DueDate` is populated ONLY for doot tasks (`item.Type == TimelineItemTypeTask && item.Source == "doot"`) — never for calendar events, gtasks, or Trello cards. +- No time-of-day editing: the reschedule flow stays date-only, matching `HandleWidgetReschedule`'s existing `YYYY-MM-DD` contract. Do not add time-of-day UI or API fields. +- Date display format: `"Due " + "MMM d"` (e.g. "Due Jul 15") when a due date exists; `"No due date · tap to schedule"` when it doesn't. + +--- + +### Task 1: Server — add DueDate to the widget API + +**Files:** +- Modify: `internal/models/widget.go` +- Modify: `internal/handlers/widget.go` +- Test: `internal/handlers/widget_test.go` + +**Interfaces:** +- Produces: `models.WidgetItem.DueDate *time.Time` (JSON field `due_date`, `omitempty`) + +- [ ] **Step 1: Write the failing tests** + +Add to `internal/handlers/widget_test.go`, after the `TestTimelineItemToWidgetItem_NotOverdueByDefault` test added by the overdue-badge feature (if that test isn't present yet when you start, add these at the end of the `TestTimelineItemToWidgetItem_*` group instead — anywhere in that group is fine): + +```go +// TestTimelineItemToWidgetItem_DootTaskGetsDueDate proves the 2026-07-12 +// clickable-reschedule fix: a doot task's raw due date must reach the +// client via a NEW field (DueDate) that is independent of Start/IsAllDay -- +// Start is deliberately left nil for doot tasks (see +// TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior) so the +// client's floating-task SlotPacker can position it, and that must keep +// working unchanged. Before this fix there was no way for the Android +// detail popup to know a doot task's current due date at all. +func TestTimelineItemToWidgetItem_DootTaskGetsDueDate(t *testing.T) { + due := time.Date(2026, 7, 15, 0, 0, 0, 0, time.Local) + item := models.TimelineItem{ + ID: "doot-1", + Title: "Pay the water bill", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: due, + IsAllDay: true, + } + + wi := TimelineItemToWidgetItem(item) + + if wi.DueDate == nil { + t.Fatal("expected DueDate to be set for a doot task with a real due date") + } + if !wi.DueDate.Equal(due) { + t.Errorf("DueDate = %v, want %v", *wi.DueDate, due) + } + // Start must stay nil -- this is the pre-existing floating-task + // behavior and this feature must not change it. + if wi.Start != nil { + t.Error("Start must remain nil for a doot task -- DueDate is a separate field, not a replacement") + } +} + +func TestTimelineItemToWidgetItem_UndatedDootTask_NilDueDate(t *testing.T) { + item := models.TimelineItem{ + ID: "doot-undated", + Title: "Someday task", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: time.Now(), + IsAllDay: true, + } + // Zero Time simulates the "no real due date" case at the field level; + // TimelineItemToWidgetItem's DueDate logic must guard on !Time.IsZero(). + item.Time = time.Time{} + + wi := TimelineItemToWidgetItem(item) + + if wi.DueDate != nil { + t.Error("expected DueDate to be nil when the source item has a zero Time") + } +} + +func TestTimelineItemToWidgetItem_CalendarEvent_NilDueDate(t *testing.T) { + start := time.Now() + item := models.TimelineItem{ + ID: "cal-1", + Title: "Team sync", + Source: "calendar", + Type: models.TimelineItemTypeEvent, + Time: start, + } + + wi := TimelineItemToWidgetItem(item) + + if wi.DueDate != nil { + t.Error("expected DueDate to stay nil for a non-doot item (calendar event)") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/handlers/ -run TestTimelineItemToWidgetItem_DootTaskGetsDueDate -v` +Expected: compile error — `wi.DueDate undefined (type models.WidgetItem has no field or method DueDate)`. + +- [ ] **Step 3: Add the field to `models.WidgetItem`** + +In `internal/models/widget.go`, add `DueDate` after `IsOverdue` (if the overdue-badge feature's field is already present) or after `IsAllDay` (if not yet landed), before `URL`: + +```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"` + DueDate *time.Time `json:"due_date,omitempty"` // doot tasks only -- see TimelineItemToWidgetItem + URL string `json:"url,omitempty"` + Completable bool `json:"completable"` // true = doot task (checkbox shown) +} +``` + +If the `IsOverdue` line isn't present yet in your checkout (the overdue-badge task may not have landed), just add `DueDate` after `IsAllDay` instead — the exact position among the boolean/pointer flags doesn't matter, only that it's a real field on the struct. + +- [ ] **Step 4: Set the field in `TimelineItemToWidgetItem`** + +In `internal/handlers/widget.go`, find the end of the function (after the existing `Start`/`End` block, before `return wi`): + +```go + if !item.Time.IsZero() && (!item.IsAllDay || item.Type == models.TimelineItemTypeEvent) { + t := item.Time + wi.Start = &t + if !item.IsAllDay { + if item.EndTime != nil { + wi.End = item.EndTime + } else { + end := item.Time.Add(time.Hour) + wi.End = &end + } + } + } + + return wi +} +``` + +Add the new `DueDate` block immediately before `return wi`, as a completely separate condition from the `Start`/`End` block above it (do not merge them): + +```go + if !item.Time.IsZero() && (!item.IsAllDay || item.Type == models.TimelineItemTypeEvent) { + t := item.Time + wi.Start = &t + if !item.IsAllDay { + if item.EndTime != nil { + wi.End = item.EndTime + } else { + end := item.Time.Add(time.Hour) + wi.End = &end + } + } + } + + // DueDate is independent of Start/IsAllDay -- doot tasks deliberately + // keep Start nil (see the "floating task" doc comment above) so the + // client's SlotPacker positions them, but the Android detail popup + // still needs to know the real due date to display and reschedule it. + if item.Type == models.TimelineItemTypeTask && item.Source == "doot" && !item.Time.IsZero() { + due := item.Time + wi.DueDate = &due + } + + return wi +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/handlers/ -run TestTimelineItemToWidgetItem -v` +Expected: PASS — all tests in this group, including the three 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 unrelated failures (`TestHandleAgentTaskWriteOperations`, `TestHandleAgentCreateOperations`) plus the pre-existing `internal/models` vet failure — confirm no new failures. + +Run: `gofmt -l internal/models/widget.go internal/handlers/widget.go internal/handlers/widget_test.go` +Expected: no output. + +- [ ] **Step 7: Commit** + +```bash +git add internal/models/widget.go internal/handlers/widget.go internal/handlers/widget_test.go +git commit -m "feat(widget): add DueDate field for doot tasks to the widget API" +``` + +--- + +### Task 2: Android — clickable due-date row replaces the Reschedule button + +**Depends on:** Task 1 (for a meaningful on-device test — the field must exist server-side for the client to receive real data; 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` +- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt` + +**Interfaces:** +- Consumes: JSON field `due_date` (produced by Task 1) +- Produces: `WidgetItem.dueDate: String?` +- Produces: `TaskDetailActivity.EXTRA_DUE_DATE: String` (extra key constant) +- Modifies: `TaskDetailSheet(title, source, completable, onComplete, onReschedule, onDismiss)` → `TaskDetailSheet(title, source, completable, dueDate, onComplete, onReschedule, onDismiss)` (new `dueDate: String?` parameter inserted after `completable`) + +- [ ] **Step 1: Add `dueDate` to the Android `WidgetItem` data class** + +In `android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt`, add the field (exact position among the other fields doesn't matter — add it after `isAllDay`/`isOverdue`, before `url`): + +```kotlin +@Serializable +data class WidgetItem( + val id: String, + val title: String, + val source: String, + val type: String, + val start: String? = null, + val end: String? = null, + @SerialName("is_all_day") val isAllDay: Boolean = false, + @SerialName("is_overdue") val isOverdue: Boolean = false, + @SerialName("due_date") val dueDate: String? = null, + val url: String = "", + val completable: Boolean = false +) +``` + +(If `isOverdue` isn't present yet in your checkout, just add `dueDate` after `isAllDay` instead — same reasoning as Task 1 Step 3.) + +- [ ] **Step 2: Pass `dueDate` through `TaskRow`'s detail `Intent`** + +In `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt`, find (around line 290-297): + +```kotlin +fun TaskRow(task: WidgetItem) { + val context = LocalContext.current + val detailIntent = Intent(context, TaskDetailActivity::class.java).apply { + putExtra(TaskDetailActivity.EXTRA_ID, task.id) + putExtra(TaskDetailActivity.EXTRA_SOURCE, task.source) + putExtra(TaskDetailActivity.EXTRA_TITLE, task.title) + putExtra(TaskDetailActivity.EXTRA_COMPLETABLE, task.completable) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } +``` + +Add one line for the due date: + +```kotlin +fun TaskRow(task: WidgetItem) { + val context = LocalContext.current + val detailIntent = Intent(context, TaskDetailActivity::class.java).apply { + putExtra(TaskDetailActivity.EXTRA_ID, task.id) + putExtra(TaskDetailActivity.EXTRA_SOURCE, task.source) + putExtra(TaskDetailActivity.EXTRA_TITLE, task.title) + putExtra(TaskDetailActivity.EXTRA_COMPLETABLE, task.completable) + putExtra(TaskDetailActivity.EXTRA_DUE_DATE, task.dueDate) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } +``` + +- [ ] **Step 3: Rewrite `TaskDetailActivity.kt`** + +Replace the full contents of `android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt`: + +```kotlin +package org.terst.doot.widget.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.appwidget.updateAll +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.WidgetRepository +import org.terst.doot.widget.data.dataStore +import org.terst.doot.widget.work.CompleteWorker +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Calendar +import java.util.TimeZone + +class TaskDetailActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val id = intent.getStringExtra(EXTRA_ID) ?: return finish() + val source = intent.getStringExtra(EXTRA_SOURCE) ?: return finish() + val title = intent.getStringExtra(EXTRA_TITLE) ?: "" + val completable = intent.getBooleanExtra(EXTRA_COMPLETABLE, false) + val dueDate = intent.getStringExtra(EXTRA_DUE_DATE) + + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + TaskDetailSheet( + title = title, + source = source, + completable = completable, + dueDate = dueDate, + onComplete = { + CompleteWorker.enqueue(this, id, source) + finish() + }, + onReschedule = { dateISO -> + lifecycleScope.launch { + val prefs = this@TaskDetailActivity.dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.trimEnd('/') ?: return@launch + val token = prefs[Keys.TOKEN] ?: return@launch + val repo = WidgetRepository(OkHttpClient(), url, token) + repo.reschedule(id, source, dateISO).onSuccess { + repo.fetchAndPersist(this@TaskDetailActivity) + DootWidget().updateAll(this@TaskDetailActivity) + finish() + } + } + }, + onDismiss = ::finish + ) + } + } + } + + companion object { + const val EXTRA_ID = "task_id" + const val EXTRA_SOURCE = "task_source" + const val EXTRA_TITLE = "task_title" + const val EXTRA_COMPLETABLE = "task_completable" + const val EXTRA_DUE_DATE = "task_due_date" + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TaskDetailSheet( + title: String, + source: String, + completable: Boolean, + dueDate: String?, + onComplete: () -> Unit, + onReschedule: (String) -> Unit, + onDismiss: () -> Unit +) { + var showDatePicker by remember { mutableStateOf(false) } + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = System.currentTimeMillis() + ) + + if (showDatePicker) { + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + datePickerState.selectedDateMillis?.let { millis -> + val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + cal.timeInMillis = millis + val dateISO = "%04d-%02d-%02d".format( + cal.get(Calendar.YEAR), + cal.get(Calendar.MONTH) + 1, + cal.get(Calendar.DAY_OF_MONTH) + ) + onReschedule(dateISO) + } + showDatePicker = false + }) { Text("Set date") } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { Text("Cancel") } + } + ) { + DatePicker(state = datePickerState) + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color(0xFF1E293B), + tonalElevation = 0.dp, + dragHandle = { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .width(36.dp) + .height(4.dp) + .background(Color.White.copy(alpha = 0.25f), RoundedCornerShape(2.dp)) + ) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .navigationBarsPadding() + ) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(10.dp) + .background(sourceColor(source), RoundedCornerShape(5.dp)) + ) + Spacer(Modifier.width(10.dp)) + Text( + text = title, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + modifier = Modifier.weight(1f) + ) + } + Spacer(Modifier.height(20.dp)) + if (completable) { + Button( + onClick = onComplete, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B82F6)) + ) { + Text("Mark Complete", fontSize = 15.sp) + } + Spacer(Modifier.height(8.dp)) + } + if (source == "doot") { + OutlinedButton( + onClick = { showDatePicker = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), + border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) + ) { + Text(formatDueDateLabel(dueDate), fontSize = 15.sp) + } + } + Spacer(Modifier.height(20.dp)) + } + } +} + +private fun formatDueDateLabel(dueDate: String?): String { + if (dueDate == null) return "No due date · tap to schedule" + return runCatching { + val date = LocalDate.parse(dueDate.substring(0, 10)) + "Due " + date.format(DateTimeFormatter.ofPattern("MMM d")) + }.getOrDefault("No due date · tap to schedule") +} +``` + +Note: only two things changed from the original file — `dueDate: String?` was added as a new parameter to `TaskDetailSheet` (and read from the intent in `onCreate`), and the button's `Text("Reschedule", ...)` became `Text(formatDueDateLabel(dueDate), ...)`. The button element itself (`OutlinedButton` with `onClick = { showDatePicker = true }`) is unchanged — it's still the same tap target opening the same picker, just relabeled with the actual date instead of the word "Reschedule". `formatDueDateLabel` takes the first 10 characters of the ISO date/time string (`YYYY-MM-DD`) before parsing, since the server sends a full RFC3339 timestamp (e.g. `2026-07-15T00:00:00-10:00`) but `LocalDate.parse` needs just the date portion. + +- [ ] **Step 4: Build and confirm no compile errors** + +```bash +cd android && ./gradlew assembleDebug +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 5: 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 \ + android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt +git commit -m "feat(widget): make the due-date display the reschedule tap target" +``` + +Manual on-device verification (release APK build, deploy, visual + interaction check) is deferred to the controller. + +--- + +## Out of scope + +Quick add and recurrence display — each gets its own spec. 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<WidgetItem> +) +``` + +- [ ] **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/plans/2026-07-12-widget-quick-add.md b/docs/superpowers/plans/2026-07-12-widget-quick-add.md new file mode 100644 index 0000000..07a1552 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-widget-quick-add.md @@ -0,0 +1,562 @@ +# Widget Quick Add 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:** Let the user create a new doot task directly from the widget, via a "+" button that opens a small text-entry sheet. + +**Architecture:** A new bearer-token-protected JSON endpoint (`POST /api/widget/add`) mirrors the existing `/api/widget/complete`/`/api/widget/reschedule` handlers and creates an undated native task via the existing `store.CreateNativeTask`. The Android side follows the same "tap widget element → launch a full Activity with a Compose bottom sheet → do the work → close" pattern already used for reschedule, via a new `QuickAddActivity`. + +**Tech Stack:** Go (`internal/handlers`, `cmd/dashboard`), Kotlin/Jetpack Glance + Compose (Android widget). + +## Global Constraints + +- No in-widget text input — Glance/RemoteViews can't reliably support it. The button launches a full Activity, same pattern as the existing detail popup. +- The new task is created undated (no due date) — quick add is for capture, not scheduling; the user can reschedule it afterward via the existing clickable-date flow. +- The Android `addTask` request body must use proper JSON encoding (`kotlinx.serialization`), NOT manual string interpolation like `reschedule`/`complete` use — those two are safe because `id`/`source` are internal identifiers that can't contain `"` or `\`, but a task title is arbitrary user text and must be safely escaped. +- Server validates title non-empty independent of the client-side disabled-button check (defense at the trust boundary, not just the UI). + +--- + +### Task 1: Server — POST /api/widget/add endpoint + +**Files:** +- Modify: `internal/handlers/widget.go` +- Modify: `cmd/dashboard/main.go` +- Test: `internal/handlers/widget_test.go` + +**Interfaces:** +- Produces: `Handler.HandleWidgetAdd(w http.ResponseWriter, r *http.Request)` +- Consumes: `h.store.CreateNativeTask(task models.Task) error` (existing), `newID() string` (existing, same package, `internal/handlers/handlers.go:24`) + +- [ ] **Step 1: Write the failing tests** + +Add to `internal/handlers/widget_test.go`, after `TestHandleWidgetComplete_UnknownID_Returns404`: + +```go +// TestHandleWidgetAdd_CreatesTask proves the quick-add feature: POSTing a +// title to /api/widget/add creates an undated native task the same way the +// web UI's HandleUnifiedAdd does, but via the widget's bearer-token JSON +// API instead of a session-authenticated HTML form. +func TestHandleWidgetAdd_CreatesTask(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + body := `{"title":"Buy milk"}` + req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + tasks, err := s.GetUndatedNativeTasks() + if err != nil { + t.Fatalf("failed to read back tasks: %v", err) + } + found := false + for _, task := range tasks { + if task.Content == "Buy milk" { + found = true + } + } + if !found { + t.Error("expected a task with content 'Buy milk' to have been created") + } +} + +func TestHandleWidgetAdd_EmptyTitle_Returns400(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + body := `{"title":""}` + req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestHandleWidgetAdd_WhitespaceOnlyTitle_Returns400(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + body := `{"title":" "}` + req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/handlers/ -run TestHandleWidgetAdd -v` +Expected: compile error — `h.HandleWidgetAdd undefined (type *Handler has no field or method HandleWidgetAdd)`. + +- [ ] **Step 3: Implement `HandleWidgetAdd`** + +In `internal/handlers/widget.go`, add this type near the other request types (`widgetCompleteRequest`, `widgetRescheduleRequest`): + +```go +type widgetAddRequest struct { + Title string `json:"title"` +} +``` + +Add the handler after `HandleWidgetComplete` (at the end of the file, or directly after `HandleWidgetComplete`'s closing brace): + +```go +// HandleWidgetAdd creates a new undated native task from the widget's quick-add sheet. +func (h *Handler) HandleWidgetAdd(w http.ResponseWriter, r *http.Request) { + var req widgetAddRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + title := strings.TrimSpace(req.Title) + if title == "" { + http.Error(w, "title is required", http.StatusBadRequest) + return + } + + task := models.Task{ + ID: newID(), + Content: title, + Priority: 1, + } + if err := h.store.CreateNativeTask(task); err != nil { + http.Error(w, "failed to create task", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} +``` + +`strings` is already imported in this file (used by `WidgetAuthMiddleware`'s `strings.HasPrefix`/`strings.TrimPrefix`) — no new import needed. `models` is already imported too. + +- [ ] **Step 4: Register the route** + +In `cmd/dashboard/main.go`, find: + +```go + r.With(widgetAuth).Get("/api/widget", h.HandleWidgetGet) + r.With(widgetAuth).Post("/api/widget/complete", h.HandleWidgetComplete) + r.With(widgetAuth).Post("/api/widget/reschedule", h.HandleWidgetReschedule) +``` + +Replace with: + +```go + r.With(widgetAuth).Get("/api/widget", h.HandleWidgetGet) + r.With(widgetAuth).Post("/api/widget/complete", h.HandleWidgetComplete) + r.With(widgetAuth).Post("/api/widget/reschedule", h.HandleWidgetReschedule) + r.With(widgetAuth).Post("/api/widget/add", h.HandleWidgetAdd) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/handlers/ -run TestHandleWidgetAdd -v` +Expected: PASS — all three new tests. + +- [ ] **Step 6: Run the full Go test suite, build, and gofmt check** + +Run: `go build ./... && go test ./... 2>&1 | tail -20` +Expected: only the two pre-existing unrelated failures (`TestHandleAgentTaskWriteOperations`, `TestHandleAgentCreateOperations`) plus the pre-existing `internal/models` vet failure — confirm no new failures. The `go build ./...` must succeed cleanly since `cmd/dashboard/main.go` was touched. + +Run: `gofmt -l internal/handlers/widget.go internal/handlers/widget_test.go cmd/dashboard/main.go` +Expected: no output. + +- [ ] **Step 7: Commit** + +```bash +git add internal/handlers/widget.go internal/handlers/widget_test.go cmd/dashboard/main.go +git commit -m "feat(widget): add POST /api/widget/add for quick-add" +``` + +--- + +### Task 2: Android — QuickAddActivity and widget button + +**Depends on:** Task 1 (for a meaningful on-device test; not a compile dependency). + +**Files:** +- Modify: `android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt` +- Create: `android/app/src/main/res/drawable/ic_add.xml` +- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt` +- Create: `android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt` +- Modify: `android/app/src/main/AndroidManifest.xml` + +**Interfaces:** +- Produces: `WidgetRepository.addTask(title: String): Result<Unit>` +- Produces: `QuickAddButton()` composable in `DootWidget.kt` +- Produces: `QuickAddActivity` (new Activity, must be declared in the manifest to be launchable) + +- [ ] **Step 1: Add `addTask` to `WidgetRepository`** + +In `android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt`, add the `@Serializable` import and a small request data class at file scope (after the `private val json = ...` line), then the new method at the end of the `WidgetRepository` class body (after `complete`): + +```kotlin +package org.terst.doot.widget.data + +import android.content.Context +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +private val json = Json { ignoreUnknownKeys = true } + +@Serializable +private data class WidgetAddRequest(val title: String) + +class WidgetRepository( + private val client: OkHttpClient, + private val serverUrl: String, + private val token: String +) { + /** Fetches /api/widget and returns the parsed response. Does NOT persist. */ + suspend fun fetchRaw(): Result<WidgetResponse> = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("$serverUrl/api/widget") + .header("Authorization", "Bearer $token") + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + val body = checkNotNull(response.body?.string()) { "Empty body" } + json.decodeFromString<WidgetResponse>(body) + } + } + + /** Fetches and persists to DataStore. Call this from workers. */ + suspend fun fetchAndPersist(context: Context): Result<WidgetResponse> { + return fetchRaw().onSuccess { resp -> + context.dataStore.edit { prefs -> + prefs[Keys.ITEMS_JSON] = json.encodeToString(resp.items) + prefs[Keys.NOW] = resp.now + prefs[Keys.LAST_UPDATED] = System.currentTimeMillis() + } + } + } + + /** POSTs a due-date update to /api/widget/reschedule. */ + suspend fun reschedule(id: String, source: String, dateISO: String): Result<Unit> = + withContext(Dispatchers.IO) { + val body = """{"id":"$id","source":"$source","date":"$dateISO"}""" + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/reschedule") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + + /** POSTs a task completion to /api/widget/complete. */ + suspend fun complete(context: Context, id: String, source: String): Result<Unit> = + withContext(Dispatchers.IO) { + val body = """{"id":"$id","source":"$source"}""" + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/complete") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + + /** + * POSTs a new task title to /api/widget/add. Uses proper JSON encoding + * (not manual string interpolation like reschedule/complete above) because + * the title is arbitrary user text that could contain characters that + * break hand-built JSON -- id/source above are safe because they're + * internal identifiers, never user-typed free text. + */ + suspend fun addTask(title: String): Result<Unit> = + withContext(Dispatchers.IO) { + val body = json.encodeToString(WidgetAddRequest(title)) + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/add") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } +} +``` + +- [ ] **Step 2: Add the `+` drawable** + +Create `android/app/src/main/res/drawable/ic_add.xml`: + +```xml +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M12,5 L12,19 M5,12 L19,12" + android:strokeWidth="2" + android:strokeColor="#FFFFFF" + android:fillColor="@android:color/transparent" + android:strokeLineCap="round" /> +</vector> +``` + +- [ ] **Step 3: Add `QuickAddButton` and wire it into the TODAY row** + +In `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt`, find the "TODAY" header `Row` (added by the refresh-button feature): + +```kotlin + Row( + modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "TODAY", + style = TextStyle( + color = ColorProvider(Color(0x66FFFFFF)), + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ), + modifier = GlanceModifier.defaultWeight() + ) + RefreshButton(isRefreshing) + } +``` + +Replace with (adds `QuickAddButton()` before the refresh button): + +```kotlin + Row( + modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "TODAY", + style = TextStyle( + color = ColorProvider(Color(0x66FFFFFF)), + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ), + modifier = GlanceModifier.defaultWeight() + ) + QuickAddButton() + Spacer(modifier = GlanceModifier.width(4.dp)) + RefreshButton(isRefreshing) + } +``` + +Add the new composable directly after `RefreshButton` (find it — it was added by the refresh-button feature, right after `AllDayRow`): + +```kotlin +@Composable +fun QuickAddButton() { + val context = LocalContext.current + val intent = Intent(context, QuickAddActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + Box( + modifier = GlanceModifier + .size(24.dp) + .clickable(actionStartActivity(intent)), + contentAlignment = Alignment.Center + ) { + Image( + provider = ImageProvider(org.terst.doot.widget.R.drawable.ic_add), + contentDescription = "Add task", + colorFilter = ColorFilter.tint(ColorProvider(Color(0x99FFFFFF))), + modifier = GlanceModifier.size(14.dp) + ) + } +} +``` + +No new imports needed — `Intent`, `LocalContext`, `actionStartActivity` are all already imported/used in this file (see `TaskRow`'s `detailIntent`). + +- [ ] **Step 4: Create `QuickAddActivity.kt`** + +Create `android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt`: + +```kotlin +package org.terst.doot.widget.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.appwidget.updateAll +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.WidgetRepository +import org.terst.doot.widget.data.dataStore + +class QuickAddActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + QuickAddSheet( + onAdd = { title -> + lifecycleScope.launch { + val prefs = this@QuickAddActivity.dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.trimEnd('/') ?: return@launch + val token = prefs[Keys.TOKEN] ?: return@launch + val repo = WidgetRepository(OkHttpClient(), url, token) + repo.addTask(title).onSuccess { + repo.fetchAndPersist(this@QuickAddActivity) + DootWidget().updateAll(this@QuickAddActivity) + finish() + } + } + }, + onDismiss = ::finish + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QuickAddSheet( + onAdd: (String) -> Unit, + onDismiss: () -> Unit +) { + var title by remember { mutableStateOf("") } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color(0xFF1E293B), + tonalElevation = 0.dp, + dragHandle = { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .width(36.dp) + .height(4.dp) + .background(Color.White.copy(alpha = 0.25f), RoundedCornerShape(2.dp)) + ) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .navigationBarsPadding() + ) { + Text( + text = "Quick Add", + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = title, + onValueChange = { title = it }, + placeholder = { Text("Task title") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color.White.copy(alpha = 0.3f) + ) + ) + Spacer(Modifier.height(16.dp)) + Button( + onClick = { onAdd(title.trim()) }, + enabled = title.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B82F6)) + ) { + Text("Add", fontSize = 15.sp) + } + Spacer(Modifier.height(20.dp)) + } + } +} +``` + +- [ ] **Step 5: Declare `QuickAddActivity` in the manifest** + +In `android/app/src/main/AndroidManifest.xml`, find the existing `<activity android:name=".widget.ui.TaskDetailActivity" ...>` declaration and add a sibling entry for `QuickAddActivity` immediately after it, copying its exact attributes (theme, exported state, etc.) — read the existing `TaskDetailActivity` entry first to match its attributes exactly, then add: + +```xml + <activity + android:name=".widget.ui.QuickAddActivity" + android:exported="false" + android:theme="@style/Theme.Doot.Transparent" /> +``` + +(Use whatever exact `android:theme` and other attributes `TaskDetailActivity`'s entry uses — copy them verbatim rather than guessing, since this plan was written without reading the manifest directly. If `TaskDetailActivity`'s entry has additional attributes not shown above, e.g. `android:launchMode` or `android:windowSoftInputMode`, include those too — a text-entry sheet in particular likely wants `android:windowSoftInputMode="adjustResize"` if `TaskDetailActivity` doesn't already set it, so the keyboard doesn't cover the input field.) + +- [ ] **Step 6: Build and confirm no compile errors** + +```bash +cd android && ./gradlew assembleDebug +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 7: Commit** + +```bash +git add android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt \ + android/app/src/main/res/drawable/ic_add.xml \ + android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt \ + android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt \ + android/app/src/main/AndroidManifest.xml +git commit -m "feat(widget): add quick-add button and entry sheet" +``` + +Manual on-device verification (release APK build, deploy, visual + interaction check, including keyboard behavior) is deferred to the controller. + +--- + +## Out of scope + +Recurrence display gets its own spec (the fifth and last feature). diff --git a/docs/superpowers/plans/2026-07-12-widget-recurrence.md b/docs/superpowers/plans/2026-07-12-widget-recurrence.md new file mode 100644 index 0000000..6c306a5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-widget-recurrence.md @@ -0,0 +1,1103 @@ +# Widget Recurrence Display 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:** Show a human-readable recurrence schedule (e.g. "Repeats weekly on Monday") in a new event-detail popup, replacing calendar events' current behavior of jumping straight out to the Google Calendar app on tap. + +**Architecture:** Capture `RecurringEventId` from Google's API (available on event instances even though the RRULE itself is master-event-only) and thread it from `CalendarEvent` through `TimelineItem`/`WidgetItem` to the Android client. A new endpoint does the actual RRULE lookup+formatting lazily, on-demand, when a user opens a recurring event's new detail popup — not during the bulk timeline fetch. + +**Tech Stack:** Go (`internal/api`, `internal/models`, `internal/store`, `internal/handlers`, SQL migration), Kotlin/Jetpack Glance + Compose (Android widget). + +## Global Constraints + +- `RecurringEventID` is empty string for non-recurring events — never nil-vs-empty ambiguity in Go (it's a plain `string`, not `*string`); the Android side treats an empty/missing JSON value as "not recurring" via a nullable `String?`. +- `formatRecurrence` is intentionally NOT a full RFC 5545 parser — cover `FREQ` (DAILY/WEEKLY/MONTHLY/YEARLY), `INTERVAL`, `BYDAY` only; anything else falls back to the literal string `"Recurring event"`. +- The existing "open in Google Calendar" behavior must still be reachable (as a button in the new popup), not removed. +- `EventDetailActivity` is a new, separate Activity class — do not fold this into `TaskDetailActivity` or `QuickAddActivity`. + +--- + +### Task 1: Server — capture and forward RecurringEventID + +**Files:** +- Create: `migrations/022_calendar_events_recurring_id.sql` +- Modify: `internal/models/types.go` +- Modify: `internal/models/timeline.go` +- Modify: `internal/models/widget.go` +- Modify: `internal/store/sqlite.go` +- Modify: `internal/api/google_calendar.go` +- Modify: `internal/handlers/timeline_logic.go` +- Modify: `internal/handlers/widget.go` +- Test: `internal/handlers/widget_test.go` + +**Interfaces:** +- Produces: `models.CalendarEvent.RecurringEventID string` +- Produces: `models.TimelineItem.RecurringEventID string` +- Produces: `models.WidgetItem.RecurringEventID string `json:"recurring_event_id,omitempty"`` + +- [ ] **Step 1: Create the migration** + +Create `migrations/022_calendar_events_recurring_id.sql`: + +```sql +ALTER TABLE calendar_events ADD COLUMN recurring_event_id TEXT DEFAULT ''; +``` + +- [ ] **Step 2: Add `RecurringEventID` to `models.CalendarEvent`** + +In `internal/models/types.go`, find: + +```go +type CalendarEvent struct { + ID string `json:"id"` + Summary string `json:"summary"` + Description string `json:"description"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + HTMLLink string `json:"html_link"` +} +``` + +Replace with: + +```go +type CalendarEvent struct { + ID string `json:"id"` + Summary string `json:"summary"` + Description string `json:"description"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + HTMLLink string `json:"html_link"` + RecurringEventID string `json:"recurring_event_id,omitempty"` // empty = not a recurring instance +} +``` + +- [ ] **Step 3: Capture `RecurringEventId` in `GoogleCalendarClient`** + +In `internal/api/google_calendar.go`, find (in `GetUpcomingEvents`): + +```go + for _, item := range events.Items { + start, end := c.parseEventTime(item) + allEvents = append(allEvents, models.CalendarEvent{ + ID: item.Id, + Summary: item.Summary, + Description: item.Description, + Start: start, + End: end, + HTMLLink: item.HtmlLink, + }) + } +``` + +Replace with: + +```go + for _, item := range events.Items { + start, end := c.parseEventTime(item) + allEvents = append(allEvents, models.CalendarEvent{ + ID: item.Id, + Summary: item.Summary, + Description: item.Description, + Start: start, + End: end, + HTMLLink: item.HtmlLink, + RecurringEventID: item.RecurringEventId, + }) + } +``` + +Then find the near-identical block in `GetEventsByDateRange`: + +```go + for _, item := range events.Items { + evtStart, evtEnd := c.parseEventTime(item) + allEvents = append(allEvents, models.CalendarEvent{ + ID: item.Id, + Summary: item.Summary, + Description: item.Description, + Start: evtStart, + End: evtEnd, + HTMLLink: item.HtmlLink, + }) + } +``` + +Replace with: + +```go + for _, item := range events.Items { + evtStart, evtEnd := c.parseEventTime(item) + allEvents = append(allEvents, models.CalendarEvent{ + ID: item.Id, + Summary: item.Summary, + Description: item.Description, + Start: evtStart, + End: evtEnd, + HTMLLink: item.HtmlLink, + RecurringEventID: item.RecurringEventId, + }) + } +``` + +Run `gofmt -w internal/api/google_calendar.go` after this step — the alignment in the two blocks above is deliberately loose (gofmt will fix column alignment automatically; don't hand-align it yourself). + +- [ ] **Step 4: Thread the column through the store** + +In `internal/store/sqlite.go`, find `SaveCalendarEvents`'s insert: + +```go + stmt, err := tx.Prepare(` + INSERT INTO calendar_events (id, summary, description, start_time, end_time, html_link) + VALUES (?, ?, ?, ?, ?, ?) + `) + if err != nil { + return err + } + defer func() { _ = stmt.Close() }() + + for _, e := range events { + _, err = stmt.Exec(e.ID, e.Summary, e.Description, e.Start, e.End, e.HTMLLink) + if err != nil { + return err + } + } +``` + +Replace with: + +```go + stmt, err := tx.Prepare(` + INSERT INTO calendar_events (id, summary, description, start_time, end_time, html_link, recurring_event_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + `) + if err != nil { + return err + } + defer func() { _ = stmt.Close() }() + + for _, e := range events { + _, err = stmt.Exec(e.ID, e.Summary, e.Description, e.Start, e.End, e.HTMLLink, e.RecurringEventID) + if err != nil { + return err + } + } +``` + +Then find `GetCalendarEventsByDateRange`: + +```go +func (s *Store) GetCalendarEventsByDateRange(start, end time.Time) ([]models.CalendarEvent, error) { + rows, err := s.db.Query(` + SELECT id, summary, description, start_time, end_time, html_link + FROM calendar_events + WHERE start_time >= ? AND start_time <= ? + ORDER BY start_time ASC + `, start, end) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var events []models.CalendarEvent + for rows.Next() { + var e models.CalendarEvent + if err := rows.Scan(&e.ID, &e.Summary, &e.Description, &e.Start, &e.End, &e.HTMLLink); err != nil { + return nil, err + } + events = append(events, e) + } + return events, rows.Err() +} +``` + +Replace with: + +```go +func (s *Store) GetCalendarEventsByDateRange(start, end time.Time) ([]models.CalendarEvent, error) { + rows, err := s.db.Query(` + SELECT id, summary, description, start_time, end_time, html_link, recurring_event_id + FROM calendar_events + WHERE start_time >= ? AND start_time <= ? + ORDER BY start_time ASC + `, start, end) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var events []models.CalendarEvent + for rows.Next() { + var e models.CalendarEvent + if err := rows.Scan(&e.ID, &e.Summary, &e.Description, &e.Start, &e.End, &e.HTMLLink, &e.RecurringEventID); err != nil { + return nil, err + } + events = append(events, e) + } + return events, rows.Err() +} +``` + +- [ ] **Step 5: Add `RecurringEventID` to `TimelineItem` and forward it in `BuildTimeline`** + +In `internal/models/timeline.go`, find: + +```go + // Source-specific metadata + ListID string `json:"list_id,omitempty"` // For Google Tasks +} +``` + +Replace with: + +```go + // Source-specific metadata + ListID string `json:"list_id,omitempty"` // For Google Tasks + RecurringEventID string `json:"recurring_event_id,omitempty"` // For calendar events +} +``` + +In `internal/handlers/timeline_logic.go`, find the calendar-events block inside `BuildTimeline`: + +```go + for _, event := range events { + endTime := event.End + item := models.TimelineItem{ + ID: event.ID, + Type: models.TimelineItemTypeEvent, + Title: event.Summary, + Time: event.Start, + EndTime: &endTime, + Description: event.Description, + URL: event.HTMLLink, + OriginalItem: event, + IsCompleted: false, + Source: "calendar", + } + item.ComputeDaySection(now) + items = append(items, item) + } +``` + +Replace with: + +```go + for _, event := range events { + endTime := event.End + item := models.TimelineItem{ + ID: event.ID, + Type: models.TimelineItemTypeEvent, + Title: event.Summary, + Time: event.Start, + EndTime: &endTime, + Description: event.Description, + URL: event.HTMLLink, + OriginalItem: event, + IsCompleted: false, + Source: "calendar", + RecurringEventID: event.RecurringEventID, + } + item.ComputeDaySection(now) + items = append(items, item) + } +``` + +- [ ] **Step 6: Add `RecurringEventID` to `WidgetItem` and forward it** + +In `internal/models/widget.go`, add `RecurringEventID` to the struct (position among the other fields doesn't matter — add it after `DueDate`, before `URL`, or wherever the struct currently ends up after prior tasks land): + +```go + RecurringEventID string `json:"recurring_event_id,omitempty"` +``` + +In `internal/handlers/widget.go`'s `TimelineItemToWidgetItem`, add this forwarding line to the initial `wi := models.WidgetItem{...}` struct literal (alongside `IsAllDay`, `URL`, etc. — wherever that literal currently is after prior tasks land): + +```go + RecurringEventID: item.RecurringEventID, +``` + +(This is a straight passthrough regardless of item type — a task's `RecurringEventID` is always empty since only the calendar-event branch in `BuildTimeline` ever sets it, so there's no need for a type check here.) + +- [ ] **Step 7: Write and run tests** + +Add to `internal/handlers/widget_test.go`: + +```go +// TestTimelineItemToWidgetItem_ForwardsRecurringEventID proves the +// 2026-07-12 recurrence-display fix's data plumbing: a calendar event's +// RecurringEventId (captured from Google's API, which only puts the RRULE +// itself on the master event, not on expanded instances) must reach the +// client so it can look up the human-readable schedule on demand. +func TestTimelineItemToWidgetItem_ForwardsRecurringEventID(t *testing.T) { + item := models.TimelineItem{ + ID: "cal-1", + Title: "Team sync", + Source: "calendar", + Type: models.TimelineItemTypeEvent, + Time: time.Now(), + RecurringEventID: "master-123", + } + + wi := TimelineItemToWidgetItem(item) + + if wi.RecurringEventID != "master-123" { + t.Errorf("RecurringEventID = %q, want %q", wi.RecurringEventID, "master-123") + } +} + +func TestTimelineItemToWidgetItem_NonRecurring_EmptyRecurringEventID(t *testing.T) { + item := models.TimelineItem{ + ID: "cal-2", + Title: "One-off meeting", + Source: "calendar", + Type: models.TimelineItemTypeEvent, + Time: time.Now(), + } + + wi := TimelineItemToWidgetItem(item) + + if wi.RecurringEventID != "" { + t.Errorf("expected empty RecurringEventID for a non-recurring event, got %q", wi.RecurringEventID) + } +} +``` + +Run: `go test ./internal/handlers/ -run TestTimelineItemToWidgetItem -v` +Expected: PASS — all tests in this group, including the two new ones. + +Run: `go build ./... && go test ./... 2>&1 | tail -20` +Expected: only the two pre-existing unrelated failures (`TestHandleAgentTaskWriteOperations`, `TestHandleAgentCreateOperations`) plus the pre-existing `internal/models` vet failure. + +Run: `gofmt -l internal/models/types.go internal/models/timeline.go internal/models/widget.go internal/store/sqlite.go internal/api/google_calendar.go internal/handlers/timeline_logic.go internal/handlers/widget.go internal/handlers/widget_test.go` +Expected: no output. + +- [ ] **Step 8: Commit** + +```bash +git add migrations/022_calendar_events_recurring_id.sql \ + internal/models/types.go internal/models/timeline.go internal/models/widget.go \ + internal/store/sqlite.go internal/api/google_calendar.go \ + internal/handlers/timeline_logic.go internal/handlers/widget.go internal/handlers/widget_test.go +git commit -m "feat(widget): capture and forward RecurringEventID for calendar events" +``` + +--- + +### Task 2: Server — recurrence lookup endpoint + +**Depends on:** Task 1 (uses `RecurringEventID`, which Task 1 introduces). + +**Files:** +- Modify: `internal/api/google_calendar.go` +- Modify: `internal/api/interfaces.go` +- Modify: `internal/handlers/widget.go` +- Modify: `cmd/dashboard/main.go` +- Test: `internal/api/google_calendar_test.go` (create if it doesn't exist) +- Test: `internal/handlers/widget_test.go` +- Test: `internal/handlers/timeline_logic_test.go` (adds a mock method) + +**Interfaces:** +- Produces: `formatRecurrence(rrules []string) string` (unexported, `internal/api` package) +- Produces: `GoogleCalendarClient.GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error)` +- Produces: `GoogleCalendarAPI.GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error)` (interface method) +- Produces: `Handler.HandleWidgetRecurrence(w http.ResponseWriter, r *http.Request)` + +- [ ] **Step 1: Write the failing tests for `formatRecurrence`** + +Check whether `internal/api/google_calendar_test.go` already exists (`ls internal/api/`). If it doesn't, create it with this content; if it does, add the test function to it: + +```go +package api + +import "testing" + +func TestFormatRecurrence(t *testing.T) { + tests := []struct { + name string + rules []string + want string + }{ + {"weekly single day", []string{"RRULE:FREQ=WEEKLY;BYDAY=MO"}, "Repeats weekly on Monday"}, + {"weekly multiple days", []string{"RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR"}, "Repeats weekly on Monday, Wednesday, Friday"}, + {"daily", []string{"RRULE:FREQ=DAILY"}, "Repeats daily"}, + {"monthly", []string{"RRULE:FREQ=MONTHLY"}, "Repeats monthly"}, + {"yearly", []string{"RRULE:FREQ=YEARLY"}, "Repeats yearly"}, + {"interval weekly", []string{"RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=SU"}, "Repeats every 2 weeks on Sunday"}, + {"interval daily", []string{"RRULE:FREQ=DAILY;INTERVAL=3"}, "Repeats every 3 days"}, + {"unrecognized frequency", []string{"RRULE:FREQ=HOURLY"}, "Recurring event"}, + {"empty", []string{}, "Recurring event"}, + {"unparseable", []string{"not a valid rule"}, "Recurring event"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := formatRecurrence(tc.rules) + if got != tc.want { + t.Errorf("formatRecurrence(%v) = %q, want %q", tc.rules, got, tc.want) + } + }) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/api/ -run TestFormatRecurrence -v` +Expected: compile error — `undefined: formatRecurrence`. + +- [ ] **Step 3: Implement `formatRecurrence`** + +Add to `internal/api/google_calendar.go` (anywhere at package level, e.g. after `deduplicateEvents`): + +```go +var recurrenceFreqNames = map[string]string{ + "DAILY": "daily", + "WEEKLY": "weekly", + "MONTHLY": "monthly", + "YEARLY": "yearly", +} + +var recurrenceFreqUnits = map[string]string{ + "DAILY": "day", + "WEEKLY": "week", + "MONTHLY": "month", + "YEARLY": "year", +} + +var recurrenceWeekdayNames = map[string]string{ + "SU": "Sunday", + "MO": "Monday", + "TU": "Tuesday", + "WE": "Wednesday", + "TH": "Thursday", + "FR": "Friday", + "SA": "Saturday", +} + +// formatRecurrence turns Google Calendar RRULE strings into short English. +// This intentionally covers only the common cases (FREQ, INTERVAL, BYDAY) -- +// not a full RFC 5545 parser. Anything it can't confidently describe falls +// back to "Recurring event" rather than showing nothing or an error. +func formatRecurrence(rrules []string) string { + for _, rule := range rrules { + rule = strings.TrimPrefix(rule, "RRULE:") + parts := make(map[string]string) + for _, kv := range strings.Split(rule, ";") { + pieces := strings.SplitN(kv, "=", 2) + if len(pieces) == 2 { + parts[pieces[0]] = pieces[1] + } + } + + freqKey := parts["FREQ"] + unit, ok := recurrenceFreqUnits[freqKey] + if !ok { + continue + } + + interval := 1 + if iv := parts["INTERVAL"]; iv != "" { + if n, err := strconv.Atoi(iv); err == nil && n > 0 { + interval = n + } + } + + var phrase string + if interval == 1 { + phrase = "Repeats " + recurrenceFreqNames[freqKey] + } else { + phrase = fmt.Sprintf("Repeats every %d %ss", interval, unit) + } + + if byday := parts["BYDAY"]; byday != "" { + var days []string + for _, code := range strings.Split(byday, ",") { + code = strings.TrimSpace(code) + if len(code) >= 2 { + code = code[len(code)-2:] + } + if name, ok := recurrenceWeekdayNames[code]; ok { + days = append(days, name) + } + } + if len(days) > 0 { + phrase += " on " + strings.Join(days, ", ") + } + } + + return phrase + } + return "Recurring event" +} +``` + +Add `"strconv"` to this file's import block (it currently imports `"context"`, `"fmt"`, `"log"`, `"sort"`, `"strings"`, `"time"`, plus the two `google.golang.org/api/...` packages — `strconv` is new, `fmt`/`strings` already exist). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/api/ -run TestFormatRecurrence -v` +Expected: PASS — all 10 subtests. + +- [ ] **Step 5: Implement `GetRecurrenceRule` and add it to the interface + mock** + +In `internal/api/google_calendar.go`, add this method after `GetCalendarList`: + +```go +// GetRecurrenceRule looks up a recurring event's master record and returns +// its formatted recurrence schedule. Google's API only puts the RRULE on +// the master event, not on expanded instances (see parseEventTime's +// SingleEvents(true) callers), so this does a live lookup by the instance's +// RecurringEventId. There's no per-event calendar attribution stored today +// (events from all configured calendars are merged without recording which +// one they came from), so this tries each configured calendar in turn. +func (c *GoogleCalendarClient) GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error) { + for _, calendarID := range c.calendarIDs { + event, err := c.srv.Events.Get(calendarID, recurringEventID).Do() + if err != nil { + continue + } + return formatRecurrence(event.Recurrence), nil + } + return "", fmt.Errorf("recurring event %s not found on any configured calendar", recurringEventID) +} +``` + +In `internal/api/interfaces.go`, find: + +```go +type GoogleCalendarAPI interface { + GetUpcomingEvents(ctx context.Context, maxResults int) ([]models.CalendarEvent, error) + GetEventsByDateRange(ctx context.Context, start, end time.Time) ([]models.CalendarEvent, error) + GetCalendarList(ctx context.Context) ([]models.CalendarInfo, error) + SetCalendarIDs(ids []string) +} +``` + +Replace with: + +```go +type GoogleCalendarAPI interface { + GetUpcomingEvents(ctx context.Context, maxResults int) ([]models.CalendarEvent, error) + GetEventsByDateRange(ctx context.Context, start, end time.Time) ([]models.CalendarEvent, error) + GetCalendarList(ctx context.Context) ([]models.CalendarInfo, error) + SetCalendarIDs(ids []string) + GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error) +} +``` + +In `internal/handlers/timeline_logic_test.go`, find `MockCalendarClient`'s method set (`GetUpcomingEvents`, `GetEventsByDateRange`, `GetCalendarList`, `SetCalendarIDs`) and add a new field plus mock method so the mock still satisfies the interface: + +```go +// MockCalendarClient implements GoogleCalendarAPI interface for testing +type MockCalendarClient struct { + Events []models.CalendarEvent + Err error + // SetCalendarIDsCalls records every ids slice SetCalendarIDs was called + // with, for tests that need to assert on how the caller resolved its + // calendar ID list (e.g. fetchCalendarEvents' comma-split fallback). + SetCalendarIDsCalls [][]string + // RecurrenceRule is returned by GetRecurrenceRule for any id when RecurrenceErr is nil. + RecurrenceRule string + RecurrenceErr error +} +``` + +Add the method (anywhere among the other `MockCalendarClient` methods): + +```go +func (m *MockCalendarClient) GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error) { + return m.RecurrenceRule, m.RecurrenceErr +} +``` + +- [ ] **Step 6: Write the failing handler test** + +Add to `internal/handlers/widget_test.go`: + +```go +// TestHandleWidgetRecurrence_ReturnsFormattedSchedule proves the recurrence +// lookup endpoint: given a recurring_event_id query param, it calls the +// calendar client's GetRecurrenceRule and returns the formatted text. +func TestHandleWidgetRecurrence_ReturnsFormattedSchedule(t *testing.T) { + mock := &MockCalendarClient{RecurrenceRule: "Repeats weekly on Monday"} + h := &Handler{googleCalendarClient: mock} + req := httptest.NewRequest("GET", "/api/widget/recurrence?recurring_event_id=master-1", nil) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetRecurrence).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp struct { + Recurrence string `json:"recurrence"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Recurrence != "Repeats weekly on Monday" { + t.Errorf("recurrence = %q, want %q", resp.Recurrence, "Repeats weekly on Monday") + } +} + +func TestHandleWidgetRecurrence_NotFound_Returns404(t *testing.T) { + mock := &MockCalendarClient{RecurrenceErr: fmt.Errorf("not found")} + h := &Handler{googleCalendarClient: mock} + req := httptest.NewRequest("GET", "/api/widget/recurrence?recurring_event_id=missing", nil) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetRecurrence).ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestHandleWidgetRecurrence_MissingParam_Returns400(t *testing.T) { + h := &Handler{} + req := httptest.NewRequest("GET", "/api/widget/recurrence", nil) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetRecurrence).ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} +``` + +Add `"fmt"` to `widget_test.go`'s imports if not already present (check the existing import block first — if `fmt` is already imported, don't add a duplicate). + +- [ ] **Step 7: Run tests to verify they fail** + +Run: `go test ./internal/handlers/ -run TestHandleWidgetRecurrence -v` +Expected: compile error — `h.HandleWidgetRecurrence undefined`. + +- [ ] **Step 8: Implement `HandleWidgetRecurrence`** + +Add to `internal/handlers/widget.go`, after `HandleWidgetComplete`: + +```go +// HandleWidgetRecurrence looks up and formats a recurring calendar event's schedule. +func (h *Handler) HandleWidgetRecurrence(w http.ResponseWriter, r *http.Request) { + recurringEventID := r.URL.Query().Get("recurring_event_id") + if recurringEventID == "" { + http.Error(w, "recurring_event_id is required", http.StatusBadRequest) + return + } + + recurrence, err := h.googleCalendarClient.GetRecurrenceRule(r.Context(), recurringEventID) + if err != nil { + http.Error(w, "recurring event not found", http.StatusNotFound) + return + } + + resp := struct { + Recurrence string `json:"recurrence"` + }{Recurrence: recurrence} + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} +``` + +Check `internal/handlers/handlers.go` (or wherever `Handler` is defined) for the exact field name of the calendar client on `Handler` — the code above assumes `h.googleCalendarClient` based on the existing `googleCalendarClient` field name already used in `timeline_logic_test.go`'s `Handler{googleCalendarClient: failingCal}` construction (see `TestFetchCalendarEvents_CacheFallbackOnAPIError`). If the actual field name differs, use the real one instead. + +- [ ] **Step 9: Register the route** + +In `cmd/dashboard/main.go`, find (after Task 1 of the quick-add feature, if that's landed, the block will have 4 lines instead of 3 — either way, find the `/api/widget/reschedule` line and add after it): + +```go + r.With(widgetAuth).Post("/api/widget/reschedule", h.HandleWidgetReschedule) +``` + +Add immediately after it (order relative to `/api/widget/add`, if present, doesn't matter): + +```go + r.With(widgetAuth).Get("/api/widget/recurrence", h.HandleWidgetRecurrence) +``` + +- [ ] **Step 10: Run tests to verify they pass, then full suite + gofmt** + +Run: `go test ./internal/handlers/ -run TestHandleWidgetRecurrence -v` +Expected: PASS — all three new tests. + +Run: `go build ./... && go test ./... 2>&1 | tail -20` +Expected: only the two pre-existing unrelated failures plus the pre-existing vet failure. + +Run: `gofmt -l internal/api/google_calendar.go internal/api/interfaces.go internal/api/google_calendar_test.go internal/handlers/widget.go internal/handlers/widget_test.go internal/handlers/timeline_logic_test.go cmd/dashboard/main.go` +Expected: no output. + +- [ ] **Step 11: Commit** + +```bash +git add internal/api/google_calendar.go internal/api/interfaces.go internal/api/google_calendar_test.go \ + internal/handlers/widget.go internal/handlers/widget_test.go internal/handlers/timeline_logic_test.go \ + cmd/dashboard/main.go +git commit -m "feat(widget): add recurrence lookup endpoint (GET /api/widget/recurrence)" +``` + +--- + +### Task 3: Android — event detail popup with recurrence + +**Depends on:** Task 2 (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/data/WidgetRepository.kt` +- Create: `android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt` +- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt` +- Modify: `android/app/src/main/AndroidManifest.xml` + +**Interfaces:** +- Produces: `WidgetItem.recurringEventId: String?` +- Produces: `WidgetRepository.getRecurrence(recurringEventId: String): Result<String>` +- Produces: `EventDetailActivity` (new Activity) + +- [ ] **Step 1: Add `recurringEventId` to the Android `WidgetItem`** + +In `android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt`, add the field (position among the other fields doesn't matter): + +```kotlin + @SerialName("recurring_event_id") val recurringEventId: String? = null, +``` + +- [ ] **Step 2: Add `getRecurrence` to `WidgetRepository`** + +In `android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt`, add this method to the class (after whichever methods prior features already added — `addTask` if quick-add landed first, or after `complete` otherwise): + +```kotlin + /** GETs the formatted recurrence schedule for a recurring event. */ + suspend fun getRecurrence(recurringEventId: String): Result<String> = + withContext(Dispatchers.IO) { + val encodedId = java.net.URLEncoder.encode(recurringEventId, "UTF-8") + val request = Request.Builder() + .url("$serverUrl/api/widget/recurrence?recurring_event_id=$encodedId") + .header("Authorization", "Bearer $token") + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + val body = checkNotNull(response.body?.string()) { "Empty body" } + val parsed = json.decodeFromString<RecurrenceResponse>(body) + parsed.recurrence + } + } +``` + +Add this small response data class at file scope (alongside any existing file-scope classes like `WidgetAddRequest`, if quick-add landed first — otherwise just above the `WidgetRepository` class): + +```kotlin +@Serializable +private data class RecurrenceResponse(val recurrence: String) +``` + +- [ ] **Step 3: Create `EventDetailActivity.kt`** + +Create `android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt`: + +```kotlin +package org.terst.doot.widget.ui + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.WidgetRepository +import org.terst.doot.widget.data.dataStore + +class EventDetailActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val title = intent.getStringExtra(EXTRA_TITLE) ?: "" + val source = intent.getStringExtra(EXTRA_SOURCE) ?: "calendar" + val url = intent.getStringExtra(EXTRA_URL) ?: "" + val recurringEventId = intent.getStringExtra(EXTRA_RECURRING_EVENT_ID) + + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + EventDetailSheet( + title = title, + source = source, + recurringEventId = recurringEventId, + onOpenCalendar = { + startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(url.ifEmpty { "https://calendar.google.com" })) + ) + finish() + }, + onDismiss = ::finish + ) + } + } + } + + companion object { + const val EXTRA_TITLE = "event_title" + const val EXTRA_SOURCE = "event_source" + const val EXTRA_URL = "event_url" + const val EXTRA_RECURRING_EVENT_ID = "event_recurring_id" + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventDetailSheet( + title: String, + source: String, + recurringEventId: String?, + onOpenCalendar: () -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + var recurrenceText by remember { mutableStateOf<String?>(null) } + + LaunchedEffect(recurringEventId) { + if (recurringEventId == null) return@LaunchedEffect + recurrenceText = "Loading…" + val prefs = context.dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.trimEnd('/') ?: return@LaunchedEffect + val token = prefs[Keys.TOKEN] ?: return@LaunchedEffect + val repo = WidgetRepository(OkHttpClient(), url, token) + repo.getRecurrence(recurringEventId).onSuccess { text -> + recurrenceText = text + }.onFailure { + recurrenceText = null + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color(0xFF1E293B), + tonalElevation = 0.dp, + dragHandle = { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .width(36.dp) + .height(4.dp) + .background(Color.White.copy(alpha = 0.25f), RoundedCornerShape(2.dp)) + ) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .navigationBarsPadding() + ) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(10.dp) + .background(sourceColor(source), RoundedCornerShape(5.dp)) + ) + Spacer(Modifier.width(10.dp)) + Text( + text = title, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + modifier = Modifier.weight(1f) + ) + } + recurrenceText?.let { text -> + Spacer(Modifier.height(8.dp)) + Text( + text = text, + fontSize = 13.sp, + color = Color.White.copy(alpha = 0.6f) + ) + } + Spacer(Modifier.height(20.dp)) + OutlinedButton( + onClick = onOpenCalendar, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), + border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) + ) { + Text("Open in Calendar", fontSize = 15.sp) + } + Spacer(Modifier.height(20.dp)) + } + } +} +``` + +Note: `recurrenceText` starts `null` (renders no recurrence line at all) and only becomes non-null if `recurringEventId != null` — a non-recurring event never shows "Loading…" or any recurrence text, per the design's requirement that a non-recurring event show no recurrence line, not an empty one. If the fetch fails, it falls back to `null` (silently hides the line) rather than showing an error state, since recurrence info is supplementary, not the primary purpose of the popup — the title and "Open in Calendar" button remain fully functional either way. + +- [ ] **Step 4: Wire `AllDayRow`, `EventBlock`, and `TomorrowEventRow` to open `EventDetailActivity`** + +In `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt`, all three composables currently build their `clickable` modifier inline as `.clickable(actionStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse(event.url.ifEmpty { "https://calendar.google.com" }))))`. Replace each with a two-step: build an `Intent` for `EventDetailActivity` carrying the event's data, then use that in `clickable`. + +For `AllDayRow` (find the composable, note it currently takes `event: WidgetItem` as its only parameter): + +```kotlin +@Composable +fun AllDayRow(event: WidgetItem) { + val context = LocalContext.current + val detailIntent = Intent(context, EventDetailActivity::class.java).apply { + putExtra(EventDetailActivity.EXTRA_TITLE, event.title) + putExtra(EventDetailActivity.EXTRA_SOURCE, event.source) + putExtra(EventDetailActivity.EXTRA_URL, event.url) + putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + val color = sourceColor(event.source) + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 3.dp) + .clickable(actionStartActivity(detailIntent)), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {} + Text( + text = event.title, + style = TextStyle(color = ColorProvider(Color.White.copy(alpha = 0.9f)), fontSize = 13.sp, fontWeight = FontWeight.Medium), + modifier = GlanceModifier.padding(start = 8.dp), + maxLines = 1 + ) + } +} +``` + +For `EventBlock` (takes `event: WidgetItem, isPast: Boolean`): + +```kotlin +@Composable +fun EventBlock(event: WidgetItem, isPast: Boolean) { + val context = LocalContext.current + val detailIntent = Intent(context, EventDetailActivity::class.java).apply { + putExtra(EventDetailActivity.EXTRA_TITLE, event.title) + putExtra(EventDetailActivity.EXTRA_SOURCE, event.source) + putExtra(EventDetailActivity.EXTRA_URL, event.url) + putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + val color = sourceColor(event.source) + val alpha = if (isPast) 0.5f else 1f + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable(actionStartActivity(detailIntent)), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = GlanceModifier + .width(3.dp) + .height(20.dp) + .background(color.copy(alpha = alpha)) + ) {} + Text( + text = event.title, + style = TextStyle( + color = ColorProvider(Color.White.copy(alpha = alpha)), + fontSize = 14.sp + ), + modifier = GlanceModifier.padding(start = 8.dp), + maxLines = 1 + ) + } +} +``` + +For `TomorrowEventRow` (takes `event: WidgetItem, zone: ZoneId`): + +```kotlin +@Composable +fun TomorrowEventRow(event: WidgetItem, zone: ZoneId) { + val context = LocalContext.current + val detailIntent = Intent(context, EventDetailActivity::class.java).apply { + putExtra(EventDetailActivity.EXTRA_TITLE, event.title) + putExtra(EventDetailActivity.EXTRA_SOURCE, event.source) + putExtra(EventDetailActivity.EXTRA_URL, event.url) + putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + val color = sourceColor(event.source) + val timeLabel = event.start?.let { + val t = Instant.parse(it).atZone(zone) + hourLabel(t.hour) + if (t.minute > 0) t.minute.toString().padStart(2, '0') else "" + } ?: "" + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 3.dp) + .clickable(actionStartActivity(detailIntent)), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = timeLabel, + style = TextStyle(color = ColorProvider(Color(0x4DFFFFFF)), fontSize = 10.sp), + modifier = GlanceModifier.width(32.dp) + ) + Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {} + Text( + text = event.title, + style = TextStyle(color = ColorProvider(Color.White.copy(alpha = 0.75f)), fontSize = 13.sp), + modifier = GlanceModifier.padding(start = 8.dp), + maxLines = 1 + ) + } +} +``` + +`Intent` and `Uri` imports already exist in this file (used by the code being replaced); `LocalContext` is already imported (used by `TaskRow`). + +- [ ] **Step 5: Declare `EventDetailActivity` in the manifest** + +In `android/app/src/main/AndroidManifest.xml`, add an entry for `EventDetailActivity` copying `TaskDetailActivity`'s (or `QuickAddActivity`'s, if quick-add landed first) exact attributes — same approach as the quick-add feature's Step 5: + +```xml + <activity + android:name=".widget.ui.EventDetailActivity" + android:exported="false" + android:theme="@style/Theme.Doot.Transparent" /> +``` + +- [ ] **Step 6: Build and confirm no compile errors** + +```bash +cd android && ./gradlew assembleDebug +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 7: 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/data/WidgetRepository.kt \ + android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt \ + android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt \ + android/app/src/main/AndroidManifest.xml +git commit -m "feat(widget): add event detail popup showing recurrence schedule" +``` + +Manual on-device verification (release APK build, deploy, tap a recurring event and a one-off event, confirm the recurrence line appears only for the recurring one and the wording is sensible, confirm "Open in Calendar" still works) is deferred to the controller. + +--- + +## Out of scope + +This is the last of the five widget features in this batch. diff --git a/docs/superpowers/plans/2026-07-12-widget-refresh-button.md b/docs/superpowers/plans/2026-07-12-widget-refresh-button.md new file mode 100644 index 0000000..9cd5e59 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-widget-refresh-button.md @@ -0,0 +1,362 @@ +# Widget Refresh Button 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:** Add a tappable refresh icon to the doot Android widget that immediately triggers a fetch, with a static "loading" icon shown while it's in flight. + +**Architecture:** A new `RefreshTaskAction` (Glance `ActionCallback`) sets a `IS_REFRESHING` DataStore flag and redraws the widget synchronously (so the icon flips instantly), then enqueues the existing `RefreshWorker.runOnce()`. `RefreshWorker` is restructured so that, regardless of outcome (success, failure, or the early "no url/token configured" return), it always clears the flag and redraws the widget once at the end — so the icon never gets stuck in the loading state. + +**Tech Stack:** Kotlin, Jetpack Glance (`GlanceAppWidget`, `RemoteViews`-backed), AndroidX `WorkManager`, AndroidX `DataStore<Preferences>`. + +## Global Constraints + +- RemoteViews cannot animate a rotating icon — the "spinner" is a static icon swap (`ic_refresh` ↔ `ic_refresh_loading`), confirmed acceptable in the design spec. +- Follow the existing fire-and-forget pattern used by `CompleteTaskAction`/`CompleteWorker`: actions enqueue `WorkManager` work and return immediately; workers call `DootWidget().updateAll()` when done. +- No new automated tests: this is Glance composition + `WorkManager` wiring, the same category of code as the rest of `DootWidget.kt` and `*Worker.kt`, none of which have unit tests today. Verification is a manual build-install-tap cycle, described exactly in Task 1 Step 8. + +--- + +### Task 1: Refresh button end-to-end (data flag, drawables, action, worker, UI) + +**Why one task:** None of these pieces are independently testable in isolation — the action needs the flag and drawables to mean anything, the worker's flag-clearing has no observable effect without the UI reading the flag, and the UI has nothing to tap without the action. This is a single vertical slice with one real deliverable: tap the icon, see it load, see it revert. + +**Files:** +- Modify: `android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt` +- Create: `android/app/src/main/res/drawable/ic_refresh.xml` +- Create: `android/app/src/main/res/drawable/ic_refresh_loading.xml` +- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt` +- Modify: `android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt` +- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt` + +**Interfaces:** +- Produces: `Keys.IS_REFRESHING: Preferences.Key<Boolean>` +- Produces: `RefreshTaskAction : ActionCallback` (no parameters, `actionRunCallback<RefreshTaskAction>()`) +- Produces: `RefreshButton(isRefreshing: Boolean)` composable in `DootWidget.kt` +- Modifies: `WidgetRoot(items: List<WidgetItem>, now: Instant)` → `WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean)` — the sole caller, `DootWidget.provideGlance()`, is updated in the same step. + +- [ ] **Step 1: Add the `IS_REFRESHING` DataStore key** + +Edit `android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt`. Add the `booleanPreferencesKey` import and the new key: + +```kotlin +package org.terst.doot.widget.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore + +val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "doot_widget") + +object Keys { + val SERVER_URL = stringPreferencesKey("server_url") + val TOKEN = stringPreferencesKey("token") + val ITEMS_JSON = stringPreferencesKey("items_json") + val NOW = stringPreferencesKey("now") + val LAST_UPDATED = longPreferencesKey("last_updated") + val IS_REFRESHING = booleanPreferencesKey("is_refreshing") +} +``` + +- [ ] **Step 2: Add the two drawables** + +Create `android/app/src/main/res/drawable/ic_refresh.xml` (idle state — circular arrow, same 24dp/stroke conventions as the existing `ic_checkbox_empty.xml`): + +```xml +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M4,12 A8,8 0 1,1 7,18" + android:strokeWidth="2" + android:strokeColor="#FFFFFF" + android:fillColor="@android:color/transparent" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> + <path + android:pathData="M4,12 L4,7 L9,7" + android:strokeWidth="2" + android:strokeColor="#FFFFFF" + android:fillColor="@android:color/transparent" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> +</vector> +``` + +Create `android/app/src/main/res/drawable/ic_refresh_loading.xml` (loading state — three dots, visually distinct from the idle icon): + +```xml +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M5,12 m-2,0 a2,2 0 1,1 4,0 a2,2 0 1,1 -4,0" + android:fillColor="#FFFFFF" /> + <path + android:pathData="M12,12 m-2,0 a2,2 0 1,1 4,0 a2,2 0 1,1 -4,0" + android:fillColor="#FFFFFF" /> + <path + android:pathData="M19,12 m-2,0 a2,2 0 1,1 4,0 a2,2 0 1,1 -4,0" + android:fillColor="#FFFFFF" /> +</vector> +``` + +- [ ] **Step 3: Add `RefreshTaskAction` to `Actions.kt`** + +Replace the full contents of `android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt`: + +```kotlin +package org.terst.doot.widget.ui + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.glance.GlanceId +import androidx.glance.action.ActionParameters +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.updateAll +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.dataStore +import org.terst.doot.widget.work.CompleteWorker +import org.terst.doot.widget.work.RefreshWorker + +class CompleteTaskAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + val id = parameters[idKey] ?: return + val source = parameters[sourceKey] ?: return + CompleteWorker.enqueue(context, id, source) + } + + companion object { + val idKey = ActionParameters.Key<String>("item_id") + val sourceKey = ActionParameters.Key<String>("item_source") + } +} + +// Sets IS_REFRESHING synchronously (before the network round trip) so the +// widget's icon flips to the loading state on the spot -- RefreshWorker +// clears the flag when it finishes, regardless of outcome, so the icon +// never gets stuck. +class RefreshTaskAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + context.dataStore.edit { it[Keys.IS_REFRESHING] = true } + DootWidget().updateAll(context) + RefreshWorker.runOnce(context) + } +} +``` + +- [ ] **Step 4: Restructure `RefreshWorker.doWork()` to always clear the flag** + +Replace the full contents of `android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt`: + +```kotlin +package org.terst.doot.widget.work + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.glance.appwidget.updateAll +import androidx.work.* +import kotlinx.coroutines.flow.first +import org.terst.doot.widget.ui.DootWidget +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.WidgetRepository +import org.terst.doot.widget.data.dataStore +import java.util.concurrent.TimeUnit + +class RefreshWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val result = performRefresh() + applicationContext.dataStore.edit { it[Keys.IS_REFRESHING] = false } + DootWidget().updateAll(applicationContext) + return result + } + + private suspend fun performRefresh(): Result { + val prefs = applicationContext.dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.takeIf { it.isNotBlank() } ?: return Result.failure() + val token = prefs[Keys.TOKEN]?.takeIf { it.isNotBlank() } ?: return Result.failure() + + val repo = WidgetRepository(okhttp3.OkHttpClient(), url, token) + return repo.fetchAndPersist(applicationContext).fold( + onSuccess = { Result.success() }, + onFailure = { Result.retry() } + ) + } + + companion object { + const val WORK_NAME = "doot_widget_refresh" + + fun schedule(context: Context) { + val request = PeriodicWorkRequestBuilder<RefreshWorker>(15, TimeUnit.MINUTES) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request + ) + } + + fun runOnce(context: Context) { + val request = OneTimeWorkRequestBuilder<RefreshWorker>().build() + WorkManager.getInstance(context).enqueue(request) + } + } +} +``` + +Note: this drops the old success-branch's own `DootWidget().updateAll()` call, since `doWork()` now always calls it exactly once after `performRefresh()` returns, on every path (success, retry, or the early-return failures from missing url/token). + +- [ ] **Step 5: Wire `isRefreshing` through `DootWidget.provideGlance()` and `WidgetRoot`** + +In `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt`, change `provideGlance`: + +```kotlin + override suspend fun provideGlance(context: Context, id: GlanceId) { + val prefs = context.dataStore.data.first() + val items = parseItems(prefs) + val now = prefs[Keys.NOW]?.let { runCatching { Instant.parse(it) }.getOrNull() } + ?: Instant.now() + val isRefreshing = prefs[Keys.IS_REFRESHING] ?: false + + provideContent { + WidgetRoot(items, now, isRefreshing) + } + } +``` + +Change the `WidgetRoot` signature and its "TODAY" header `Row` (this is the top of the function body — the `allDayEvents`/`rest`/etc. computation below is unchanged): + +```kotlin +@Composable +fun WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean) { +``` + +Then find this block inside `WidgetRoot`: + +```kotlin + Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp)) { + Text( + "TODAY", + style = TextStyle( + color = ColorProvider(Color(0x66FFFFFF)), + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + ) + } +``` + +Replace it with: + +```kotlin + Row( + modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "TODAY", + style = TextStyle( + color = ColorProvider(Color(0x66FFFFFF)), + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ), + modifier = GlanceModifier.defaultWeight() + ) + RefreshButton(isRefreshing) + } +``` + +- [ ] **Step 6: Add the `RefreshButton` composable** + +In the same file, add this new composable directly after the existing `AllDayRow` composable (so it sits near the other small icon/row composables): + +```kotlin +@Composable +fun RefreshButton(isRefreshing: Boolean) { + Box( + modifier = GlanceModifier + .size(24.dp) + .clickable(actionRunCallback<RefreshTaskAction>()), + contentAlignment = Alignment.Center + ) { + Image( + provider = ImageProvider( + if (isRefreshing) org.terst.doot.widget.R.drawable.ic_refresh_loading + else org.terst.doot.widget.R.drawable.ic_refresh + ), + contentDescription = if (isRefreshing) "Refreshing" else "Refresh", + colorFilter = ColorFilter.tint(ColorProvider(Color(0x99FFFFFF))), + modifier = GlanceModifier.size(14.dp) + ) + } +} +``` + +No new imports needed — `actionRunCallback`, `Box`, `Image`, `ImageProvider`, `ColorFilter`, `ColorProvider`, `GlanceModifier`, `Color`, `Alignment` are all already imported in this file (used by `TaskRow`'s checkbox, which this mirrors). + +- [ ] **Step 7: Build and confirm no compile errors** + +```bash +cd android && ./gradlew assembleDebug +``` + +Expected: `BUILD SUCCESSFUL`. If it fails, the error output will name the file/line — common mistakes here are a missing import or a typo in the drawable resource names (`R.drawable.ic_refresh` / `R.drawable.ic_refresh_loading` must exactly match the two file names from Step 2, minus `.xml`). + +- [ ] **Step 8: Manual verification on device** + +Build and install the release APK the same way prior widget fixes in this session were verified: + +```bash +cd android && ./gradlew assembleRelease +cp app/build/outputs/apk/release/app-release.apk /site/static.terst.org/public/files/doot-widget.apk +chown www-data:www-data /site/static.terst.org/public/files/doot-widget.apk +``` + +Then on the device: reinstall the widget's APK, open the widget, and confirm: +1. The refresh icon is visible in the top-right of the "TODAY" row. +2. Tapping it immediately swaps the icon to the loading (three-dot) glyph — no delay, no network wait. +3. Within a few seconds (once the fetch completes), the icon swaps back to the idle refresh icon. +4. The widget's item list reflects freshly-fetched data (e.g. toggle airplane mode off/on between taps if you want to force a visible content change, or just confirm no crash and the icon cycle completes). +5. Turn off wifi/data entirely, tap refresh: confirm the icon still reverts to idle after `WorkManager`'s constraint check fails to satisfy `NetworkType.CONNECTED` and the retry backs off — it must not get stuck on the loading icon indefinitely. (`RefreshWorker` has no explicit network constraint on `runOnce()`'s one-time request, only `schedule()`'s periodic request does — so this checks the underlying `fetchAndPersist()` call's own failure path via `Result.retry()`, not a WorkManager constraint block.) + +- [ ] **Step 9: Commit** + +```bash +git add android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt \ + android/app/src/main/res/drawable/ic_refresh.xml \ + android/app/src/main/res/drawable/ic_refresh_loading.xml \ + android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt \ + android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt \ + android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt +git commit -m "feat(widget): add manual refresh button with loading-state icon" +``` + +--- + +## Out of scope + +The other four widget features (quick add, clickable-date reschedule, recurrence display, overdue badge) — each gets its own spec and plan, per the design doc's sequencing (refresh → overdue badge → clickable reschedule → quick add → recurrence). |
