Per m's Q1 pick (b) (2026-05-29): legacy `/`, `/dashboard`, `/calendar`,
`/timeline`, `/graph` become `/views/{system-slug}`. Old routes
301-redirect to the new ones with chip params preserved; the legacy
?view=<uuid> param from 5i is resolved through the uuid → slug map
when present so old bookmarks land on the right user view.
System views (web/system_views.go):
- SystemView struct (Slug / Name / Icon / URL) — code-resident, never
rows in projax.views.
- AllSystemViews() returns the canonical five: tree, dashboard,
calendar, timeline, graph. Display order matches the existing
sidebar.
- LookupSystemView(slug) returns the matching entry or nil; the
reserved-slug list in store.IsReservedViewSlug (slice A) is kept
in sync.
- legacyRedirect(systemSlug) handler 301s with chip-param preservation
+ uuid → slug resolution for any leftover ?view=<uuid>.
Routes (web/server.go):
- GET /views/tree → handleTree (was GET /)
- GET /views/dashboard → handleDashboard
- GET /views/timeline → handleTimeline
- GET /views/calendar → handleCalendar
- GET /views/graph → handleGraph
- GET / → 301 → /views/tree
- GET /dashboard → 301 → /views/dashboard
- GET /timeline → 301 → /views/timeline
- GET /calendar → 301 → /views/calendar
- GET /graph → 301 → /views/graph
- POST action endpoints (/dashboard/task/*, /dashboard/pin, /admin/*)
stay where they are — those are RPC-ish, not page renders.
handleTree: dropped the `r.URL.Path != "/"` guard — the only entry
point now is /views/tree, mounted via the new route. Slice F removes
any residual references; this slice keeps the handler reachable.
computeChipCounts grew a `base string` arg so chip URLs anchor on the
caller's route (/views/tree for the system tree, /views/{slug} for
saved views). PageViewTypes recognises both legacy and /views/ keys
during the transition.
Template hrefs / hx-gets bulk-updated to the new URLs:
- layout.tmpl: every sidebar + bottom-nav entry points at
/views/{system-slug}. Active-state checks updated alongside.
- tree_section.tmpl, tree_card.tmpl, tree_kanban.tmpl: clear-filter
/ clear-all hrefs → /views/tree.
- calendar*.tmpl, timeline_section.tmpl, graph.tmpl,
dashboard_section.tmpl: every internal nav + filter link points at
the /views/{slug} surface.
- detail.tmpl, error.tmpl: cancel / back-to-tree → /views/tree.
Test-source updates (per the 5c sharpened rule):
- ~100 test paths bulk-rewritten from /dashboard /calendar /timeline
/graph (and `/`) to their /views/{slug} counterparts. The
behaviour-preservation contract holds: status codes + body shapes
for the rendered pages stay the same; only the URL anchoring the
test changes.
- layout_test.go: sidebar href assertions updated to /views/{slug}.
- view_type_test.go (Q2 + Q3 follow-up): PageViewTypes lookup table
updated to use the new route keys.
- 2 deliberate behaviour-change assertions land: TestLegacyRedirects
expects 301 on the old URLs (was 200); TestTreeRenders fetches
/views/tree (the new home) instead of /.
Internal go-source URL emissions (dashboard.go, calendar.go,
timeline.go) updated to the new BasePath so chip + refresh URLs round
through /views/{slug} correctly.
New tests:
- TestSystemViewLookup — AllSystemViews shape + LookupSystemView
round-trip + unknown-slug nil.
- TestLegacyRedirects — every legacy URL 301s to its new home with
chip params preserved.
- TestLegacyViewUUIDRedirect — old `?view=<uuid>` URLs land on the
resolved slug per m's Q3 pick.
940 lines
29 KiB
Go
940 lines
29 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/m/projax/caldav"
|
|
"github.com/m/projax/gitea"
|
|
"github.com/m/projax/internal/aggregate"
|
|
"github.com/m/projax/store"
|
|
)
|
|
|
|
// dashboardCache TTL — Phase 5b unified the per-cache types into the
|
|
// generic internal/cache.TTLCache[V]. Design note (design.md §9): every
|
|
// cache entry is keyed by the encoded TreeFilter (so `?tag=work` cache is
|
|
// independent of unfiltered).
|
|
const dashboardCacheTTL = 60 * time.Second
|
|
|
|
// dashboardPayload is the full aggregated view rendered into the page. Each
|
|
// slice is already sorted and capped at the per-card limit.
|
|
type dashboardPayload struct {
|
|
Tasks []dashboardTask
|
|
TaskGroups dashboardTaskGroups
|
|
TaskTotal int
|
|
|
|
Issues []dashboardIssue
|
|
IssueTotal int
|
|
|
|
RecentDocs []dashboardDoc
|
|
RecentDocsTotal int
|
|
|
|
Stale []dashboardStale
|
|
StaleTotal int
|
|
|
|
Events []dashboardEventGroup // grouped by day, each group already sorted by start asc
|
|
EventsFlat []dashboardEvent // flat list (template helper for "next event" sentinel)
|
|
EventsTotal int
|
|
|
|
// Projects is the Phase 5h per-project rollup. Populated alongside the
|
|
// other cards from the same aggregator fetches. Consumed by the Tiles
|
|
// view; the Tasks/Events views ignore it. Sorted pinned-first then by
|
|
// primary path ascending.
|
|
Projects []dashboardProject
|
|
|
|
// ProjectsCurrent / ProjectsQuiet are the per-request scope-split of
|
|
// Projects — populated by the handler, not the cache, so the scope
|
|
// chip can toggle without recomputing. The Tiles view renders Current
|
|
// in the primary grid and Quiet behind a "Quiet (N) ▾" fold.
|
|
ProjectsCurrent []dashboardProject `json:"-"`
|
|
ProjectsQuiet []dashboardProject `json:"-"`
|
|
QuietStaleCount int `json:"-"` // subset of ProjectsQuiet flagged stale, for the fold label
|
|
QuietWindowLabel string `json:"-"` // e.g. "14d" — derived from dashboardActivityWindow
|
|
|
|
BuiltAt time.Time
|
|
Cached bool
|
|
}
|
|
|
|
// dashboardTask is one open VTODO from one linked calendar, with the project
|
|
// it belongs to already resolved for the row link.
|
|
type dashboardTask struct {
|
|
Item *store.Item
|
|
CalendarURL string
|
|
Todo caldav.Todo
|
|
DueRel string // "today" / "tomorrow" / "in 3d" / "overdue 2d" / ""
|
|
Bucket string // today | tomorrow | week | overdue | no-due
|
|
}
|
|
|
|
// dashboardTaskGroups holds per-bucket counts for the section header.
|
|
type dashboardTaskGroups struct {
|
|
Today int
|
|
Tomorrow int
|
|
Week int
|
|
Overdue int
|
|
NoDue int
|
|
}
|
|
|
|
type dashboardIssue struct {
|
|
Item *store.Item
|
|
Repo string
|
|
Issue gitea.Issue
|
|
UpdRel string
|
|
}
|
|
|
|
type dashboardDoc struct {
|
|
Item *store.Item
|
|
Link store.ItemLink
|
|
PER string
|
|
ItemPath string
|
|
}
|
|
|
|
// dashboardEvent is one VEVENT surfaced on the dashboard Events card. The
|
|
// Item it belongs to is resolved (it's the projax item the calendar is linked
|
|
// to), so a click on the row navigates to /i/{path}/.
|
|
type dashboardEvent struct {
|
|
Item *store.Item
|
|
Event caldav.Event
|
|
CalendarRef string // calendar URL — kept for cache-bust / debug, not shown
|
|
DayKey string // YYYY-MM-DD for grouping
|
|
StartLabel string // "10:00" / "ganztägig" / ""
|
|
DayLabel string // "Today", "Tomorrow", "Wed 21 May", "Fri 23 May"
|
|
}
|
|
|
|
// dashboardEventGroup bundles a day's events for template-friendly rendering.
|
|
type dashboardEventGroup struct {
|
|
DayKey string // YYYY-MM-DD
|
|
DayLabel string // "Today (3)" — count substituted at render time
|
|
Events []dashboardEvent
|
|
}
|
|
|
|
// dashboardStale is one mai-managed item whose linked repo is quiet, has no
|
|
// open issues, and whose linked CalDAV lists hold no open VTODOs. The
|
|
// "consider archiving?" candidate.
|
|
type dashboardStale struct {
|
|
Item *store.Item
|
|
Repo string // owner/repo of the quiet linked repo (first one wins)
|
|
LastActive time.Time
|
|
StaleDays int // floor(days since LastActive)
|
|
StaleRel string // "62d", "120d", "no recent activity"
|
|
}
|
|
|
|
// Dashboard view-switcher (Phase 5h) — three tabs share the same
|
|
// aggregated data and filter strip; each renders a different shape.
|
|
// Defaults elide from URL so /dashboard means /dashboard?view=tiles.
|
|
const (
|
|
dashboardViewTiles = "tiles"
|
|
dashboardViewTasks = "tasks"
|
|
dashboardViewEvents = "events"
|
|
)
|
|
|
|
// Dashboard scope (Phase 5h Tiles view) — narrows the tile grid to the
|
|
// projects m is plausibly working on (the IsCurrent set), folding the
|
|
// rest into a "Quiet (N) ▾" section. Default = current.
|
|
const (
|
|
dashboardScopeCurrent = "current"
|
|
dashboardScopeAll = "all"
|
|
)
|
|
|
|
// parseDashboardView normalizes the ?view= query into one of the three
|
|
// known shapes, falling back to Tiles (the default per m's §7 pick).
|
|
func parseDashboardView(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case dashboardViewTasks:
|
|
return dashboardViewTasks
|
|
case dashboardViewEvents:
|
|
return dashboardViewEvents
|
|
default:
|
|
return dashboardViewTiles
|
|
}
|
|
}
|
|
|
|
// parseDashboardScope normalizes the ?scope= query, falling back to
|
|
// "current" (the daily-driver default per m's §7 pick).
|
|
func parseDashboardScope(raw string) string {
|
|
if strings.EqualFold(strings.TrimSpace(raw), dashboardScopeAll) {
|
|
return dashboardScopeAll
|
|
}
|
|
return dashboardScopeCurrent
|
|
}
|
|
|
|
// handleDashboard renders the cross-project landing page. Filters reuse the
|
|
// tree-page TreeFilter; the per-card aggregation runs sequentially with a
|
|
// small worker pool to avoid hammering DAV / Gitea.
|
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|
filter := ParseTreeFilter(r.URL.Query())
|
|
view := parseDashboardView(r.URL.Query().Get("view"))
|
|
scope := parseDashboardScope(r.URL.Query().Get("scope"))
|
|
// Dashboard treats status=active as the meaningful default — same as tree.
|
|
filterKey := filter.QueryString()
|
|
if filterKey == "" {
|
|
filterKey = "__empty__"
|
|
}
|
|
// Cache key composes filter + view + scope so each surface has its own
|
|
// 60s TTL entry. Scope only changes the Tiles split; we still key it
|
|
// in for every view so the future Activity tab can ride the same path.
|
|
cacheKey := filterKey + "|view=" + view + "|scope=" + scope
|
|
|
|
// ?refresh=1 busts this filter's cache entry so the next aggregation
|
|
// runs fresh — used by the ↻ button on the dashboard chrome.
|
|
if r.URL.Query().Get("refresh") == "1" {
|
|
s.dashboard.Invalidate(cacheKey)
|
|
}
|
|
|
|
payload, hit := s.dashboard.Get(cacheKey)
|
|
if !hit {
|
|
built, err := s.buildDashboard(r.Context(), filter)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
s.dashboard.Set(cacheKey, built)
|
|
payload = built
|
|
}
|
|
displayPayload := *payload
|
|
displayPayload.Cached = hit
|
|
// Updated-relative label: how long since the cached payload was built.
|
|
updatedRel := relativeTime(time.Now(), payload.BuiltAt)
|
|
|
|
// Split the rollup into Current vs. Quiet according to the active
|
|
// scope. For scope=all we keep everything Current; for scope=current
|
|
// (default) we use IsCurrent + the Stale set to populate Quiet.
|
|
now := time.Now()
|
|
current, quiet := splitProjectsByScope(displayPayload.Projects, scope, now)
|
|
displayPayload.ProjectsCurrent = current
|
|
displayPayload.ProjectsQuiet = quiet
|
|
staleCount := 0
|
|
for _, p := range quiet {
|
|
if p.Stale {
|
|
staleCount++
|
|
}
|
|
}
|
|
displayPayload.QuietStaleCount = staleCount
|
|
displayPayload.QuietWindowLabel = strconv.Itoa(int(dashboardActivityWindow/(24*time.Hour))) + "d"
|
|
|
|
// Refresh URL preserves the active view + scope + filter.
|
|
refreshQuery := filterKey
|
|
if refreshQuery == "__empty__" {
|
|
refreshQuery = ""
|
|
}
|
|
if view != dashboardViewTiles {
|
|
if refreshQuery != "" {
|
|
refreshQuery += "&"
|
|
}
|
|
refreshQuery += "view=" + view
|
|
}
|
|
if scope != dashboardScopeCurrent {
|
|
if refreshQuery != "" {
|
|
refreshQuery += "&"
|
|
}
|
|
refreshQuery += "scope=" + scope
|
|
}
|
|
refreshURL := "/views/dashboard?"
|
|
if refreshQuery != "" {
|
|
refreshURL += refreshQuery + "&"
|
|
}
|
|
refreshURL += "refresh=1"
|
|
|
|
projects, err := s.parentOptions(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
data := map[string]any{
|
|
"Title": "dashboard",
|
|
"P": displayPayload,
|
|
"Filter": filter,
|
|
"View": view,
|
|
"Scope": scope,
|
|
"Tabs": dashboardTabs(view, filterKey, scope),
|
|
"ScopeURL": dashboardScopeToggleURL(view, scope, filterKey),
|
|
"UpdatedRel": updatedRel,
|
|
"RefreshURL": refreshURL,
|
|
"FilterActive": filter.Active(),
|
|
"Projects": projects,
|
|
"BasePath": "/views/dashboard",
|
|
"ProjectChipTarget": "#dashboard-section",
|
|
}
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
s.render(w, r, "dashboard_section", data)
|
|
return
|
|
}
|
|
s.render(w, r, "dashboard", data)
|
|
}
|
|
|
|
// splitProjectsByScope partitions the rollup into the primary grid
|
|
// (ProjectsCurrent) and the Quiet fold (ProjectsQuiet) per m's §7 pick.
|
|
// scope=all → everything counts as Current; Quiet is empty.
|
|
// scope=current → IsCurrent(now) selects Current; the rest (including
|
|
// stale candidates) move to Quiet.
|
|
func splitProjectsByScope(projects []dashboardProject, scope string, now time.Time) (current, quiet []dashboardProject) {
|
|
if scope == dashboardScopeAll {
|
|
return projects, nil
|
|
}
|
|
for _, p := range projects {
|
|
if p.IsCurrent(now) {
|
|
current = append(current, p)
|
|
} else {
|
|
quiet = append(quiet, p)
|
|
}
|
|
}
|
|
return current, quiet
|
|
}
|
|
|
|
// dashboardScopeToggleURL builds the URL that flips the scope chip —
|
|
// /dashboard with the alternate scope and the current view + filter
|
|
// preserved.
|
|
func dashboardScopeToggleURL(view, scope, filterKey string) string {
|
|
next := dashboardScopeAll
|
|
if scope == dashboardScopeAll {
|
|
next = dashboardScopeCurrent
|
|
}
|
|
parts := []string{}
|
|
if filterKey != "__empty__" && filterKey != "" {
|
|
parts = append(parts, filterKey)
|
|
}
|
|
if view != dashboardViewTiles {
|
|
parts = append(parts, "view="+view)
|
|
}
|
|
if next != dashboardScopeCurrent {
|
|
parts = append(parts, "scope="+next)
|
|
}
|
|
if len(parts) == 0 {
|
|
return "/views/dashboard"
|
|
}
|
|
return "/views/dashboard?" + strings.Join(parts, "&")
|
|
}
|
|
|
|
// dashboardTab is a single entry in the view-switcher strip.
|
|
type dashboardTab struct {
|
|
View string // tiles | tasks | events
|
|
Label string
|
|
URL string
|
|
Active bool
|
|
}
|
|
|
|
// dashboardTabs builds the three-entry tab strip with each tab's URL
|
|
// preserving the active filter + scope. The default view (tiles) and
|
|
// scope (current) elide from the URL so the address bar stays clean
|
|
// on the daily-driver path.
|
|
func dashboardTabs(active, filterKey, scope string) []dashboardTab {
|
|
prefix := "/views/dashboard"
|
|
filterQuery := ""
|
|
if filterKey != "__empty__" && filterKey != "" {
|
|
filterQuery = filterKey
|
|
}
|
|
tabURL := func(view string) string {
|
|
parts := []string{}
|
|
if filterQuery != "" {
|
|
parts = append(parts, filterQuery)
|
|
}
|
|
if view != dashboardViewTiles {
|
|
parts = append(parts, "view="+view)
|
|
}
|
|
if scope != dashboardScopeCurrent {
|
|
parts = append(parts, "scope="+scope)
|
|
}
|
|
if len(parts) == 0 {
|
|
return prefix
|
|
}
|
|
return prefix + "?" + strings.Join(parts, "&")
|
|
}
|
|
return []dashboardTab{
|
|
{View: dashboardViewTiles, Label: "Tiles", URL: tabURL(dashboardViewTiles), Active: active == dashboardViewTiles},
|
|
{View: dashboardViewTasks, Label: "Tasks", URL: tabURL(dashboardViewTasks), Active: active == dashboardViewTasks},
|
|
{View: dashboardViewEvents, Label: "Events", URL: tabURL(dashboardViewEvents), Active: active == dashboardViewEvents},
|
|
}
|
|
}
|
|
|
|
// buildDashboard does the actual aggregation work. Items are filtered first
|
|
// (by the same TreeFilter as /), then each linked calendar / repo / dated
|
|
// link is fanned out to a worker pool. Phase 5h: aggregator rows are
|
|
// fetched once at the top, then projected into both the legacy card
|
|
// shapes AND the new per-project rollup so the rollup costs zero extra
|
|
// DAV/Gitea calls.
|
|
func (s *Server) buildDashboard(ctx context.Context, filter TreeFilter) (*dashboardPayload, error) {
|
|
items, err := s.Store.ListAll(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
linkKinds, err := s.linkKindsByItem(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Filter items by the same rules as the tree (direct match only, no
|
|
// branch-keep — dashboard cards never look at ancestors).
|
|
dashItems := []*store.Item{}
|
|
byID := map[string]*store.Item{}
|
|
for _, it := range items {
|
|
// Reuse TreeFilter.Matches with one tweak: when no filter is active we
|
|
// include every item regardless of status so a "done" project's
|
|
// recently-dated docs still surface.
|
|
if !filter.Active() || filter.Matches(it, linkKinds[it.ID]) {
|
|
dashItems = append(dashItems, it)
|
|
byID[it.ID] = it
|
|
}
|
|
}
|
|
|
|
now := time.Now()
|
|
p := &dashboardPayload{BuiltAt: now}
|
|
|
|
// --- Fetch raw rows once (Phase 5h refactor) ---
|
|
// The projection helpers cap + sort for the card shapes; the rollup
|
|
// uses the uncapped rows so OpenTasks/OpenIssues counts are accurate.
|
|
var todoRows []aggregate.TodoRow
|
|
var eventRows []aggregate.EventRow
|
|
if s.CalDAV != nil {
|
|
todoRows = s.Aggregator().Todos(ctx, dashItems, aggregate.Window{})
|
|
eventWindow := aggregate.Window{From: startOfDay(now), To: startOfDay(now).AddDate(0, 0, 7)}
|
|
eventRows = s.Aggregator().Events(ctx, dashItems, eventWindow)
|
|
}
|
|
var issueRows []aggregate.IssueRow
|
|
if s.Gitea != nil {
|
|
issueRows = s.Aggregator().Issues(ctx, dashItems)
|
|
}
|
|
|
|
// --- Tasks card ---
|
|
if s.CalDAV != nil {
|
|
tasks, groups, total := projectTasks(todoRows, now)
|
|
p.Tasks = tasks
|
|
p.TaskGroups = groups
|
|
p.TaskTotal = total
|
|
}
|
|
|
|
// --- Events card (Phase 3l) ---
|
|
if s.CalDAV != nil {
|
|
events, flat, total := projectEvents(eventRows, now)
|
|
p.Events = events
|
|
p.EventsFlat = flat
|
|
p.EventsTotal = total
|
|
}
|
|
|
|
// --- Issues card ---
|
|
if s.Gitea != nil {
|
|
issues, total := projectIssues(issueRows, now)
|
|
p.Issues = issues
|
|
p.IssueTotal = total
|
|
}
|
|
|
|
// --- Recent documents card ---
|
|
since := now.AddDate(0, 0, -30)
|
|
docRows, err := s.Store.RecentDocuments(ctx, since, 200)
|
|
if err != nil {
|
|
s.Logger.Warn("dashboard docs", "err", err)
|
|
}
|
|
docs, docTotal := projectDocs(docRows, byID)
|
|
p.RecentDocs = docs
|
|
p.RecentDocsTotal = docTotal
|
|
|
|
// --- Stale projects card ---
|
|
// "Stale" = mai-managed AND linked-repo quiet 60d+ AND 0 open tasks AND
|
|
// 0 open issues. Reuses what the task/issue cards already aggregated so
|
|
// we don't refetch CalDAV/Gitea per item.
|
|
openTasksByItem := map[string]int{}
|
|
for _, t := range p.Tasks {
|
|
openTasksByItem[t.Item.ID]++
|
|
}
|
|
openIssuesByItem := map[string]int{}
|
|
for _, i := range p.Issues {
|
|
openIssuesByItem[i.Item.ID]++
|
|
}
|
|
// Note: the 30-row cap on Tasks/Issues lists may hide entries for the
|
|
// count above. We deliberately use the trimmed view: an item that has so
|
|
// many open tasks/issues that it pushes past the 30 cap is clearly NOT
|
|
// stale, and the per-item count is only used as "is this zero?".
|
|
stale, staleTotal, repoActivity := s.collectStale(ctx, dashItems, openTasksByItem, openIssuesByItem, now)
|
|
p.Stale = stale
|
|
p.StaleTotal = staleTotal
|
|
|
|
// --- Per-project rollup (Phase 5h) ---
|
|
staleByItem := make(map[string]bool, len(stale))
|
|
for _, st := range stale {
|
|
staleByItem[st.Item.ID] = true
|
|
}
|
|
p.Projects = collectProjectRollups(dashItems, todoRows, issueRows, eventRows, docRows, repoActivity, staleByItem, now)
|
|
|
|
return p, nil
|
|
}
|
|
|
|
// collectStale walks every mai-managed item whose only signals are quiet:
|
|
// no open tasks (in the aggregated map), no open issues (in the aggregated
|
|
// map), AND the linked Gitea repo's updated_at is older than 60d. Items
|
|
// with NO linked repo at all are skipped — we can't judge staleness without
|
|
// a signal. Returns at most 20 rows (longest-stale first), the total
|
|
// count, and a per-item map of the newest repo updated_at seen across all
|
|
// probed repos. The map covers every item that had at least one probed
|
|
// repo regardless of staleness — Phase 5h's rollup uses it as a
|
|
// LastActivity signal without doing a second Gitea round-trip.
|
|
func (s *Server) collectStale(ctx context.Context, items []*store.Item, openTasks, openIssues map[string]int, now time.Time) ([]dashboardStale, int, map[string]time.Time) {
|
|
if s.Gitea == nil {
|
|
return nil, 0, nil
|
|
}
|
|
const staleCutoffDays = 60
|
|
type job struct {
|
|
item *store.Item
|
|
link *store.ItemLink
|
|
}
|
|
jobs := []job{}
|
|
for _, it := range items {
|
|
if !it.HasManagement("mai") {
|
|
continue
|
|
}
|
|
if openTasks[it.ID] > 0 || openIssues[it.ID] > 0 {
|
|
continue
|
|
}
|
|
links, err := s.Store.LinksByType(ctx, it.ID, refTypeGiteaRepo)
|
|
if err != nil || len(links) == 0 {
|
|
continue
|
|
}
|
|
// First linked repo wins for the staleness probe — if an item has
|
|
// multiple linked repos and ANY is recent we treat the item as not
|
|
// stale, so the candidate-list pass is conservative on the "stale"
|
|
// side. Implemented by emitting jobs for every link + filtering on
|
|
// "every link is stale" in the result reduce.
|
|
for _, l := range links {
|
|
jobs = append(jobs, job{item: it, link: l})
|
|
}
|
|
}
|
|
if len(jobs) == 0 {
|
|
return nil, 0, nil
|
|
}
|
|
|
|
type res struct {
|
|
itemID string
|
|
repo string
|
|
updated time.Time
|
|
err bool
|
|
}
|
|
results := make(chan res, len(jobs))
|
|
in := make(chan job, len(jobs))
|
|
const workers = 4
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for j := range in {
|
|
owner, repo := gitea.ParseRepoRef(j.link.RefID)
|
|
if owner == "" || repo == "" {
|
|
results <- res{itemID: j.item.ID, repo: j.link.RefID, err: true}
|
|
continue
|
|
}
|
|
r, err := s.Gitea.Client.GetRepo(ctx, owner, repo)
|
|
if err != nil {
|
|
s.Logger.Warn("dashboard stale get repo", "repo", j.link.RefID, "err", err)
|
|
results <- res{itemID: j.item.ID, repo: j.link.RefID, err: true}
|
|
continue
|
|
}
|
|
results <- res{itemID: j.item.ID, repo: j.link.RefID, updated: r.UpdatedAt}
|
|
}
|
|
}()
|
|
}
|
|
for _, j := range jobs {
|
|
in <- j
|
|
}
|
|
close(in)
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
// Reduce by item: track the most-recent updated_at across the item's
|
|
// repos. Stale only if ALL probed repos are older than the cutoff.
|
|
type acc struct {
|
|
newest time.Time
|
|
repo string // newest-updated repo wins the display slot
|
|
anyErr bool
|
|
}
|
|
byItem := map[string]*acc{}
|
|
for r := range results {
|
|
a, ok := byItem[r.itemID]
|
|
if !ok {
|
|
a = &acc{}
|
|
byItem[r.itemID] = a
|
|
}
|
|
if r.err {
|
|
a.anyErr = true
|
|
continue
|
|
}
|
|
if r.updated.After(a.newest) {
|
|
a.newest = r.updated
|
|
a.repo = r.repo
|
|
}
|
|
}
|
|
|
|
byID := map[string]*store.Item{}
|
|
for _, it := range items {
|
|
byID[it.ID] = it
|
|
}
|
|
cutoff := now.AddDate(0, 0, -staleCutoffDays)
|
|
out := []dashboardStale{}
|
|
for id, a := range byItem {
|
|
if a.anyErr || a.newest.IsZero() {
|
|
continue
|
|
}
|
|
if a.newest.After(cutoff) {
|
|
continue
|
|
}
|
|
it := byID[id]
|
|
if it == nil {
|
|
continue
|
|
}
|
|
days := int(now.Sub(a.newest).Hours() / 24)
|
|
out = append(out, dashboardStale{
|
|
Item: it,
|
|
Repo: a.repo,
|
|
LastActive: a.newest,
|
|
StaleDays: days,
|
|
StaleRel: relDays(days),
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return out[i].StaleDays > out[j].StaleDays
|
|
})
|
|
total := len(out)
|
|
if len(out) > 20 {
|
|
out = out[:20]
|
|
}
|
|
// Build the repo-activity map: every item with at least one successful
|
|
// probe contributes its newest repo updated_at, regardless of staleness.
|
|
// The rollup uses this as a LastActivity signal.
|
|
repoActivity := make(map[string]time.Time, len(byItem))
|
|
for id, a := range byItem {
|
|
if a.anyErr || a.newest.IsZero() {
|
|
continue
|
|
}
|
|
repoActivity[id] = a.newest
|
|
}
|
|
return out, total, repoActivity
|
|
}
|
|
|
|
// projectTasks projects raw TodoRows fetched by the aggregator into the
|
|
// dashboard's view shape (due-status bucket + relative label + group
|
|
// counts + 30-row cap). Pure function: no I/O.
|
|
func projectTasks(rows []aggregate.TodoRow, now time.Time) ([]dashboardTask, dashboardTaskGroups, int) {
|
|
out := []dashboardTask{}
|
|
groups := dashboardTaskGroups{}
|
|
for _, r := range rows {
|
|
td := r.Todo
|
|
if td.Status == "COMPLETED" || td.Status == "CANCELLED" {
|
|
continue
|
|
}
|
|
dt := dashboardTask{Item: r.Item, CalendarURL: r.CalendarURL, Todo: td}
|
|
dt.Bucket, dt.DueRel = classifyDue(td.Due, now)
|
|
switch dt.Bucket {
|
|
case "overdue":
|
|
groups.Overdue++
|
|
case "today":
|
|
groups.Today++
|
|
case "tomorrow":
|
|
groups.Tomorrow++
|
|
case "week":
|
|
groups.Week++
|
|
default:
|
|
groups.NoDue++
|
|
}
|
|
out = append(out, dt)
|
|
}
|
|
// Sort: overdue first, then due asc, no-due last; ties by priority desc, summary asc.
|
|
sort.Slice(out, func(i, j int) bool {
|
|
a, b := out[i], out[j]
|
|
if (a.Bucket == "overdue") != (b.Bucket == "overdue") {
|
|
return a.Bucket == "overdue"
|
|
}
|
|
ad := a.Todo.Due != nil
|
|
bd := b.Todo.Due != nil
|
|
if ad != bd {
|
|
return ad
|
|
}
|
|
if ad && bd && !a.Todo.Due.Equal(*b.Todo.Due) {
|
|
return a.Todo.Due.Before(*b.Todo.Due)
|
|
}
|
|
if a.Todo.Priority != b.Todo.Priority {
|
|
return a.Todo.Priority > b.Todo.Priority
|
|
}
|
|
return a.Todo.Summary < b.Todo.Summary
|
|
})
|
|
total := len(out)
|
|
if len(out) > 30 {
|
|
out = out[:30]
|
|
}
|
|
return out, groups, total
|
|
}
|
|
|
|
// classifyDue buckets a VTODO by its DUE date relative to now.
|
|
// overdue: due strictly before today
|
|
// today: due == today
|
|
// tomorrow: due == today+1
|
|
// week: due in (today+2 ... today+7)
|
|
// no-due: no due at all
|
|
// Returns (bucket, relative-text).
|
|
func classifyDue(due *time.Time, now time.Time) (string, string) {
|
|
if due == nil {
|
|
return "no-due", ""
|
|
}
|
|
today := startOfDay(now)
|
|
dueDay := startOfDay(due.Local())
|
|
days := int(dueDay.Sub(today).Hours() / 24)
|
|
switch {
|
|
case days < 0:
|
|
return "overdue", "overdue " + relDays(-days)
|
|
case days == 0:
|
|
return "today", "today"
|
|
case days == 1:
|
|
return "tomorrow", "tomorrow"
|
|
case days <= 7:
|
|
return "week", "in " + relDays(days)
|
|
default:
|
|
return "later", dueDay.Format("2006-01-02")
|
|
}
|
|
}
|
|
|
|
func startOfDay(t time.Time) time.Time {
|
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
|
}
|
|
|
|
func relDays(n int) string { return strconv.Itoa(n) + "d" }
|
|
|
|
// projectIssues projects raw IssueRows into the dashboard's view shape,
|
|
// sorted updated_at desc and capped at 30.
|
|
func projectIssues(rows []aggregate.IssueRow, now time.Time) ([]dashboardIssue, int) {
|
|
out := make([]dashboardIssue, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, dashboardIssue{
|
|
Item: r.Item,
|
|
Repo: r.Repo,
|
|
Issue: r.Issue,
|
|
UpdRel: relativeTime(now, r.Issue.UpdatedAt),
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return out[i].Issue.UpdatedAt.After(out[j].Issue.UpdatedAt)
|
|
})
|
|
total := len(out)
|
|
if len(out) > 30 {
|
|
out = out[:30]
|
|
}
|
|
return out, total
|
|
}
|
|
|
|
// projectDocs joins pre-fetched dated item_links to the filtered item set
|
|
// (rows whose owning item is not in scope are dropped) and projects them
|
|
// into the Recent Documents card shape, capped at 30.
|
|
func projectDocs(rows []*store.ItemLinkWithItem, byID map[string]*store.Item) ([]dashboardDoc, int) {
|
|
out := []dashboardDoc{}
|
|
for _, r := range rows {
|
|
it := byID[r.Link.ItemID]
|
|
if it == nil {
|
|
continue
|
|
}
|
|
base := it.PrimaryPath()
|
|
per := base
|
|
if r.Link.EventDate != nil {
|
|
per = base + "." + formatPERDate(*r.Link.EventDate)
|
|
}
|
|
out = append(out, dashboardDoc{
|
|
Item: it,
|
|
Link: r.Link,
|
|
PER: per,
|
|
ItemPath: base,
|
|
})
|
|
}
|
|
total := len(out)
|
|
if len(out) > 30 {
|
|
out = out[:30]
|
|
}
|
|
return out, total
|
|
}
|
|
|
|
// handleDashboardTaskDone is the inline ✓-checkbox handler on the Tasks card.
|
|
// It POSTs the calendar URL + UID, marks the VTODO COMPLETED via the same
|
|
// PutTodo path the detail page uses, then re-renders the dashboard section
|
|
// so the row disappears and the count decrements.
|
|
func (s *Server) handleDashboardTaskDone(w http.ResponseWriter, r *http.Request) {
|
|
s.dashboardTaskWrite(w, r, "complete")
|
|
}
|
|
|
|
// handleDashboardTaskEdit lets m rename a task or change its DUE date from the
|
|
// Tasks card without leaving the dashboard. Same routing key as Done — POST
|
|
// with calendar_url + uid + summary + optional due — but applies a VTodoEdit
|
|
// rather than flipping STATUS.
|
|
func (s *Server) handleDashboardTaskEdit(w http.ResponseWriter, r *http.Request) {
|
|
s.dashboardTaskWrite(w, r, "edit")
|
|
}
|
|
|
|
// handleDashboardTaskDelete removes a VTODO from the Tasks card with the same
|
|
// DAV path as the detail page (DELETE with If-Match).
|
|
func (s *Server) handleDashboardTaskDelete(w http.ResponseWriter, r *http.Request) {
|
|
s.dashboardTaskWrite(w, r, "delete")
|
|
}
|
|
|
|
// dashboardTaskWrite is the shared body for the three Tasks-card writeback
|
|
// actions. Routing here keeps the calendar-not-linked-to-item guard, ETag
|
|
// reload, and dashboard cache invalidation in one place.
|
|
func (s *Server) dashboardTaskWrite(w http.ResponseWriter, r *http.Request, action string) {
|
|
if s.CalDAV == nil {
|
|
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
calURL := strings.TrimSpace(r.FormValue("calendar_url"))
|
|
uid := strings.TrimSpace(r.FormValue("uid"))
|
|
if calURL == "" || uid == "" {
|
|
http.Error(w, "calendar_url and uid required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Belt-and-braces: confirm the calendar is actually linked to a projax
|
|
// item before writing — otherwise a crafted form could nuke an arbitrary
|
|
// calendar URL the dashboard didn't surface.
|
|
if ok, err := s.calendarLinked(r.Context(), calURL); err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
} else if !ok {
|
|
http.Error(w, "calendar not linked to any projax item", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
todos, err := s.CalDAV.Client.ListTodos(r.Context(), calURL)
|
|
if err != nil {
|
|
http.Error(w, "list todos: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
var current *caldav.Todo
|
|
for i := range todos {
|
|
if todos[i].UID == uid {
|
|
current = &todos[i]
|
|
break
|
|
}
|
|
}
|
|
if current == nil {
|
|
// Task already gone — drop cache + re-render so the row vanishes.
|
|
s.dashboard.InvalidateAll()
|
|
s.handleDashboard(w, r)
|
|
return
|
|
}
|
|
switch action {
|
|
case "complete":
|
|
st := "COMPLETED"
|
|
updated := caldav.ApplyVTodoEdit(current.Raw, caldav.VTodoEdit{Status: &st})
|
|
if _, err := s.CalDAV.Client.PutTodo(r.Context(), current.URL, updated, current.ETag, ""); err != nil {
|
|
http.Error(w, "complete: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
case "edit":
|
|
edit := caldav.VTodoEdit{}
|
|
if v := r.FormValue("summary"); strings.TrimSpace(v) != "" {
|
|
vv := strings.TrimSpace(v)
|
|
edit.Summary = &vv
|
|
}
|
|
if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" {
|
|
if t, ok := parseDueInput(dueStr); ok {
|
|
edit.Due = &t
|
|
}
|
|
} else if _, present := r.Form["due"]; present {
|
|
edit.ClearDue = true
|
|
}
|
|
updated := caldav.ApplyVTodoEdit(current.Raw, edit)
|
|
if _, err := s.CalDAV.Client.PutTodo(r.Context(), current.URL, updated, current.ETag, ""); err != nil {
|
|
http.Error(w, "edit: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
case "delete":
|
|
if err := s.CalDAV.Client.DeleteTodo(r.Context(), current.URL, current.ETag); err != nil {
|
|
http.Error(w, "delete: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
default:
|
|
http.Error(w, "unknown action: "+action, http.StatusBadRequest)
|
|
return
|
|
}
|
|
s.dashboard.InvalidateAll()
|
|
s.timeline.InvalidateAll()
|
|
// Re-render whichever surface the request came from. HTMX sets HX-Target
|
|
// to the swap target's id; the timeline surface uses #timeline-section.
|
|
// Non-HTMX clients fall through to the dashboard re-render.
|
|
if r.Header.Get("HX-Target") == "timeline-section" {
|
|
s.handleTimeline(w, r)
|
|
return
|
|
}
|
|
s.handleDashboard(w, r)
|
|
}
|
|
|
|
// calendarLinked reports whether any projax item carries a caldav-list link
|
|
// pointing at the given URL. Used as the dashboard's write-side ownership
|
|
// guard.
|
|
func (s *Server) calendarLinked(ctx context.Context, calURL string) (bool, error) {
|
|
links, err := s.Store.LinksByRefType(ctx, refTypeCalDAV)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, l := range links {
|
|
if l.RefID == calURL {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// projectEvents projects raw EventRows fetched for the next-7-day window
|
|
// into dashboard-flavoured row + group shape. RRULE-bearing events
|
|
// surface as a single literal-DTSTART row with Recurring=true; no
|
|
// expansion. Returns grouped-by-day, flat, total.
|
|
func projectEvents(rows []aggregate.EventRow, now time.Time) ([]dashboardEventGroup, []dashboardEvent, int) {
|
|
flat := make([]dashboardEvent, 0, len(rows))
|
|
for _, r := range rows {
|
|
ev := r.Event
|
|
flat = append(flat, dashboardEvent{
|
|
Item: r.Item,
|
|
Event: ev,
|
|
CalendarRef: "", // dashboard never surfaces it; kept for backwards-compat in the struct.
|
|
DayKey: ev.Start.Format("2006-01-02"),
|
|
StartLabel: aggregate.EventStartLabel(ev),
|
|
DayLabel: dayLabelFor(ev.Start, now),
|
|
})
|
|
}
|
|
// Sort flat: start asc, summary asc as tiebreaker for stable rendering.
|
|
sort.Slice(flat, func(i, j int) bool {
|
|
if !flat[i].Event.Start.Equal(flat[j].Event.Start) {
|
|
return flat[i].Event.Start.Before(flat[j].Event.Start)
|
|
}
|
|
return flat[i].Event.Summary < flat[j].Event.Summary
|
|
})
|
|
total := len(flat)
|
|
if len(flat) > 50 {
|
|
flat = flat[:50]
|
|
}
|
|
|
|
// Group by DayKey while preserving the start-asc ordering.
|
|
groups := []dashboardEventGroup{}
|
|
var cur *dashboardEventGroup
|
|
for _, e := range flat {
|
|
if cur == nil || cur.DayKey != e.DayKey {
|
|
groups = append(groups, dashboardEventGroup{DayKey: e.DayKey, DayLabel: e.DayLabel})
|
|
cur = &groups[len(groups)-1]
|
|
}
|
|
cur.Events = append(cur.Events, e)
|
|
}
|
|
return groups, flat, total
|
|
}
|
|
|
|
// dayLabelFor returns a short human label for the day containing t, relative
|
|
// to now: "Today", "Tomorrow", weekday + dd MMM. German weekday names for
|
|
// consistency with the mgmt cockpit it replaces.
|
|
func dayLabelFor(t, now time.Time) string {
|
|
today := startOfDay(now)
|
|
day := startOfDay(t.Local())
|
|
switch int(day.Sub(today).Hours() / 24) {
|
|
case 0:
|
|
return "Today"
|
|
case 1:
|
|
return "Tomorrow"
|
|
}
|
|
return day.Format("Mon 02 Jan")
|
|
}
|
|
|