Merge branch 'mai/fuller/phase-5h-phase-a-design' (phase 5h slices 1-2: rollup model + Tiles tab)

This commit is contained in:
mAi
2026-05-26 12:23:07 +02:00
13 changed files with 1391 additions and 86 deletions

View File

@@ -0,0 +1,325 @@
# Dashboard overhaul — Phase 5h design
**Status:** Phase A (design). No code touched. Awaiting m's go/no-go via head.
**Branch:** `mai/fuller/phase-5h-phase-a-design`.
**Author:** fuller (inventor), 2026-05-26.
**Source request, verbatim:** *"The Dashboard could really use an overhaul as well. Of course we should continue to use unified filters but the dashboard should allow all kinds of views and easily show a helpful overview over my current projects."*
---
## §1 — Current state diagnosis
`/dashboard` today is a 5-card stream renderer. The shape, with row counts derived from the live data:
| Card | What it shows | Sort | Cap |
|---|---|---|---|
| Open tasks | Every open VTODO across all linked CalDAV lists, bucketed Overdue/Today/Tomorrow/Week/No-due | overdue→due asc→prio→summary | 30 |
| Events | Every VEVENT next 7d, grouped by day | start asc | 50 |
| Open issues | Every open Gitea issue across all linked repos | updated_at desc | 30 |
| Recent documents | Every dated `item_link` in the last 30d | event_date desc | 30 |
| Stale projects | mai-managed items with quiet repo + 0 open tasks + 0 open issues | stale_days desc | 20 |
Filter strip: `tag`, `mgmt`, `has` chips reusing `TreeFilter`. 60s cache keyed by encoded filter. `?refresh=1` busts. Empty cards collapse to one-liners when no filter is active. Inline VTODO ✓/✎/× on the Tasks card.
### What works (don't throw away)
- **Aggregation pipeline** (`internal/aggregate.Aggregator`) — solid, cached, fan-out workers. Any new shape should ride on the same Todos/Events/Issues/Docs primitives.
- **Filter parity** with `/`, `/timeline`, `/calendar`, `/graph`. The chip vocabulary is a real asset; m said keep it.
- **Cache shape** — 60s TTL keyed by filter is fine. Tasks-card writeback already invalidates correctly.
- **Inline VTODO writeback** is genuinely useful — one ✓ click clears a row without leaving the page.
- **Stale card** as a "consider archiving" candidate list. Tiny, surfaces a question that's otherwise hidden.
### What doesn't fit m's request
1. **Task-centric, not project-centric.** m sees "every open task" / "every open issue" as a giant flat stream. He cannot scan the list and answer *"how is project X doing?"* without ctrl-F. He explicitly asked for a project overview.
2. **No notion of "current".** Every active project contributes equally. Of 47 active items, m's likely day-to-day focus is ~5-10 (the ones with recent activity + open work). The dashboard buries those signals.
3. **One view, no switcher.** m said *"all kinds of views"*. Today it's the 5-card stream and that's it.
4. **Pinning is underused.** Only 2 items are pinned (`dev`, `projax`) — pinning today has no visible payoff anywhere, so m doesn't bother. A project-centric dashboard would give pins a job.
5. **Stale card surfaces non-actionable noise.** Once m sees a project once and decides "not now, not archive", the row stays forever. No snooze, no dismiss. Becomes wallpaper.
6. **Per-project signals are scattered.** To see paliad's status m has to scan five separate cards for paliad rows. Should be one tile.
### What's already settled (don't redesign)
- Unified filter chips (`?q`, `?tag`, `?mgmt`, `?has`, `?public`) — preserved verbatim.
- Server-rendered Go `html/template` + HTMX. No JS framework.
- Mobile + desktop both via the new sidebar (≥768px) / bottom-nav (≤767px) layout.
- No file uploads, no new schema columns without head sign-off, no CLI surface.
---
## §2 — m's mental model: what "current projects" means
Reading the live data (`mcp__projax__list_items`):
- **47 active items**, of which 7 are root areas (`admin`, `dev`, `finances`, `health`, `home`, `social`, `work`, plus the synthetic `public` parent).
- **14 items are public** — `dev.fdbck`, `dev.flexsiebels`, `dev.goldi`, `dev.imagen`, `dev.mai`, `dev.mclone`, `dev.mvoice`, `dev.otto`, `dev.mcompete`, `dev.mlex`, `dev.upc-kommentar`, `dev.youpcorg`, `health.sports.manjin`, `work.paliad`. These are clearly the projects m considers *portfolio-worthy* — code projects he wants the world to see.
- **Only 2 items are pinned**: `dev` (root area) and `projax` (this very project). Pinning is structurally there but visually invisible, so it's not used. That's an opportunity, not a constraint — if pins surface clearly, m starts using them.
- **Activity signal** is reachable cheaply: every mai-managed item has a `gitea-repo` link → repo `updated_at` (already cached for the Stale card). Every linked CalDAV list has VTODO/VEVENT counts (already aggregated). A project is "alive" if any of: open VTODO, open issue, repo touched in last 14d, dated link in last 14d.
So "**current projects**" isn't a fixed predicate — it's a small ordered subset. A workable definition for v1 is the union of:
- **Pinned** (m's explicit "this matters now" signal — promote pins to UI-visible),
- **Active recently** (repo activity OR open VTODO with due ≤ 14d OR dated link in last 14d),
- **At risk** (any overdue VTODO, or open issues with stale `updated_at`).
Everything else — the 30+ projects that are technically `active` but not on m's plate this week — should be reachable but **not on the primary surface**. A "show all active" toggle handles them.
Crucially: the *definition* of "current" is configurable. The chip strip already lets m narrow by tag / mgmt / has-links. The new dashboard adds an implicit "current" prefilter on top of that, with a chip to lift it.
---
## §3 — Candidate shapes
Three viable directions. For each: sketch, signals, view switcher behaviour, filter integration, tradeoffs, cost.
### Candidate A — Project Tiles + view switcher
**Sketch (desktop, ~1200px main column, 3-column tile grid):**
```
┌─ Dashboard ──────────────────────────────────────────────────────────┐
│ [Tiles] [Tasks] [Events] [Activity] · tag▾ mgmt▾ has▾ · ↻ updated 2m │
│ ◇ pinned + active ○ show all active │
├──────────────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ ★ paliad │ │ ★ projax │ │ ★ youpcorg │ │
│ │ work · live │ │ dev · this │ │ dev · live │ │
│ │ 4 open · 2! │ │ 7 open │ │ 1 open │ │
│ │ • next call │ │ • 5h dash │ │ • 1600+ judg │ │
│ │ 2d ago │ │ now │ │ 4d ago │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ flexsiebels │ │ otto │ │ mai │ │
│ │ dev · live │ │ dev · live │ │ dev · live │ │
│ │ 2 open │ │ 9 open · 3! │ │ 5 open │ │
│ │ • home page │ │ • i64 deploy │ │ • #218 mAi │ │
│ │ 1d ago │ │ 3h ago │ │ 6h ago │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ Quiet (12) ▾ Archived (3) ▾ │
└──────────────────────────────────────────────────────────────────────┘
```
**Per-tile signals** (top→bottom):
- Title + pin star (click to toggle). Primary path under title (`work.paliad`). Live URL pill if `public_live_url` is set.
- **Counts row**: `N open` (open VTODOs across linked calendars), `M!` (overdue), optional `K issues` (open Gitea issues). Color: overdue red, otherwise default. Click navigates to `/i/<path>/`.
- **Top signal line**: the single most-relevant next thing — either the soonest-due open VTODO, or the most-recently-updated open issue, or last commit summary. One line, truncated.
- **Last-activity stamp**: `2d ago` / `3h ago` / `now`. Derived from max(repo.updated_at, latest_vtodo_modified, latest_dated_link).
- **Optional micro-bar** at the bottom — colored stripe showing the ratio overdue/today/week tasks. Skip for v1 if cluttered.
**View switcher** (top tab strip, 4 views, same filter + scope chips apply across all):
- **Tiles** (default) — the grid above. Project-centric.
- **Tasks** — the existing Open-tasks card + Events card, full-width. Task-centric, today's view. Same inline ✓/✎/× writeback.
- **Events** — the existing Events card alone, full-width with bigger day headers. The "what's on my calendar" view.
- **Activity** — a chronological feed merging recent commits (repo `updated_at` per linked repo), closed issues, completed VTODOs, dated docs. "What just happened across my projects." This is genuinely new; deferable to v2 if scope-tight.
**Scope chip** (next to filter chips, top-row): `◇ pinned + active` (default — the "current projects" prefilter) ↔ `○ show all active`. Single radio. Lifts the implicit "current" filter so m can browse the long tail.
**Filter integration**: existing `tag` / `mgmt` / `has` / `public` chips narrow the project set across all four views. URL stays a single `/dashboard?view=tiles&tag=work&scope=current` so links bookmark cleanly. Default view (`tiles`) + default scope (`current`) elided from URL.
**Tradeoffs**:
- ✅ Best match for m's explicit ask. "Helpful overview over my current projects" maps almost literally to the tile grid.
- ✅ Pinning gets a visible job. Stars on tiles make the feature legible.
- ✅ View switcher gives m the "all kinds of views" he asked for, without losing today's task-centric value (it's the `Tasks` tab).
- ✅ Per-tile signals tell a story at a glance. Scrolling 8 tiles is faster than scrolling 30 unrelated task rows.
- ❌ Tile signals require a per-project rollup the current `dashboardPayload` doesn't compute. Moderate aggregation work — group existing TodoRow/IssueRow/repo-updated by item.ID.
- ❌ The Activity view is the most novel and the highest risk; can be cut from v1 if needed (3 tabs is fine).
- ❌ Tiles trade information density for legibility. A power user wanting "everything everywhere" has to flip to Tasks view.
**Implementation cost**: moderate.
- `dashboard.go` grows a per-project rollup (group existing rows by `Item.ID`, compute `LastActivity = max(repoUpdated, dueSoonest, eventSoonest, docLatest)`).
- New `dashboardProject` struct + tiles template partial.
- View-switcher = a URL `?view=` param routing into one of 4 template partials. Each partial re-uses the existing `*_section` templates wherever possible.
- Scope chip = one boolean URL param + a `IsCurrent(item, rollup, now)` predicate.
- Pin toggle = a POST handler that flips `Pinned` and re-renders. The detail page already has the field; surface it on the tile.
### Candidate B — Project Rows + signal columns
**Sketch (desktop, full-width table):**
```
┌─ Dashboard ──────────────────────────────────────────────────────────────┐
│ [Compact] [Detailed] [Kanban] · tag▾ mgmt▾ has▾ · ◇ pinned + active │
├──────────────────────────────────────────────────────────────────────────┤
│ Project Path Open Over Issues Last Status │
│ ─────────────────────────────────────────────────────────────────────────│
│ ★ paliad work.paliad 4 2 3 2d ago live │
│ ★ projax dev.projax 7 0 1 now dev │
│ ★ youpcorg dev.youpcorg 1 0 2 4d ago live │
│ ★ otto dev.otto 9 3 5 3h ago dev │
│ mai dev.mai 5 1 8 6h ago dev │
│ flexsiebels dev.flex… 2 0 1 1d ago live │
│ fdbck dev.fdbck 0 0 0 12d ago live │
│ ─────────────────────────────────────────────────────────────────────────│
│ Quiet (12) ▾ Archived (3) ▾ │
└──────────────────────────────────────────────────────────────────────────┘
```
**Per-row signals**: pin star, title, primary path, open-task count, overdue count, open-issue count, last-activity relative, status pill. Each column sortable (column-header click flips sort, persisted in URL).
**View switcher**:
- **Compact** (default) — the table above.
- **Detailed** — the current 5-card layout, preserved verbatim. "Take me back to the old dashboard."
- **Kanban-by-status** — three columns: `Active`, `Stale`, `At risk` (overdue or open-issue-heavy). Cards in each column = same per-row signals condensed.
**Filter integration**: identical to Candidate A — chips narrow the project set across all three views.
**Tradeoffs**:
- ✅ Highest information density. m sees ~25 projects without scrolling on a wide screen.
- ✅ Sortable columns are a "spreadsheet" superpower — *"show me everything ordered by overdue count desc"* is one click.
- ✅ Includes a "back to old layout" via the Detailed view — zero regret for muscle memory.
- ✅ Same per-project rollup work as Candidate A, so impl-cost largely overlaps.
- ❌ Tables feel less like a "dashboard" and more like a report. The visual texture is monotone — no big numbers, no color, no story per row. Less daily-driver, more weekly review.
- ❌ Mobile is uglier — a 6-column table degrades into a tall card list, losing the table's compactness advantage.
- ❌ Kanban view as proposed is shallow — three status columns is just a filter dimension dressed up. Not a real flow.
**Implementation cost**: moderate, mostly overlapping Candidate A. Sortable headers add ~50 lines of JS (or server-side via URL `?sort=col,dir`).
### Candidate C — Workspace-style 3-pane
**Sketch (desktop, fixed 3-column layout):**
```
┌─ Dashboard · tag▾ mgmt▾ has▾ · ↻ updated 2m ago ────────────────────────┐
│ ┌─ FOCUS ──────────┐ ┌─ TODAY ────────────┐ ┌─ ACTIVITY ───────────────┐│
│ │ ★ paliad │ │ Overdue (2) │ │ otto · i64 deployed 3h ││
│ │ 4 · 2! 2d │ │ • paliad/call 1d │ │ projax · 5h commit 4h ││
│ │ ★ projax │ │ • otto/i65 2d │ │ mai · #218 closed 6h ││
│ │ 7 · 0 now │ │ Today (5) │ │ youpcorg · scrape 1d ││
│ │ ★ youpcorg │ │ • paliad/spec │ │ flex · #45 merged 1d ││
│ │ 1 · 0 4d │ │ • projax/dash │ │ paliad · push 2d ││
│ │ ★ otto │ │ ⋮ │ │ ⋮ ││
│ │ 9 · 3! 3h │ │ Events (3) │ │ ││
│ │ ───────── │ │ • 14:00 paliad-call│ │ ││
│ │ Pinned (4) │ │ • 16:30 sport │ │ ││
│ │ + Pin a project │ │ • Tue 09:00 standup│ │ ││
│ └──────────────────┘ └────────────────────┘ └─────────────────────────┘│
└──────────────────────────────────────────────────────────────────────────┘
```
**Three fixed panes** (each independently scrollable):
- **FOCUS** (left, ~25%) — pinned-only project tiles. Vertical strip. Star-toggle to pin/unpin. The "what matters this week" surface. Empty state has a "+ Pin a project" affordance.
- **TODAY** (middle, ~40%) — the existing Open-tasks + Events cards condensed, no per-project cards. Overdue → Today → Tomorrow → Events for today/tomorrow.
- **ACTIVITY** (right, ~35%) — chronological feed: recent commits + issue updates + completed VTODOs + dated docs, newest first. The "what happened recently" view.
**View switcher**: none. The layout is the layout. Mobile collapses to a vertical stack (FOCUS → TODAY → ACTIVITY).
**Filter integration**: chips narrow all three panes simultaneously (filter to `tag=work` and FOCUS shows only pinned work-projects, TODAY shows only work-tagged tasks, ACTIVITY shows only work-tagged events).
**Tradeoffs**:
- ✅ Strongest "workspace" feel — closer to Linear / Notion than to a stream reader. Most "dashboard-y".
- ✅ FOCUS pane is the most direct expression of "show me my current projects" and forces pinning to do real work.
- ✅ No view-switcher means no decision fatigue — open it, see everything that matters in three panes.
- ❌ No view-switcher also means **no answer to "I want a different view"** — directly contradicts m's "all kinds of views" ask.
- ❌ Three-pane on desktop only. Mobile becomes a vertical scroll that's not meaningfully different from today's stack. The whole shape is desktop-first.
- ❌ Requires pinning to be useful, but pinning is currently unused. Bootstrap problem — m has to invest before he sees value.
- ❌ Highest implementation cost: three new layouts (pane shells + responsive collapse + per-pane templates) and the brand-new Activity feed.
**Implementation cost**: large. Three new template surfaces, the Activity aggregator is new code, and the responsive mobile collapse needs care.
---
## §4 — Recommendation: **Candidate A (Project Tiles + view switcher)**
A best matches m's three signals in order:
1. *"Helpful overview over my current projects"* → the Tiles grid is literally that. One tile per project, scannable, the most-relevant next thing surfaced per tile.
2. *"All kinds of views"* → the view switcher gives 3-4 different shapes (Tiles / Tasks / Events / Activity), all sharing the same filter + scope, all bookmarkable. C has no switcher and B's "Detailed" view is just nostalgia for today's layout.
3. *"Continue to use unified filters"* → same chip strip as today, extended with a `scope=current|all` chip that's the only addition. No filter vocabulary changes.
B is the runner-up, and a strong runner-up — its sortable table is genuinely useful for weekly-review, and its "Detailed" tab is a comfortable escape hatch. But the *first impression* of a table-of-projects on the daily-driver surface is "report," not "dashboard," and m opened the request with the word *helpful*. Tiles feel more helpful at a glance.
C has the best architectural shape (no mode-switching, everything visible) but flunks the "all kinds of views" ask outright, and depends on pinning that m hasn't adopted. The FOCUS pane idea is good enough that it shows up inside Candidate A's Tiles view as the "pinned-first sort" — we keep the strongest signal from C without paying its cost.
**Recommended scope-cut for v1** (if cost is a worry): ship Tiles + Tasks + Events tabs only. Defer Activity to a follow-up phase. That's 3 tabs, the first of which is new and the other two are partial reuses of today's templates.
---
## §5 — Implementation plan (if greenlit)
### Files touched
- `web/dashboard.go`
- Add `dashboardProject` rollup struct: `{Item, OpenTasks, Overdue, OpenIssues, LastActivity, NextSignal, IsLive}`.
- Add `collectProjectRollups(items, todos, events, issues, repos)` — groups the existing rows by `Item.ID`, computes `LastActivity = max(repo.updated_at, last_modified_vtodo, latest_dated_link, last_event)`, picks `NextSignal` (soonest-due VTODO summary, else latest issue title, else latest commit summary).
- Add `IsCurrent(rollup, now)` predicate: pinned OR (open tasks > 0) OR (open issues > 0) OR (LastActivity within 14d) OR (any overdue VTODO).
- Extend `handleDashboard` to read `?view=` (tiles | tasks | events | activity, default tiles) and `?scope=` (current | all, default current). Both elide from URL when default.
- Cache key extends to include `view` + `scope` so `/dashboard?view=tasks` is a separate cache entry — keeps the 60s TTL meaningful per shape.
- `web/templates/dashboard.tmpl` — top tab strip + scope chip + per-view partial dispatch.
- `web/templates/dashboard_tiles.tmpl` (new) — tile grid + "Quiet (N) ▾" collapsible footer.
- `web/templates/dashboard_section.tmpl` — retained as the `tasks` view's partial (the existing 5-card layout, minus Stale which moves under Quiet).
- `web/templates/dashboard_events.tmpl` (new, lightweight) — the existing Events card surface promoted to a full-tab view with bigger day headers.
- `web/static/style.css``.dash-tiles` grid (CSS grid, auto-fill, `minmax(280px, 1fr)`), `.tile` shell, `.tile-counts`, `.tile-signal`, `.tile-stamp`. Mobile breakpoint stacks tiles 1-column ≤ 600px, 2-col 600900px, 3-col ≥ 900px.
- `web/dashboard_pin.go` (new) — `handleDashboardPin` POSTs `?id=<uuid>&pin=true|false`, calls `Store.SetPin([id], bool)`, invalidates dashboard cache, re-renders.
- `store/store.go` — add `SetPin(ctx, ids []string, pinned bool) error` if not already there (mirrors `SetPublic`).
Activity view (if v1): `web/dashboard_activity.go` — merges recent gitea commits (new: lift commit lister out of detail page), recent closed issues, recent completed VTODOs, recent dated docs. Sort by event_time desc. Cap 50. Cache piggybacks on dashboard's 60s.
### Commit slicing
1. **Rollup + tile data model**, no UI change. Adds `dashboardProject` + `collectProjectRollups` + tests against a fixture set of items+todos+issues. `view=tiles` not wired yet.
2. **Tiles tab + view switcher chrome**. `?view=tiles` (default), `?view=tasks` falls through to today's layout. Tab strip in `dashboard.tmpl`. CSS for tiles.
3. **Scope chip** (`?scope=current|all`) + `IsCurrent` predicate + "Quiet (N) ▾" expandable footer that lists collapsed projects.
4. **Pin toggle** on tile + `handleDashboardPin` + cache invalidation.
5. **Events tab** as its own URL — `?view=events` renders the events partial standalone.
6. (Optional, v2-mergeable) **Activity tab**: aggregator extension + template + tab entry.
7. **Mobile polish**: media-query breakpoints for tile grid, touch-friendly pin star, drawer fits.
8. **Design.md addendum**: a new "Dashboard overhaul (Phase 5h)" section documenting the final shape, the URL contract, and the rollup definition.
Each commit on this branch is independently revertable. The Tasks tab (today's layout) survives the whole sequence — if m hates Tiles, switching to Tasks gets him back his familiar dashboard with zero data loss.
### Test coverage
- `dashboard_rollup_test.go` — fixture-driven: feed in items + synthetic todos/issues, assert `dashboardProject` fields. Cover edge cases: project with 0 links, project with multiple linked repos, overdue counting.
- `dashboard_view_test.go` — request `/dashboard?view=tiles`, `/dashboard?view=tasks`, `/dashboard?view=events`; assert the right template selected + the expected sections present.
- `dashboard_scope_test.go` — assert `scope=current` filters out the right items; `scope=all` includes them.
- `dashboard_pin_test.go` — POST flip → SetPin called → cache invalidated → re-render shows star state.
- Existing `dashboard_test.go`, `dashboard_events_test.go`, `dashboard_edit_test.go` keep passing unchanged — the Tasks tab is the same surface.
### Deploy strategy
Standard projax flow: push to `mai/fuller/phase-5h-*` worktree-branch as commits land, head merges to `main` per slice, Gitea webhook → Dockerfile → `https://projax.msbls.de/healthz` SHA check. The view-switcher default of `tiles` means the first deploy that lands flips m's daily surface — but the Tasks tab is one click away, so m has a fallback the moment he wants it.
### Rollback if it doesn't feel right
Three safety levers, in order of cheapness:
1. **Change the default tab**: flip `view` default from `tiles` to `tasks` in one line of `handleDashboard`. m sees today's dashboard, the Tiles view still exists for opt-in.
2. **Hide the tab strip**: comment out the tab-strip template block. View URL params still work but no UI to switch.
3. **Revert the merge commits**: each phase is a clean merge, so `git revert -m 1 <sha>` per slice cleanly unwinds.
A week of dogfood after Phase 5h ships is the natural soak. If m still likes today's dashboard better after 7 days, lever 1.
---
## §6 — Open questions (the 4 worth chip-asking)
These are decisions with 2-4 mutually-exclusive options. The remaining design choices have sensible defaults and can be settled in implementation chat.
1. **Default tab on `/dashboard`** — Tiles (new) or Tasks (today's layout preserved)?
2. **"Current" definition** — pinned recently-active (recommended), or activity-only, or pinned-only, or pinned open-work?
3. **Activity tab in v1** — ship it now (4th tab), or defer to a follow-up phase (3 tabs at launch)?
4. **Stale card fate** — fold into Quiet section under Tiles, keep as its own card on the Tasks tab, or retire entirely (replaced by the LastActivity stamp on each tile)?
---
## §7 — m's decisions (2026-05-26)
Picks via `AskUserQuestion`, all four matching the inventor's recommendation:
- **Q1 (Default tab): Tiles.** New project-tile grid is the landing surface. Tasks tab one click away.
- **Q2 (Current = ?): Pinned recently-active open-work.** Generous union — catches everything plausibly relevant without forcing m to pin first. Quiet long-tail collapses into a "Quiet (N) ▾" fold under the grid.
- **Q3 (Activity tab): Defer to v2.** Phase 5h ships 3 tabs (Tiles / Tasks / Events). Activity feed is the most novel piece and can land as a follow-up after Tiles has dogfood.
- **Q4 (Stale card): Fold into Quiet under Tiles.** Per-tile `LastActivity` stamp carries the staleness signal; the "consider archiving?" nudge migrates to the Quiet fold's framing. The standalone Stale card retires.
No inventor↔m disagreements; no reasoning notes needed. All four picks tighten the recommended scope to "Tiles-first, 3 tabs, no separate Stale card" — a smaller phase than the doc's optional-Activity v1 sketch.
### Scope-locked summary for the coder gate
If head greenlights the coder shift, the brief is:
1. Rollup data model + `IsCurrent(rollup, now)` = pinned OR (open tasks > 0) OR (open issues > 0) OR (LastActivity within 14d).
2. Tab strip on `/dashboard` with three tabs: **Tiles** (default), **Tasks** (today's 5-card layout minus Stale), **Events** (events card promoted to its own surface).
3. Tile grid with per-project signals + pin star + "Quiet (N) ▾" fold containing both `IsCurrent=false` projects AND the Stale candidates.
4. URL contract: `?view=tiles|tasks|events` (default `tiles`, elided), `?scope=current|all` (default `current`, elided). Existing chip vocabulary unchanged.
5. Cache key extends to `(filter, view, scope)`. Pin toggle invalidates dashboard cache.
6. Tests cover rollup math + view routing + scope predicate + pin flip.
7. Mobile: 1-col ≤ 600px, 2-col 600900px, 3-col ≥ 900px tile grid; tab strip stays at top.
8. Design.md addendum documenting the final shape lands in the same branch.
Activity tab + sortable-table view + multi-pane workspace are explicit non-goals for Phase 5h. Revisit after dogfood if Tiles proves the model.

View File

@@ -41,6 +41,12 @@ type dashboardPayload struct {
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
}
@@ -108,16 +114,43 @@ type dashboardStale struct {
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.
cacheKey := filter.QueryString()
if cacheKey == "" {
cacheKey = "__empty__"
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.
@@ -140,16 +173,29 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
// Updated-relative label: how long since the cached payload was built.
updatedRel := relativeTime(time.Now(), payload.BuiltAt)
// Refresh URL: clone the current query, drop ?refresh, prepend it back.
refreshURL := "/dashboard?refresh=1"
if cacheKey != "__empty__" {
refreshURL = "/dashboard?" + cacheKey + "&refresh=1"
// 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(),
@@ -161,9 +207,49 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
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.
// 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 {
@@ -190,20 +276,32 @@ func (s *Server) buildDashboard(ctx context.Context, filter TreeFilter) (*dashbo
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 := s.collectTasks(ctx, dashItems, now)
tasks, groups, total := projectTasks(todoRows, now)
p.Tasks = tasks
p.TaskGroups = groups
p.TaskTotal = total
}
// --- Events card (Phase 3l) ---
// Same caldav-list link source as Tasks, time-range filtered to the next
// 7 days. Re-uses the 4-worker pool pattern; no extra DAV calls when the
// dashboard cache hits.
if s.CalDAV != nil {
events, flat, total := s.collectEvents(ctx, dashItems, now)
events, flat, total := projectEvents(eventRows, now)
p.Events = events
p.EventsFlat = flat
p.EventsTotal = total
@@ -211,18 +309,20 @@ func (s *Server) buildDashboard(ctx context.Context, filter TreeFilter) (*dashbo
// --- Issues card ---
if s.Gitea != nil {
issues, total := s.collectIssues(ctx, dashItems, now)
issues, total := projectIssues(issueRows, now)
p.Issues = issues
p.IssueTotal = total
}
// --- Recent documents card ---
docs, total, err := s.collectRecentDocs(ctx, byID, dashItems, filter, now)
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 = total
p.RecentDocsTotal = docTotal
// --- Stale projects card ---
// "Stale" = mai-managed AND linked-repo quiet 60d+ AND 0 open tasks AND
@@ -240,10 +340,17 @@ func (s *Server) buildDashboard(ctx context.Context, filter TreeFilter) (*dashbo
// 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 := s.collectStale(ctx, dashItems, openTasksByItem, openIssuesByItem, now)
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
}
@@ -251,10 +358,14 @@ func (s *Server) buildDashboard(ctx context.Context, filter TreeFilter) (*dashbo
// 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.
func (s *Server) collectStale(ctx context.Context, items []*store.Item, openTasks, openIssues map[string]int, now time.Time) ([]dashboardStale, int) {
// 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
return nil, 0, nil
}
const staleCutoffDays = 60
type job struct {
@@ -283,7 +394,7 @@ func (s *Server) collectStale(ctx context.Context, items []*store.Item, openTask
}
}
if len(jobs) == 0 {
return nil, 0
return nil, 0, nil
}
type res struct {
@@ -380,15 +491,23 @@ func (s *Server) collectStale(ctx context.Context, items []*store.Item, openTask
if len(out) > 20 {
out = out[:20]
}
return out, total
// 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
}
// collectTasks fans out across every (item, caldav-list link) pair via the
// aggregator and projects the typed TodoRows into the dashboard-flavoured
// dashboardTask shape (due-status bucket + relative label). Per-calendar
// errors are absorbed inside the aggregator.
func (s *Server) collectTasks(ctx context.Context, items []*store.Item, now time.Time) ([]dashboardTask, dashboardTaskGroups, int) {
rows := s.Aggregator().Todos(ctx, items, aggregate.Window{})
// 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 {
@@ -472,11 +591,9 @@ func startOfDay(t time.Time) time.Time {
func relDays(n int) string { return strconv.Itoa(n) + "d" }
// collectIssues fans out across every (item, gitea-repo link) pair via the
// aggregator (which owns the cache + worker pool) and projects each
// IssueRow into the dashboard's view shape.
func (s *Server) collectIssues(ctx context.Context, items []*store.Item, now time.Time) ([]dashboardIssue, int) {
rows := s.Aggregator().Issues(ctx, items)
// 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{
@@ -496,14 +613,10 @@ func (s *Server) collectIssues(ctx context.Context, items []*store.Item, now tim
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
}
// 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]
@@ -526,7 +639,7 @@ func (s *Server) collectRecentDocs(ctx context.Context, byID map[string]*store.I
if len(out) > 30 {
out = out[:30]
}
return out, total, nil
return out, total
}
// handleDashboardTaskDone is the inline ✓-checkbox handler on the Tasks card.
@@ -661,16 +774,11 @@ func (s *Server) calendarLinked(ctx context.Context, calURL string) (bool, error
return false, nil
}
// collectEvents asks the aggregator for the next-7-day VEVENT window and
// projects each EventRow 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 (s *Server) collectEvents(ctx context.Context, items []*store.Item, now time.Time) ([]dashboardEventGroup, []dashboardEvent, int) {
window := aggregate.Window{
From: startOfDay(now),
To: startOfDay(now).AddDate(0, 0, 7),
}
rows := s.Aggregator().Events(ctx, items, window)
// 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

View File

@@ -69,9 +69,10 @@ END:VCALENDAR`
}
h := srv.Routes()
code, body := get(t, h, "/dashboard")
// Inline VTODO writeback rows live on the Tasks tab (Phase 5h).
code, body := get(t, h, "/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard → %d", code)
t.Fatalf("GET /dashboard?view=tasks → %d", code)
}
for _, want := range []string{
`Edit me please`,

View File

@@ -69,9 +69,10 @@ func TestDashboardEventsCardSurfacesUpcoming(t *testing.T) {
}
h := srv.Routes()
code, body := get(t, h, "/dashboard")
// The card-events markup lives on the Tasks tab (Phase 5h).
code, body := get(t, h, "/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard → %d", code)
t.Fatalf("GET /dashboard?view=tasks → %d", code)
}
for _, want := range []string{
`card-events`,
@@ -105,7 +106,7 @@ func TestDashboardEventsCardCollapsesWhenEmpty(t *testing.T) {
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.URL+"/", "u", "p")}
h := srv.Routes()
_, body := get(t, h, "/dashboard")
_, body := get(t, h, "/dashboard?view=tasks")
if !strings.Contains(body, "No upcoming events") {
t.Errorf("expected collapsed Events card with 'No upcoming events' note")
}

230
web/dashboard_rollup.go Normal file
View File

@@ -0,0 +1,230 @@
package web
import (
"sort"
"strconv"
"strings"
"time"
"github.com/m/projax/internal/aggregate"
"github.com/m/projax/store"
)
// dashboardActivityWindow is the lookback IsCurrent uses to decide whether
// a project sits on the primary Tiles grid or under the Quiet (N) ▾ fold.
// 14 days per the Phase 5h §7 contract — long enough to catch a project
// that shipped last week but is between sprints, short enough that a
// project nobody's touched in three weeks doesn't crowd the top.
const dashboardActivityWindow = 14 * 24 * time.Hour
// dashboardProject is the per-project rollup that drives the Tiles view.
// One row per item.ID across every signal source (CalDAV, Gitea, dated
// links). Built from the same aggregator outputs the existing cards
// already fetch — no extra DAV/Gitea calls.
type dashboardProject struct {
Item *store.Item
OpenTasks int // open VTODOs across every linked calendar
Overdue int // subset of OpenTasks with Due strictly before today
OpenIssues int // open Gitea issues across every linked repo
LastActivity time.Time // zero = no signal seen yet
LastActivityRel string // "2d" / "3h" / "12m" / "now"; "" when zero
NextSignal string // soonest-due open VTODO summary, else latest issue title, else ""
NextSignalKind string // "task" | "issue" | ""
IsLive bool // has public_live_url
Stale bool // mai-managed quiet-repo + 0 open work — fed by the existing stale set
}
// IsCurrent implements the §7 rule: pinned OR open-tasks > 0 OR
// open-issues > 0 OR LastActivity within dashboardActivityWindow.
func (p dashboardProject) IsCurrent(now time.Time) bool {
if p.Item != nil && p.Item.Pinned {
return true
}
if p.OpenTasks > 0 || p.OpenIssues > 0 {
return true
}
if !p.LastActivity.IsZero() && now.Sub(p.LastActivity) < dashboardActivityWindow {
return true
}
return false
}
// collectProjectRollups groups the aggregator's per-row signals by
// item.ID into one dashboardProject per item, then sorts the output
// pinned-first then by primary path ascending. Rollups are returned for
// every item even when every signal is zero — the caller (IsCurrent +
// the Tiles template) decides what surfaces.
//
// repoActivity is optional: when set, item.ID → newest repo updated_at
// feeds the LastActivity max. The existing stale collector already
// fetches repo updated_at, so the dashboard wiring (Slice 2) can pass
// it through without a second Gitea round-trip.
//
// staleByItem is optional: when set, item.ID → true tags the rollup
// with the Stale flag so the Quiet fold can badge it without re-running
// the staleness probe.
func collectProjectRollups(
items []*store.Item,
todos []aggregate.TodoRow,
issues []aggregate.IssueRow,
events []aggregate.EventRow,
docs []*store.ItemLinkWithItem,
repoActivity map[string]time.Time,
staleByItem map[string]bool,
now time.Time,
) []dashboardProject {
today := startOfDay(now)
byID := make(map[string]*dashboardProject, len(items))
for _, it := range items {
byID[it.ID] = &dashboardProject{
Item: it,
IsLive: strings.TrimSpace(it.PublicLiveURL) != "",
Stale: staleByItem[it.ID],
}
}
// Soonest-due open VTODO per item wins NextSignal (task kind).
// Done/cancelled VTODOs still contribute LastActivity via LastModified
// so a project that shipped tasks recently surfaces as current.
soonestDue := map[string]time.Time{}
for i := range todos {
td := &todos[i]
p, ok := byID[td.Item.ID]
if !ok {
continue
}
status := td.Todo.Status
if status == "COMPLETED" || status == "CANCELLED" {
if td.Todo.LastModified != nil && td.Todo.LastModified.After(p.LastActivity) {
p.LastActivity = *td.Todo.LastModified
}
continue
}
p.OpenTasks++
if td.Todo.Due != nil {
if startOfDay(td.Todo.Due.Local()).Before(today) {
p.Overdue++
}
cur, seen := soonestDue[td.Item.ID]
if !seen || td.Todo.Due.Before(cur) {
soonestDue[td.Item.ID] = *td.Todo.Due
p.NextSignal = td.Todo.Summary
p.NextSignalKind = "task"
}
} else if p.NextSignal == "" {
// No-due task: only fills NextSignal if nothing else has yet.
p.NextSignal = td.Todo.Summary
p.NextSignalKind = "task"
}
if td.Todo.LastModified != nil && td.Todo.LastModified.After(p.LastActivity) {
p.LastActivity = *td.Todo.LastModified
}
}
// Issues feed OpenIssues + LastActivity. NextSignal only when no task
// has claimed the slot yet — tasks are more actionable per m's daily
// driver pattern.
latestIssueUpd := map[string]time.Time{}
for i := range issues {
ir := &issues[i]
p, ok := byID[ir.Item.ID]
if !ok {
continue
}
p.OpenIssues++
prev := latestIssueUpd[ir.Item.ID]
if ir.Issue.UpdatedAt.After(prev) {
latestIssueUpd[ir.Item.ID] = ir.Issue.UpdatedAt
if p.NextSignalKind != "task" {
p.NextSignal = ir.Issue.Title
p.NextSignalKind = "issue"
}
}
if ir.Issue.UpdatedAt.After(p.LastActivity) {
p.LastActivity = ir.Issue.UpdatedAt
}
}
// Events feed LastActivity only — they don't appear on tiles but a
// recent or imminent event keeps the project current via the window.
for i := range events {
ev := &events[i]
p, ok := byID[ev.Item.ID]
if !ok {
continue
}
if ev.Event.Start.After(p.LastActivity) {
p.LastActivity = ev.Event.Start
}
}
// Dated links feed LastActivity via event_date.
for _, d := range docs {
if d == nil || d.Link.EventDate == nil {
continue
}
p, ok := byID[d.Link.ItemID]
if !ok {
continue
}
if d.Link.EventDate.After(p.LastActivity) {
p.LastActivity = *d.Link.EventDate
}
}
// Repo activity from the stale-card probe (optional, no extra fetch).
for itemID, at := range repoActivity {
p, ok := byID[itemID]
if !ok || at.IsZero() {
continue
}
if at.After(p.LastActivity) {
p.LastActivity = at
}
}
out := make([]dashboardProject, 0, len(byID))
for _, p := range byID {
if !p.LastActivity.IsZero() {
p.LastActivityRel = activityRel(now, p.LastActivity)
}
out = append(out, *p)
}
sort.SliceStable(out, func(i, j int) bool {
ai, aj := out[i].Item.Pinned, out[j].Item.Pinned
if ai != aj {
return ai
}
return out[i].Item.PrimaryPath() < out[j].Item.PrimaryPath()
})
return out
}
// activityRel formats a tight relative-time label for the tile stamp.
// Different shape from relativeTime (used on rows) — tiles have less
// horizontal space, one-or-two-character labels read better.
//
// <1m → "now"
// <1h → "12m"
// <24h → "3h"
// else → "5d"
//
// Future timestamps (e.g. an event tomorrow) are flipped to absolute
// duration so the label still reads sensibly — "in 14h" would push the
// column wider than the design allows.
func activityRel(now, t time.Time) string {
d := now.Sub(t)
if d < 0 {
d = -d
}
switch {
case d < time.Minute:
return "now"
case d < time.Hour:
return strconv.Itoa(int(d/time.Minute)) + "m"
case d < 24*time.Hour:
return strconv.Itoa(int(d/time.Hour)) + "h"
default:
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
}
}

View File

@@ -0,0 +1,246 @@
package web
import (
"testing"
"time"
"github.com/m/projax/caldav"
"github.com/m/projax/gitea"
"github.com/m/projax/internal/aggregate"
"github.com/m/projax/store"
)
func mustItem(id, path string) *store.Item {
return &store.Item{ID: id, Slug: path, Title: path, Paths: []string{path}}
}
// TestCollectProjectRollupsCountsTasksAndOverdue feeds three open VTODOs at
// staggered due dates into one item and asserts OpenTasks counts all three,
// Overdue counts just the one in the past, and NextSignal picks the
// soonest-due summary.
func TestCollectProjectRollupsCountsTasksAndOverdue(t *testing.T) {
now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
it := mustItem("i1", "dev.alpha")
yesterday := now.AddDate(0, 0, -1)
tomorrow := now.AddDate(0, 0, 1)
weekOut := now.AddDate(0, 0, 6)
todos := []aggregate.TodoRow{
{Item: it, CalendarURL: "cal-a", Todo: caldav.Todo{UID: "t1", Summary: "due tomorrow", Status: "NEEDS-ACTION", Due: &tomorrow}},
{Item: it, CalendarURL: "cal-a", Todo: caldav.Todo{UID: "t2", Summary: "overdue", Status: "NEEDS-ACTION", Due: &yesterday}},
{Item: it, CalendarURL: "cal-a", Todo: caldav.Todo{UID: "t3", Summary: "next week", Status: "NEEDS-ACTION", Due: &weekOut}},
}
rollups := collectProjectRollups([]*store.Item{it}, todos, nil, nil, nil, nil, nil, now)
if len(rollups) != 1 {
t.Fatalf("expected 1 rollup, got %d", len(rollups))
}
p := rollups[0]
if p.OpenTasks != 3 {
t.Errorf("OpenTasks: want 3 got %d", p.OpenTasks)
}
if p.Overdue != 1 {
t.Errorf("Overdue: want 1 got %d", p.Overdue)
}
if p.NextSignal != "overdue" {
t.Errorf("NextSignal: want soonest-due summary 'overdue' got %q", p.NextSignal)
}
if p.NextSignalKind != "task" {
t.Errorf("NextSignalKind: want 'task' got %q", p.NextSignalKind)
}
}
// TestCollectProjectRollupsSkipsClosedTasksButFeedsActivity asserts that a
// COMPLETED VTODO doesn't bump OpenTasks but its LastModified still feeds
// LastActivity — a project that shipped tasks last week stays "current".
func TestCollectProjectRollupsSkipsClosedTasksButFeedsActivity(t *testing.T) {
now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
it := mustItem("i1", "dev.alpha")
lastTouch := now.AddDate(0, 0, -3)
todos := []aggregate.TodoRow{
{Item: it, CalendarURL: "cal-a", Todo: caldav.Todo{UID: "t1", Summary: "shipped", Status: "COMPLETED", LastModified: &lastTouch}},
}
rollups := collectProjectRollups([]*store.Item{it}, todos, nil, nil, nil, nil, nil, now)
p := rollups[0]
if p.OpenTasks != 0 {
t.Errorf("COMPLETED VTODO must not count: got OpenTasks=%d", p.OpenTasks)
}
if !p.LastActivity.Equal(lastTouch) {
t.Errorf("LastActivity: want %v got %v", lastTouch, p.LastActivity)
}
if !p.IsCurrent(now) {
t.Errorf("3-day-old shipped task must keep project current")
}
}
// TestCollectProjectRollupsIssuesContributeAndFillNextSignal asserts that
// issues feed OpenIssues + LastActivity, and that the most-recently-updated
// issue title fills NextSignal when no task has claimed it.
func TestCollectProjectRollupsIssuesContributeAndFillNextSignal(t *testing.T) {
now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
it := mustItem("i1", "dev.alpha")
old := now.AddDate(0, 0, -5)
fresh := now.AddDate(0, 0, -1)
issues := []aggregate.IssueRow{
{Item: it, Repo: "org/r", Issue: gitea.Issue{Number: 1, Title: "old one", UpdatedAt: old}},
{Item: it, Repo: "org/r", Issue: gitea.Issue{Number: 2, Title: "fresh one", UpdatedAt: fresh}},
}
rollups := collectProjectRollups([]*store.Item{it}, nil, issues, nil, nil, nil, nil, now)
p := rollups[0]
if p.OpenIssues != 2 {
t.Errorf("OpenIssues: want 2 got %d", p.OpenIssues)
}
if p.NextSignal != "fresh one" {
t.Errorf("NextSignal: want latest-issue title 'fresh one' got %q", p.NextSignal)
}
if p.NextSignalKind != "issue" {
t.Errorf("NextSignalKind: want 'issue' got %q", p.NextSignalKind)
}
if !p.LastActivity.Equal(fresh) {
t.Errorf("LastActivity should pick the newest of the two issue updates")
}
}
// TestCollectProjectRollupsTaskBeatsIssueForNextSignal confirms the
// task-wins precedence: when both a task and an issue exist, NextSignal is
// the task summary even if the issue is more recent.
func TestCollectProjectRollupsTaskBeatsIssueForNextSignal(t *testing.T) {
now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
it := mustItem("i1", "dev.alpha")
due := now.AddDate(0, 0, 2)
issueUpd := now.AddDate(0, 0, -1)
todos := []aggregate.TodoRow{
{Item: it, CalendarURL: "cal-a", Todo: caldav.Todo{UID: "t1", Summary: "task wins", Status: "NEEDS-ACTION", Due: &due}},
}
issues := []aggregate.IssueRow{
{Item: it, Repo: "org/r", Issue: gitea.Issue{Number: 1, Title: "issue loses", UpdatedAt: issueUpd}},
}
rollups := collectProjectRollups([]*store.Item{it}, todos, issues, nil, nil, nil, nil, now)
p := rollups[0]
if p.NextSignal != "task wins" || p.NextSignalKind != "task" {
t.Errorf("task should win NextSignal slot, got %q (%s)", p.NextSignal, p.NextSignalKind)
}
}
// TestCollectProjectRollupsRepoActivityFeedsLastActivity covers the
// optional repoActivity map: stale-card already fetches repo updated_at,
// passing the map through must drive LastActivity for projects with no
// other signal.
func TestCollectProjectRollupsRepoActivityFeedsLastActivity(t *testing.T) {
now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
it := mustItem("i1", "dev.alpha")
commitAt := now.AddDate(0, 0, -2)
rollups := collectProjectRollups([]*store.Item{it}, nil, nil, nil, nil, map[string]time.Time{"i1": commitAt}, nil, now)
p := rollups[0]
if !p.LastActivity.Equal(commitAt) {
t.Errorf("LastActivity: want repo commit time %v got %v", commitAt, p.LastActivity)
}
if p.LastActivityRel != "2d" {
t.Errorf("LastActivityRel: want '2d' got %q", p.LastActivityRel)
}
}
// TestCollectProjectRollupsLastActivityPicksMaxAcrossSources feeds every
// source (todo, event, doc, repo) for one item and asserts LastActivity
// is the max of them all.
func TestCollectProjectRollupsLastActivityPicksMaxAcrossSources(t *testing.T) {
now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
it := mustItem("i1", "dev.alpha")
t1 := now.AddDate(0, 0, -10)
t2 := now.AddDate(0, 0, -5)
t3 := now.AddDate(0, 0, -2) // newest
t4 := now.AddDate(0, 0, -7)
todos := []aggregate.TodoRow{
{Item: it, CalendarURL: "cal", Todo: caldav.Todo{UID: "t", Summary: "x", Status: "COMPLETED", LastModified: &t1}},
}
events := []aggregate.EventRow{
{Item: it, Event: caldav.Event{UID: "e", Summary: "y", Start: t2}},
}
docs := []*store.ItemLinkWithItem{
{Link: store.ItemLink{ItemID: "i1", EventDate: &t4}},
}
repo := map[string]time.Time{"i1": t3}
rollups := collectProjectRollups([]*store.Item{it}, todos, nil, events, docs, repo, nil, now)
if got := rollups[0].LastActivity; !got.Equal(t3) {
t.Errorf("LastActivity: want %v (the newest signal) got %v", t3, got)
}
}
// TestIsCurrentCoversAllBranches walks the four paths through IsCurrent:
// pinned, open-tasks > 0, open-issues > 0, LastActivity within 14d. The
// fifth (no signal at all) returns false.
func TestIsCurrentCoversAllBranches(t *testing.T) {
now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
in := now.AddDate(0, 0, -10) // inside window
out := now.AddDate(0, 0, -20) // outside window
cases := []struct {
name string
p dashboardProject
want bool
}{
{"pinned", dashboardProject{Item: &store.Item{Pinned: true}}, true},
{"open task", dashboardProject{Item: &store.Item{}, OpenTasks: 1}, true},
{"open issue", dashboardProject{Item: &store.Item{}, OpenIssues: 3}, true},
{"recent activity", dashboardProject{Item: &store.Item{}, LastActivity: in}, true},
{"stale activity", dashboardProject{Item: &store.Item{}, LastActivity: out}, false},
{"empty", dashboardProject{Item: &store.Item{}}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := c.p.IsCurrent(now); got != c.want {
t.Errorf("IsCurrent: want %v got %v", c.want, got)
}
})
}
}
// TestCollectProjectRollupsSortsPinnedFirstThenPath asserts the output
// order: pinned items first, then alphabetical by primary path.
func TestCollectProjectRollupsSortsPinnedFirstThenPath(t *testing.T) {
now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
a := mustItem("ia", "dev.aaa")
b := mustItem("ib", "dev.bbb")
c := mustItem("ic", "dev.ccc")
c.Pinned = true
rollups := collectProjectRollups([]*store.Item{a, b, c}, nil, nil, nil, nil, nil, nil, now)
got := []string{rollups[0].Item.PrimaryPath(), rollups[1].Item.PrimaryPath(), rollups[2].Item.PrimaryPath()}
want := []string{"dev.ccc", "dev.aaa", "dev.bbb"}
for i, g := range got {
if g != want[i] {
t.Errorf("position %d: want %s got %s", i, want[i], g)
}
}
}
// TestCollectProjectRollupsStaleFlagPassesThrough asserts the staleByItem
// map tags rollups without re-running the staleness probe.
func TestCollectProjectRollupsStaleFlagPassesThrough(t *testing.T) {
now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
it := mustItem("i1", "dev.quiet")
rollups := collectProjectRollups([]*store.Item{it}, nil, nil, nil, nil, nil, map[string]bool{"i1": true}, now)
if !rollups[0].Stale {
t.Errorf("Stale flag should pass through from staleByItem map")
}
}
// TestActivityRelLabels covers the rel-label shapes: now / Nm / Nh / Nd
// and the future-flip behavior.
func TestActivityRelLabels(t *testing.T) {
now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
cases := []struct {
name string
t time.Time
want string
}{
{"now", now.Add(-30 * time.Second), "now"},
{"12m", now.Add(-12 * time.Minute), "12m"},
{"3h", now.Add(-3 * time.Hour), "3h"},
{"5d", now.AddDate(0, 0, -5), "5d"},
{"future event flips to absolute", now.AddDate(0, 0, 2), "2d"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := activityRel(now, c.t); got != c.want {
t.Errorf("want %s got %s", c.want, got)
}
})
}
}

View File

@@ -14,16 +14,18 @@ import (
"github.com/m/projax/web"
)
// 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.
// TestDashboardRendersWithoutDeps asserts that GET /dashboard?view=tasks
// renders cleanly when CalDAV + Gitea are both disabled (no integrations
// wired). The handler should still render the three card scaffolds and
// "Nothing" copy. Phase 5h: this asserts the Tasks tab; the new default
// /dashboard (Tiles) is covered by TestDashboardTilesViewRenders.
func TestDashboardRendersWithoutDeps(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard")
code, body := get(t, h, "/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard → %d body=%s", code, body)
t.Fatalf("GET /dashboard?view=tasks → %d body=%s", code, body)
}
// Empty-card collapse (phase 3g) replaces full card chrome with a
// one-line "No open tasks." style note when there is no filter active
@@ -74,9 +76,10 @@ func TestDashboardRecentDocsSurfacesDatedLinks(t *testing.T) {
t.Fatalf("seed link: %v", err)
}
code, body := get(t, h, "/dashboard")
// The Recent Documents card lives on the Tasks tab (Phase 5h).
code, body := get(t, h, "/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard → %d", code)
t.Fatalf("GET /dashboard?view=tasks → %d", code)
}
wantPER := "dev." + slug + "." + time.Now().UTC().Format("060102")
if !strings.Contains(body, wantPER) {
@@ -130,9 +133,10 @@ func TestDashboardFilterByTagNarrowsCard(t *testing.T) {
}
}()
code, body := get(t, h, "/dashboard?tag=dev")
// Doc rows surface on the Tasks tab; the filter narrows both views.
code, body := get(t, h, "/dashboard?tag=dev&view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?tag=dev → %d", code)
t.Fatalf("GET /dashboard?tag=dev&view=tasks → %d", code)
}
if !strings.Contains(body, "dev."+devSlug) {
t.Errorf("expected dev row in filtered dashboard")
@@ -143,8 +147,8 @@ func TestDashboardFilterByTagNarrowsCard(t *testing.T) {
}
// TestDashboardRefreshBustsCache asserts that ?refresh=1 invalidates the
// cache entry for the matching filter key: the response no longer says
// "cached" even when called within the 60s TTL of a preceding fetch.
// cache entry for the matching (filter, view) key: the response no longer
// says "cached" even when called within the 60s TTL of a preceding fetch.
func TestDashboardRefreshBustsCache(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
@@ -155,7 +159,11 @@ func TestDashboardRefreshBustsCache(t *testing.T) {
// Second hit shows cached label.
_, cachedBody := get(t, h, "/dashboard")
if !strings.Contains(cachedBody, "cached") {
t.Fatalf("setup: second load should be cached, got body:\n%s", cachedBody[:600])
n := len(cachedBody)
if n > 600 {
n = 600
}
t.Fatalf("setup: second load should be cached, got body:\n%s", cachedBody[:n])
}
// Third hit with ?refresh=1 should be fresh again.
code, body := get(t, h, "/dashboard?refresh=1")
@@ -171,15 +179,16 @@ func TestDashboardRefreshBustsCache(t *testing.T) {
}
// TestDashboardCollapsesEmptyCardsWhenNoFilter checks the 3g empty-collapse
// behaviour: when there are zero rows AND no filter active, cards render as
// one-line "No open tasks" muted notes instead of the full card chrome.
// behaviour on the Tasks tab: when there are zero rows AND no filter active,
// cards render as one-line "No open tasks" muted notes instead of the full
// card chrome.
func TestDashboardCollapsesEmptyCardsWhenNoFilter(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard")
code, body := get(t, h, "/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard → %d", code)
t.Fatalf("GET /dashboard?view=tasks → %d", code)
}
if !strings.Contains(body, "card-collapsed") {
t.Errorf("expected at least one card-collapsed inline note (no rows + no filter)")
@@ -197,7 +206,7 @@ func TestDashboardFilterKeepsFullCardChrome(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard?tag=nothing-matches-zzz")
code, body := get(t, h, "/dashboard?tag=nothing-matches-zzz&view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard?tag=… → %d", code)
}
@@ -256,9 +265,11 @@ func TestDashboardStaleCardSurfacesDormantMaiProject(t *testing.T) {
}
h := srv.Routes()
code, body := get(t, h, "/dashboard")
// The Stale card lives on the Tasks tab (Phase 5h folds it under
// Quiet on Tiles — that's a separate slice).
code, body := get(t, h, "/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /dashboard → %d", code)
t.Fatalf("GET /dashboard?view=tasks → %d", code)
}
if !strings.Contains(body, "card-stale") {
t.Fatalf("expected stale card to render — body lacks 'card-stale'")
@@ -314,9 +325,13 @@ func TestDashboardStaleCardSkipsRecentRepo(t *testing.T) {
}
h := srv.Routes()
_, body := get(t, h, "/dashboard")
if strings.Contains(body, "/i/dev."+slug) {
t.Errorf("recent repo should NOT surface in stale card — body contains /i/dev.%s", slug)
// Match the inverse check against the Tasks tab where Stale lives.
_, body := get(t, h, "/dashboard?view=tasks")
// A recent repo creates a tile (under Tiles view) AND a /i/ link on
// the Stale card-collapsed area would be unexpected. The Tasks tab's
// Stale card is what this guards.
if strings.Contains(body, `class="stale-row"`) && strings.Contains(body, "/i/dev."+slug) {
t.Errorf("recent repo should NOT surface in stale card — body contains stale-row with /i/dev.%s", slug)
}
}

184
web/dashboard_view_test.go Normal file
View File

@@ -0,0 +1,184 @@
package web_test
import (
"context"
"strings"
"testing"
"time"
)
// TestDashboardDefaultViewIsTiles asserts the default landing surface on
// /dashboard (no ?view= param) is the Tiles tab — m's Phase 5h pick.
func TestDashboardDefaultViewIsTiles(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", code)
}
if !strings.Contains(body, `class="dash-tiles"`) {
t.Errorf("default view should be Tiles — body lacks 'class=\"dash-tiles\"'")
}
if strings.Contains(body, `class="card card-tasks"`) {
t.Errorf("default view should NOT render the Tasks 5-card layout")
}
}
// TestDashboardTabsRenderAllThree confirms the tab strip shows the three
// expected entries (Tiles / Tasks / Events) and marks the active one.
func TestDashboardTabsRenderAllThree(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
cases := []struct {
url string
activeTab string
activeLabel string
}{
{"/dashboard", "tiles", "Tiles"},
{"/dashboard?view=tasks", "tasks", "Tasks"},
{"/dashboard?view=events", "events", "Events"},
}
for _, c := range cases {
t.Run(c.activeTab, func(t *testing.T) {
code, body := get(t, h, c.url)
if code != 200 {
t.Fatalf("GET %s → %d", c.url, code)
}
if !strings.Contains(body, `class="dash-tabs"`) {
t.Errorf("expected dash-tabs nav element")
}
for _, label := range []string{"Tiles", "Tasks", "Events"} {
if !strings.Contains(body, label+"</a>") {
t.Errorf("tab strip missing label %q", label)
}
}
// Each <a class="dash-tab ..."> carries many HTMX attrs between
// the class and the label; look for the active class + the
// label somewhere later in the body. Approximate but stable.
activeIdx := strings.Index(body, `class="dash-tab active"`)
if activeIdx < 0 {
t.Fatalf("no active tab marker in body")
}
// Active label must appear after the active class marker AND
// within a reasonable window (one tab worth of HTML, ~300 chars).
window := body[activeIdx:]
if cut := strings.Index(window, `class="dash-tab"`); cut > 0 {
window = window[:cut]
}
if !strings.Contains(window, c.activeLabel) {
t.Errorf("active tab should be %q — active-class window does not contain it", c.activeLabel)
}
})
}
}
// TestDashboardTasksViewFallback confirms that ?view=tasks renders the
// today's 5-card layout (cards), not the tile grid.
func TestDashboardTasksViewFallback(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard?view=tasks")
if strings.Contains(body, `class="dash-tiles"`) {
t.Errorf("view=tasks should NOT render the Tiles grid")
}
// Cards either render with chrome or collapse to muted notes; either
// shape proves the cards partial dispatched, not Tiles.
if !strings.Contains(body, "No open tasks") {
t.Errorf("view=tasks with no deps should show collapsed 'No open tasks' note")
}
}
// TestDashboardEventsViewRenders confirms that ?view=events renders the
// promoted Events surface (dash-events-view) and not the cards or tiles.
func TestDashboardEventsViewRenders(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard?view=events")
if !strings.Contains(body, `class="dash-events-view"`) {
t.Errorf("view=events should render the promoted Events surface")
}
if strings.Contains(body, `class="dash-tiles"`) {
t.Errorf("view=events should NOT render the Tiles grid")
}
if strings.Contains(body, `class="card card-tasks"`) {
t.Errorf("view=events should NOT render the Tasks 5-card layout")
}
}
// TestDashboardUnknownViewFallsBackToTiles confirms graceful default
// behaviour: an unknown ?view= value renders Tiles, not a 404 or empty.
func TestDashboardUnknownViewFallsBackToTiles(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/dashboard?view=gibberish")
if code != 200 {
t.Fatalf("GET /dashboard?view=gibberish → %d", code)
}
if !strings.Contains(body, `class="dash-tiles"`) {
t.Errorf("unknown view should fall back to Tiles")
}
}
// TestDashboardTilesViewShowsRollupForSeededItem seeds an item, asserts
// the Tiles view renders a tile for it (the rollup runs across every
// active item, regardless of links).
func TestDashboardTilesViewShowsRollupForSeededItem(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 := "tile-target-" + 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[], 'tile target', $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)
code, body := get(t, h, "/dashboard")
if code != 200 {
t.Fatalf("GET /dashboard → %d", code)
}
if !strings.Contains(body, `data-item-path="dev.`+slug+`"`) {
t.Errorf("expected tile for dev.%s on default Tiles view", slug)
}
// Title is rendered as text inside <a class="tile-title">…</a> with
// surrounding whitespace; a substring check is enough.
if !strings.Contains(body, "tile target") {
t.Errorf("expected tile title 'tile target' to appear in body")
}
}
// TestDashboardCacheKeySeparatesViews ensures the cache layer keys by
// (filter, view): the same filter under different views must hit
// independent cache entries. We prove this by priming /dashboard, then
// /dashboard?view=tasks, and asserting both report "fresh" on their
// first call (i.e. they don't share a cache slot).
func TestDashboardCacheKeySeparatesViews(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body1 := get(t, h, "/dashboard")
if !strings.Contains(body1, "fresh") {
t.Fatalf("first /dashboard load should be fresh")
}
_, body2 := get(t, h, "/dashboard?view=tasks")
if !strings.Contains(body2, "fresh") {
t.Errorf("first /dashboard?view=tasks load should be fresh — sharing a cache slot with Tiles would mark it cached")
}
}

View File

@@ -85,13 +85,23 @@ func TestLayoutCollapseScript(t *testing.T) {
// TestLayoutNoTopHeader proves the pre-5g <header> chrome is gone so
// callers that asserted on old top-nav markup can't keep passing by
// accident. Belt-and-braces guard for the migration.
//
// Scope: only the TOP-of-body header is forbidden. <header> elements
// inside <main> (card heads, tile heads) are valid HTML5 and used by
// the existing card-tasks template and the Phase 5h tile template.
func TestLayoutNoTopHeader(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
if strings.Contains(body, `<header>`) || strings.Contains(body, `<header `) {
t.Errorf("expected the pre-5g top <header> to be gone, but rendered body has one: %s", truncate(body, 400))
// Slice out the region between <body> and <main> — that's where the
// pre-5g top header lived. Inside <main> belongs to content templates.
chrome := body
if i := strings.Index(chrome, "<main"); i >= 0 {
chrome = chrome[:i]
}
if strings.Contains(chrome, `<header>`) || strings.Contains(chrome, `<header `) {
t.Errorf("expected the pre-5g top <header> to be gone, but body chrome (before <main>) has one: %s", truncate(chrome, 400))
}
if strings.Contains(body, `class="logout-btn"`) {
t.Errorf("expected the pre-5g .logout-btn to be replaced by the sidebar .logout-item")

View File

@@ -243,17 +243,23 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
}
pages["admin"] = adminTmpl
// Dashboard page + its section fragment.
// Dashboard page + its section fragment. Phase 5h: the section fragment
// dispatches to one of three view partials (tiles / cards / events-view),
// so the tiles partial joins both bundles.
dashTmpl, err := template.New("dashboard").Funcs(funcs).ParseFS(templatesFS,
"templates/layout.tmpl",
"templates/dashboard.tmpl",
"templates/dashboard_section.tmpl",
"templates/dashboard_tiles.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")
dashSection, err := template.New("dashboard_section").Funcs(funcs).ParseFS(templatesFS,
"templates/dashboard_section.tmpl",
"templates/dashboard_tiles.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse dashboard_section: %w", err)
}

View File

@@ -310,6 +310,90 @@ table.bulk .chip-add-btn:hover { background: var(--accent); color: var(--accent-
.graph-legend .key-mixed { border-color: var(--graph-mixed); color: var(--graph-mixed); border-style: dashed; }
.graph-legend .key-unmanaged { border-color: var(--graph-unmanaged); color: var(--graph-unmanaged); }
/* ------------------------------------------------------------------
Phase 5h — Dashboard view switcher (Tiles / Tasks / Events tabs)
The tab strip sits below the filter bar; the tile grid is the
default surface. Tiles use CSS grid auto-fill so the column count
follows the viewport width.
------------------------------------------------------------------ */
.dashboard .dash-tabs {
display: flex; gap: 4px; margin: 8px 0 12px;
border-bottom: 1px solid var(--border);
}
.dashboard .dash-tab {
padding: 6px 14px; font-size: 0.95em;
border: 1px solid transparent; border-bottom: none; border-radius: 4px 4px 0 0;
color: var(--muted); text-decoration: none;
margin-bottom: -1px;
}
.dashboard .dash-tab:hover { color: var(--fg); }
.dashboard .dash-tab.active {
color: var(--fg); background: var(--surface);
border-color: var(--border);
}
.dashboard .dash-tiles {
display: grid; gap: 12px; margin-top: 8px;
grid-template-columns: 1fr;
}
.dashboard .tile {
border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px;
background: var(--surface);
display: flex; flex-direction: column; gap: 6px;
min-height: 110px;
}
.dashboard .tile.tile-pinned { border-left: 3px solid var(--accent); }
.dashboard .tile.tile-stale { border-style: dashed; opacity: 0.85; }
.dashboard .tile .tile-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.dashboard .tile .tile-title { font-weight: 600; color: var(--fg); text-decoration: none; }
.dashboard .tile .tile-title:hover { text-decoration: underline; }
.dashboard .tile .tile-star { color: var(--accent); margin-right: 2px; }
.dashboard .tile .tile-path {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.78em;
}
.dashboard .tile .tile-live {
font-size: 0.72em; padding: 1px 6px; border-radius: 999px;
background: var(--bg-alt); color: var(--accent); border: 1px solid var(--accent);
margin-left: auto;
}
.dashboard .tile .tile-counts {
display: flex; gap: 10px; flex-wrap: wrap; margin: 0;
font-size: 0.85em;
}
.dashboard .tile .tile-counts .tile-overdue { color: var(--bad); }
.dashboard .tile .tile-counts strong { font-size: 1.05em; }
.dashboard .tile .tile-signal {
margin: 0; font-size: 0.85em;
display: flex; gap: 6px; align-items: baseline;
overflow: hidden;
}
.dashboard .tile .tile-signal-text {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dashboard .tile .tile-foot {
margin-top: auto; font-size: 0.78em;
display: flex; justify-content: flex-end;
}
.dashboard .dash-events-view { margin-top: 8px; }
.dashboard .dash-events-view .event-day-large { margin: 16px 0; }
.dashboard .dash-events-view .event-day-large h2 {
font-size: 1em; font-weight: 600; margin: 0 0 8px 0;
border-bottom: 1px dotted var(--border); padding-bottom: 4px;
}
.dashboard .dash-events-view .event-list { list-style: none; padding: 0; margin: 0; }
.dashboard .dash-events-view .event-row {
display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap;
padding: 6px 0; border-bottom: 1px dotted var(--border);
}
.dashboard .dash-events-view .event-row:last-child { border-bottom: none; }
.dashboard .dash-events-view .start { font-family: ui-monospace, SFMono-Regular, monospace; min-width: 4em; }
.dashboard .dash-events-view .proj {
font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.85em; color: var(--muted);
}
.dashboard .dash-events-view .summary { flex: 1; }
/* --- /dashboard polish (3g) --- */
.dashboard .counts .refresh { margin-left: 12px; color: var(--accent); cursor: pointer; }
.dashboard .counts .refresh:hover { text-decoration: underline; }
@@ -453,6 +537,14 @@ table.bulk .chip-add-btn:hover { background: var(--accent); color: var(--accent-
.dashboard .card-stale { grid-column: span 2; }
}
/* Phase 5h — Tiles grid breakpoints (1/2/3 cols at 600/900/—). */
@media (min-width: 600px) {
.dashboard .dash-tiles { grid-template-columns: 1fr 1fr; }
}
@media (min-width: 900px) {
.dashboard .dash-tiles { grid-template-columns: 1fr 1fr 1fr; }
}
/* Graph fit-to-screen toggle (Phase 3i): when ".fit" is on the canvas,
the SVG width:100% squashes it to the viewport instead of natural size.
Maintains aspect ratio via preserveAspectRatio default. */

View File

@@ -3,7 +3,7 @@
<section class="tagbar" id="dashboard-filterbar">
<form id="dashboard-filter" class="search"
hx-get="/dashboard"
hx-get="/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}"
hx-target="#dashboard-section"
hx-swap="outerHTML"
hx-trigger="change from:select"
@@ -29,7 +29,8 @@
<option value="gitea-repo" {{if contains $selH "gitea-repo"}}selected{{end}}>gitea</option>
</select>
</label>
{{if .Filter.Active}}<a class="clear" href="/dashboard">clear filters</a>{{end}}
{{if ne .View "tiles"}}<input type="hidden" name="view" value="{{.View}}">{{end}}
{{if .Filter.Active}}<a class="clear" href="/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}">clear filters</a>{{end}}
</form>
<p class="counts muted">
{{if .P.Cached}}<small title="Served from 60s in-memory cache · built {{.P.BuiltAt.Format "15:04:05"}}">updated {{.UpdatedRel}} · cached</small>
@@ -42,6 +43,28 @@
</p>
</section>
<nav class="dash-tabs" aria-label="Dashboard view">
{{range .Tabs}}
<a href="{{.URL}}" class="dash-tab{{if .Active}} active{{end}}"
hx-get="{{.URL}}"
hx-target="#dashboard-section"
hx-swap="outerHTML"
hx-push-url="true">{{.Label}}</a>
{{end}}
</nav>
{{if eq .View "tasks"}}
{{template "dashboard-cards" .}}
{{else if eq .View "events"}}
{{template "dashboard-events-view" .}}
{{else}}
{{template "dashboard-tiles" .}}
{{end}}
</section>
{{end}}
{{define "dashboard-cards"}}
<section class="dash-grid">
{{$collapse := not .FilterActive}}
@@ -211,5 +234,29 @@
{{end}}
</section>
</section>
{{end}}
{{define "dashboard-events-view"}}
<section class="dash-events-view">
{{if .P.Events}}
{{range .P.Events}}
<section class="event-day-large">
<h2 class="muted">{{.DayLabel}} <small>({{len .Events}})</small></h2>
<ul class="event-list">
{{range .Events}}
<li class="event-row">
<span class="start">{{.StartLabel}}</span>
<a class="proj" href="/i/{{.Item.PrimaryPath}}">{{.Item.PrimaryPath}}</a>
<span class="summary">{{.Event.Summary}}</span>
{{if .Event.Location}}<span class="loc muted">· {{.Event.Location}}</span>{{end}}
{{if .Event.Recurring}}<span class="recurring" title="recurring — only literal DTSTART shown">↻</span>{{end}}
</li>
{{end}}
</ul>
</section>
{{end}}
{{else}}
<p class="empty muted">No events in the next 7 days.</p>
{{end}}
</section>
{{end}}

View File

@@ -0,0 +1,40 @@
{{define "dashboard-tiles"}}
<section class="dash-tiles">
{{if .P.Projects}}
{{range .P.Projects}}
{{$path := .Item.PrimaryPath}}
<article class="tile{{if .Item.Pinned}} tile-pinned{{end}}{{if .Stale}} tile-stale{{end}}" data-item-path="{{$path}}">
<header class="tile-head">
<a class="tile-title" href="/i/{{$path}}">
{{if .Item.Pinned}}<span class="tile-star" title="pinned">★</span>{{end}}
{{.Item.Title}}
</a>
<span class="tile-path muted">{{$path}}</span>
{{if .IsLive}}<a class="tile-live" href="{{.Item.PublicLiveURL}}" target="_blank" rel="noopener" title="live">live</a>{{end}}
</header>
<p class="tile-counts">
{{if .OpenTasks}}<span class="tile-open"><strong>{{.OpenTasks}}</strong> open</span>{{end}}
{{if .Overdue}}<span class="tile-overdue"><strong>{{.Overdue}}</strong>!</span>{{end}}
{{if .OpenIssues}}<span class="tile-issues"><strong>{{.OpenIssues}}</strong> issue{{if ne .OpenIssues 1}}s{{end}}</span>{{end}}
{{if and (not .OpenTasks) (not .OpenIssues)}}<span class="tile-quiet muted">quiet</span>{{end}}
</p>
{{if .NextSignal}}
<p class="tile-signal" title="{{.NextSignalKind}}">
<span class="tile-signal-kind muted">{{if eq .NextSignalKind "task"}}•{{else}}◆{{end}}</span>
<span class="tile-signal-text">{{.NextSignal}}</span>
</p>
{{end}}
<footer class="tile-foot">
{{if .LastActivityRel}}
<span class="tile-stamp muted" title="{{.LastActivity.Format "2006-01-02 15:04"}}">{{.LastActivityRel}}</span>
{{else}}
<span class="tile-stamp muted">—</span>
{{end}}
</footer>
</article>
{{end}}
{{else}}
<p class="empty muted">No projects to show.</p>
{{end}}
</section>
{{end}}