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).
This commit is contained in:
mAi
2026-05-26 12:10:12 +02:00
parent 3647472ce8
commit 1a508332b3
2 changed files with 476 additions and 0 deletions

230
web/dashboard_rollup.go Normal file
View File

@@ -0,0 +1,230 @@
package web
import (
"sort"
"strconv"
"strings"
"time"
"github.com/m/projax/internal/aggregate"
"github.com/m/projax/store"
)
// dashboardActivityWindow is the lookback IsCurrent uses to decide whether
// a project sits on the primary Tiles grid or under the Quiet (N) ▾ fold.
// 14 days per the Phase 5h §7 contract — long enough to catch a project
// that shipped last week but is between sprints, short enough that a
// project nobody's touched in three weeks doesn't crowd the top.
const dashboardActivityWindow = 14 * 24 * time.Hour
// dashboardProject is the per-project rollup that drives the Tiles view.
// One row per item.ID across every signal source (CalDAV, Gitea, dated
// links). Built from the same aggregator outputs the existing cards
// already fetch — no extra DAV/Gitea calls.
type dashboardProject struct {
Item *store.Item
OpenTasks int // open VTODOs across every linked calendar
Overdue int // subset of OpenTasks with Due strictly before today
OpenIssues int // open Gitea issues across every linked repo
LastActivity time.Time // zero = no signal seen yet
LastActivityRel string // "2d" / "3h" / "12m" / "now"; "" when zero
NextSignal string // soonest-due open VTODO summary, else latest issue title, else ""
NextSignalKind string // "task" | "issue" | ""
IsLive bool // has public_live_url
Stale bool // mai-managed quiet-repo + 0 open work — fed by the existing stale set
}
// IsCurrent implements the §7 rule: pinned OR open-tasks > 0 OR
// open-issues > 0 OR LastActivity within dashboardActivityWindow.
func (p dashboardProject) IsCurrent(now time.Time) bool {
if p.Item != nil && p.Item.Pinned {
return true
}
if p.OpenTasks > 0 || p.OpenIssues > 0 {
return true
}
if !p.LastActivity.IsZero() && now.Sub(p.LastActivity) < dashboardActivityWindow {
return true
}
return false
}
// collectProjectRollups groups the aggregator's per-row signals by
// item.ID into one dashboardProject per item, then sorts the output
// pinned-first then by primary path ascending. Rollups are returned for
// every item even when every signal is zero — the caller (IsCurrent +
// the Tiles template) decides what surfaces.
//
// repoActivity is optional: when set, item.ID → newest repo updated_at
// feeds the LastActivity max. The existing stale collector already
// fetches repo updated_at, so the dashboard wiring (Slice 2) can pass
// it through without a second Gitea round-trip.
//
// staleByItem is optional: when set, item.ID → true tags the rollup
// with the Stale flag so the Quiet fold can badge it without re-running
// the staleness probe.
func collectProjectRollups(
items []*store.Item,
todos []aggregate.TodoRow,
issues []aggregate.IssueRow,
events []aggregate.EventRow,
docs []*store.ItemLinkWithItem,
repoActivity map[string]time.Time,
staleByItem map[string]bool,
now time.Time,
) []dashboardProject {
today := startOfDay(now)
byID := make(map[string]*dashboardProject, len(items))
for _, it := range items {
byID[it.ID] = &dashboardProject{
Item: it,
IsLive: strings.TrimSpace(it.PublicLiveURL) != "",
Stale: staleByItem[it.ID],
}
}
// Soonest-due open VTODO per item wins NextSignal (task kind).
// Done/cancelled VTODOs still contribute LastActivity via LastModified
// so a project that shipped tasks recently surfaces as current.
soonestDue := map[string]time.Time{}
for i := range todos {
td := &todos[i]
p, ok := byID[td.Item.ID]
if !ok {
continue
}
status := td.Todo.Status
if status == "COMPLETED" || status == "CANCELLED" {
if td.Todo.LastModified != nil && td.Todo.LastModified.After(p.LastActivity) {
p.LastActivity = *td.Todo.LastModified
}
continue
}
p.OpenTasks++
if td.Todo.Due != nil {
if startOfDay(td.Todo.Due.Local()).Before(today) {
p.Overdue++
}
cur, seen := soonestDue[td.Item.ID]
if !seen || td.Todo.Due.Before(cur) {
soonestDue[td.Item.ID] = *td.Todo.Due
p.NextSignal = td.Todo.Summary
p.NextSignalKind = "task"
}
} else if p.NextSignal == "" {
// No-due task: only fills NextSignal if nothing else has yet.
p.NextSignal = td.Todo.Summary
p.NextSignalKind = "task"
}
if td.Todo.LastModified != nil && td.Todo.LastModified.After(p.LastActivity) {
p.LastActivity = *td.Todo.LastModified
}
}
// Issues feed OpenIssues + LastActivity. NextSignal only when no task
// has claimed the slot yet — tasks are more actionable per m's daily
// driver pattern.
latestIssueUpd := map[string]time.Time{}
for i := range issues {
ir := &issues[i]
p, ok := byID[ir.Item.ID]
if !ok {
continue
}
p.OpenIssues++
prev := latestIssueUpd[ir.Item.ID]
if ir.Issue.UpdatedAt.After(prev) {
latestIssueUpd[ir.Item.ID] = ir.Issue.UpdatedAt
if p.NextSignalKind != "task" {
p.NextSignal = ir.Issue.Title
p.NextSignalKind = "issue"
}
}
if ir.Issue.UpdatedAt.After(p.LastActivity) {
p.LastActivity = ir.Issue.UpdatedAt
}
}
// Events feed LastActivity only — they don't appear on tiles but a
// recent or imminent event keeps the project current via the window.
for i := range events {
ev := &events[i]
p, ok := byID[ev.Item.ID]
if !ok {
continue
}
if ev.Event.Start.After(p.LastActivity) {
p.LastActivity = ev.Event.Start
}
}
// Dated links feed LastActivity via event_date.
for _, d := range docs {
if d == nil || d.Link.EventDate == nil {
continue
}
p, ok := byID[d.Link.ItemID]
if !ok {
continue
}
if d.Link.EventDate.After(p.LastActivity) {
p.LastActivity = *d.Link.EventDate
}
}
// Repo activity from the stale-card probe (optional, no extra fetch).
for itemID, at := range repoActivity {
p, ok := byID[itemID]
if !ok || at.IsZero() {
continue
}
if at.After(p.LastActivity) {
p.LastActivity = at
}
}
out := make([]dashboardProject, 0, len(byID))
for _, p := range byID {
if !p.LastActivity.IsZero() {
p.LastActivityRel = activityRel(now, p.LastActivity)
}
out = append(out, *p)
}
sort.SliceStable(out, func(i, j int) bool {
ai, aj := out[i].Item.Pinned, out[j].Item.Pinned
if ai != aj {
return ai
}
return out[i].Item.PrimaryPath() < out[j].Item.PrimaryPath()
})
return out
}
// activityRel formats a tight relative-time label for the tile stamp.
// Different shape from relativeTime (used on rows) — tiles have less
// horizontal space, one-or-two-character labels read better.
//
// <1m → "now"
// <1h → "12m"
// <24h → "3h"
// else → "5d"
//
// Future timestamps (e.g. an event tomorrow) are flipped to absolute
// duration so the label still reads sensibly — "in 14h" would push the
// column wider than the design allows.
func activityRel(now, t time.Time) string {
d := now.Sub(t)
if d < 0 {
d = -d
}
switch {
case d < time.Minute:
return "now"
case d < time.Hour:
return strconv.Itoa(int(d/time.Minute)) + "m"
case d < 24*time.Hour:
return strconv.Itoa(int(d/time.Hour)) + "h"
default:
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
}
}

View File

@@ -0,0 +1,246 @@
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)
}
})
}
}