diff options
| -rw-r--r-- | android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt | 9 | ||||
| -rw-r--r-- | android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt | 47 | ||||
| -rw-r--r-- | android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt | 6 | ||||
| -rw-r--r-- | android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt | 57 | ||||
| -rw-r--r-- | android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt | 75 | ||||
| -rw-r--r-- | cmd/dashboard/main.go | 2 | ||||
| -rw-r--r-- | internal/api/google_tasks.go | 12 | ||||
| -rw-r--r-- | internal/api/interfaces.go | 1 | ||||
| -rw-r--r-- | internal/api/trello.go | 13 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 6 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic_test.go | 1 | ||||
| -rw-r--r-- | internal/handlers/widget.go | 169 | ||||
| -rw-r--r-- | internal/handlers/widget_test.go | 362 | ||||
| -rw-r--r-- | internal/models/types.go | 15 | ||||
| -rw-r--r-- | internal/store/native_tasks.go | 16 | ||||
| -rw-r--r-- | internal/store/sqlite.go | 85 | ||||
| -rw-r--r-- | internal/store/sqlite_test.go | 189 | ||||
| -rw-r--r-- | migrations/022_add_card_description.sql | 3 |
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 ''; |
