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 @@