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