diff options
Diffstat (limited to 'internal/handlers')
| -rw-r--r-- | internal/handlers/chains_timeline_test.go | 69 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 177 | ||||
| -rw-r--r-- | internal/handlers/templates_test.go | 24 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic.go | 38 |
4 files changed, 242 insertions, 66 deletions
diff --git a/internal/handlers/chains_timeline_test.go b/internal/handlers/chains_timeline_test.go index e9c8a3a..d4bc369 100644 --- a/internal/handlers/chains_timeline_test.go +++ b/internal/handlers/chains_timeline_test.go @@ -5,10 +5,15 @@ import ( "testing" "time" + "task-dashboard/internal/config" "task-dashboard/internal/models" ) -func TestBuildTimeline_PopulatesChainBadgeForUnlockedTask(t *testing.T) { +// Chain steps carry no due date and are kept out of the undated list, so they +// stay off the day's timeline entirely until deliberately committed. This is the +// whole point of the pull model: the day contains what was chosen plus real +// deadlines, so finishing at zero is reachable. +func TestBuildTimeline_UnlockedChainTaskAbsentUntilCommitted(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() @@ -28,6 +33,36 @@ func TestBuildTimeline_PopulatesChainBadgeForUnlockedTask(t *testing.T) { if err != nil { t.Fatalf("BuildTimeline: %v", err) } + for _, item := range items { + if item.ID == tasks[0].ID { + t.Error("uncommitted chain step appeared on the timeline; chain work should only arrive by being committed") + } + } +} + +func TestBuildTimeline_CommittedChainTaskAppearsWithBadge(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + + chain, err := s.CreateChain("Ham Radio Track", []models.ChainTaskInput{ + {Content: "Study Technician"}, {Content: "Pass exam"}, {Content: "Study General"}, + }) + if err != nil { + t.Fatal(err) + } + tasks, err := s.GetChainTasks(chain.ID) + if err != nil { + t.Fatal(err) + } + if err := s.SetTaskCommitted(tasks[0].ID, config.Now()); err != nil { + t.Fatal(err) + } + + now := time.Now() + items, err := BuildTimeline(context.Background(), s, now.Add(-time.Hour), now.Add(24*time.Hour)) + if err != nil { + t.Fatalf("BuildTimeline: %v", err) + } var found bool for _, item := range items { @@ -42,6 +77,36 @@ func TestBuildTimeline_PopulatesChainBadgeForUnlockedTask(t *testing.T) { } } if !found { - t.Fatal("expected the unlocked chain task (position 0) to appear in the timeline") + t.Fatal("expected the committed chain step to appear on the timeline") + } +} + +// A task that is both committed and due today must appear once, not twice. +func TestBuildTimeline_CommittedAndDueTaskNotDuplicated(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + + due := config.Now() + if err := s.CreateNativeTask(models.Task{ID: "dual", Content: "Bills", DueDate: &due}); err != nil { + t.Fatal(err) + } + if err := s.SetTaskCommitted("dual", config.Now()); err != nil { + t.Fatal(err) + } + + now := time.Now() + items, err := BuildTimeline(context.Background(), s, now.Add(-24*time.Hour), now.Add(24*time.Hour)) + if err != nil { + t.Fatalf("BuildTimeline: %v", err) + } + + var count int + for _, item := range items { + if item.ID == "dual" { + count++ + } + } + if count != 1 { + t.Errorf("task appeared %d times, want exactly 1 (committed and due are two reasons for the same row)", count) } } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 69e1c65..e70650d 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -51,70 +51,7 @@ type Handler struct { // 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, ", ") - }, - } + funcMap := templateFuncMap() // Parse templates including partials tmpl, err := template.New("").Funcs(funcMap).ParseGlob(filepath.Join(cfg.TemplateDir, "*.html")) @@ -1263,3 +1200,115 @@ type ScheduledItem struct { SourceIcon string Priority int } + +// HandleTaskCommit marks a doot-native task as chosen for today, which is how +// chain steps and undated backlog work reach the day's list at all -- they carry +// no due date by design, so nothing pulls them in automatically. Posting again +// for an already-committed task is harmless (it just re-stamps today). +func (h *Handler) HandleTaskCommit(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + JSONError(w, http.StatusBadRequest, "Missing id", nil) + return + } + if err := h.store.SetTaskCommitted(id, config.Now()); err != nil { + if errors.Is(err, store.ErrNativeTaskNotFound) { + JSONError(w, http.StatusNotFound, "task not found", err) + return + } + JSONError(w, http.StatusInternalServerError, "Failed to commit task", err) + return + } + w.Header().Set("HX-Trigger", "refresh-tasks") + w.WriteHeader(http.StatusOK) +} + +// HandleTaskUncommit drops a task back to the backlog without completing it -- +// the honest way to walk back an over-committed day. +func (h *Handler) HandleTaskUncommit(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + JSONError(w, http.StatusBadRequest, "Missing id", nil) + return + } + if err := h.store.ClearTaskCommitted(id); err != nil { + if errors.Is(err, store.ErrNativeTaskNotFound) { + JSONError(w, http.StatusNotFound, "task not found", err) + return + } + JSONError(w, http.StatusInternalServerError, "Failed to uncommit task", err) + return + } + w.Header().Set("HX-Trigger", "refresh-tasks") + w.WriteHeader(http.StatusOK) +} + +// templateFuncMap returns the functions available to every template. Extracted +// from New so the template set can be parse-checked in a test -- New only logs a +// warning on a parse failure, so a malformed template would otherwise degrade +// silently at runtime instead of failing anything. +func templateFuncMap() template.FuncMap { + return 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, ", ") + }, + } +} diff --git a/internal/handlers/templates_test.go b/internal/handlers/templates_test.go new file mode 100644 index 0000000..ffaf137 --- /dev/null +++ b/internal/handlers/templates_test.go @@ -0,0 +1,24 @@ +package handlers + +import ( + "html/template" + "path/filepath" + "testing" +) + +// New() only logs a warning when template parsing fails, so a malformed template +// -- an unknown function, an unclosed action, a typo'd field pipeline -- ships +// silently and only shows up as a broken page at runtime. This parses the real +// template set with the real func map so that failure is a build-time failure +// instead. +func TestTemplates_AllParse(t *testing.T) { + dir := filepath.Join("..", "..", "web", "templates") + + tmpl, err := template.New("").Funcs(templateFuncMap()).ParseGlob(filepath.Join(dir, "*.html")) + if err != nil { + t.Fatalf("parsing top-level templates: %v", err) + } + if _, err := tmpl.ParseGlob(filepath.Join(dir, "partials", "*.html")); err != nil { + t.Fatalf("parsing partials: %v", err) + } +} diff --git a/internal/handlers/timeline_logic.go b/internal/handlers/timeline_logic.go index b25be1f..881ae78 100644 --- a/internal/handlers/timeline_logic.go +++ b/internal/handlers/timeline_logic.go @@ -245,6 +245,44 @@ func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([ } } + // Tasks explicitly committed to today. This is the only way chain steps and + // other undated backlog work reach the day at all -- they carry no due date + // by design (see migration 031), so nothing above picks them up. + // + // Deduped against everything already added: "committed" and "due today" are + // two independent reasons for the same row, and a task can have both. + seen := make(map[string]bool, len(items)) + for _, item := range items { + seen[item.ID] = true + } + committed, err := s.GetCommittedTasks(now) + if err != nil { + log.Printf("Warning: failed to read committed tasks: %v", err) + } else { + for _, task := range committed { + if seen[task.ID] { + continue + } + item := models.TimelineItem{ + ID: task.ID, + Type: models.TimelineItemTypeTask, + Title: task.Content, + Time: now, + Description: task.Description, + OriginalItem: task, + IsCompleted: task.Completed, + Source: "doot", + IsAllDay: true, + Undated: true, + ProjectColor: projectColors[task.ProjectID], + } + setChainBadge(s, &item, task) + item.BucketState = task.BucketState + item.ComputeDaySection(now) + items = append(items, item) + } + } + // Sort items by Time sort.Slice(items, func(i, j int) bool { return items[i].Time.Before(items[j].Time) |
