summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-13 06:54:13 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-13 06:54:13 +0000
commit8310f802dd9fc6ef5dff0be7f640f79c5b39987f (patch)
treed145bd7be5801f12563e99b6828b0f2facdc370c
parent73fbd05a1230552bcef9834d3ee999ef19957693 (diff)
feat(widget): editable task details, scrollable list, and overdue-task fixes
- Add description editing to the widget's task detail popup for doot/gtasks/trello, backed by new GET /api/widget/detail and POST /api/widget/update endpoints - Make Google Tasks and Trello cards completable via the widget (Trello completion archives the card); fix Trello description never being fetched, which meant saving could silently wipe a card's real desc - Fix google_tasks.due_date/updated_at (TEXT columns) never round-tripping through sql.NullTime, which broke cached Google Tasks reads whenever the cache was valid - Fix native-task and Google-Task date-range queries excluding anything due before the window start, which dropped incomplete tasks off the widget the moment their due day passed (the "overdue tasks disappeared" bug) - Fix native task description edits blanking the task's title - Make the widget's day list scroll (LazyColumn) instead of clipping - Optimistically remove a task from the widget immediately on completion, ahead of the authoritative background refresh Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt9
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt47
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt6
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt57
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt75
-rw-r--r--cmd/dashboard/main.go2
-rw-r--r--internal/api/google_tasks.go12
-rw-r--r--internal/api/interfaces.go1
-rw-r--r--internal/api/trello.go13
-rw-r--r--internal/handlers/handlers.go6
-rw-r--r--internal/handlers/timeline_logic_test.go1
-rw-r--r--internal/handlers/widget.go169
-rw-r--r--internal/handlers/widget_test.go362
-rw-r--r--internal/models/types.go15
-rw-r--r--internal/store/native_tasks.go16
-rw-r--r--internal/store/sqlite.go85
-rw-r--r--internal/store/sqlite_test.go189
-rw-r--r--migrations/022_add_card_description.sql3
18 files changed, 979 insertions, 89 deletions
diff --git a/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt b/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt
index 40fc190..4666dd9 100644
--- a/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt
+++ b/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt
@@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable
data class WidgetItem(
val id: String,
val title: String,
- val source: String, // "todoist" | "trello" | "calendar" | "plantoeat" | "gtasks"
+ val source: String, // "doot" | "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
@@ -21,3 +21,10 @@ data class WidgetResponse(
val now: String,
val items: List<WidgetItem>
)
+
+@Serializable
+data class TaskDetail(
+ val title: String,
+ val description: String,
+ val editable: Boolean
+)
diff --git a/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt b/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt
index a54910d..b9f52ce 100644
--- a/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt
+++ b/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt
@@ -4,6 +4,7 @@ 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
@@ -13,6 +14,21 @@ import okhttp3.RequestBody.Companion.toRequestBody
private val json = Json { ignoreUnknownKeys = true }
+@Serializable
+private data class UpdateDescriptionRequest(val id: String, val source: String, val description: String)
+
+/**
+ * Optimistically drops an item from the cached list so the widget can update instantly on
+ * completion, ahead of the authoritative refresh a worker performs once the API call succeeds.
+ */
+suspend fun removeWidgetItemLocally(context: Context, id: String, source: String) {
+ context.dataStore.edit { prefs ->
+ val raw = prefs[Keys.ITEMS_JSON] ?: return@edit
+ val items = runCatching { json.decodeFromString<List<WidgetItem>>(raw) }.getOrNull() ?: return@edit
+ prefs[Keys.ITEMS_JSON] = json.encodeToString(items.filterNot { it.id == id && it.source == source })
+ }
+}
+
class WidgetRepository(
private val client: OkHttpClient,
private val serverUrl: String,
@@ -43,6 +59,37 @@ class WidgetRepository(
}
}
+ /** Fetches title/description/editability for a single item from /api/widget/detail. */
+ suspend fun fetchDetail(id: String, source: String): Result<TaskDetail> =
+ withContext(Dispatchers.IO) {
+ val request = Request.Builder()
+ .url("$serverUrl/api/widget/detail?id=$id&source=$source")
+ .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<TaskDetail>(body)
+ }
+ }
+
+ /** POSTs an edited description to /api/widget/update. */
+ suspend fun updateDescription(id: String, source: String, description: String): Result<Unit> =
+ withContext(Dispatchers.IO) {
+ val body = json.encodeToString(UpdateDescriptionRequest(id, source, description))
+ .toRequestBody("application/json".toMediaType())
+ val request = Request.Builder()
+ .url("$serverUrl/api/widget/update")
+ .header("Authorization", "Bearer $token")
+ .post(body)
+ .build()
+ runCatching {
+ val response = client.newCall(request).execute()
+ check(response.isSuccessful) { "HTTP ${response.code}" }
+ }
+ }
+
/** POSTs a due-date update to /api/widget/reschedule. */
suspend fun reschedule(id: String, source: String, dateISO: String): Result<Unit> =
withContext(Dispatchers.IO) {
diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt b/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt
index 27a49e6..f81815f 100644
--- a/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt
+++ b/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt
@@ -4,6 +4,8 @@ import android.content.Context
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.removeWidgetItemLocally
import org.terst.doot.widget.work.CompleteWorker
class CompleteTaskAction : ActionCallback {
@@ -14,6 +16,10 @@ class CompleteTaskAction : ActionCallback {
) {
val id = parameters[idKey] ?: return
val source = parameters[sourceKey] ?: return
+ // Optimistically drop it from the visible list right away; CompleteWorker below does the
+ // real completion call and an authoritative refresh once it succeeds.
+ removeWidgetItemLocally(context, id, source)
+ DootWidget().updateAll(context)
CompleteWorker.enqueue(context, id, source)
}
diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt
index d8f1140..1575350 100644
--- a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt
+++ b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt
@@ -14,6 +14,7 @@ import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.provideContent
import androidx.glance.layout.*
import androidx.glance.text.FontWeight
@@ -78,30 +79,45 @@ fun WidgetRoot(items: List<WidgetItem>, now: Instant) {
.filter { Instant.parse(it.start!!) >= tomorrowStart && Instant.parse(it.start!!) < tomorrowEnd }
val tomorrowFrags = fragments
.filter { it.startTime >= tomorrowStart && it.startTime < tomorrowEnd }
+ val showTomorrow = tomorrowItems.isNotEmpty() || tomorrowFrags.any { it.slots.isNotEmpty() }
+ val tomorrowTaskSlots = tomorrowFrags.flatMap { it.slots }
- Column(
+ // Glance's LazyColumn (backed by a RemoteViews ListView) is what actually scrolls in an
+ // app widget — a plain Column clips its content to the widget's current height instead.
+ LazyColumn(
modifier = GlanceModifier
.fillMaxSize()
.background(Color.Transparent)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
- Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp)) {
- Text(
- "TODAY",
- style = TextStyle(
- color = ColorProvider(Color(0x66FFFFFF)),
- fontSize = 11.sp,
- fontWeight = FontWeight.Bold
+ item {
+ Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp)) {
+ Text(
+ "TODAY",
+ style = TextStyle(
+ color = ColorProvider(Color(0x66FFFFFF)),
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold
+ )
)
- )
+ }
}
- for (hour in gridStart..gridEnd) {
- HourRow(hour, nowZoned, scheduledEvents, fragments, zone)
+ items(count = gridEnd - gridStart + 1) { index ->
+ HourRow(gridStart + index, nowZoned, scheduledEvents, fragments, zone)
}
- if (tomorrowItems.isNotEmpty() || tomorrowFrags.any { it.slots.isNotEmpty() }) {
- TomorrowSection(tomorrowItems, tomorrowFrags, zone)
+ if (showTomorrow) {
+ item { TomorrowHeader() }
+
+ items(count = tomorrowItems.size) { index ->
+ val item = tomorrowItems[index]
+ if (item.type == "event") TomorrowEventRow(item, zone) else TaskRow(item)
+ }
+
+ items(count = tomorrowTaskSlots.size) { index ->
+ TaskRow(tomorrowTaskSlots[index].task)
+ }
}
}
}
@@ -284,7 +300,7 @@ fun TaskRow(task: WidgetItem) {
}
@Composable
-fun TomorrowSection(items: List<WidgetItem>, fragments: List<TaskFragment>, zone: ZoneId) {
+fun TomorrowHeader() {
Box(modifier = GlanceModifier.fillMaxWidth().height(1.dp).padding(vertical = 4.dp).background(Color(0x1AFFFFFF))) {}
Row(modifier = GlanceModifier.fillMaxWidth().padding(top = 6.dp, bottom = 2.dp)) {
@@ -297,19 +313,6 @@ fun TomorrowSection(items: List<WidgetItem>, fragments: List<TaskFragment>, zone
)
)
}
-
- items.forEach { item ->
- val isPast = false
- if (item.type == "event") {
- TomorrowEventRow(item, zone)
- } else {
- TaskRow(item)
- }
- }
-
- fragments.forEach { frag ->
- frag.slots.forEach { slot -> TaskRow(slot.task) }
- }
}
@Composable
diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt b/android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt
index 0dac1cc..d14386c 100644
--- a/android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt
+++ b/android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt
@@ -19,8 +19,10 @@ 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.TaskDetail
import org.terst.doot.widget.data.WidgetRepository
import org.terst.doot.widget.data.dataStore
+import org.terst.doot.widget.data.removeWidgetItemLocally
import org.terst.doot.widget.work.CompleteWorker
import java.util.Calendar
import java.util.TimeZone
@@ -37,13 +39,30 @@ class TaskDetailActivity : ComponentActivity() {
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
+ var detail by remember { mutableStateOf<TaskDetail?>(null) }
+
+ LaunchedEffect(id, source) {
+ if (source in EDITABLE_DETAIL_SOURCES) {
+ val prefs = this@TaskDetailActivity.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.fetchDetail(id, source).onSuccess { detail = it }
+ }
+ }
+
TaskDetailSheet(
title = title,
source = source,
completable = completable,
+ detail = detail,
onComplete = {
- CompleteWorker.enqueue(this, id, source)
- finish()
+ lifecycleScope.launch {
+ removeWidgetItemLocally(this@TaskDetailActivity, id, source)
+ DootWidget().updateAll(this@TaskDetailActivity)
+ CompleteWorker.enqueue(this@TaskDetailActivity, id, source)
+ finish()
+ }
},
onReschedule = { dateISO ->
lifecycleScope.launch {
@@ -58,6 +77,19 @@ class TaskDetailActivity : ComponentActivity() {
}
}
},
+ onSaveDescription = { description ->
+ 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.updateDescription(id, source, description).onSuccess {
+ repo.fetchAndPersist(this@TaskDetailActivity)
+ DootWidget().updateAll(this@TaskDetailActivity)
+ finish()
+ }
+ }
+ },
onDismiss = ::finish
)
}
@@ -72,17 +104,23 @@ class TaskDetailActivity : ComponentActivity() {
}
}
+/** Sources whose description can be fetched/edited inline in the detail popup. */
+private val EDITABLE_DETAIL_SOURCES = setOf("doot", "gtasks", "trello")
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskDetailSheet(
title: String,
source: String,
completable: Boolean,
+ detail: TaskDetail?,
onComplete: () -> Unit,
onReschedule: (String) -> Unit,
+ onSaveDescription: (String) -> Unit,
onDismiss: () -> Unit
) {
var showDatePicker by remember { mutableStateOf(false) }
+ var descriptionText by remember(detail) { mutableStateOf(detail?.description ?: "") }
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = System.currentTimeMillis()
)
@@ -150,6 +188,39 @@ fun TaskDetailSheet(
)
}
Spacer(Modifier.height(20.dp))
+ if (source in EDITABLE_DETAIL_SOURCES) {
+ if (detail?.editable == true) {
+ OutlinedTextField(
+ value = descriptionText,
+ onValueChange = { descriptionText = it },
+ label = { Text("Description") },
+ modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedTextColor = Color.White,
+ unfocusedTextColor = Color.White,
+ focusedBorderColor = Color.White.copy(alpha = 0.5f),
+ unfocusedBorderColor = Color.White.copy(alpha = 0.3f),
+ focusedLabelColor = Color.White.copy(alpha = 0.7f),
+ unfocusedLabelColor = Color.White.copy(alpha = 0.5f)
+ )
+ )
+ Spacer(Modifier.height(8.dp))
+ Button(
+ onClick = { onSaveDescription(descriptionText) },
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B82F6))
+ ) {
+ Text("Save", fontSize = 15.sp)
+ }
+ } else {
+ Text(
+ "Loading…",
+ color = Color.White.copy(alpha = 0.5f),
+ fontSize = 13.sp
+ )
+ }
+ Spacer(Modifier.height(12.dp))
+ }
if (completable) {
Button(
onClick = onComplete,
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index b38b9e1..4b67c2a 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -369,6 +369,8 @@ func main() {
return handlers.WidgetAuthMiddleware(cfg.WidgetToken, next)
}
r.With(widgetAuth).Get("/api/widget", h.HandleWidgetGet)
+ r.With(widgetAuth).Get("/api/widget/detail", h.HandleWidgetDetail)
+ r.With(widgetAuth).Post("/api/widget/update", h.HandleWidgetUpdate)
r.With(widgetAuth).Post("/api/widget/complete", h.HandleWidgetComplete)
r.With(widgetAuth).Post("/api/widget/reschedule", h.HandleWidgetReschedule)
} else {
diff --git a/internal/api/google_tasks.go b/internal/api/google_tasks.go
index da3cd3b..b580be4 100644
--- a/internal/api/google_tasks.go
+++ b/internal/api/google_tasks.go
@@ -163,6 +163,18 @@ func (c *GoogleTasksClient) CompleteTask(ctx context.Context, listID, taskID str
return nil
}
+// UpdateTaskNotes updates a task's notes (description)
+func (c *GoogleTasksClient) UpdateTaskNotes(ctx context.Context, listID, taskID, notes string) error {
+ task := &tasks.Task{
+ Notes: notes,
+ }
+ _, err := c.srv.Tasks.Patch(listID, taskID, task).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("failed to update task notes: %v", err)
+ }
+ return nil
+}
+
// UncompleteTask marks a task as not completed
func (c *GoogleTasksClient) UncompleteTask(ctx context.Context, listID, taskID string) error {
task := &tasks.Task{
diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go
index c5e154d..183f3f0 100644
--- a/internal/api/interfaces.go
+++ b/internal/api/interfaces.go
@@ -38,6 +38,7 @@ type GoogleTasksAPI interface {
GetTasksByDateRange(ctx context.Context, start, end time.Time) ([]models.GoogleTask, error)
CompleteTask(ctx context.Context, listID, taskID string) error
UncompleteTask(ctx context.Context, listID, taskID string) error
+ UpdateTaskNotes(ctx context.Context, listID, taskID, notes string) error
GetTaskLists(ctx context.Context) ([]models.TaskListInfo, error)
SetTaskListID(id string)
}
diff --git a/internal/api/trello.go b/internal/api/trello.go
index 1a5642c..d580d98 100644
--- a/internal/api/trello.go
+++ b/internal/api/trello.go
@@ -88,7 +88,7 @@ func (c *TrelloClient) GetBoards(ctx context.Context) ([]models.Board, error) {
func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.Card, error) {
params := c.authParams()
params.Set("filter", "open")
- params.Set("fields", "id,name,idList,due,url,idBoard")
+ params.Set("fields", "id,name,desc,idList,due,url,idBoard")
var apiCards []trelloCardResponse
path := fmt.Sprintf("/boards/%s/cards?%s", boardID, params.Encode())
@@ -116,11 +116,12 @@ func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.C
cards := make([]models.Card, 0, len(apiCards))
for _, apiCard := range apiCards {
card := models.Card{
- ID: apiCard.ID,
- Name: apiCard.Name,
- ListID: apiCard.IDList,
- ListName: listMap[apiCard.IDList],
- URL: apiCard.URL,
+ ID: apiCard.ID,
+ Name: apiCard.Name,
+ Description: apiCard.Desc,
+ ListID: apiCard.IDList,
+ ListName: listMap[apiCard.IDList],
+ URL: apiCard.URL,
}
if apiCard.Due != nil && *apiCard.Due != "" {
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index d1a7512..aaf1d0d 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -685,7 +685,7 @@ func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) {
for _, b := range boards {
for _, c := range b.Cards {
if c.ID == id {
- title = c.Name
+ title, description = c.Name, c.Description
break
}
}
@@ -727,7 +727,7 @@ func (h *Handler) HandleTaskDetailPage(w http.ResponseWriter, r *http.Request) {
for _, b := range boards {
for _, c := range b.Cards {
if c.ID == id {
- title = c.Name
+ title, description = c.Name, c.Description
break
}
}
@@ -772,7 +772,7 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) {
var err error
switch source {
case "doot":
- err = h.store.UpdateNativeTask(id, "", description)
+ err = h.store.UpdateNativeTaskDescription(id, description)
case "trello":
err = h.trelloClient.UpdateCard(r.Context(), id, map[string]interface{}{"desc": description})
default:
diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go
index 3678b62..02dd5fb 100644
--- a/internal/handlers/timeline_logic_test.go
+++ b/internal/handlers/timeline_logic_test.go
@@ -62,6 +62,7 @@ func setupTestStore(t *testing.T) *store.Store {
CREATE TABLE IF NOT EXISTS cards (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
+ description TEXT DEFAULT '',
board_id TEXT NOT NULL,
list_id TEXT,
list_name TEXT,
diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go
index 876aaad..3b94bf1 100644
--- a/internal/handlers/widget.go
+++ b/internal/handlers/widget.go
@@ -8,6 +8,7 @@ import (
"task-dashboard/internal/config"
"task-dashboard/internal/models"
+ "task-dashboard/internal/store"
)
// WidgetAuthMiddleware validates the static bearer token for widget API endpoints.
@@ -42,9 +43,12 @@ func TimelineItemToWidgetItem(item models.TimelineItem) models.WidgetItem {
wi.Type = "event"
case models.TimelineItemTypeMeal:
wi.Type = "event"
- case models.TimelineItemTypeCard, models.TimelineItemTypeGTask:
+ case models.TimelineItemTypeCard:
wi.Type = "task"
- // not completable via widget API
+ wi.Completable = true
+ case models.TimelineItemTypeGTask:
+ wi.Type = "task"
+ wi.Completable = true
default:
wi.Type = "task"
wi.Completable = item.Source == "doot"
@@ -132,6 +136,135 @@ func (h *Handler) HandleWidgetReschedule(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusOK)
}
+// findGoogleTask looks up a cached Google Task by ID.
+func (h *Handler) findGoogleTask(id string) (models.GoogleTask, bool) {
+ gTasks, err := h.store.GetGoogleTasks()
+ if err != nil {
+ return models.GoogleTask{}, false
+ }
+ for _, t := range gTasks {
+ if t.ID == id {
+ return t, true
+ }
+ }
+ return models.GoogleTask{}, false
+}
+
+// findCard looks up a cached Trello card by ID.
+func (h *Handler) findCard(id string) (models.Card, bool) {
+ boards, err := h.store.GetBoards()
+ if err != nil {
+ return models.Card{}, false
+ }
+ for _, b := range boards {
+ for _, c := range b.Cards {
+ if c.ID == id {
+ return c, true
+ }
+ }
+ }
+ return models.Card{}, false
+}
+
+type widgetDetailResponse struct {
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Editable bool `json:"editable"`
+}
+
+// HandleWidgetDetail returns a task's title/description for the widget's edit popup.
+func (h *Handler) HandleWidgetDetail(w http.ResponseWriter, r *http.Request) {
+ id := r.URL.Query().Get("id")
+ source := r.URL.Query().Get("source")
+ if id == "" || source == "" {
+ http.Error(w, "missing id or source", http.StatusBadRequest)
+ return
+ }
+
+ var resp widgetDetailResponse
+ switch source {
+ case "doot":
+ tasks, err := h.store.GetNativeTasks()
+ if err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ for _, t := range tasks {
+ if t.ID == id {
+ resp = widgetDetailResponse{Title: t.Content, Description: t.Description, Editable: true}
+ break
+ }
+ }
+ case "gtasks":
+ if t, ok := h.findGoogleTask(id); ok {
+ resp = widgetDetailResponse{Title: t.Title, Description: t.Notes, Editable: h.googleTasksClient != nil}
+ }
+ case "trello":
+ if c, ok := h.findCard(id); ok {
+ resp = widgetDetailResponse{Title: c.Name, Description: c.Description, Editable: h.trelloClient != nil}
+ }
+ default:
+ http.Error(w, "unsupported source", http.StatusBadRequest)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+}
+
+type widgetUpdateRequest struct {
+ ID string `json:"id"`
+ Source string `json:"source"`
+ Description string `json:"description"`
+}
+
+// HandleWidgetUpdate saves an edited description from the widget's edit popup.
+func (h *Handler) HandleWidgetUpdate(w http.ResponseWriter, r *http.Request) {
+ var req widgetUpdateRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+
+ switch req.Source {
+ case "doot":
+ if err := h.store.UpdateNativeTaskDescription(req.ID, req.Description); err != nil {
+ http.Error(w, "failed to update task", http.StatusInternalServerError)
+ return
+ }
+ case "gtasks":
+ if h.googleTasksClient == nil {
+ http.Error(w, "google tasks not configured", http.StatusServiceUnavailable)
+ return
+ }
+ t, ok := h.findGoogleTask(req.ID)
+ if !ok {
+ http.Error(w, "task not found", http.StatusNotFound)
+ return
+ }
+ if err := h.googleTasksClient.UpdateTaskNotes(r.Context(), t.ListID, req.ID, req.Description); err != nil {
+ http.Error(w, "failed to update task", http.StatusInternalServerError)
+ return
+ }
+ _ = h.store.InvalidateCache(store.CacheKeyGoogleTasks)
+ case "trello":
+ if h.trelloClient == nil {
+ http.Error(w, "trello not configured", http.StatusServiceUnavailable)
+ return
+ }
+ if err := h.trelloClient.UpdateCard(r.Context(), req.ID, map[string]interface{}{"desc": req.Description}); err != nil {
+ http.Error(w, "failed to update task", http.StatusInternalServerError)
+ return
+ }
+ _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards)
+ default:
+ http.Error(w, "source not editable", http.StatusBadRequest)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+}
+
// HandleWidgetComplete proxies a task completion to the source API or native store.
func (h *Handler) HandleWidgetComplete(w http.ResponseWriter, r *http.Request) {
var req widgetCompleteRequest
@@ -147,6 +280,38 @@ func (h *Handler) HandleWidgetComplete(w http.ResponseWriter, r *http.Request) {
return
}
_ = h.store.SaveCompletedTask("doot", req.ID, "", nil)
+ case "gtasks":
+ if h.googleTasksClient == nil {
+ http.Error(w, "google tasks not configured", http.StatusServiceUnavailable)
+ return
+ }
+ t, ok := h.findGoogleTask(req.ID)
+ if !ok {
+ http.Error(w, "task not found", http.StatusNotFound)
+ return
+ }
+ if err := h.googleTasksClient.CompleteTask(r.Context(), t.ListID, req.ID); err != nil {
+ http.Error(w, "failed to complete task", http.StatusInternalServerError)
+ return
+ }
+ _ = h.store.SaveCompletedTask("gtasks", req.ID, t.Title, t.DueDate)
+ _ = h.store.InvalidateCache(store.CacheKeyGoogleTasks)
+ case "trello":
+ if h.trelloClient == nil {
+ http.Error(w, "trello not configured", http.StatusServiceUnavailable)
+ return
+ }
+ c, ok := h.findCard(req.ID)
+ if !ok {
+ http.Error(w, "task not found", http.StatusNotFound)
+ return
+ }
+ if err := h.trelloClient.UpdateCard(r.Context(), req.ID, map[string]interface{}{"closed": true}); err != nil {
+ http.Error(w, "failed to complete task", http.StatusInternalServerError)
+ return
+ }
+ _ = h.store.SaveCompletedTask("trello", req.ID, c.Name, c.DueDate)
+ _ = h.store.DeleteCard(req.ID)
default:
http.Error(w, "source not completable", http.StatusBadRequest)
return
diff --git a/internal/handlers/widget_test.go b/internal/handlers/widget_test.go
index f56fe07..1d8dba9 100644
--- a/internal/handlers/widget_test.go
+++ b/internal/handlers/widget_test.go
@@ -1,6 +1,8 @@
package handlers
import (
+ "context"
+ "encoding/json"
"net/http"
"net/http/httptest"
"strings"
@@ -8,8 +10,65 @@ import (
"time"
"task-dashboard/internal/models"
+ "task-dashboard/internal/store"
)
+// mockGoogleTasksClient is a minimal api.GoogleTasksAPI stub for widget handler tests.
+type mockGoogleTasksClient struct {
+ completedListID, completedTaskID string
+ notesListID, notesTaskID, notes string
+}
+
+func (m *mockGoogleTasksClient) GetTasks(ctx context.Context) ([]models.GoogleTask, error) {
+ return nil, nil
+}
+func (m *mockGoogleTasksClient) GetTasksByDateRange(ctx context.Context, start, end time.Time) ([]models.GoogleTask, error) {
+ return nil, nil
+}
+func (m *mockGoogleTasksClient) CompleteTask(ctx context.Context, listID, taskID string) error {
+ m.completedListID, m.completedTaskID = listID, taskID
+ return nil
+}
+func (m *mockGoogleTasksClient) UncompleteTask(ctx context.Context, listID, taskID string) error {
+ return nil
+}
+func (m *mockGoogleTasksClient) UpdateTaskNotes(ctx context.Context, listID, taskID, notes string) error {
+ m.notesListID, m.notesTaskID, m.notes = listID, taskID, notes
+ return nil
+}
+func (m *mockGoogleTasksClient) GetTaskLists(ctx context.Context) ([]models.TaskListInfo, error) {
+ return nil, nil
+}
+func (m *mockGoogleTasksClient) SetTaskListID(id string) {}
+
+// mockTrelloWidgetClient is a minimal api.TrelloAPI stub for widget handler tests
+// that records UpdateCard calls (the shared mockTrelloClient in handlers_test.go doesn't).
+type mockTrelloWidgetClient struct {
+ updatedCardID string
+ updates map[string]interface{}
+}
+
+func (m *mockTrelloWidgetClient) GetBoards(ctx context.Context) ([]models.Board, error) {
+ return nil, nil
+}
+func (m *mockTrelloWidgetClient) GetCards(ctx context.Context, boardID string) ([]models.Card, error) {
+ return nil, nil
+}
+func (m *mockTrelloWidgetClient) GetLists(ctx context.Context, boardID string) ([]models.List, error) {
+ return nil, nil
+}
+func (m *mockTrelloWidgetClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, error) {
+ return nil, nil
+}
+func (m *mockTrelloWidgetClient) CreateCard(ctx context.Context, listID, name, description string, dueDate *time.Time) (*models.Card, error) {
+ return nil, nil
+}
+func (m *mockTrelloWidgetClient) UpdateCard(ctx context.Context, cardID string, updates map[string]interface{}) error {
+ m.updatedCardID = cardID
+ m.updates = updates
+ return nil
+}
+
func TestWidgetAuthMiddleware_NoToken(t *testing.T) {
called := false
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })
@@ -121,6 +180,309 @@ func TestHandleWidgetComplete_NonCompletable(t *testing.T) {
}
}
+func TestHandleWidgetDetail_NativeTask(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ task := models.Task{ID: "t1", Content: "Buy milk", Description: "2%, not skim"}
+ if err := db.CreateNativeTask(task); err != nil {
+ t.Fatalf("failed to seed native task: %v", err)
+ }
+
+ h := &Handler{store: db}
+ req := httptest.NewRequest("GET", "/api/widget/detail?id=t1&source=doot", nil)
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetDetail).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp widgetDetailResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+ if resp.Title != "Buy milk" || resp.Description != "2%, not skim" || !resp.Editable {
+ t.Errorf("unexpected detail: %+v", resp)
+ }
+}
+
+func TestHandleWidgetDetail_UnsupportedSource(t *testing.T) {
+ h := &Handler{}
+ req := httptest.NewRequest("GET", "/api/widget/detail?id=x&source=calendar", nil)
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetDetail).ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", w.Code)
+ }
+}
+
+func TestHandleWidgetUpdate_NativeTask_PreservesContent(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ task := models.Task{ID: "t2", Content: "Original title", Description: "old desc"}
+ if err := db.CreateNativeTask(task); err != nil {
+ t.Fatalf("failed to seed native task: %v", err)
+ }
+
+ h := &Handler{store: db}
+ body := `{"id":"t2","source":"doot","description":"new desc"}`
+ req := httptest.NewRequest("POST", "/api/widget/update", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetUpdate).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ tasks, err := db.GetNativeTasks()
+ if err != nil {
+ t.Fatalf("failed to read back native tasks: %v", err)
+ }
+ if len(tasks) != 1 {
+ t.Fatalf("expected 1 native task, got %d", len(tasks))
+ }
+ if tasks[0].Content != "Original title" {
+ t.Errorf("title should be preserved, got %q", tasks[0].Content)
+ }
+ if tasks[0].Description != "new desc" {
+ t.Errorf("description should be updated, got %q", tasks[0].Description)
+ }
+}
+
+func TestHandleWidgetUpdate_UnsupportedSource(t *testing.T) {
+ h := &Handler{}
+ body := `{"id":"x","source":"calendar","description":"anything"}`
+ req := httptest.NewRequest("POST", "/api/widget/update", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetUpdate).ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", w.Code)
+ }
+}
+
+func TestTimelineItemToWidgetItem_GTask_Completable(t *testing.T) {
+ item := models.TimelineItem{
+ ID: "g1",
+ Title: "Renew passport",
+ Source: "gtasks",
+ Type: models.TimelineItemTypeGTask,
+ }
+
+ wi := TimelineItemToWidgetItem(item)
+
+ if wi.Type != "task" {
+ t.Errorf("Type: got %q, want %q", wi.Type, "task")
+ }
+ if !wi.Completable {
+ t.Error("gtask should be completable")
+ }
+}
+
+func TestTimelineItemToWidgetItem_Card_Completable(t *testing.T) {
+ item := models.TimelineItem{
+ ID: "c1",
+ Title: "Trello card",
+ Source: "trello",
+ Type: models.TimelineItemTypeCard,
+ }
+
+ wi := TimelineItemToWidgetItem(item)
+
+ if !wi.Completable {
+ t.Error("trello card should be completable via widget")
+ }
+}
+
+func TestHandleWidgetComplete_GoogleTask(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ if err := db.SaveGoogleTasks([]models.GoogleTask{
+ {ID: "g1", Title: "Renew passport", ListID: "list-a", UpdatedAt: time.Now()},
+ }); err != nil {
+ t.Fatalf("failed to seed google task: %v", err)
+ }
+
+ mock := &mockGoogleTasksClient{}
+ h := &Handler{store: db, googleTasksClient: mock}
+
+ body := `{"id":"g1","source":"gtasks"}`
+ req := httptest.NewRequest("POST", "/api/widget/complete", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetComplete).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ if mock.completedListID != "list-a" || mock.completedTaskID != "g1" {
+ t.Errorf("unexpected complete call: listID=%q taskID=%q", mock.completedListID, mock.completedTaskID)
+ }
+}
+
+func TestHandleWidgetComplete_GoogleTask_NotConfigured(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ h := &Handler{store: db}
+ body := `{"id":"g1","source":"gtasks"}`
+ req := httptest.NewRequest("POST", "/api/widget/complete", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetComplete).ServeHTTP(w, req)
+
+ if w.Code != http.StatusServiceUnavailable {
+ t.Fatalf("expected 503, got %d", w.Code)
+ }
+}
+
+func TestHandleWidgetDetail_GoogleTask(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ if err := db.SaveGoogleTasks([]models.GoogleTask{
+ {ID: "g1", Title: "Renew passport", Notes: "bring photo", ListID: "list-a", UpdatedAt: time.Now()},
+ }); err != nil {
+ t.Fatalf("failed to seed google task: %v", err)
+ }
+
+ h := &Handler{store: db, googleTasksClient: &mockGoogleTasksClient{}}
+ req := httptest.NewRequest("GET", "/api/widget/detail?id=g1&source=gtasks", nil)
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetDetail).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp widgetDetailResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+ if resp.Title != "Renew passport" || resp.Description != "bring photo" || !resp.Editable {
+ t.Errorf("unexpected detail: %+v", resp)
+ }
+}
+
+func TestHandleWidgetUpdate_GoogleTask(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ if err := db.SaveGoogleTasks([]models.GoogleTask{
+ {ID: "g1", Title: "Renew passport", ListID: "list-a", UpdatedAt: time.Now()},
+ }); err != nil {
+ t.Fatalf("failed to seed google task: %v", err)
+ }
+
+ mock := &mockGoogleTasksClient{}
+ h := &Handler{store: db, googleTasksClient: mock}
+
+ body := `{"id":"g1","source":"gtasks","description":"bring photo and $170"}`
+ req := httptest.NewRequest("POST", "/api/widget/update", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetUpdate).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ if mock.notesListID != "list-a" || mock.notesTaskID != "g1" || mock.notes != "bring photo and $170" {
+ t.Errorf("unexpected update call: listID=%q taskID=%q notes=%q", mock.notesListID, mock.notesTaskID, mock.notes)
+ }
+}
+
+func seedTestCard(t *testing.T, db *store.Store) {
+ t.Helper()
+ err := db.SaveBoards([]models.Board{
+ {
+ ID: "board1",
+ Name: "Test Board",
+ Cards: []models.Card{
+ {ID: "c1", Name: "Ship the widget", Description: "don't forget the checkbox", ListName: "Doing"},
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to seed card: %v", err)
+ }
+}
+
+func TestHandleWidgetDetail_TrelloCard(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ seedTestCard(t, db)
+
+ h := &Handler{store: db, trelloClient: &mockTrelloWidgetClient{}}
+ req := httptest.NewRequest("GET", "/api/widget/detail?id=c1&source=trello", nil)
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetDetail).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp widgetDetailResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+ if resp.Title != "Ship the widget" || resp.Description != "don't forget the checkbox" || !resp.Editable {
+ t.Errorf("unexpected detail: %+v", resp)
+ }
+}
+
+func TestHandleWidgetUpdate_TrelloCard(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ seedTestCard(t, db)
+
+ mock := &mockTrelloWidgetClient{}
+ h := &Handler{store: db, trelloClient: mock}
+
+ body := `{"id":"c1","source":"trello","description":"updated desc"}`
+ req := httptest.NewRequest("POST", "/api/widget/update", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetUpdate).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ if mock.updatedCardID != "c1" || mock.updates["desc"] != "updated desc" {
+ t.Errorf("unexpected update call: id=%q updates=%v", mock.updatedCardID, mock.updates)
+ }
+}
+
+func TestHandleWidgetComplete_TrelloCard_Archives(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ seedTestCard(t, db)
+
+ mock := &mockTrelloWidgetClient{}
+ h := &Handler{store: db, trelloClient: mock}
+
+ body := `{"id":"c1","source":"trello"}`
+ req := httptest.NewRequest("POST", "/api/widget/complete", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetComplete).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ if mock.updatedCardID != "c1" || mock.updates["closed"] != true {
+ t.Errorf("expected card to be archived (closed=true), got id=%q updates=%v", mock.updatedCardID, mock.updates)
+ }
+
+ boards, err := db.GetBoards()
+ if err != nil {
+ t.Fatalf("failed to read back boards: %v", err)
+ }
+ for _, b := range boards {
+ for _, c := range b.Cards {
+ if c.ID == "c1" {
+ t.Error("completed card should have been removed from local cache")
+ }
+ }
+ }
+}
+
func TestWidgetAuthMiddleware_EmptyToken(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h := WidgetAuthMiddleware("", inner)
diff --git a/internal/models/types.go b/internal/models/types.go
index d9dd515..58d3888 100644
--- a/internal/models/types.go
+++ b/internal/models/types.go
@@ -109,13 +109,14 @@ type Board struct {
// Card represents a Trello card
type Card struct {
- ID string `json:"id"`
- Name string `json:"name"`
- ListID string `json:"list_id"`
- ListName string `json:"list_name"`
- BoardName string `json:"board_name"`
- DueDate *time.Time `json:"due_date,omitempty"`
- URL string `json:"url"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ ListID string `json:"list_id"`
+ ListName string `json:"list_name"`
+ BoardName string `json:"board_name"`
+ DueDate *time.Time `json:"due_date,omitempty"`
+ URL string `json:"url"`
}
// Project represents a project (used for source config grouping)
diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go
index 5758407..218675e 100644
--- a/internal/store/native_tasks.go
+++ b/internal/store/native_tasks.go
@@ -22,14 +22,15 @@ func (s *Store) GetNativeTasks() ([]models.Task, error) {
return scanNativeTasks(rows)
}
-// GetNativeTasksByDateRange returns non-completed native tasks due within the given range.
+// GetNativeTasksByDateRange returns non-completed native tasks due within the given range,
+// including overdue tasks (due before start) so they keep appearing until completed.
func (s *Store) GetNativeTasksByDateRange(start, end time.Time) ([]models.Task, error) {
rows, err := s.db.Query(`
SELECT id, content, description, project_name, due_date, priority, completed, labels, created_at
FROM native_tasks
- WHERE completed = 0 AND due_date IS NOT NULL AND due_date >= ? AND due_date < ?
+ WHERE completed = 0 AND due_date IS NOT NULL AND due_date < ?
ORDER BY due_date ASC, priority DESC
- `, start, end)
+ `, end)
if err != nil {
return nil, err
}
@@ -71,6 +72,15 @@ func (s *Store) UpdateNativeTask(id, content, description string) error {
return err
}
+// UpdateNativeTaskDescription updates only a native task's description, leaving content untouched.
+func (s *Store) UpdateNativeTaskDescription(id, description string) error {
+ _, err := s.db.Exec(`
+ UPDATE native_tasks SET description = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ `, description, id)
+ return err
+}
+
// CompleteNativeTask marks a task as completed.
func (s *Store) CompleteNativeTask(id string) error {
_, err := s.db.Exec(`
diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go
index a6e9fd4..f955f71 100644
--- a/internal/store/sqlite.go
+++ b/internal/store/sqlite.go
@@ -292,8 +292,8 @@ func (s *Store) SaveBoards(boards []models.Board) error {
// Save cards
cardStmt, err := tx.Prepare(`
INSERT OR REPLACE INTO cards
- (id, name, board_id, list_id, list_name, due_date, url, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ (id, name, description, board_id, list_id, list_name, due_date, url, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`)
if err != nil {
return err
@@ -315,6 +315,7 @@ func (s *Store) SaveBoards(boards []models.Board) error {
_, err := cardStmt.Exec(
card.ID,
card.Name,
+ card.Description,
board.ID,
card.ListID,
card.ListName,
@@ -372,7 +373,7 @@ func (s *Store) GetBoards() ([]models.Board, error) {
// Fetch cards
cardRows, err := s.db.Query(`
- SELECT id, name, board_id, list_id, list_name, due_date, url
+ SELECT id, name, description, board_id, list_id, list_name, due_date, url
FROM cards
ORDER BY board_id, list_name, name
`)
@@ -389,6 +390,7 @@ func (s *Store) GetBoards() ([]models.Board, error) {
err := cardRows.Scan(
&card.ID,
&card.Name,
+ &card.Description,
&boardID,
&card.ListID,
&card.ListName,
@@ -696,22 +698,29 @@ func (s *Store) SaveGoogleTasks(tasks []models.GoogleTask) error {
return tx.Commit()
}
-// GetGoogleTasks retrieves all cached Google Tasks
-func (s *Store) GetGoogleTasks() ([]models.GoogleTask, error) {
- rows, err := s.db.Query(`
- SELECT id, title, notes, status, completed, due_date, updated_at, list_id, url
- FROM google_tasks
- ORDER BY completed ASC, CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC
- `)
- if err != nil {
- return nil, err
+// parseGoogleTaskTime parses a TEXT-column timestamp written by mattn/go-sqlite3's
+// default time.Time binding. The google_tasks.due_date/updated_at columns are declared
+// TEXT (not DATETIME), so the driver never auto-converts them back to time.Time on read.
+func parseGoogleTaskTime(s string) (time.Time, bool) {
+ for _, layout := range []string{
+ "2006-01-02 15:04:05.999999999-07:00",
+ time.RFC3339Nano,
+ time.RFC3339,
+ "2006-01-02 15:04:05",
+ "2006-01-02",
+ } {
+ if parsed, err := time.Parse(layout, s); err == nil {
+ return parsed, true
+ }
}
- defer func() { _ = rows.Close() }()
+ return time.Time{}, false
+}
+func scanGoogleTasks(rows *sql.Rows) ([]models.GoogleTask, error) {
var tasks []models.GoogleTask
for rows.Next() {
var t models.GoogleTask
- var dueDate, updatedAt sql.NullTime
+ var dueDate, updatedAt sql.NullString
err := rows.Scan(&t.ID, &t.Title, &t.Notes, &t.Status, &t.Completed, &dueDate, &updatedAt, &t.ListID, &t.URL)
if err != nil {
@@ -719,10 +728,14 @@ func (s *Store) GetGoogleTasks() ([]models.GoogleTask, error) {
}
if dueDate.Valid {
- t.DueDate = &dueDate.Time
+ if parsed, ok := parseGoogleTaskTime(dueDate.String); ok {
+ t.DueDate = &parsed
+ }
}
if updatedAt.Valid {
- t.UpdatedAt = updatedAt.Time
+ if parsed, ok := parseGoogleTaskTime(updatedAt.String); ok {
+ t.UpdatedAt = parsed
+ }
}
tasks = append(tasks, t)
@@ -731,40 +744,36 @@ func (s *Store) GetGoogleTasks() ([]models.GoogleTask, error) {
return tasks, rows.Err()
}
-// GetGoogleTasksByDateRange retrieves cached Google Tasks in a date range
-func (s *Store) GetGoogleTasksByDateRange(start, end time.Time) ([]models.GoogleTask, error) {
+// GetGoogleTasks retrieves all cached Google Tasks
+func (s *Store) GetGoogleTasks() ([]models.GoogleTask, error) {
rows, err := s.db.Query(`
SELECT id, title, notes, status, completed, due_date, updated_at, list_id, url
FROM google_tasks
- WHERE due_date IS NULL OR (due_date >= ? AND due_date < ?)
ORDER BY completed ASC, CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC
- `, start, end)
+ `)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
- var tasks []models.GoogleTask
- for rows.Next() {
- var t models.GoogleTask
- var dueDate, updatedAt sql.NullTime
-
- err := rows.Scan(&t.ID, &t.Title, &t.Notes, &t.Status, &t.Completed, &dueDate, &updatedAt, &t.ListID, &t.URL)
- if err != nil {
- return nil, err
- }
-
- if dueDate.Valid {
- t.DueDate = &dueDate.Time
- }
- if updatedAt.Valid {
- t.UpdatedAt = updatedAt.Time
- }
+ return scanGoogleTasks(rows)
+}
- tasks = append(tasks, t)
+// GetGoogleTasksByDateRange retrieves cached Google Tasks due before end, including overdue
+// tasks (due before start) so they keep appearing until completed.
+func (s *Store) GetGoogleTasksByDateRange(start, end time.Time) ([]models.GoogleTask, error) {
+ rows, err := s.db.Query(`
+ SELECT id, title, notes, status, completed, due_date, updated_at, list_id, url
+ FROM google_tasks
+ WHERE due_date IS NULL OR due_date < ?
+ ORDER BY completed ASC, CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC
+ `, end)
+ if err != nil {
+ return nil, err
}
+ defer func() { _ = rows.Close() }()
- return tasks, rows.Err()
+ return scanGoogleTasks(rows)
}
// Agent operations
diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go
index 2467f96..4d3c8f8 100644
--- a/internal/store/sqlite_test.go
+++ b/internal/store/sqlite_test.go
@@ -103,6 +103,7 @@ func setupTestStoreWithCards(t *testing.T) *Store {
CREATE TABLE IF NOT EXISTS cards (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
+ description TEXT DEFAULT '',
board_id TEXT NOT NULL,
list_id TEXT,
list_name TEXT,
@@ -118,6 +119,194 @@ func setupTestStoreWithCards(t *testing.T) *Store {
return store
}
+func setupTestStoreWithGoogleTasks(t *testing.T) *Store {
+ t.Helper()
+
+ tempDir := t.TempDir()
+ dbPath := filepath.Join(tempDir, "test.db")
+
+ db, err := sql.Open("sqlite3", dbPath)
+ if err != nil {
+ t.Fatalf("Failed to open test database: %v", err)
+ }
+ db.SetMaxOpenConns(1)
+
+ store := &Store{db: db}
+
+ schema := `
+ CREATE TABLE IF NOT EXISTS google_tasks (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ notes TEXT,
+ status TEXT NOT NULL,
+ completed BOOLEAN NOT NULL DEFAULT 0,
+ due_date TEXT,
+ updated_at TEXT,
+ list_id TEXT NOT NULL,
+ url TEXT
+ );
+ `
+ if _, err := db.Exec(schema); err != nil {
+ t.Fatalf("Failed to create schema: %v", err)
+ }
+
+ return store
+}
+
+func setupTestStoreWithNativeTasks(t *testing.T) *Store {
+ t.Helper()
+
+ tempDir := t.TempDir()
+ dbPath := filepath.Join(tempDir, "test.db")
+
+ db, err := sql.Open("sqlite3", dbPath)
+ if err != nil {
+ t.Fatalf("Failed to open test database: %v", err)
+ }
+ db.SetMaxOpenConns(1)
+
+ store := &Store{db: db}
+
+ schema := `
+ CREATE TABLE IF NOT EXISTS native_tasks (
+ id TEXT PRIMARY KEY,
+ content TEXT NOT NULL,
+ description TEXT DEFAULT '',
+ project_name TEXT DEFAULT '',
+ due_date DATETIME,
+ priority INTEGER DEFAULT 1,
+ completed BOOLEAN DEFAULT 0,
+ labels TEXT DEFAULT '[]',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ `
+ if _, err := db.Exec(schema); err != nil {
+ t.Fatalf("Failed to create schema: %v", err)
+ }
+
+ return store
+}
+
+// TestGetNativeTasksByDateRange_IncludesOverdue guards against a regression where a native task
+// due before the window's start (e.g. yesterday, still incomplete) silently dropped out of the
+// widget/timeline the moment the day rolled over, because the query required due_date >= start.
+func TestGetNativeTasksByDateRange_IncludesOverdue(t *testing.T) {
+ store := setupTestStoreWithNativeTasks(t)
+
+ now := time.Now()
+ overdue := now.Add(-48 * time.Hour)
+ today := now
+ future := now.Add(72 * time.Hour) // outside the window
+
+ for _, task := range []models.Task{
+ {ID: "t-overdue", Content: "Overdue task", DueDate: &overdue},
+ {ID: "t-today", Content: "Today task", DueDate: &today},
+ {ID: "t-future", Content: "Future task", DueDate: &future},
+ } {
+ if err := store.CreateNativeTask(task); err != nil {
+ t.Fatalf("CreateNativeTask(%s) failed: %v", task.ID, err)
+ }
+ }
+
+ start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ end := start.Add(48 * time.Hour)
+
+ results, err := store.GetNativeTasksByDateRange(start, end)
+ if err != nil {
+ t.Fatalf("GetNativeTasksByDateRange failed: %v", err)
+ }
+
+ ids := make(map[string]bool)
+ for _, r := range results {
+ ids[r.ID] = true
+ }
+ if !ids["t-overdue"] {
+ t.Error("expected overdue task to be included, but it was excluded")
+ }
+ if !ids["t-today"] {
+ t.Error("expected today's task to be included")
+ }
+ if ids["t-future"] {
+ t.Error("expected far-future task to be excluded")
+ }
+}
+
+// TestSaveAndGetGoogleTasks_RoundTripsTimestamps guards against a regression where
+// due_date/updated_at (TEXT columns, not DATETIME) failed to scan back into time.Time
+// via sql.NullTime whenever a row had a non-null timestamp.
+func TestSaveAndGetGoogleTasks_RoundTripsTimestamps(t *testing.T) {
+ store := setupTestStoreWithGoogleTasks(t)
+
+ due := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
+ updated := time.Now()
+
+ err := store.SaveGoogleTasks([]models.GoogleTask{
+ {ID: "g1", Title: "Renew passport", Notes: "bring photo", Status: "needsAction", ListID: "list-a", DueDate: &due, UpdatedAt: updated},
+ })
+ if err != nil {
+ t.Fatalf("SaveGoogleTasks failed: %v", err)
+ }
+
+ tasks, err := store.GetGoogleTasks()
+ if err != nil {
+ t.Fatalf("GetGoogleTasks failed: %v", err)
+ }
+ if len(tasks) != 1 {
+ t.Fatalf("expected 1 task, got %d", len(tasks))
+ }
+ got := tasks[0]
+ if got.DueDate == nil || !got.DueDate.Equal(due) {
+ t.Errorf("DueDate: got %v, want %v", got.DueDate, due)
+ }
+ if got.UpdatedAt.IsZero() {
+ t.Error("UpdatedAt should have round-tripped, got zero value")
+ }
+}
+
+// TestGetGoogleTasksByDateRange_IncludesOverdue guards against a regression where a task due
+// before the window's start (e.g. yesterday, still incomplete) silently dropped out of the
+// widget/timeline the moment the day rolled over, because the query required due_date >= start.
+func TestGetGoogleTasksByDateRange_IncludesOverdue(t *testing.T) {
+ store := setupTestStoreWithGoogleTasks(t)
+
+ now := time.Now()
+ overdue := now.Add(-48 * time.Hour)
+ today := now
+ future := now.Add(72 * time.Hour) // outside the window
+
+ err := store.SaveGoogleTasks([]models.GoogleTask{
+ {ID: "g-overdue", Title: "Overdue task", ListID: "list-a", DueDate: &overdue, UpdatedAt: now},
+ {ID: "g-today", Title: "Today task", ListID: "list-a", DueDate: &today, UpdatedAt: now},
+ {ID: "g-future", Title: "Future task", ListID: "list-a", DueDate: &future, UpdatedAt: now},
+ })
+ if err != nil {
+ t.Fatalf("SaveGoogleTasks failed: %v", err)
+ }
+
+ start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ end := start.Add(48 * time.Hour)
+
+ results, err := store.GetGoogleTasksByDateRange(start, end)
+ if err != nil {
+ t.Fatalf("GetGoogleTasksByDateRange failed: %v", err)
+ }
+
+ ids := make(map[string]bool)
+ for _, r := range results {
+ ids[r.ID] = true
+ }
+ if !ids["g-overdue"] {
+ t.Error("expected overdue task to be included, but it was excluded")
+ }
+ if !ids["g-today"] {
+ t.Error("expected today's task to be included")
+ }
+ if ids["g-future"] {
+ t.Error("expected far-future task to be excluded")
+ }
+}
+
// setupTestStoreWithMeals creates a test store with meals table
func setupTestStoreWithMeals(t *testing.T) *Store {
t.Helper()
diff --git a/migrations/022_add_card_description.sql b/migrations/022_add_card_description.sql
new file mode 100644
index 0000000..6a03c7c
--- /dev/null
+++ b/migrations/022_add_card_description.sql
@@ -0,0 +1,3 @@
+-- Add description column to cards so Trello card descriptions can be cached
+-- and shown/edited in the web dashboard and Android widget.
+ALTER TABLE cards ADD COLUMN description TEXT DEFAULT '';