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).
231 lines
7.0 KiB
Go
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"
|
|
}
|
|
}
|