diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-12 09:48:59 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-12 09:48:59 +0000 |
| commit | f32061352a423d9d383db470e86cc82b31133564 (patch) | |
| tree | d22451049c16e10267f898d33fc762de5183cf19 /docs/superpowers/plans/2026-07-12-widget-clickable-reschedule.md | |
| parent | 3ada2fc17fd1bc8f4203eaa6fde2ea23d49b87a4 (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/2026-07-12-widget-clickable-reschedule.md')
| -rw-r--r-- | docs/superpowers/plans/2026-07-12-widget-clickable-reschedule.md | 498 |
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. |
