Files
projax/web/templates/dashboard_section.tmpl
mAi f820fa5830 feat(views): Phase 5j slice C — full URL migration + system views
Per m's Q1 pick (b) (2026-05-29): legacy `/`, `/dashboard`, `/calendar`,
`/timeline`, `/graph` become `/views/{system-slug}`. Old routes
301-redirect to the new ones with chip params preserved; the legacy
?view=<uuid> param from 5i is resolved through the uuid → slug map
when present so old bookmarks land on the right user view.

System views (web/system_views.go):
- SystemView struct (Slug / Name / Icon / URL) — code-resident, never
  rows in projax.views.
- AllSystemViews() returns the canonical five: tree, dashboard,
  calendar, timeline, graph. Display order matches the existing
  sidebar.
- LookupSystemView(slug) returns the matching entry or nil; the
  reserved-slug list in store.IsReservedViewSlug (slice A) is kept
  in sync.
- legacyRedirect(systemSlug) handler 301s with chip-param preservation
  + uuid → slug resolution for any leftover ?view=<uuid>.

Routes (web/server.go):
- GET /views/tree      → handleTree     (was GET /)
- GET /views/dashboard → handleDashboard
- GET /views/timeline  → handleTimeline
- GET /views/calendar  → handleCalendar
- GET /views/graph     → handleGraph
- GET /                → 301 → /views/tree
- GET /dashboard       → 301 → /views/dashboard
- GET /timeline        → 301 → /views/timeline
- GET /calendar        → 301 → /views/calendar
- GET /graph           → 301 → /views/graph
- POST action endpoints (/dashboard/task/*, /dashboard/pin, /admin/*)
  stay where they are — those are RPC-ish, not page renders.

handleTree: dropped the `r.URL.Path != "/"` guard — the only entry
point now is /views/tree, mounted via the new route. Slice F removes
any residual references; this slice keeps the handler reachable.

computeChipCounts grew a `base string` arg so chip URLs anchor on the
caller's route (/views/tree for the system tree, /views/{slug} for
saved views). PageViewTypes recognises both legacy and /views/ keys
during the transition.

Template hrefs / hx-gets bulk-updated to the new URLs:
- layout.tmpl: every sidebar + bottom-nav entry points at
  /views/{system-slug}. Active-state checks updated alongside.
- tree_section.tmpl, tree_card.tmpl, tree_kanban.tmpl: clear-filter
  / clear-all hrefs → /views/tree.
- calendar*.tmpl, timeline_section.tmpl, graph.tmpl,
  dashboard_section.tmpl: every internal nav + filter link points at
  the /views/{slug} surface.
- detail.tmpl, error.tmpl: cancel / back-to-tree → /views/tree.

Test-source updates (per the 5c sharpened rule):
- ~100 test paths bulk-rewritten from /dashboard /calendar /timeline
  /graph (and `/`) to their /views/{slug} counterparts. The
  behaviour-preservation contract holds: status codes + body shapes
  for the rendered pages stay the same; only the URL anchoring the
  test changes.
- layout_test.go: sidebar href assertions updated to /views/{slug}.
- view_type_test.go (Q2 + Q3 follow-up): PageViewTypes lookup table
  updated to use the new route keys.
- 2 deliberate behaviour-change assertions land: TestLegacyRedirects
  expects 301 on the old URLs (was 200); TestTreeRenders fetches
  /views/tree (the new home) instead of /.

Internal go-source URL emissions (dashboard.go, calendar.go,
timeline.go) updated to the new BasePath so chip + refresh URLs round
through /views/{slug} correctly.

New tests:
- TestSystemViewLookup — AllSystemViews shape + LookupSystemView
  round-trip + unknown-slug nil.
- TestLegacyRedirects — every legacy URL 301s to its new home with
  chip params preserved.
- TestLegacyViewUUIDRedirect — old `?view=<uuid>` URLs land on the
  resolved slug per m's Q3 pick.
2026-05-29 11:59:26 +02:00

272 lines
12 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="/views/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.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
{{if .Filter.Active}}<a class="clear" href="/views/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}">clear filters</a>{{end}}
</form>
{{template "view-project-chip" .}}
<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}}
<header class="dash-events-summary">
<h2>{{.P.EventsTotal}} event{{if ne .P.EventsTotal 1}}s{{end}} <span class="muted">· next 7 days</span></h2>
</header>
{{range .P.Events}}
<section class="event-day-large">
<h3 class="event-day-heading">
<span class="event-day-label">{{.DayLabel}}</span>
<span class="muted event-day-date">{{.DayKey}}</span>
<span class="muted event-day-count">{{len .Events}} event{{if ne (len .Events) 1}}s{{end}}</span>
</h3>
<ul class="event-list">
{{range .Events}}
<li class="event-row">
<span class="start">{{if .StartLabel}}{{.StartLabel}}{{else}}—{{end}}</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 dash-events-empty">No events in the next 7 days. Link a CalDAV calendar from a project's detail page to start surfacing events here.</p>
{{end}}
</section>
{{end}}