Files
projax/web/dashboard.go
mAi 316b4e408a feat(dashboard): tab strip + Tiles view + view-switcher URL routing
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).
2026-05-26 12:22:32 +02:00

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