Files
projax/web/dashboard_rollup.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

231 lines
7.0 KiB
Go

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