summaryrefslogtreecommitdiff
path: root/internal/handlers/handlers.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-25 17:05:49 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-25 17:09:41 -1000
commitdedda31d064ddcb4f857f2db851c5a8c1e19deba (patch)
tree2f76f41806727afa54449cdac8672056a5f8615c /internal/handlers/handlers.go
parentec8a9c0ea46dec7d26caa763e3adefcaf3fc7552 (diff)
Implement architectural refactors for feature requests #28, #30, #31, #33-38
Phase 1: Bugs as First-Class Atoms (#28) - Add resolved_at column to bugs table (migration 007) - Add GetUnresolvedBugs(), ResolveBug(), UnresolveBug() store methods - Include bugs in Tasks tab via BugToAtom() with completion toggle - Add unit tests for bug resolution Phase 2: Timeline as Default + Enhancements (#35, #37) - Change default tab from tasks to timeline - Add IsCompleted, DaySection, Source fields to TimelineItem - Group timeline items by today/tomorrow/later sections - Add completion checkboxes for tasks/cards, grey completed items - Collapse tomorrow/later sections by default Phase 3: Shopping Quick-Add (#33) - Add user_shopping_items table (migration 008) - Add SaveUserShoppingItem(), GetUserShoppingItems(), ToggleUserShoppingItem() - Add HandleShoppingQuickAdd() and HandleShoppingToggle() handlers - Add quick-add form to shopping tab Phase 4: Mobile Swipe Navigation (#38) - Add touch event handlers for swipe left/right tab switching - 50px threshold triggers tab change Phase 5: Consistent Background Opacity (#30) - Add CSS variables for panel/card/input/modal backgrounds - Update templates to use consistent opacity classes Phase 6: Tab Reorganization (#37) - Reorganize tabs: Timeline, Shopping, Conditions as main tabs - Move Tasks, Planning, Meals under Details dropdown Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'internal/handlers/handlers.go')
-rw-r--r--internal/handlers/handlers.go150
1 files changed, 144 insertions, 6 deletions
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index 5c86ce2..ee28a87 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -62,7 +62,7 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
// Extract tab query parameter for state persistence
tab := r.URL.Query().Get("tab")
if tab == "" {
- tab = "tasks"
+ tab = "timeline"
}
// Aggregate data from all sources
@@ -626,6 +626,18 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl
}
case "trello":
err = h.trelloClient.UpdateCard(ctx, id, map[string]interface{}{"closed": complete})
+ case "bug":
+ // Bug IDs are prefixed with "bug-", extract the numeric ID
+ var bugID int64
+ if _, parseErr := fmt.Sscanf(id, "bug-%d", &bugID); parseErr != nil {
+ JSONError(w, http.StatusBadRequest, "Invalid bug ID format", parseErr)
+ return
+ }
+ if complete {
+ err = h.store.ResolveBug(bugID)
+ } else {
+ err = h.store.UnresolveBug(bugID)
+ }
default:
JSONError(w, http.StatusBadRequest, "Unknown source: "+source, nil)
return
@@ -684,7 +696,7 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl
}
}
-// getAtomTitle retrieves the title for a task/card from the store
+// getAtomTitle retrieves the title for a task/card/bug from the store
func (h *Handler) getAtomTitle(id, source string) string {
switch source {
case "todoist":
@@ -705,6 +717,16 @@ func (h *Handler) getAtomTitle(id, source string) string {
}
}
}
+ case "bug":
+ if bugs, err := h.store.GetBugs(); err == nil {
+ var bugID int64
+ fmt.Sscanf(id, "bug-%d", &bugID)
+ for _, b := range bugs {
+ if b.ID == bugID {
+ return b.Description
+ }
+ }
+ }
}
return "Task"
}
@@ -943,7 +965,7 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
-// HandleTabTasks renders the unified Tasks tab (Todoist + Trello cards with due dates)
+// HandleTabTasks renders the unified Tasks tab (Todoist + Trello cards with due dates + Bugs)
func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) {
tasks, err := h.store.GetTasks()
if err != nil {
@@ -957,6 +979,12 @@ func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) {
return
}
+ bugs, err := h.store.GetUnresolvedBugs()
+ if err != nil {
+ log.Printf("Warning: failed to fetch bugs: %v", err)
+ bugs = nil
+ }
+
atoms := make([]models.Atom, 0)
for _, task := range tasks {
@@ -973,6 +1001,15 @@ func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) {
}
}
+ // Add unresolved bugs as atoms
+ for _, bug := range bugs {
+ atoms = append(atoms, models.BugToAtom(models.Bug{
+ ID: bug.ID,
+ Description: bug.Description,
+ CreatedAt: bug.CreatedAt,
+ }))
+ }
+
for i := range atoms {
atoms[i].ComputeUIFields()
}
@@ -1171,14 +1208,89 @@ func (h *Handler) HandleTabMeals(w http.ResponseWriter, r *http.Request) {
HTMLResponse(w, h.templates, "meals-tab", struct{ Meals []models.Meal }{meals})
}
-// HandleTabShopping renders the Shopping tab (Trello Shopping board + PlanToEat)
+// HandleTabShopping renders the Shopping tab (Trello Shopping board + PlanToEat + User items)
func (h *Handler) HandleTabShopping(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
stores := h.aggregateShoppingLists(ctx)
HTMLResponse(w, h.templates, "shopping-tab", struct{ Stores []models.ShoppingStore }{stores})
}
-// aggregateShoppingLists combines Trello and PlanToEat shopping items by store
+// HandleShoppingQuickAdd adds a user shopping item
+func (h *Handler) HandleShoppingQuickAdd(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ JSONError(w, http.StatusBadRequest, "Failed to parse form", err)
+ return
+ }
+
+ name := strings.TrimSpace(r.FormValue("name"))
+ store := strings.TrimSpace(r.FormValue("store"))
+
+ if name == "" {
+ JSONError(w, http.StatusBadRequest, "Name is required", nil)
+ return
+ }
+ if store == "" {
+ store = "Quick Add"
+ }
+
+ if err := h.store.SaveUserShoppingItem(name, store); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to save item", err)
+ return
+ }
+
+ // Return refreshed shopping tab
+ ctx := r.Context()
+ stores := h.aggregateShoppingLists(ctx)
+ HTMLResponse(w, h.templates, "shopping-tab", struct{ Stores []models.ShoppingStore }{stores})
+}
+
+// HandleShoppingToggle toggles a shopping item's checked state
+func (h *Handler) HandleShoppingToggle(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")
+ checked := r.FormValue("checked") == "true"
+
+ if source == "user" {
+ var userID int64
+ if _, err := fmt.Sscanf(id, "user-%d", &userID); err != nil {
+ JSONError(w, http.StatusBadRequest, "Invalid user item ID", err)
+ return
+ }
+ if err := h.store.ToggleUserShoppingItem(userID, checked); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to toggle item", err)
+ return
+ }
+ }
+ // Note: Trello and PlanToEat toggle would need their own API calls
+
+ // Return updated item HTML
+ checkedClass := ""
+ checkedAttr := ""
+ textClass := "text-white"
+ if checked {
+ checkedClass = "opacity-50"
+ checkedAttr = "checked"
+ textClass = "line-through text-white/40"
+ }
+ html := fmt.Sprintf(`<li class="flex items-center gap-3 p-3 bg-white/5 hover:bg-white/10 transition-colors rounded-lg border border-white/5 %s">
+ <input type="checkbox" %s
+ hx-post="/shopping/toggle"
+ hx-vals='{"id":"%s","source":"%s","checked":%t}'
+ hx-target="closest li"
+ hx-swap="outerHTML"
+ class="h-5 w-5 rounded bg-black/40 border-white/30 text-green-500 focus:ring-white/30 cursor-pointer flex-shrink-0">
+ <span class="flex-1 %s">%s</span>
+ <span class="text-xs px-2 py-0.5 rounded bg-purple-900/50 text-purple-300">user</span>
+ </li>`, checkedClass, checkedAttr, template.HTMLEscapeString(id), template.HTMLEscapeString(source), !checked, textClass, template.HTMLEscapeString(r.FormValue("name")))
+ HTMLString(w, html)
+}
+
+// aggregateShoppingLists combines Trello, PlanToEat, and user shopping items by store
func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingStore {
storeMap := make(map[string]map[string][]models.UnifiedShoppingItem)
@@ -1245,7 +1357,33 @@ func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingS
log.Printf("DEBUG [Shopping/PlanToEat]: Client not configured")
}
- // 3. Convert map to sorted slice
+ // 3. Fetch user-added shopping items
+ userItems, err := h.store.GetUserShoppingItems()
+ if err != nil {
+ log.Printf("ERROR [Shopping/User]: %v", err)
+ } else {
+ for _, item := range userItems {
+ storeName := item.Store
+ if storeName == "" {
+ storeName = "Quick Add"
+ }
+
+ if storeMap[storeName] == nil {
+ storeMap[storeName] = make(map[string][]models.UnifiedShoppingItem)
+ }
+
+ unified := models.UnifiedShoppingItem{
+ ID: fmt.Sprintf("user-%d", item.ID),
+ Name: item.Name,
+ Store: storeName,
+ Source: "user",
+ Checked: item.Checked,
+ }
+ storeMap[storeName][""] = append(storeMap[storeName][""], unified)
+ }
+ }
+
+ // 4. Convert map to sorted slice
var stores []models.ShoppingStore
for storeName, categories := range storeMap {
store := models.ShoppingStore{Name: storeName}