Phase 5h slice 2 — adds the three-tab dashboard chrome (Tiles / Tasks /
Events) and lands the Tiles view as the default landing surface per
m's §7 pick.
URL contract:
/dashboard — Tiles (default, elided)
/dashboard?view=tasks — today's 5-card layout
/dashboard?view=events — Events card promoted to a full-tab view
Unknown ?view= falls back to Tiles.
Refactor: aggregator calls (Todos / Events / Issues) hoisted up into
buildDashboard so the rollup can consume the same uncapped rows without
a second DAV/Gitea round-trip. The legacy collect* helpers split into
pure projectTasks / projectEvents / projectIssues / projectDocs that
take pre-fetched rows. collectStale extended to return its per-item
repo-activity map alongside the trimmed stale list — the rollup uses
the map as a LastActivity signal.
Cache: key now composes (filter | view=X) so each tab has its own 60s
TTL slot. Tab switches don't poison the cache for siblings.
Tiles render with: pin star (when pinned), title + path + live badge,
counts row (open / overdue! / issues / quiet), NextSignal one-liner
(task wins over issue), and a tile-foot LastActivity stamp.
CSS:
- .dash-tabs strip with active-state border bridge.
- .dash-tiles grid: 1/2/3 cols at 600/900px breakpoints.
- .dash-events-view scaffolding for the promoted Events surface.
Templates: dashboard_section.tmpl restructured to dispatch by .View.
The cards layout is now {{define "dashboard-cards"}} and the
events-only surface is {{define "dashboard-events-view"}}. New
dashboard_tiles.tmpl defines {{define "dashboard-tiles"}}. Both
templates registered in the dashboard + dashboard_section bundles.
Tests:
- Existing dashboard tests retargeted at ?view=tasks for the legacy
Tasks-tab expectations (5-card layout, inline writeback, stale card).
- New dashboard_view_test.go covers: default view = Tiles, three-tab
strip rendering + active marker, view=tasks fallback, view=events
promotion, unknown view fallback, tile rendering for seeded item,
cache-key separation between views.
- TestLayoutNoTopHeader scoped to the body chrome before <main> so it
no longer trips on legitimate <header> elements inside cards/tiles.
Out of scope (later slices): scope chip + Quiet fold (slice 3), pin
toggle handler (slice 4), Events tab dedicated polish (slice 5),
mobile polish (slice 7), design.md addendum (slice 8).
834 lines
25 KiB
Go
834 lines
25 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
|
|
|
|
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"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// 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"))
|
|
// Dashboard treats status=active as the meaningful default — same as tree.
|
|
filterKey := filter.QueryString()
|
|
if filterKey == "" {
|
|
filterKey = "__empty__"
|
|
}
|
|
// Cache key composes filter + view so each tab has its own 60s TTL
|
|
// entry — the underlying data is shared, but the rendered template
|
|
// differs and caching the render saves the template work.
|
|
cacheKey := filterKey + "|view=" + view
|
|
|
|
// ?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)
|
|
|
|
// Refresh URL preserves the active view + filter.
|
|
refreshQuery := filterKey
|
|
if refreshQuery == "__empty__" {
|
|
refreshQuery = ""
|
|
}
|
|
if view != dashboardViewTiles {
|
|
if refreshQuery != "" {
|
|
refreshQuery += "&"
|
|
}
|
|
refreshQuery += "view=" + view
|
|
}
|
|
refreshURL := "/dashboard?"
|
|
if refreshQuery != "" {
|
|
refreshURL += refreshQuery + "&"
|
|
}
|
|
refreshURL += "refresh=1"
|
|
|
|
data := map[string]any{
|
|
"Title": "dashboard",
|
|
"P": displayPayload,
|
|
"Filter": filter,
|
|
"View": view,
|
|
"Tabs": dashboardTabs(view, filterKey),
|
|
"UpdatedRel": updatedRel,
|
|
"RefreshURL": refreshURL,
|
|
"FilterActive": filter.Active(),
|
|
}
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
s.render(w, r, "dashboard_section", data)
|
|
return
|
|
}
|
|
s.render(w, r, "dashboard", data)
|
|
}
|
|
|
|
// 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. The default view (tiles) elides from
|
|
// the URL so the address bar stays clean on the daily-driver path.
|
|
func dashboardTabs(active, filterKey string) []dashboardTab {
|
|
prefix := "/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 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")
|
|
}
|
|
|