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