Merge branch 'mai/fuller/phase-5h-phase-a-design' (phase 5h slices 1-2: rollup model + Tiles tab)
This commit is contained in:
325
docs/plans/dashboard-overhaul.md
Normal file
325
docs/plans/dashboard-overhaul.md
Normal 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 600–900px, 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 600–900px, 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.
|
||||
212
web/dashboard.go
212
web/dashboard.go
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
230
web/dashboard_rollup.go
Normal 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"
|
||||
}
|
||||
}
|
||||
246
web/dashboard_rollup_test.go
Normal file
246
web/dashboard_rollup_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
184
web/dashboard_view_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}}
|
||||
|
||||
40
web/templates/dashboard_tiles.tmpl
Normal file
40
web/templates/dashboard_tiles.tmpl
Normal 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}}
|
||||
Reference in New Issue
Block a user