Files
projax/web/templates/dashboard_section.tmpl
mAi 87132ee166 feat(dashboard): scope chip + Quiet (N) ▾ fold + Stale folded into Tiles
Phase 5h slice 3 — splits the Tiles rollup into ProjectsCurrent (primary
grid) and ProjectsQuiet (collapsible fold) per m's §7 'pinned ∪
recently-active ∪ open-work' rule.

URL contract extended:
  /dashboard                      — Tiles, scope=current (defaults elided)
  /dashboard?scope=all            — every active project in the grid
  /dashboard?scope=current        — same as default (chip allows explicit)

Scope chip lives next to the tab strip on Tiles only; Tasks + Events
tabs hide it (no scope concept there). Default chip label: '◇ current',
flips to '○ all' when scope=all. Chip href toggles to the alternate
state preserving filter + view.

Quiet fold:
- <details> element opened on click — projects with IsCurrent=false land
  here, including all stale candidates.
- Fold summary: 'Quiet (N) — older than 14d · M stale' (M omitted when 0).
- Quiet tiles render with the same shape as primary tiles, slightly
  faded; stale tiles also carry a 'tile-stale' class (dashed border) and
  a 'stale' flag in the header.

Stale card on the Tasks tab retires entirely — m's pick. The
LastActivity stamp on each tile carries the staleness signal; the
'consider archiving?' nudge migrates to the Quiet fold framing. Stale
data still computes (collectStale runs in buildDashboard) because the
rollup needs the per-item stale flag and the repo-activity map for
LastActivity.

Cache key extends: (filter | view=X | scope=Y) so toggling scope from
the chip lands in a separate cache slot (no stale render).

Tests:
- TestDashboardStaleCardSurfacesDormantMaiProject retargeted at the new
  Quiet fold + tile-stale class on Tiles.
- TestDashboardStaleCardSkipsRecentRepo asserts the inverse via class
  inspection on the tile <article>.
- 4 new tests cover the scope chip: renders on Tiles only, label flips
  on scope=all, scope=all hides the Quiet fold, chip URL flips correctly.

Empty state: scope=current with no current projects shows a
'Nothing current. Pin a project, or show all active.' note with a
direct link to scope=all.
2026-05-26 12:27:13 +02:00

