Files
projax/web/dashboard_rollup_test.go
mAi 1a508332b3 feat(dashboard): per-project rollup data model + IsCurrent predicate
Phase 5h slice 1 — adds dashboardProject struct that groups per-row
signals (TodoRow / IssueRow / EventRow / dated docs / optional repo
updated_at) by item.ID into one rollup per project. IsCurrent(now)
implements the §7 contract: pinned OR open-tasks>0 OR open-issues>0 OR
LastActivity within 14d.

No UI change — slice 2 wires the rollup into buildDashboard and the
Tiles template. activityRel produces tight tile-friendly labels
(now / Nm / Nh / Nd) distinct from relativeTime (used on rows).

Test coverage:
- task counts + overdue + soonest-due NextSignal
- COMPLETED VTODOs skipped but their LastModified feeds activity
- issues fill OpenIssues + LastActivity, task beats issue for NextSignal
- repoActivity map feeds LastActivity
- LastActivity = max across todo/event/doc/repo sources
- IsCurrent four branches + the no-signal false case
- pinned-first then path-sorted output order
- Stale flag passes through staleByItem map
- activityRel label shapes + future-flip

Refs §7 of docs/plans/dashboard-overhaul.md (commit 3647472).
2026-05-26 12:10:12 +02:00

247 lines
9.7 KiB
Go

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)
}
})
}
}