package web import ( "testing" "time" "github.com/m/projax/caldav" "github.com/m/projax/gitea" "github.com/m/projax/internal/aggregate" "github.com/m/projax/store" ) func mustItem(id, path string) *store.Item { return &store.Item{ID: id, Slug: path, Title: path, Paths: []string{path}} } // TestCollectProjectRollupsCountsTasksAndOverdue feeds three open VTODOs at // staggered due dates into one item and asserts OpenTasks counts all three, // Overdue counts just the one in the past, and NextSignal picks the // soonest-due summary. func TestCollectProjectRollupsCountsTasksAndOverdue(t *testing.T) { now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) it := mustItem("i1", "dev.alpha") yesterday := now.AddDate(0, 0, -1) tomorrow := now.AddDate(0, 0, 1) weekOut := now.AddDate(0, 0, 6) todos := []aggregate.TodoRow{ {Item: it, CalendarURL: "cal-a", Todo: caldav.Todo{UID: "t1", Summary: "due tomorrow", Status: "NEEDS-ACTION", Due: &tomorrow}}, {Item: it, CalendarURL: "cal-a", Todo: caldav.Todo{UID: "t2", Summary: "overdue", Status: "NEEDS-ACTION", Due: &yesterday}}, {Item: it, CalendarURL: "cal-a", Todo: caldav.Todo{UID: "t3", Summary: "next week", Status: "NEEDS-ACTION", Due: &weekOut}}, } rollups := collectProjectRollups([]*store.Item{it}, todos, nil, nil, nil, nil, nil, now) if len(rollups) != 1 { t.Fatalf("expected 1 rollup, got %d", len(rollups)) } p := rollups[0] if p.OpenTasks != 3 { t.Errorf("OpenTasks: want 3 got %d", p.OpenTasks) } if p.Overdue != 1 { t.Errorf("Overdue: want 1 got %d", p.Overdue) } if p.NextSignal != "overdue" { t.Errorf("NextSignal: want soonest-due summary 'overdue' got %q", p.NextSignal) } if p.NextSignalKind != "task" { t.Errorf("NextSignalKind: want 'task' got %q", p.NextSignalKind) } } // TestCollectProjectRollupsSkipsClosedTasksButFeedsActivity asserts that a // COMPLETED VTODO doesn't bump OpenTasks but its LastModified still feeds // LastActivity — a project that shipped tasks last week stays "current". func TestCollectProjectRollupsSkipsClosedTasksButFeedsActivity(t *testing.T) { now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) it := mustItem("i1", "dev.alpha") lastTouch := now.AddDate(0, 0, -3) todos := []aggregate.TodoRow{ {Item: it, CalendarURL: "cal-a", Todo: caldav.Todo{UID: "t1", Summary: "shipped", Status: "COMPLETED", LastModified: &lastTouch}}, } rollups := collectProjectRollups([]*store.Item{it}, todos, nil, nil, nil, nil, nil, now) p := rollups[0] if p.OpenTasks != 0 { t.Errorf("COMPLETED VTODO must not count: got OpenTasks=%d", p.OpenTasks) } if !p.LastActivity.Equal(lastTouch) { t.Errorf("LastActivity: want %v got %v", lastTouch, p.LastActivity) } if !p.IsCurrent(now) { t.Errorf("3-day-old shipped task must keep project current") } } // TestCollectProjectRollupsIssuesContributeAndFillNextSignal asserts that // issues feed OpenIssues + LastActivity, and that the most-recently-updated // issue title fills NextSignal when no task has claimed it. func TestCollectProjectRollupsIssuesContributeAndFillNextSignal(t *testing.T) { now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) it := mustItem("i1", "dev.alpha") old := now.AddDate(0, 0, -5) fresh := now.AddDate(0, 0, -1) issues := []aggregate.IssueRow{ {Item: it, Repo: "org/r", Issue: gitea.Issue{Number: 1, Title: "old one", UpdatedAt: old}}, {Item: it, Repo: "org/r", Issue: gitea.Issue{Number: 2, Title: "fresh one", UpdatedAt: fresh}}, } rollups := collectProjectRollups([]*store.Item{it}, nil, issues, nil, nil, nil, nil, now) p := rollups[0] if p.OpenIssues != 2 { t.Errorf("OpenIssues: want 2 got %d", p.OpenIssues) } if p.NextSignal != "fresh one" { t.Errorf("NextSignal: want latest-issue title 'fresh one' got %q", p.NextSignal) } if p.NextSignalKind != "issue" { t.Errorf("NextSignalKind: want 'issue' got %q", p.NextSignalKind) } if !p.LastActivity.Equal(fresh) { t.Errorf("LastActivity should pick the newest of the two issue updates") } } // TestCollectProjectRollupsTaskBeatsIssueForNextSignal confirms the // task-wins precedence: when both a task and an issue exist, NextSignal is // the task summary even if the issue is more recent. func TestCollectProjectRollupsTaskBeatsIssueForNextSignal(t *testing.T) { now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) it := mustItem("i1", "dev.alpha") due := now.AddDate(0, 0, 2) issueUpd := now.AddDate(0, 0, -1) todos := []aggregate.TodoRow{ {Item: it, CalendarURL: "cal-a", Todo: caldav.Todo{UID: "t1", Summary: "task wins", Status: "NEEDS-ACTION", Due: &due}}, } issues := []aggregate.IssueRow{ {Item: it, Repo: "org/r", Issue: gitea.Issue{Number: 1, Title: "issue loses", UpdatedAt: issueUpd}}, } rollups := collectProjectRollups([]*store.Item{it}, todos, issues, nil, nil, nil, nil, now) p := rollups[0] if p.NextSignal != "task wins" || p.NextSignalKind != "task" { t.Errorf("task should win NextSignal slot, got %q (%s)", p.NextSignal, p.NextSignalKind) } } // TestCollectProjectRollupsRepoActivityFeedsLastActivity covers the // optional repoActivity map: stale-card already fetches repo updated_at, // passing the map through must drive LastActivity for projects with no // other signal. func TestCollectProjectRollupsRepoActivityFeedsLastActivity(t *testing.T) { now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) it := mustItem("i1", "dev.alpha") commitAt := now.AddDate(0, 0, -2) rollups := collectProjectRollups([]*store.Item{it}, nil, nil, nil, nil, map[string]time.Time{"i1": commitAt}, nil, now) p := rollups[0] if !p.LastActivity.Equal(commitAt) { t.Errorf("LastActivity: want repo commit time %v got %v", commitAt, p.LastActivity) } if p.LastActivityRel != "2d" { t.Errorf("LastActivityRel: want '2d' got %q", p.LastActivityRel) } } // TestCollectProjectRollupsLastActivityPicksMaxAcrossSources feeds every // source (todo, event, doc, repo) for one item and asserts LastActivity // is the max of them all. func TestCollectProjectRollupsLastActivityPicksMaxAcrossSources(t *testing.T) { now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) it := mustItem("i1", "dev.alpha") t1 := now.AddDate(0, 0, -10) t2 := now.AddDate(0, 0, -5) t3 := now.AddDate(0, 0, -2) // newest t4 := now.AddDate(0, 0, -7) todos := []aggregate.TodoRow{ {Item: it, CalendarURL: "cal", Todo: caldav.Todo{UID: "t", Summary: "x", Status: "COMPLETED", LastModified: &t1}}, } events := []aggregate.EventRow{ {Item: it, Event: caldav.Event{UID: "e", Summary: "y", Start: t2}}, } docs := []*store.ItemLinkWithItem{ {Link: store.ItemLink{ItemID: "i1", EventDate: &t4}}, } repo := map[string]time.Time{"i1": t3} rollups := collectProjectRollups([]*store.Item{it}, todos, nil, events, docs, repo, nil, now) if got := rollups[0].LastActivity; !got.Equal(t3) { t.Errorf("LastActivity: want %v (the newest signal) got %v", t3, got) } } // TestIsCurrentCoversAllBranches walks the four paths through IsCurrent: // pinned, open-tasks > 0, open-issues > 0, LastActivity within 14d. The // fifth (no signal at all) returns false. func TestIsCurrentCoversAllBranches(t *testing.T) { now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) in := now.AddDate(0, 0, -10) // inside window out := now.AddDate(0, 0, -20) // outside window cases := []struct { name string p dashboardProject want bool }{ {"pinned", dashboardProject{Item: &store.Item{Pinned: true}}, true}, {"open task", dashboardProject{Item: &store.Item{}, OpenTasks: 1}, true}, {"open issue", dashboardProject{Item: &store.Item{}, OpenIssues: 3}, true}, {"recent activity", dashboardProject{Item: &store.Item{}, LastActivity: in}, true}, {"stale activity", dashboardProject{Item: &store.Item{}, LastActivity: out}, false}, {"empty", dashboardProject{Item: &store.Item{}}, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := c.p.IsCurrent(now); got != c.want { t.Errorf("IsCurrent: want %v got %v", c.want, got) } }) } } // TestCollectProjectRollupsSortsPinnedFirstThenPath asserts the output // order: pinned items first, then alphabetical by primary path. func TestCollectProjectRollupsSortsPinnedFirstThenPath(t *testing.T) { now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) a := mustItem("ia", "dev.aaa") b := mustItem("ib", "dev.bbb") c := mustItem("ic", "dev.ccc") c.Pinned = true rollups := collectProjectRollups([]*store.Item{a, b, c}, nil, nil, nil, nil, nil, nil, now) got := []string{rollups[0].Item.PrimaryPath(), rollups[1].Item.PrimaryPath(), rollups[2].Item.PrimaryPath()} want := []string{"dev.ccc", "dev.aaa", "dev.bbb"} for i, g := range got { if g != want[i] { t.Errorf("position %d: want %s got %s", i, want[i], g) } } } // TestCollectProjectRollupsStaleFlagPassesThrough asserts the staleByItem // map tags rollups without re-running the staleness probe. func TestCollectProjectRollupsStaleFlagPassesThrough(t *testing.T) { now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) it := mustItem("i1", "dev.quiet") rollups := collectProjectRollups([]*store.Item{it}, nil, nil, nil, nil, nil, map[string]bool{"i1": true}, now) if !rollups[0].Stale { t.Errorf("Stale flag should pass through from staleByItem map") } } // TestActivityRelLabels covers the rel-label shapes: now / Nm / Nh / Nd // and the future-flip behavior. func TestActivityRelLabels(t *testing.T) { now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) cases := []struct { name string t time.Time want string }{ {"now", now.Add(-30 * time.Second), "now"}, {"12m", now.Add(-12 * time.Minute), "12m"}, {"3h", now.Add(-3 * time.Hour), "3h"}, {"5d", now.AddDate(0, 0, -5), "5d"}, {"future event flips to absolute", now.AddDate(0, 0, 2), "2d"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := activityRel(now, c.t); got != c.want { t.Errorf("want %s got %s", c.want, got) } }) } }