package handlers import ( "context" "crypto/rand" "errors" "fmt" "html/template" "log" "net/http" "path/filepath" "sort" "strconv" "strings" "sync" "time" "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" "golang.org/x/oauth2" "task-dashboard/internal/api" "task-dashboard/internal/auth" "task-dashboard/internal/config" "task-dashboard/internal/models" "task-dashboard/internal/store" ) // newID generates a random hex ID for native tasks. func newID() string { b := make([]byte, 12) _, _ = rand.Read(b) return fmt.Sprintf("%x", b) } // Handler holds dependencies for HTTP handlers type Handler struct { store *store.Store trelloClient api.TrelloAPI planToEatClient api.PlanToEatAPI googleCalendarClient api.GoogleCalendarAPI googleTasksClient api.GoogleTasksAPI googleTasksOAuthConfig *oauth2.Config claudomatorClient api.ClaudomatorClient config *config.Config renderer Renderer sessions *scs.SessionManager BuildVersion string WebAuthnEnabled bool } // New creates a new Handler instance func New(s *store.Store, trello api.TrelloAPI, planToEat api.PlanToEatAPI, googleCalendar api.GoogleCalendarAPI, googleTasks api.GoogleTasksAPI, googleTasksOAuthConfig *oauth2.Config, claudomator api.ClaudomatorClient, cfg *config.Config, sessions *scs.SessionManager, buildVersion string, webAuthnEnabled bool) *Handler { // Template functions funcMap := template.FuncMap{ "subtract": func(a, b int) int { return a - b }, "add": func(a, b int) int { return a + b }, // multiDayLabel returns the "(starts/ends HH:MM)" suffix for a // multi-day calendar event's row on the day it's rendered, or "" // for a normal item or a "spans" (pass-through) day, which shows // as a plain all-day row with no time label. "multiDayLabel": func(item TimelineItemView) string { switch item.MultiDayVariant { case "starts": if item.Time.Hour() == 0 && item.Time.Minute() == 0 { return "" } return " (starts " + item.Time.Format("3:04 PM") + ")" case "ends": if item.EndTime == nil || (item.EndTime.Hour() == 0 && item.EndTime.Minute() == 0) { return "" } return " (ends " + item.EndTime.Format("3:04 PM") + ")" default: return "" } }, // hasInt reports whether n is present in a []int -- used to pre-check // the weekday checkboxes on a recurring task's edit form. "hasInt": func(list []int, n int) bool { for _, v := range list { if v == n { return true } } return false }, // recurrenceUnit maps a recurrence freq to its singular noun, so the // task-detail template can say "every 2 weeks" instead of gluing an // "s" onto the adjective "weekly" ("every 2 weeklys"). "recurrenceUnit": func(freq string) string { switch freq { case "daily": return "day" case "weekly": return "week" case "monthly": return "month" case "yearly": return "year" default: return freq } }, // weekdayNames formats recurrence weekday indices (0=Sunday, matching // time.Weekday, see models/recurrence.go) as "Mon, Wed, Fri". "weekdayNames": func(weekdays []int) string { names := [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} parts := make([]string, 0, len(weekdays)) for _, d := range weekdays { if d >= 0 && d < 7 { parts = append(parts, names[d]) } } return strings.Join(parts, ", ") }, } // Parse templates including partials tmpl, err := template.New("").Funcs(funcMap).ParseGlob(filepath.Join(cfg.TemplateDir, "*.html")) if err != nil { log.Printf("Warning: failed to parse templates: %v", err) } // Also parse partials tmpl, err = tmpl.ParseGlob(filepath.Join(cfg.TemplateDir, "partials", "*.html")) if err != nil { log.Printf("Warning: failed to parse partial templates: %v", err) } return &Handler{ store: s, trelloClient: trello, planToEatClient: planToEat, googleCalendarClient: googleCalendar, googleTasksClient: googleTasks, googleTasksOAuthConfig: googleTasksOAuthConfig, claudomatorClient: claudomator, config: cfg, renderer: NewTemplateRenderer(tmpl), sessions: sessions, BuildVersion: buildVersion, WebAuthnEnabled: webAuthnEnabled, } } // PingDB checks database reachability within the given context, for use by // a deep health check that actually exercises the DB layer (unlike a bare // "200 ok" liveness check, which stays green even if every DB-touching // request path is wedged -- see the 2026-08-04 incident). func (h *Handler) PingDB(ctx context.Context) error { return h.store.DB().PingContext(ctx) } // HandleDashboard renders the main dashboard view func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Extract tab query parameter for state persistence tab := r.URL.Query().Get("tab") if tab == "" { tab = "timeline" } // Aggregate data from all sources dashboardData, err := h.aggregateData(ctx, false) if err != nil { http.Error(w, "Failed to load dashboard data", http.StatusInternalServerError) log.Printf("Error aggregating data: %v", err) return } // Render template if h.renderer == nil { http.Error(w, "Renderer not configured", http.StatusInternalServerError) return } // Background URL: seed from 5-minute time bucket so all sites show the same image simultaneously backgroundURL := fmt.Sprintf("https://picsum.photos/1920/1080?random=%d", time.Now().Unix()/300) // Wrap dashboard data with active tab for template data := struct { *models.DashboardData ActiveTab string CSRFToken string BackgroundURL string BuildVersion string }{ DashboardData: dashboardData, ActiveTab: tab, CSRFToken: auth.GetCSRFTokenFromContext(ctx), BackgroundURL: backgroundURL, BuildVersion: h.BuildVersion, } if err := h.renderer.Render(w, "index.html", data); err != nil { http.Error(w, "Failed to render template", http.StatusInternalServerError) log.Printf("Error rendering template: %v", err) } } // HandleRefresh forces a refresh of all data func (h *Handler) HandleRefresh(w http.ResponseWriter, r *http.Request) { data, err := h.aggregateData(r.Context(), true) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to refresh data", err) return } JSONResponse(w, data) } // HandleGetMeals returns meals as JSON func (h *Handler) HandleGetMeals(w http.ResponseWriter, r *http.Request) { startDate := config.Now() endDate := startDate.AddDate(0, 0, 7) meals, err := h.store.GetMeals(startDate, endDate) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to get meals", err) return } JSONResponse(w, meals) } // HandleGetBoards returns Trello boards with cards as JSON func (h *Handler) HandleGetBoards(w http.ResponseWriter, r *http.Request) { boards, err := h.store.GetBoards() if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to get boards", err) return } JSONResponse(w, boards) } // HandleGetShoppingList returns PlanToEat shopping list as JSON func (h *Handler) HandleGetShoppingList(w http.ResponseWriter, r *http.Request) { if h.planToEatClient == nil { JSONError(w, http.StatusServiceUnavailable, "PlanToEat not configured", nil) return } items, err := h.planToEatClient.GetShoppingList(r.Context()) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to get shopping list", err) return } JSONResponse(w, items) } // aggregateData fetches and caches data from all sources concurrently func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models.DashboardData, error) { data := &models.DashboardData{ LastUpdated: config.Now(), Errors: make([]string, 0), } var wg sync.WaitGroup var mu sync.Mutex // Helper to run fetch in goroutine with error collection. Each fetch gets // its own bounded sub-context, which bounds wg.Wait() PROVIDED fn actually // wires fetchCtx into whatever blocking call it makes. This context alone // does not and cannot guarantee that -- a client that ignores the ctx it's // handed (as google_calendar.go's .Do() calls did until the 2026-08-04 // incident, see internal/api/context_audit_test.go for the regression // guard against that specific class of bug) will still hang forever // regardless of this timeout. This is defense-in-depth, not a substitute // for every fn() correctly propagating context into its actual I/O. // Previously nothing here bounded fn()'s worst case at all, only // best-effort ctx cancellation checked before the call even started. fetch := func(name string, fn func(context.Context) error) { wg.Add(1) go func() { defer wg.Done() select { case <-ctx.Done(): return default: } fetchCtx, cancel := context.WithTimeout(ctx, config.HTTPClientTimeout) defer cancel() if err := fn(fetchCtx); err != nil { log.Printf("ERROR [%s]: %v", name, err) mu.Lock() data.Errors = append(data.Errors, name+": "+err.Error()) mu.Unlock() } }() } fetch("Trello", func(fetchCtx context.Context) error { boards, err := h.fetchBoards(fetchCtx, forceRefresh) if err == nil { mu.Lock() data.Boards = boards mu.Unlock() } return err }) if h.planToEatClient != nil { fetch("PlanToEat", func(fetchCtx context.Context) error { meals, err := h.fetchMeals(fetchCtx, forceRefresh) if err == nil { mu.Lock() data.Meals = meals mu.Unlock() } return err }) } if h.googleCalendarClient != nil { fetch("Google Calendar", func(fetchCtx context.Context) error { events, err := h.fetchCalendarEvents(fetchCtx, false) if err == nil { mu.Lock() data.Events = events mu.Unlock() } return err }) } if h.googleTasksClient != nil { fetch("Google Tasks", func(fetchCtx context.Context) error { tasks, err := h.fetchGoogleTasks(fetchCtx, forceRefresh) if err == nil { mu.Lock() data.GoogleTasks = tasks mu.Unlock() } return err }) } wg.Wait() // Extract and sort Trello tasks data.TrelloTasks = filterAndSortTrelloTasks(data.Boards) return data, nil } // filterAndSortTrelloTasks extracts task-like cards from boards func filterAndSortTrelloTasks(boards []models.Board) []models.Card { var tasks []models.Card for _, board := range boards { for _, card := range board.Cards { if card.DueDate != nil || isActionableList(card.ListName) { tasks = append(tasks, card) } } } sort.Slice(tasks, func(i, j int) bool { if tasks[i].DueDate != nil && tasks[j].DueDate != nil { if !tasks[i].DueDate.Equal(*tasks[j].DueDate) { return tasks[i].DueDate.Before(*tasks[j].DueDate) } } if tasks[i].DueDate != nil && tasks[j].DueDate == nil { return true } if tasks[i].DueDate == nil && tasks[j].DueDate != nil { return false } return tasks[i].BoardName < tasks[j].BoardName }) return tasks } // fetchMeals fetches meals from cache or API func (h *Handler) fetchMeals(ctx context.Context, forceRefresh bool) ([]models.Meal, error) { startDate := config.Now() endDate := startDate.AddDate(0, 0, 7) fetcher := &CacheFetcher[models.Meal]{ Store: h.store, CacheKey: store.CacheKeyPlanToEatMeals, TTLMinutes: h.config.CacheTTLMinutes, Fetch: func(ctx context.Context) ([]models.Meal, error) { return h.planToEatClient.GetUpcomingMeals(ctx, 7) }, GetFromCache: func() ([]models.Meal, error) { return h.store.GetMeals(startDate, endDate) }, SaveToCache: h.store.SaveMeals, } return fetcher.FetchWithCache(ctx, forceRefresh) } // fetchCalendarEvents fetches Google Calendar events from cache or API func (h *Handler) fetchCalendarEvents(ctx context.Context, forceRefresh bool) ([]models.CalendarEvent, error) { if h.googleCalendarClient == nil { return nil, nil } // Get enabled calendars from store configs, _ := h.store.GetSourceConfigsBySource("gcal") var enabledIDs []string for _, cfg := range configs { if cfg.Enabled { enabledIDs = append(enabledIDs, cfg.ItemID) } } if len(enabledIDs) == 0 { // No source_configs synced yet — fall back to the configured calendar // ID(s). GoogleCalendarID is a single env var that itself holds a // comma-separated list (see GOOGLE_CALENDAR_ID in .env) -- it must be // split before use. Passing the raw joined string straight through // as a single calendar ID (the previous behavior) sends Google's API // a calendarId that matches nothing, failing every fetch with a 404 — // this is a real production incident, not a hypothetical: it silently // broke all calendar events (web and widget both) until fixed. if len(configs) == 0 && h.config.GoogleCalendarID != "" { for _, id := range strings.Split(h.config.GoogleCalendarID, ",") { if trimmed := strings.TrimSpace(id); trimmed != "" { enabledIDs = append(enabledIDs, trimmed) } } } else { // Configs exist but all disabled — respect that return nil, nil } } h.googleCalendarClient.SetCalendarIDs(enabledIDs) fetcher := &CacheFetcher[models.CalendarEvent]{ Store: h.store, CacheKey: store.CacheKeyGoogleCalendar, TTLMinutes: h.config.CacheTTLMinutes, Fetch: func(ctx context.Context) ([]models.CalendarEvent, error) { return h.googleCalendarClient.GetUpcomingEvents(ctx, 50) }, GetFromCache: h.store.GetCalendarEvents, SaveToCache: h.store.SaveCalendarEvents, } return fetcher.FetchWithCache(ctx, forceRefresh) } // fetchGoogleTasks fetches Google Tasks from cache or API func (h *Handler) fetchGoogleTasks(ctx context.Context, forceRefresh bool) ([]models.GoogleTask, error) { if h.googleTasksClient == nil { return nil, nil } // Get enabled task lists from store configs, _ := h.store.GetSourceConfigsBySource("gtasks") var enabledIDs []string for _, cfg := range configs { if cfg.Enabled { enabledIDs = append(enabledIDs, cfg.ItemID) } } if len(enabledIDs) == 0 { return nil, nil } h.googleTasksClient.SetTaskListID(strings.Join(enabledIDs, ",")) fetcher := &CacheFetcher[models.GoogleTask]{ Store: h.store, CacheKey: store.CacheKeyGoogleTasks, TTLMinutes: h.config.CacheTTLMinutes, Fetch: func(ctx context.Context) ([]models.GoogleTask, error) { return h.googleTasksClient.GetTasks(ctx) }, GetFromCache: h.store.GetGoogleTasks, SaveToCache: h.store.SaveGoogleTasks, } return fetcher.FetchWithCache(ctx, forceRefresh) } // fetchBoards fetches Trello boards from cache or API func (h *Handler) fetchBoards(ctx context.Context, forceRefresh bool) ([]models.Board, error) { fetcher := &CacheFetcher[models.Board]{ Store: h.store, CacheKey: store.CacheKeyTrelloBoards, TTLMinutes: h.config.CacheTTLMinutes, Fetch: func(ctx context.Context) ([]models.Board, error) { boards, err := h.trelloClient.GetBoardsWithCards(ctx) if err == nil { // Debug logging totalCards := 0 for _, b := range boards { totalCards += len(b.Cards) if len(b.Cards) > 0 { log.Printf("Trello API: Board %q has %d cards", b.Name, len(b.Cards)) } } log.Printf("Trello API: Fetched %d boards with %d total cards", len(boards), totalCards) } return boards, err }, GetFromCache: h.store.GetBoards, SaveToCache: h.store.SaveBoards, } return fetcher.FetchWithCache(ctx, forceRefresh) } // HandleCreateCard creates a new Trello card func (h *Handler) HandleCreateCard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if err := r.ParseForm(); err != nil { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } boardID := r.FormValue("board_id") listID := r.FormValue("list_id") name := r.FormValue("name") if boardID == "" || listID == "" || name == "" { JSONError(w, http.StatusBadRequest, "Missing required fields", nil) return } if _, err := h.trelloClient.CreateCard(ctx, listID, name, "", nil); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to create card", err) return } data, err := h.aggregateData(ctx, true) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to refresh data", err) return } var targetBoard *models.Board for i := range data.Boards { if data.Boards[i].ID == boardID { targetBoard = &data.Boards[i] break } } if targetBoard == nil { JSONError(w, http.StatusNotFound, "Board not found", nil) return } HTMLResponse(w, h.renderer, "trello-board", targetBoard) } // HandleCompleteCard marks a Trello card as complete func (h *Handler) HandleCompleteCard(w http.ResponseWriter, r *http.Request) { cardID, ok := requireFormValue(w, r, "card_id") if !ok { return } if err := h.trelloClient.UpdateCard(r.Context(), cardID, map[string]interface{}{"closed": true}); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to complete card", err) return } w.WriteHeader(http.StatusOK) } // HandleDeferAtom returns an active maintenance-bucket item to its pool // without crediting it as done -- distinct from complete/uncomplete. // Doot-native tasks only (bucket items don't exist for other sources). // No special swap needed: a successful defer removes the task's due date, // so the same timeline refresh that follows completion just makes it // disappear from the current view like any other now-undated task would. func (h *Handler) HandleDeferAtom(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } id := r.FormValue("id") if id == "" { JSONError(w, http.StatusBadRequest, "Missing id", nil) return } if err := h.store.DeferNativeTask(id); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to defer task", err) return } w.Header().Set("HX-Reswap", "none") w.Header().Set("HX-Trigger", "refresh-tasks") w.WriteHeader(http.StatusOK) } // HandleCompleteAtom handles completion of a unified task (Atom) func (h *Handler) HandleCompleteAtom(w http.ResponseWriter, r *http.Request) { h.handleAtomToggle(w, r, true) } // HandleUncompleteAtom handles reopening a completed task func (h *Handler) HandleUncompleteAtom(w http.ResponseWriter, r *http.Request) { h.handleAtomToggle(w, r, false) } // handleAtomToggle handles both complete and uncomplete operations func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, complete bool) { ctx := r.Context() if err := r.ParseForm(); err != nil { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } id := r.FormValue("id") source := r.FormValue("source") if id == "" || source == "" { JSONError(w, http.StatusBadRequest, "Missing id or source", nil) return } // Capture title/dueDate before mutating state so doot tasks (which are // queried with WHERE completed=0) are still findable. var preTitle string var preDueDate *time.Time if complete { preTitle, preDueDate = h.getAtomDetails(id, source) } var err error switch source { case "doot": if complete { err = h.store.CompleteNativeTask(id) } else { err = h.store.UncompleteNativeTask(id) } case "trello": err = h.trelloClient.UpdateCard(ctx, id, map[string]interface{}{"closed": complete}) case "gtasks": // Google Tasks - need list ID from form or use default listID := r.FormValue("listId") if listID == "" { listID = "@default" } if h.googleTasksClient != nil { if complete { err = h.googleTasksClient.CompleteTask(ctx, listID, id) } else { err = h.googleTasksClient.UncompleteTask(ctx, listID, id) } } else { JSONError(w, http.StatusServiceUnavailable, "Google Tasks not configured", nil) return } default: JSONError(w, http.StatusBadRequest, "Unknown source: "+source, nil) return } if err != nil { if errors.Is(err, store.ErrChainTaskLocked) { JSONError(w, http.StatusBadRequest, "Task is locked in its chain", err) return } action := "complete" if !complete { action = "reopen" } JSONError(w, http.StatusInternalServerError, "Failed to "+action+" task", err) return } if complete { // Use pre-fetched details (captured before mutation above). title, dueDate := preTitle, preDueDate // Log to completed tasks _ = h.store.SaveCompletedTask(source, id, title, dueDate) // Remove from local cache switch source { case "trello": _ = h.store.DeleteCard(id) case "gtasks": _ = h.store.InvalidateCache(store.CacheKeyGoogleTasks) case "doot": // native tasks stay in DB marked completed; no cache to invalidate } // Return completed task HTML with uncomplete option data := struct { ID string Source string Title string }{id, source, title} HTMLResponse(w, h.renderer, "completed-atom", data) } else { // Invalidate cache to force refresh if source == "trello" { _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards) } // Don't swap empty response - just trigger refresh w.Header().Set("HX-Reswap", "none") w.Header().Set("HX-Trigger", "refresh-tasks") w.WriteHeader(http.StatusOK) } } // getAtomDetails retrieves title and due date for a task/card/bug from the store func (h *Handler) getAtomDetails(id, source string) (string, *time.Time) { switch source { case "doot": if tasks, err := h.store.GetNativeTasks(); err == nil { for _, t := range tasks { if t.ID == id { return t.Content, t.DueDate } } } case "trello": if boards, err := h.store.GetBoards(); err == nil { for _, b := range boards { for _, c := range b.Cards { if c.ID == id { return c.Name, c.DueDate } } } } case "gtasks": if t, ok := h.findGoogleTask(id); ok { return t.Title, t.DueDate } } return "Task", nil } // HandleUnifiedAdd creates a native task or a card in Trello from the Quick Add form func (h *Handler) HandleUnifiedAdd(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if err := r.ParseForm(); err != nil { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } title := r.FormValue("title") source := r.FormValue("source") dueDateStr := r.FormValue("due_date") if title == "" { JSONError(w, http.StatusBadRequest, "Title is required", nil) return } var dueDate *time.Time if dueDateStr != "" { if parsed, err := config.ParseDateInDisplayTZ(dueDateStr); err == nil { dueDate = &parsed } } switch source { case "doot": task := models.Task{ ID: newID(), Content: title, DueDate: dueDate, Priority: 1, Labels: nil, } if err := h.store.CreateNativeTask(task); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to create task", err) return } case "trello": listID := r.FormValue("list_id") if listID == "" { JSONError(w, http.StatusBadRequest, "List is required for Trello", nil) return } if _, err := h.trelloClient.CreateCard(ctx, listID, title, "", dueDate); err != nil { JSONError(w, http.StatusInternalServerError, "Failed to create Trello card", err) return } _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards) default: JSONError(w, http.StatusBadRequest, "Invalid source", nil) return } w.Header().Set("HX-Trigger", "refresh-tasks") w.WriteHeader(http.StatusOK) } // HandleGetListsOptions returns HTML options for lists in a given board func (h *Handler) HandleGetListsOptions(w http.ResponseWriter, r *http.Request) { boardID := r.URL.Query().Get("board_id") if boardID == "" { JSONError(w, http.StatusBadRequest, "board_id is required", nil) return } lists, err := h.trelloClient.GetLists(r.Context(), boardID) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to fetch lists", err) return } HTMLResponse(w, h.renderer, "lists-options", lists) } // taskDetailData is what the "task-detail" template renders (the modal // opened from any tab's task card). Recurrence fields are doot-native only. type taskDetailData struct { Title string ID string Source string Description string IsDoot bool RecurrenceFreq string RecurrenceInterval int RecurrenceWeekdays []int } // loadTaskDetailData fetches a task's current fields for the detail modal, // shared between the initial GET and re-rendering after a recurrence edit. func (h *Handler) loadTaskDetailData(id, source string) taskDetailData { data := taskDetailData{ID: id, Source: source} switch source { case "trello": if boards, err := h.store.GetBoards(); err == nil { for _, b := range boards { for _, c := range b.Cards { if c.ID == id { data.Title, data.Description = c.Name, c.Description break } } } } case "doot": if task, err := h.store.GetNativeTaskByID(id); err == nil { data.IsDoot = true data.Title = task.Content data.Description = task.Description data.RecurrenceFreq = task.RecurrenceFreq data.RecurrenceInterval = task.RecurrenceInterval data.RecurrenceWeekdays = task.RecurrenceWeekdays } case "gtasks": if t, ok := h.findGoogleTask(id); ok { data.Title, data.Description = t.Title, t.Notes } } return data } // HandleGetTaskDetail returns task details as HTML for modal. func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("id") source := r.URL.Query().Get("source") if id == "" || source == "" { JSONError(w, http.StatusBadRequest, "Missing id or source", nil) return } HTMLResponse(w, h.renderer, "task-detail", h.loadTaskDetailData(id, source)) } // HandleSetTaskRecurrence sets or clears a doot-native task's recurrence // pattern from the web task-detail modal -- the HTMX counterpart to the // widget API's HandleWidgetTaskRecurrence (internal/handlers/widget.go). // freq == "" clears recurrence. Re-renders the detail modal so it reflects // the new state without closing. func (h *Handler) HandleSetTaskRecurrence(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } id := r.FormValue("id") if id == "" { JSONError(w, http.StatusBadRequest, "Missing id", nil) return } freq := r.FormValue("freq") if freq != "" && freq != "daily" && freq != "weekly" && freq != "monthly" && freq != "yearly" { JSONError(w, http.StatusBadRequest, "Invalid freq", nil) return } interval, _ := strconv.Atoi(r.FormValue("interval")) if interval <= 0 { interval = 1 } var weekdays []int for _, d := range r.Form["weekdays"] { if n, err := strconv.Atoi(d); err == nil { weekdays = append(weekdays, n) } } if err := h.store.SetTaskRecurrence(id, freq, interval, weekdays); err != nil { if errors.Is(err, store.ErrNativeTaskNotFound) { JSONError(w, http.StatusNotFound, "task not found", err) return } JSONError(w, http.StatusInternalServerError, "Failed to update recurrence", err) return } HTMLResponse(w, h.renderer, "task-detail", h.loadTaskDetailData(id, "doot")) } // HandleTaskDetailPage renders a standalone full-page task detail view (used by Android widget deep-links). func (h *Handler) HandleTaskDetailPage(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 } detail := h.loadTaskDetailData(id, source) title := detail.Title if title == "" { title = "Task" } data := struct { Title string ID string Source string Description string CSRFToken string Saved bool }{title, id, source, detail.Description, auth.GetCSRFTokenFromContext(r.Context()), r.URL.Query().Get("saved") == "1"} if err := h.renderer.Render(w, "task-detail-page.html", data); err != nil { http.Error(w, "Failed to render template", http.StatusInternalServerError) } } // HandleUpdateTask updates a task description func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { JSONError(w, http.StatusBadRequest, "Failed to parse form", err) return } id := r.FormValue("id") source := r.FormValue("source") title := r.FormValue("title") description := r.FormValue("description") if id == "" || source == "" { JSONError(w, http.StatusBadRequest, "Missing id or source", nil) return } var err error switch source { case "doot": if title != "" { err = h.store.UpdateNativeTask(id, title, description) } else { err = h.store.UpdateNativeTaskDescription(id, description) } case "trello": updates := map[string]interface{}{"desc": description} if title != "" { updates["name"] = title } err = h.trelloClient.UpdateCard(r.Context(), id, updates) case "gtasks": if h.googleTasksClient == nil { JSONError(w, http.StatusServiceUnavailable, "Google Tasks not configured", nil) return } t, ok := h.findGoogleTask(id) if !ok { JSONError(w, http.StatusNotFound, "task not found", nil) return } saveTitle := title if saveTitle == "" { saveTitle = t.Title } err = h.googleTasksClient.UpdateTask(r.Context(), t.ListID, id, saveTitle, description) if err == nil { _ = h.store.InvalidateCache(store.CacheKeyGoogleTasks) } default: JSONError(w, http.StatusBadRequest, "Unknown source", nil) return } if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to update task", err) return } if r.Header.Get("HX-Request") != "" { w.WriteHeader(http.StatusOK) } else { http.Redirect(w, r, "/task?id="+id+"&source="+source+"&saved=1", http.StatusSeeOther) } } // HandleDeleteTask permanently deletes a doot-native task. Other sources // (Trello cards, Google Tasks, calendar events) aren't deletable through // doot -- the task-detail modal only shows the Delete button for source == // "doot" (task-detail.html's IsDoot flag), matching this scoping. func (h *Handler) HandleDeleteTask(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") source := r.URL.Query().Get("source") if id == "" { JSONError(w, http.StatusBadRequest, "Missing id", nil) return } if source != "doot" { JSONError(w, http.StatusBadRequest, "Delete is only supported for doot-native tasks", nil) return } if err := h.store.DeleteNativeTask(id); err != nil { if errors.Is(err, store.ErrNativeTaskNotFound) { JSONError(w, http.StatusNotFound, "task not found", err) return } JSONError(w, http.StatusInternalServerError, "Failed to delete task", err) return } w.WriteHeader(http.StatusOK) } // HandleTabTasks renders the unified Tasks tab (native tasks + Trello cards with due dates + Google Tasks) func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) { atoms, boards, err := BuildUnifiedAtomList(h.store, h.claudomatorClient) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to fetch tasks", err) return } chainSummaries, err := BuildChainSummaries(h.store) if err != nil { log.Printf("Warning: failed to build chain summaries: %v", err) } bucketSummaries, err := BuildBucketSummaries(h.store) if err != nil { log.Printf("Warning: failed to build bucket summaries: %v", err) } projects, err := h.store.GetProjects() if err != nil { log.Printf("Warning: failed to load projects: %v", err) } labels, err := h.store.GetLabelColors() if err != nil { log.Printf("Warning: failed to load label colors: %v", err) } SortAtomsByUrgency(atoms) currentAtoms, futureAtoms := PartitionAtomsByTime(atoms) data := struct { Atoms []models.Atom FutureAtoms []models.Atom Boards []models.Board Chains []models.ChainSummary Buckets []models.BucketSummary Projects []models.Project Labels []models.LabelColor Today string }{ Atoms: currentAtoms, FutureAtoms: futureAtoms, Boards: boards, Chains: chainSummaries, Buckets: bucketSummaries, Projects: projects, Labels: labels, Today: config.Now().Format("2006-01-02"), } HTMLResponse(w, h.renderer, "tasks-tab", data) } // HandleTabPlanning renders the Planning tab with structured sections func (h *Handler) HandleTabPlanning(w http.ResponseWriter, r *http.Request) { today := config.Today() tomorrow := today.AddDate(0, 0, 1) in3Days := today.AddDate(0, 0, 4) boards, _ := h.store.GetBoards() gTasks, _ := h.store.GetGoogleTasks() events, _ := h.fetchCalendarEvents(r.Context(), false) var scheduled []ScheduledItem var unscheduled []models.Atom var upcoming []ScheduledItem for _, event := range events { item := ScheduledItem{ Type: "event", ID: event.ID, Title: event.Summary, Start: event.Start, End: event.End, URL: event.HTMLLink, Source: "calendar", SourceIcon: "🗓️", } if event.Start.Before(tomorrow) { scheduled = append(scheduled, item) } else if event.Start.Before(in3Days) { upcoming = append(upcoming, item) } } for _, board := range boards { for _, card := range board.Cards { if card.DueDate == nil { continue } dueDate := *card.DueDate hasTime := dueDate.Hour() != 0 || dueDate.Minute() != 0 if dueDate.Before(tomorrow) { if hasTime { scheduled = append(scheduled, ScheduledItem{ Type: "task", ID: card.ID, Title: card.Name, Start: dueDate, URL: card.URL, Source: "trello", SourceIcon: "📋", }) } else { atom := models.CardToAtom(card) atom.ComputeUIFields() unscheduled = append(unscheduled, atom) } } else if dueDate.Before(in3Days) { upcoming = append(upcoming, ScheduledItem{ Type: "task", ID: card.ID, Title: card.Name, Start: dueDate, URL: card.URL, Source: "trello", SourceIcon: "📋", }) } } } for _, gTask := range gTasks { if gTask.Completed { continue } if gTask.DueDate == nil { atom := models.GoogleTaskToAtom(gTask) atom.ComputeUIFields() unscheduled = append(unscheduled, atom) continue } dueDate := *gTask.DueDate // Google Tasks usually don't have times, but if they did we'd handle them here hasTime := dueDate.Hour() != 0 || dueDate.Minute() != 0 if dueDate.Before(tomorrow) { if hasTime { scheduled = append(scheduled, ScheduledItem{ Type: "task", ID: gTask.ID, Title: gTask.Title, Start: dueDate, URL: gTask.URL, Source: "gtasks", SourceIcon: "🔵", Priority: 2, }) } else { atom := models.GoogleTaskToAtom(gTask) atom.ComputeUIFields() unscheduled = append(unscheduled, atom) } } else if dueDate.Before(in3Days) { upcoming = append(upcoming, ScheduledItem{ Type: "task", ID: gTask.ID, Title: gTask.Title, Start: dueDate, URL: gTask.URL, Source: "gtasks", SourceIcon: "🔵", Priority: 2, }) } } sort.Slice(scheduled, func(i, j int) bool { return scheduled[i].Start.Before(scheduled[j].Start) }) sort.Slice(unscheduled, func(i, j int) bool { return unscheduled[i].Priority > unscheduled[j].Priority }) sort.Slice(upcoming, func(i, j int) bool { return upcoming[i].Start.Before(upcoming[j].Start) }) data := struct { Scheduled []ScheduledItem Unscheduled []models.Atom Upcoming []ScheduledItem Boards []models.Board Today string }{ Scheduled: scheduled, Unscheduled: unscheduled, Upcoming: upcoming, Boards: boards, Today: today.Format("2006-01-02"), } HTMLResponse(w, h.renderer, "planning-tab", data) } // CombinedMeal represents multiple meals combined for same date+mealType type CombinedMeal struct { ID string RecipeNames []string Date time.Time MealType string RecipeURL string // URL of first meal Meals []models.Meal // original meal records } // HandleTabMeals renders the Meals tab (PlanToEat) func (h *Handler) HandleTabMeals(w http.ResponseWriter, r *http.Request) { startDate := config.Today() endDate := startDate.AddDate(0, 0, 7) meals, err := h.store.GetMeals(startDate, endDate) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to fetch meals", err) return } HTMLResponse(w, h.renderer, "meals-tab", struct{ Meals []CombinedMeal }{groupMeals(meals)}) } // mealTypeOrder returns sort order for meal types func mealTypeOrder(mealType string) int { switch mealType { case "breakfast": return 0 case "lunch": return 1 case "dinner": return 2 default: return 3 } } // HandleConditionsPage renders the standalone Conditions page with live feeds func (h *Handler) HandleConditionsPage(w http.ResponseWriter, r *http.Request) { if err := h.renderer.Render(w, "conditions.html", nil); err != nil { http.Error(w, "Failed to render conditions page", http.StatusInternalServerError) log.Printf("Error rendering conditions page: %v", err) } } // isActionableList returns true if the list name indicates an actionable list func isActionableList(name string) bool { lower := strings.ToLower(name) return strings.Contains(lower, "doing") || strings.Contains(lower, "in progress") || strings.Contains(lower, "to do") || strings.Contains(lower, "todo") || strings.Contains(lower, "tasks") || strings.Contains(lower, "next") || strings.Contains(lower, "today") } // ScheduledItem represents a scheduled event or task for the planning view type ScheduledItem struct { Type string ID string Title string Start time.Time End time.Time URL string Source string SourceIcon string Priority int }