summaryrefslogtreecommitdiff
path: root/docs/superpowers/plans
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-12 09:48:59 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-12 09:48:59 +0000
commitf32061352a423d9d383db470e86cc82b31133564 (patch)
treed22451049c16e10267f898d33fc762de5183cf19 /docs/superpowers/plans
parent3ada2fc17fd1bc8f4203eaa6fde2ea23d49b87a4 (diff)
docs: add widget clickable-reschedule implementation plan
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VTUSAEKfsPc6WGDq45yPHD
Diffstat (limited to 'docs/superpowers/plans')
-rw-r--r--docs/superpowers/plans/2026-07-12-widget-clickable-reschedule.md498
1 files changed, 498 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.