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