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