260 lines
11 KiB
Cheetah
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{define "dashboard-section"}}
<section id="dashboard-section" class="dashboard">
<section class="tagbar" id="dashboard-filterbar">
<form id="dashboard-filter" class="search"
hx-get="/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}"
hx-target="#dashboard-section"
hx-swap="outerHTML"
hx-trigger="change from:select"
hx-push-url="true">
<label>tag&nbsp;
<select name="tag" multiple size="3">
{{$sel := .Filter.Tags}}
{{range $.Filter.Tags}}<option value="{{.}}" selected>{{.}}</option>{{end}}
</select>
</label>
<label>mgmt&nbsp;
<select name="mgmt" multiple size="4">
{{$selM := .Filter.Management}}
<option value="mai" {{if contains $selM "mai"}}selected{{end}}>mai</option>
<option value="self" {{if contains $selM "self"}}selected{{end}}>self</option>
<option value="external" {{if contains $selM "external"}}selected{{end}}>external</option>
</select>
</label>
<label>has&nbsp;
<select name="has" multiple size="2">
{{$selH := .Filter.HasLinks}}
<option value="caldav-list" {{if contains $selH "caldav-list"}}selected{{end}}>caldav</option>
<option value="gitea-repo" {{if contains $selH "gitea-repo"}}selected{{end}}>gitea</option>
</select>
</label>
{{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>
{{else}}<small>updated {{.UpdatedRel}} · fresh</small>{{end}}
<a class="refresh" href="{{.RefreshURL}}"
hx-get="{{.RefreshURL}}"
hx-target="#dashboard-section"
hx-swap="outerHTML"
title="force-refresh: bust the 60s cache for this filter">↻ refresh</a>
</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}}
{{if eq .View "tiles"}}
<a href="{{.ScopeURL}}" class="dash-scope-chip"
hx-get="{{.ScopeURL}}"
hx-target="#dashboard-section"
hx-swap="outerHTML"
hx-push-url="true"
title="Toggle between current projects and the full set">
{{if eq .Scope "current"}}◇ current{{else}}○ all{{end}}
</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}}
{{if or .P.Tasks (not $collapse)}}
<article class="card card-tasks">
<header>
<h2>Open tasks <small class="muted">({{.P.TaskTotal}})</small></h2>
{{if or .P.TaskGroups.Overdue .P.TaskGroups.Today .P.TaskGroups.Tomorrow .P.TaskGroups.Week .P.TaskGroups.NoDue}}
<p class="task-groups muted">
{{if .P.TaskGroups.Overdue}}<span class="overdue">Overdue ({{.P.TaskGroups.Overdue}})</span>{{end}}
{{if .P.TaskGroups.Today}}<span>Today ({{.P.TaskGroups.Today}})</span>{{end}}
{{if .P.TaskGroups.Tomorrow}}<span>Tomorrow ({{.P.TaskGroups.Tomorrow}})</span>{{end}}
{{if .P.TaskGroups.Week}}<span>This week ({{.P.TaskGroups.Week}})</span>{{end}}
{{if .P.TaskGroups.NoDue}}<span>No due ({{.P.TaskGroups.NoDue}})</span>{{end}}
</p>
{{end}}
</header>
{{if .P.Tasks}}
<ul class="task-list">
{{range .P.Tasks}}
{{$cal := .CalendarURL}}
{{$uid := .Todo.UID}}
{{$path := .Item.PrimaryPath}}
<li class="task-row bucket-{{.Bucket}}" id="task-{{$uid}}" data-item-path="{{$path}}" data-vtodo-uid="{{$uid}}">
<form class="check"
hx-post="/dashboard/task/done"
hx-target="#dashboard-section"
hx-swap="outerHTML">
<input type="hidden" name="calendar_url" value="{{$cal}}">
<input type="hidden" name="uid" value="{{$uid}}">
<button type="submit" title="mark complete">✓</button>
</form>
<a class="proj" href="/i/{{$path}}">{{$path}}</a>
<span class="summary">{{.Todo.Summary}}</span>
{{if .DueRel}}<span class="due {{if eq .Bucket "overdue"}}bad{{end}}" title="{{if .Todo.Due}}{{.Todo.Due.Format "2006-01-02"}}{{end}}">{{.DueRel}}</span>{{end}}
<details class="task-edit">
<summary class="muted" title="edit summary / due">✎</summary>
<form class="todo-edit inline"
hx-post="/dashboard/task/edit"
hx-target="#dashboard-section"
hx-swap="outerHTML">
<input type="hidden" name="calendar_url" value="{{$cal}}">
<input type="hidden" name="uid" value="{{$uid}}">
<input type="text" name="summary" value="{{.Todo.Summary}}" required>
<input type="date" name="due" value="{{if .Todo.Due}}{{.Todo.Due.Format "2006-01-02"}}{{end}}">
<button type="submit">Save</button>
</form>
</details>
<form class="todo-delete inline"
hx-post="/dashboard/task/delete"
hx-target="#dashboard-section"
hx-swap="outerHTML"
hx-confirm="Delete this task? This cannot be undone.">
<input type="hidden" name="calendar_url" value="{{$cal}}">
<input type="hidden" name="uid" value="{{$uid}}">
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="empty muted">Nothing open. Nice.</p>
{{end}}
</article>
{{else}}
<p class="card-collapsed muted">No open tasks.</p>
{{end}}
{{if or .P.Events (not $collapse)}}
<article class="card card-events">
<header>
<h2>Events <small class="muted">({{.P.EventsTotal}} upcoming, next 7d)</small></h2>
</header>
{{if .P.Events}}
{{range .P.Events}}
<div class="event-day">
<h3 class="muted">{{.DayLabel}} <small>({{len .Events}})</small></h3>
<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>
</div>
{{end}}
{{else}}
<p class="empty muted">No events in the next 7 days.</p>
{{end}}
</article>
{{else}}
<p class="card-collapsed muted">No upcoming events.</p>
{{end}}
{{if or .P.Issues (not $collapse)}}
<article class="card card-issues">
<header>
<h2>Open issues <small class="muted">({{.P.IssueTotal}})</small></h2>
</header>
{{if .P.Issues}}
<ul class="issue-list">
{{range .P.Issues}}
<li class="issue-row">
<a class="proj" href="/i/{{.Item.PrimaryPath}}">{{.Item.PrimaryPath}}</a>
<a class="iss" href="{{.Issue.HTMLURL}}" target="_blank" rel="noopener">#{{.Issue.Number}} {{.Issue.Title}}</a>
{{range .Issue.Labels}}<span class="label">{{.}}</span>{{end}}
{{if .Issue.Assignees}}<small class="muted">@ {{range $i, $a := .Issue.Assignees}}{{if $i}}, {{end}}{{$a}}{{end}}</small>{{end}}
<span class="upd muted" title="{{.Issue.UpdatedAt.Format "2006-01-02 15:04"}}">{{.UpdRel}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="empty muted">No open issues across linked repos.</p>
{{end}}
</article>
{{else}}
<p class="card-collapsed muted">No open issues.</p>
{{end}}
{{if or .P.RecentDocs (not $collapse)}}
<article class="card card-docs">
<header>
<h2>Recent documents <small class="muted">({{.P.RecentDocsTotal}}, last 30d)</small></h2>
</header>
{{if .P.RecentDocs}}
<ul class="doc-list">
{{range .P.RecentDocs}}
<li class="doc-row">
<span class="per">{{.PER}}</span>
<span class="ref-type ref-type-{{.Link.RefType}}">{{.Link.RefType}}</span>
{{if .Link.Note}}<span class="note">{{deref .Link.Note}}</span>{{end}}
<a class="ref-id" href="{{.Link.RefID}}" target="_blank" rel="noopener">{{.Link.RefID}}</a>
<a class="proj muted" href="/i/{{.ItemPath}}">{{.ItemPath}}</a>
</li>
{{end}}
</ul>
{{else}}
<p class="empty muted">Nothing dated in the last 30 days.</p>
{{end}}
</article>
{{else}}
<p class="card-collapsed muted">No recent documents.</p>
{{end}}
{{/* Phase 5h: Stale card retired on the Tasks tab — m's pick was
to fold stale into the Quiet (N) ▾ section under Tiles. The
LastActivity stamp on each tile carries the staleness signal. */}}
</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}}