diff --git a/docs/design.md b/docs/design.md
index 68fcae7..99bb21c 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -359,6 +359,22 @@ Out of scope (parked):
- **Auth**: re-use flexsiebels Supabase auth, or simpler shared-secret cookie? msupabase auth is heavier than v1 needs.
- **mBrian topic-hub linkage**: do we auto-suggest mbrian topic links when an item is created with a matching slug? Defer to phase 3.
+## Dashboard / daily-driver view (Phase 3e)
+
+A single landing surface at `/dashboard` that aggregates open work and recent activity across every linked project, so projax can be opened first thing in the morning instead of clicking through each project's detail page.
+
+**Sections** (each a card, count in header):
+
+1. **Open tasks** — every open VTODO (`Status != COMPLETED/CANCELLED`) from every `caldav-list` item_link, fanned out via a 4-worker pool to avoid DAV-server hammering. Bucketed by due date: `Overdue` (red), `Today`, `Tomorrow`, `This week` (≤7d), `No due`. Sort: overdue first, then due asc with no-due last; ties by priority desc, summary asc. Capped at 30 rows; total + per-bucket counts surface in the section header. Each row has a ✓ button that POSTs `/dashboard/task/done` with `calendar_url + uid`, flips the VTODO to `COMPLETED` via the existing PUT path, busts the dashboard cache, and re-renders the full section (so the row vanishes and counts decrement).
+2. **Open issues** — every open Gitea issue from every `gitea-repo` item_link, sorted `updated_at desc`. Read-only (Gitea writeback parked). Reuses the existing `GiteaDeps.Cache` (3-min TTL) so repeated dashboard loads share Gitea hits with the detail page.
+3. **Recent documents** — every dated `item_link` (`event_date IS NOT NULL`) with `event_date >= now - 30d`, joined to its parent item. Sorted newest-first. Each row renders the canonical PER (`{primary_path}.{YYMMDD}`), ref_type badge, note, ref_id link, and project path. Capped at 30.
+
+**Filters**: small chip row at the top reuses `tree_filter.go` URL params (`tag`, `mgmt`, `has`) so `/dashboard?tag=work` scopes all three cards to work-tagged items. The same filter has another use as the cache key — `/dashboard` and `/dashboard?tag=work` are independent cache entries.
+
+**TTL cache**: 60s in-memory map keyed by the encoded TreeFilter. The cache is single-replica only (no shared state needed at single-user scale). The ✓-mark-done handler explicitly invalidates the cache so the row disappears immediately on the next render.
+
+**Out of scope for 3e**: stale-projects card (3f), real-time updates, full per-section pagination, dashboard-as-root-landing. Tree at `/` stays the default surface; nav bar adds a "dashboard" link so m chooses when to switch.
+
## 9. Phase-1 deliverable checklist
- [ ] `projax.items` + `projax.item_links` migrations in `db/migrations/`
diff --git a/store/store.go b/store/store.go
index e05bf0a..eed8d31 100644
--- a/store/store.go
+++ b/store/store.go
@@ -450,6 +450,61 @@ func (s *Store) AddLinkDated(ctx context.Context, itemID, refType, refID, rel st
return &l, nil
}
+// RecentDocuments returns every dated item_link across the whole schema with
+// event_date in [since, now], newest-first. Used by the /dashboard "Recent
+// documents" card. Soft-deleted items are excluded — 0013's cascade trigger
+// removes their links, so the join against projax.items is technically
+// redundant but kept defensively to match the items_unified guarantee.
+func (s *Store) RecentDocuments(ctx context.Context, since time.Time, limit int) ([]*ItemLinkWithItem, error) {
+ if limit <= 0 {
+ limit = 30
+ }
+ rows, err := s.Pool.Query(ctx, `
+ select l.id, l.item_id, l.ref_type, l.ref_id, l.rel, l.note, l.metadata, l.created_at, l.event_date,
+ i.slug, i.title, i.paths
+ from projax.item_links l
+ join projax.items i on i.id = l.item_id and i.deleted_at is null
+ where l.event_date is not null
+ and l.event_date >= $1
+ order by l.event_date desc, l.created_at desc
+ limit $2`, since, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []*ItemLinkWithItem{}
+ for rows.Next() {
+ var x ItemLinkWithItem
+ if err := rows.Scan(
+ &x.Link.ID, &x.Link.ItemID, &x.Link.RefType, &x.Link.RefID, &x.Link.Rel,
+ &x.Link.Note, &x.Link.Metadata, &x.Link.CreatedAt, &x.Link.EventDate,
+ &x.ItemSlug, &x.ItemTitle, &x.ItemPaths,
+ ); err != nil {
+ return nil, err
+ }
+ out = append(out, &x)
+ }
+ return out, rows.Err()
+}
+
+// ItemLinkWithItem bundles an item_link with a thin slice of its parent
+// item's fields — enough for the dashboard "Recent documents" row to render
+// without a second store hop.
+type ItemLinkWithItem struct {
+ Link ItemLink
+ ItemSlug string
+ ItemTitle string
+ ItemPaths []string
+}
+
+// PrimaryPath returns the first path of the bundled item, mirroring Item.PrimaryPath.
+func (x *ItemLinkWithItem) PrimaryPath() string {
+ if len(x.ItemPaths) == 0 {
+ return ""
+ }
+ return x.ItemPaths[0]
+}
+
// DatedLinks returns every item_link with an event_date set, ordered
// newest-first then by insertion order. Used by the detail-page Documents
// section.
diff --git a/web/dashboard.go b/web/dashboard.go
new file mode 100644
index 0000000..41ec3bb
--- /dev/null
+++ b/web/dashboard.go
@@ -0,0 +1,500 @@
+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/store"
+)
+
+// dashboardCache holds the aggregated dashboard payload for up to TTL. Per
+// design.md §9 every cache entry is keyed by the encoded TreeFilter (so
+// `?tag=work` cache is independent of unfiltered), and the TTL is 60s.
+type dashboardCache struct {
+ ttl time.Duration
+ mu sync.Mutex
+ rows map[string]cachedDashboard
+}
+
+type cachedDashboard struct {
+ at time.Time
+ payload *dashboardPayload
+}
+
+func newDashboardCache(ttl time.Duration) *dashboardCache {
+ return &dashboardCache{ttl: ttl, rows: map[string]cachedDashboard{}}
+}
+
+func (c *dashboardCache) get(key string) (*dashboardPayload, bool) {
+ if c == nil {
+ return nil, false
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ v, ok := c.rows[key]
+ if !ok {
+ return nil, false
+ }
+ if time.Since(v.at) > c.ttl {
+ delete(c.rows, key)
+ return nil, false
+ }
+ return v.payload, true
+}
+
+func (c *dashboardCache) set(key string, p *dashboardPayload) {
+ if c == nil {
+ return
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.rows[key] = cachedDashboard{at: time.Now(), payload: p}
+}
+
+// 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
+
+ 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
+}
+
+// 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())
+ // Dashboard treats status=active as the meaningful default — same as tree.
+ cacheKey := filter.QueryString()
+ if cacheKey == "" {
+ cacheKey = "__empty__"
+ }
+
+ 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
+
+ data := map[string]any{
+ "Title": "dashboard",
+ "P": displayPayload,
+ "Filter": filter,
+ }
+ if r.Header.Get("HX-Request") == "true" {
+ s.render(w, "dashboard_section", data)
+ return
+ }
+ s.render(w, "dashboard", data)
+}
+
+// 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.
+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}
+
+ // --- Tasks card ---
+ if s.CalDAV != nil {
+ tasks, groups, total := s.collectTasks(ctx, dashItems, now)
+ p.Tasks = tasks
+ p.TaskGroups = groups
+ p.TaskTotal = total
+ }
+
+ // --- Issues card ---
+ if s.Gitea != nil {
+ issues, total := s.collectIssues(ctx, dashItems, now)
+ p.Issues = issues
+ p.IssueTotal = total
+ }
+
+ // --- Recent documents card ---
+ docs, total, err := s.collectRecentDocs(ctx, byID, dashItems, filter, now)
+ if err != nil {
+ s.Logger.Warn("dashboard docs", "err", err)
+ }
+ p.RecentDocs = docs
+ p.RecentDocsTotal = total
+
+ return p, nil
+}
+
+// collectTasks fans out across every (item, caldav-list link) pair using a
+// small worker pool. Per-calendar errors are logged and skipped so one down
+// calendar doesn't blank the whole card.
+func (s *Server) collectTasks(ctx context.Context, items []*store.Item, now time.Time) ([]dashboardTask, dashboardTaskGroups, int) {
+ type job struct {
+ item *store.Item
+ link *store.ItemLink
+ }
+ jobs := []job{}
+ for _, it := range items {
+ links, err := s.Store.LinksByType(ctx, it.ID, refTypeCalDAV)
+ if err != nil {
+ s.Logger.Warn("dashboard caldav links", "item", it.PrimaryPath(), "err", err)
+ continue
+ }
+ for _, l := range links {
+ jobs = append(jobs, job{item: it, link: l})
+ }
+ }
+ type result struct {
+ item *store.Item
+ calendarURL string
+ todos []caldav.Todo
+ }
+ results := make(chan result, 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 {
+ todos, err := s.CalDAV.Client.ListTodos(ctx, j.link.RefID)
+ if err != nil {
+ s.Logger.Warn("dashboard list todos", "calendar", j.link.RefID, "err", err)
+ continue
+ }
+ open := []caldav.Todo{}
+ for _, t := range todos {
+ if t.Status == "COMPLETED" || t.Status == "CANCELLED" {
+ continue
+ }
+ open = append(open, t)
+ }
+ results <- result{item: j.item, calendarURL: j.link.RefID, todos: open}
+ }
+ }()
+ }
+ for _, j := range jobs {
+ in <- j
+ }
+ close(in)
+ wg.Wait()
+ close(results)
+
+ out := []dashboardTask{}
+ groups := dashboardTaskGroups{}
+ for r := range results {
+ for _, td := range r.todos {
+ 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]
+ // Overdue precedes everything.
+ if (a.Bucket == "overdue") != (b.Bucket == "overdue") {
+ return a.Bucket == "overdue"
+ }
+ // No-due sinks below dated.
+ 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" }
+
+// collectIssues fans out across every (item, gitea-repo link) pair, reusing
+// the Gitea client's existing TTL cache (set by NewGiteaDeps).
+func (s *Server) collectIssues(ctx context.Context, items []*store.Item, now time.Time) ([]dashboardIssue, int) {
+ type job struct {
+ item *store.Item
+ link *store.ItemLink
+ }
+ jobs := []job{}
+ for _, it := range items {
+ links, err := s.Store.LinksByType(ctx, it.ID, refTypeGiteaRepo)
+ if err != nil {
+ s.Logger.Warn("dashboard gitea links", "item", it.PrimaryPath(), "err", err)
+ continue
+ }
+ for _, l := range links {
+ jobs = append(jobs, job{item: it, link: l})
+ }
+ }
+ type result struct {
+ item *store.Item
+ repo string
+ open []gitea.Issue
+ }
+ results := make(chan result, 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 == "" {
+ continue
+ }
+ key := j.link.RefID + "|open"
+ open, ok := s.Gitea.Cache.get(key)
+ if !ok {
+ var err error
+ open, err = s.Gitea.Client.ListIssues(ctx, owner, repo, gitea.ListOpts{State: "open"})
+ if err != nil {
+ s.Logger.Warn("dashboard gitea list", "repo", j.link.RefID, "err", err)
+ continue
+ }
+ s.Gitea.Cache.set(key, open)
+ }
+ results <- result{item: j.item, repo: j.link.RefID, open: open}
+ }
+ }()
+ }
+ for _, j := range jobs {
+ in <- j
+ }
+ close(in)
+ wg.Wait()
+ close(results)
+
+ out := []dashboardIssue{}
+ for r := range results {
+ for _, iss := range r.open {
+ out = append(out, dashboardIssue{
+ Item: r.item,
+ Repo: r.repo,
+ Issue: iss,
+ UpdRel: relativeTime(now, iss.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
+}
+
+// collectRecentDocs reads the last-30-days dated item_links and joins them to
+// the filtered items. Items the filter dropped don't contribute docs.
+func (s *Server) collectRecentDocs(ctx context.Context, byID map[string]*store.Item, _ []*store.Item, _ TreeFilter, now time.Time) ([]dashboardDoc, int, error) {
+ since := now.AddDate(0, 0, -30)
+ rows, err := s.Store.RecentDocuments(ctx, since, 200)
+ if err != nil {
+ return nil, 0, err
+ }
+ 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, nil
+}
+
+// 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) {
+ 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
+ }
+
+ 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 = newDashboardCache(s.dashboard.ttl)
+ s.handleDashboard(w, r)
+ return
+ }
+ 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
+ }
+ // Bust the dashboard cache so the row disappears on next render.
+ s.dashboard = newDashboardCache(s.dashboard.ttl)
+ s.handleDashboard(w, r)
+}
diff --git a/web/dashboard_test.go b/web/dashboard_test.go
new file mode 100644
index 0000000..2d5b77b
--- /dev/null
+++ b/web/dashboard_test.go
@@ -0,0 +1,155 @@
+package web_test
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestDashboardRendersWithoutDeps asserts that GET /dashboard renders cleanly
+// when CalDAV + Gitea are both disabled (no integrations wired). The handler
+// should still render the three card scaffolds and "Nothing" copy.
+func TestDashboardRendersWithoutDeps(t *testing.T) {
+ srv, pool := mustServer(t)
+ defer pool.Close()
+ h := srv.Routes()
+ code, body := get(t, h, "/dashboard")
+ if code != 200 {
+ t.Fatalf("GET /dashboard → %d body=%s", code, body)
+ }
+ for _, want := range []string{
+ `id="dashboard-section"`,
+ `Open tasks`,
+ `Open issues`,
+ `Recent documents`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("dashboard missing %q", want)
+ }
+ }
+}
+
+// TestDashboardRecentDocsSurfacesDatedLinks seeds an item + a dated item_link
+// (event_date today), then asserts the dashboard's Recent Documents card
+// surfaces the row.
+func TestDashboardRecentDocsSurfacesDatedLinks(t *testing.T) {
+ srv, pool := mustServer(t)
+ defer pool.Close()
+ h := srv.Routes()
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
+ slug := "dash-doc-" + stamp
+ var dev, id string
+ if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
+ t.Fatalf("dev: %v", err)
+ }
+ if err := pool.QueryRow(ctx,
+ `insert into projax.items (kind, title, slug, parent_ids)
+ values (array['project']::text[], 'Dash doc', $1, ARRAY[$2]::uuid[])
+ returning id`,
+ slug, dev,
+ ).Scan(&id); err != nil {
+ t.Fatalf("seed item: %v", err)
+ }
+ defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
+
+ if _, err := pool.Exec(ctx,
+ `insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
+ values ($1, 'document', $2, 'contains', $3, current_date)`,
+ id, "https://example.com/dash-doc-"+stamp, fmt.Sprintf("dash test %s", stamp),
+ ); err != nil {
+ t.Fatalf("seed link: %v", err)
+ }
+
+ code, body := get(t, h, "/dashboard")
+ if code != 200 {
+ t.Fatalf("GET /dashboard → %d", code)
+ }
+ wantPER := "dev." + slug + "." + time.Now().UTC().Format("060102")
+ if !strings.Contains(body, wantPER) {
+ t.Errorf("dashboard body missing PER %q (event_date today should surface)", wantPER)
+ }
+}
+
+// TestDashboardFilterByTagNarrowsCard seeds two items in different areas, each
+// with a dated link, then asserts /dashboard?tag=dev only shows the dev one.
+func TestDashboardFilterByTagNarrowsCard(t *testing.T) {
+ srv, pool := mustServer(t)
+ defer pool.Close()
+ h := srv.Routes()
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
+ var dev, home string
+ if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
+ t.Fatalf("dev: %v", err)
+ }
+ if err := pool.QueryRow(ctx, `select id from projax.items where slug='home' and cardinality(parent_ids)=0`).Scan(&home); err != nil {
+ t.Fatalf("home: %v", err)
+ }
+ mkItem := func(parent, slug, tag string) string {
+ var id string
+ if err := pool.QueryRow(ctx,
+ `insert into projax.items (kind, title, slug, parent_ids, tags)
+ values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[], ARRAY[$4]::text[])
+ returning id`,
+ "X "+slug, slug, parent, tag,
+ ).Scan(&id); err != nil {
+ t.Fatalf("seed %s: %v", slug, err)
+ }
+ if _, err := pool.Exec(ctx,
+ `insert into projax.item_links (item_id, ref_type, ref_id, rel, event_date)
+ values ($1, 'document', $2, 'contains', current_date)`,
+ id, "https://example.com/"+slug,
+ ); err != nil {
+ t.Fatalf("link %s: %v", slug, err)
+ }
+ return id
+ }
+ devSlug := "filter-dev-" + stamp
+ homeSlug := "filter-home-" + stamp
+ devID := mkItem(dev, devSlug, "dev")
+ homeID := mkItem(home, homeSlug, "home")
+ defer func() {
+ for _, id := range []string{devID, homeID} {
+ _, _ = pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
+ }
+ }()
+
+ code, body := get(t, h, "/dashboard?tag=dev")
+ if code != 200 {
+ t.Fatalf("GET /dashboard?tag=dev → %d", code)
+ }
+ if !strings.Contains(body, "dev."+devSlug) {
+ t.Errorf("expected dev row in filtered dashboard")
+ }
+ if strings.Contains(body, "home."+homeSlug) {
+ t.Errorf("home row should be filtered out when ?tag=dev")
+ }
+}
+
+// TestDashboardCacheHitOnSecondLoad asserts the in-memory TTL cache returns
+// the same payload (and marks Cached=true) on the second request within 60s.
+func TestDashboardCacheHitOnSecondLoad(t *testing.T) {
+ srv, pool := mustServer(t)
+ defer pool.Close()
+ h := srv.Routes()
+
+ _, _ = get(t, h, "/dashboard")
+ code, body := get(t, h, "/dashboard")
+ if code != 200 {
+ t.Fatalf("second GET /dashboard → %d", code)
+ }
+ if !strings.Contains(body, "cached") {
+ n := len(body)
+ if n > 500 {
+ n = 500
+ }
+ t.Errorf("second load should hit cache (look for 'cached' label) — body:\n%s", body[:n])
+ }
+}
diff --git a/web/server.go b/web/server.go
index 9ab1667..d986e61 100644
--- a/web/server.go
+++ b/web/server.go
@@ -24,13 +24,14 @@ var staticFS embed.FS
// Server bundles handlers, templates, and the store.
type Server struct {
- Store *store.Store
- pages map[string]*template.Template
- Logger *slog.Logger
- Auth *AuthConfig // nil → no auth (local dev / tests)
- CalDAV *CalDAVDeps // nil → CalDAV integration disabled
- Gitea *GiteaDeps // nil → Gitea integration disabled
- MCP http.Handler // nil → /mcp/ routes return 404 (off cleanly)
+ Store *store.Store
+ pages map[string]*template.Template
+ Logger *slog.Logger
+ Auth *AuthConfig // nil → no auth (local dev / tests)
+ CalDAV *CalDAVDeps // nil → CalDAV integration disabled
+ Gitea *GiteaDeps // nil → Gitea integration disabled
+ MCP http.Handler // nil → /mcp/ routes return 404 (off cleanly)
+ dashboard *dashboardCache
}
// New builds a Server. Each page is parsed alongside the layout into its own
@@ -134,6 +135,22 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
}
pages["login"] = loginTmpl
+ // Dashboard page + its section fragment.
+ dashTmpl, err := template.New("dashboard").Funcs(funcs).ParseFS(templatesFS,
+ "templates/layout.tmpl",
+ "templates/dashboard.tmpl",
+ "templates/dashboard_section.tmpl",
+ )
+ if err != nil {
+ return nil, fmt.Errorf("parse dashboard: %w", err)
+ }
+ pages["dashboard"] = dashTmpl
+ dashSection, err := template.New("dashboard_section").Funcs(funcs).ParseFS(templatesFS, "templates/dashboard_section.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("parse dashboard_section: %w", err)
+ }
+ pages["dashboard_section"] = dashSection
+
// Bulk-edit page + its fragment + per-row chip cells. The chip cells share
// definitions with bulk_section so we parse them together every time.
bulkTmpl, err := template.New("bulk").Funcs(funcs).ParseFS(templatesFS,
@@ -161,7 +178,12 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
}
pages["bulk_chip_mgmt"] = bulkChipMgmt
- return &Server{Store: s, pages: pages, Logger: logger}, nil
+ return &Server{
+ Store: s,
+ pages: pages,
+ Logger: logger,
+ dashboard: newDashboardCache(60 * time.Second),
+ }, nil
}
// Routes wires every URL to a handler and returns the mux.
@@ -174,6 +196,8 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /new", s.handleNewForm)
mux.HandleFunc("POST /new", s.handleNewSubmit)
mux.HandleFunc("GET /admin/classify", s.handleClassify)
+ mux.HandleFunc("GET /dashboard", s.handleDashboard)
+ mux.HandleFunc("POST /dashboard/task/done", s.handleDashboardTaskDone)
mux.HandleFunc("GET /admin/bulk", s.handleBulk)
mux.HandleFunc("POST /admin/bulk/apply", s.handleBulkApply)
mux.HandleFunc("POST /admin/bulk/chip", s.handleBulkChip)
@@ -602,6 +626,8 @@ func (s *Server) render(w http.ResponseWriter, name string, data map[string]any)
entry = "documents-section"
case "bulk_section":
entry = "bulk-section"
+ case "dashboard_section":
+ entry = "dashboard-section"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, entry, data); err != nil {
diff --git a/web/static/style.css b/web/static/style.css
index 71195ad..2fe943b 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -181,3 +181,39 @@ table.bulk .chip-x {
table.bulk .chip-x:hover { color: var(--bad); }
table.bulk .chip-add { display: inline-block; margin-left: 4px; }
table.bulk .chip-add input { padding: 1px 4px; font-size: 0.85em; width: 7em; }
+
+/* --- /dashboard --- */
+.dashboard .dash-grid { display: grid; grid-template-columns: 1fr; gap: 16px; margin-top: 12px; }
+.dashboard .card {
+ border: 1px solid var(--border); border-radius: 6px; padding: 12px 16px;
+ background: #fff;
+}
+.dashboard .card header h2 { margin: 0 0 4px 0; font-size: 1.1em; }
+.dashboard .card header .task-groups { display: flex; gap: 12px; flex-wrap: wrap; font-size: 0.85em; margin: 4px 0 8px; }
+.dashboard .card header .task-groups .overdue { color: var(--bad); font-weight: 600; }
+.dashboard .empty { padding: 8px 0; font-style: italic; }
+.dashboard .task-list, .dashboard .issue-list, .dashboard .doc-list { list-style: none; padding: 0; margin: 0; }
+.dashboard .task-row, .dashboard .issue-row, .dashboard .doc-row {
+ display: flex; gap: 8px; align-items: baseline;
+ padding: 6px 0; border-bottom: 1px dotted var(--border); flex-wrap: wrap;
+}
+.dashboard .task-row:last-child, .dashboard .issue-row:last-child, .dashboard .doc-row:last-child { border-bottom: none; }
+.dashboard .task-row .check button {
+ background: transparent; border: 1px solid var(--border); border-radius: 3px;
+ padding: 0 6px; cursor: pointer; color: var(--muted);
+}
+.dashboard .task-row .check button:hover { color: var(--ok); border-color: var(--ok); }
+.dashboard .task-row .proj, .dashboard .issue-row .proj, .dashboard .doc-row .proj {
+ font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.85em; color: var(--muted);
+ min-width: 10em;
+}
+.dashboard .task-row .summary { flex: 1; }
+.dashboard .task-row .due { font-size: 0.85em; color: var(--muted); }
+.dashboard .task-row .due.bad { color: var(--bad); font-weight: 600; }
+.dashboard .task-row.bucket-overdue { background: #fff5f5; }
+.dashboard .issue-row .iss { flex: 1; }
+.dashboard .issue-row .label {
+ font-size: 0.72em; padding: 1px 6px; border-radius: 999px;
+ background: var(--bg-alt); color: var(--muted); border: 1px solid var(--border);
+}
+.dashboard .issue-row .upd { font-size: 0.8em; }
diff --git a/web/templates/dashboard.tmpl b/web/templates/dashboard.tmpl
new file mode 100644
index 0000000..7aa4f71
--- /dev/null
+++ b/web/templates/dashboard.tmpl
@@ -0,0 +1,4 @@
+{{define "content"}}
+
Dashboard
+{{template "dashboard-section" .}}
+{{end}}
diff --git a/web/templates/dashboard_section.tmpl b/web/templates/dashboard_section.tmpl
new file mode 100644
index 0000000..bbed6e6
--- /dev/null
+++ b/web/templates/dashboard_section.tmpl
@@ -0,0 +1,122 @@
+{{define "dashboard-section"}}
+
+
+
+
+
+ {{if .P.Cached}}cached
+ {{else}}fresh — built {{.P.BuiltAt.Format "15:04:05"}}{{end}}
+
+
+
+
+
+
+
+ Open tasks ({{.P.TaskTotal}})
+ {{if or .P.TaskGroups.Overdue .P.TaskGroups.Today .P.TaskGroups.Tomorrow .P.TaskGroups.Week .P.TaskGroups.NoDue}}
+
+ {{if .P.TaskGroups.Overdue}}Overdue ({{.P.TaskGroups.Overdue}}){{end}}
+ {{if .P.TaskGroups.Today}}Today ({{.P.TaskGroups.Today}}){{end}}
+ {{if .P.TaskGroups.Tomorrow}}Tomorrow ({{.P.TaskGroups.Tomorrow}}){{end}}
+ {{if .P.TaskGroups.Week}}This week ({{.P.TaskGroups.Week}}){{end}}
+ {{if .P.TaskGroups.NoDue}}No due ({{.P.TaskGroups.NoDue}}){{end}}
+
+ {{end}}
+
+ {{if .P.Tasks}}
+
+ {{range .P.Tasks}}
+ -
+
+ {{.Item.PrimaryPath}}
+ {{.Todo.Summary}}
+ {{if .DueRel}}{{.DueRel}}{{end}}
+
+ {{end}}
+
+ {{else}}
+ Nothing open. Nice.
+ {{end}}
+
+
+
+
+ Open issues ({{.P.IssueTotal}})
+
+ {{if .P.Issues}}
+
+ {{else}}
+ No open issues across linked repos.
+ {{end}}
+
+
+
+
+ Recent documents ({{.P.RecentDocsTotal}}, last 30d)
+
+ {{if .P.RecentDocs}}
+
+ {{range .P.RecentDocs}}
+ -
+ {{.PER}}
+ {{.Link.RefType}}
+ {{if .Link.Note}}{{deref .Link.Note}}{{end}}
+ {{.Link.RefID}}
+ {{.ItemPath}}
+
+ {{end}}
+
+ {{else}}
+ Nothing dated in the last 30 days.
+ {{end}}
+
+
+
+
+{{end}}
diff --git a/web/templates/layout.tmpl b/web/templates/layout.tmpl
index 2469c16..fe58424 100644
--- a/web/templates/layout.tmpl
+++ b/web/templates/layout.tmpl
@@ -10,6 +10,7 @@