1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
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.
|