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:
230
web/dashboard_rollup.go
Normal file
230
web/dashboard_rollup.go
Normal 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"
|
||||
}
|
||||
}
|
||||
246
web/dashboard_rollup_test.go
Normal file
246
web/dashboard_rollup_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user