# 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.