diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-13 07:11:04 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-13 07:11:04 +0000 |
| commit | 44abf42ed45aa8f285e7ce031cbb9ef1ade667ea (patch) | |
| tree | dbc21cadc2750c6e39e7f7613be0b3f73790c445 /internal/handlers/widget.go | |
| parent | 8310f802dd9fc6ef5dff0be7f640f79c5b39987f (diff) | |
| parent | 4126fe4f56a6eb9703a084d4793a597f37bf2867 (diff) | |
Merge github/master: reconcile with parallel widget work
Another session pushed 27 commits in parallel covering quick-add, event
detail popups, recurrence display, overdue badges, a manual refresh button,
and its own fix for the same overdue-tasks bug (via a separate
GetOverdueNativeTasks fetch folded into BuildTimeline, rather than widening
GetNativeTasksByDateRange's bound directly). Reconciled rather than blindly
taking one side:
- Reverted GetNativeTasksByDateRange to its original bounded query and kept
upstream's GetOverdueNativeTasks + BuildTimeline fold-in as the sole
overdue mechanism for native tasks, to avoid double-counting overdue
items (my widened query + their separate fetch would have both returned
them). Re-pointed the regression test at the now-correct contract and
added a store-level test for GetOverdueNativeTasks directly.
- Kept my GetGoogleTasksByDateRange fix as-is (single unbounded query) --
upstream never touched Google Tasks overdue handling, so there's no
duplication risk there.
- Rewove WidgetRoot's LazyColumn structure (added for scrolling) around
upstream's new header buttons, pinned all-day event rows, and the
enhanced TomorrowSection, none of which were written LazyColumn-aware
since that work landed on this side only.
- Combined both sides' additions to TaskDetailActivity/TaskDetailSheet
(description-edit detail popup + due-date reschedule label) and
WidgetRepository/Actions (optimistic local removal + refresh button
wiring) -- these were independent, non-overlapping features that both
needed to survive.
- Renumbered the migration collision: both sides independently added a
migration numbered 022. Card-description was already applied to the live
production DB under that filename earlier this session (migrations are
tracked by filename), so it keeps 022; the recurring-event-id migration,
never deployed under any name here, moves to 023.
Verified: go build clean, full test suite passes (only the two
pre-existing agent-handler failures and the pre-existing models package
build error remain, both confirmed unrelated via git stash before this
session began), and a dry run against a copy of the live production
database applies both migrations cleanly with no re-run conflicts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'internal/handlers/widget.go')
| -rw-r--r-- | internal/handlers/widget.go | 112 |
1 files changed, 100 insertions, 12 deletions
diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go index 3b94bf1..f1f7452 100644 --- a/internal/handlers/widget.go +++ b/internal/handlers/widget.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "errors" "net/http" "strings" "time" @@ -31,11 +32,13 @@ func WidgetAuthMiddleware(token string, next http.Handler) http.Handler { // Exported for testability. func TimelineItemToWidgetItem(item models.TimelineItem) models.WidgetItem { wi := models.WidgetItem{ - ID: item.ID, - Title: item.Title, - Source: item.Source, - IsAllDay: item.IsAllDay, - URL: item.URL, + ID: item.ID, + Title: item.Title, + Source: item.Source, + IsAllDay: item.IsAllDay, + IsOverdue: item.IsOverdue, + URL: item.URL, + RecurringEventID: item.RecurringEventID, } switch item.Type { @@ -54,18 +57,38 @@ func TimelineItemToWidgetItem(item models.TimelineItem) models.WidgetItem { wi.Completable = item.Source == "doot" } - // Only populate Start/End for items with a real time (non-all-day, non-zero) - if !item.IsAllDay && !item.Time.IsZero() { + // Only populate Start/End for items with a real time. All-day CALENDAR + // EVENTS (not undated doot/gtask tasks, which are also flagged IsAllDay + // as a "no specific time" fallback -- see TimelineItem.ComputeDaySection) + // get Start populated too, using their real event date, so the widget + // client can pin them to the top of the correct day's section instead of + // losing them in the floating-task hourly-slot packer, which has no + // concept of "all day" and can push a slot past the visible grid range + // entirely. Tasks keep the existing nil-Start "floating" treatment + // regardless of IsAllDay -- only Start is set for them (never End), and + // only when they have a real time. + if !item.Time.IsZero() && (!item.IsAllDay || item.Type == models.TimelineItemTypeEvent) { t := item.Time wi.Start = &t - if item.EndTime != nil { - wi.End = item.EndTime - } else { - end := item.Time.Add(time.Hour) - wi.End = &end + if !item.IsAllDay { + if item.EndTime != nil { + wi.End = item.EndTime + } else { + end := item.Time.Add(time.Hour) + wi.End = &end + } } } + // DueDate is independent of Start/IsAllDay -- doot tasks deliberately + // keep Start nil (see the "floating task" doc comment above) so the + // client's SlotPacker positions them, but the Android detail popup + // still needs to know the real due date to display and reschedule it. + if item.Type == models.TimelineItemTypeTask && item.Source == "doot" && !item.Time.IsZero() { + due := item.Time + wi.DueDate = &due + } + return wi } @@ -105,6 +128,10 @@ type widgetCompleteRequest struct { Source string `json:"source"` } +type widgetAddRequest struct { + Title string `json:"title"` +} + type widgetRescheduleRequest struct { ID string `json:"id"` Source string `json:"source"` @@ -130,6 +157,10 @@ func (h *Handler) HandleWidgetReschedule(w http.ResponseWriter, r *http.Request) tz := config.GetDisplayTimezone() dueDate := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, tz) if err := h.store.RescheduleNativeTask(req.ID, dueDate); err != nil { + if errors.Is(err, store.ErrNativeTaskNotFound) { + http.Error(w, "task not found", http.StatusNotFound) + return + } http.Error(w, "failed to reschedule", http.StatusInternalServerError) return } @@ -276,6 +307,14 @@ func (h *Handler) HandleWidgetComplete(w http.ResponseWriter, r *http.Request) { switch req.Source { case "doot": if err := h.store.CompleteNativeTask(req.ID); err != nil { + if errors.Is(err, store.ErrNativeTaskNotFound) { + // Surfaced as 404 (not a silent 200) so the caller -- the + // Android widget -- can tell "nothing changed" apart from + // "it worked", instead of the previous behavior where a + // stale/wrong id looked identical to a real completion. + http.Error(w, "task not found", http.StatusNotFound) + return + } http.Error(w, "failed to complete task", http.StatusInternalServerError) return } @@ -319,3 +358,52 @@ func (h *Handler) HandleWidgetComplete(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } + +// HandleWidgetRecurrence looks up and formats a recurring calendar event's schedule. +func (h *Handler) HandleWidgetRecurrence(w http.ResponseWriter, r *http.Request) { + recurringEventID := r.URL.Query().Get("recurring_event_id") + if recurringEventID == "" { + http.Error(w, "recurring_event_id is required", http.StatusBadRequest) + return + } + + recurrence, err := h.googleCalendarClient.GetRecurrenceRule(r.Context(), recurringEventID) + if err != nil { + http.Error(w, "recurring event not found", http.StatusNotFound) + return + } + + resp := struct { + Recurrence string `json:"recurrence"` + }{Recurrence: recurrence} + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// HandleWidgetAdd creates a new undated native task from the widget's quick-add sheet. +func (h *Handler) HandleWidgetAdd(w http.ResponseWriter, r *http.Request) { + var req widgetAddRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + title := strings.TrimSpace(req.Title) + if title == "" { + http.Error(w, "title is required", http.StatusBadRequest) + return + } + + task := models.Task{ + ID: newID(), + Content: title, + Priority: 1, + } + if err := h.store.CreateNativeTask(task); err != nil { + http.Error(w, "failed to create task", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} |
