Files
projax/web/templates/dashboard_tiles.tmpl
mAi 2925c43a1e feat(dashboard): pin toggle on tiles + handleDashboardPin handler
Phase 5h slice 4 — adds the star button on each tile that flips
Pinned on the projax item via POST /dashboard/pin.

Backend:
- store.SetPinned(ids, pinned bool) — minimal-write helper that
  mirrors SetPublic, only touching the pinned column.
- web/dashboard_pin.go — handleDashboardPin parses id + pin from
  form, calls SetPinned, invalidates the entire dashboard cache (pin
  affects sort order across every view/scope/filter combo), then
  re-renders by delegating to handleDashboard so HTMX receives the
  updated #dashboard-section HTML.
- Route: POST /dashboard/pin (sibling of /dashboard/task/*).

Frontend:
- Tile template now leads with a <form class="tile-pin-form"> that
  POSTs id + the inverted pin state. Button glyph is ☆ when unpinned,
  ★ when pinned; aria-label flips accordingly.
- HTMX swaps the entire #dashboard-section so the tile moves to the
  pinned-first position (or back to alphabetical) without a full reload.
- CSS: .tile-pin (transparent button, muted color, accent on hover);
  .tile-pin.pinned for the filled-star state.

Test helper: server_test.go gains a post() helper paired with the
existing get() — form-encoded POSTs for writeback tests.

Tests (dashboard_pin_test.go):
- TestDashboardPinTogglesItem — POST pin=true flips the row, and the
  re-render shows the .tile-pinned class on the tile <article>.
- TestDashboardPinUnpinsItem — POST pin=false on a pinned row unpins.
- TestDashboardPinRequiresID — missing id returns 400.
- TestDashboardPinInvalidatesCache — primes with unpinned cache,
  POSTs pin, asserts the next GET reflects the pinned class (proving
  the prior cache entry was busted).
2026-05-26 12:31:24 +02:00

78 lines
3.2 KiB
Cheetah

{{define "dashboard-tiles"}}
{{if .P.ProjectsCurrent}}
<section class="dash-tiles">
{{range .P.ProjectsCurrent}}
{{template "tile" .}}
{{end}}
</section>
{{else}}
<p class="empty muted dash-tiles-empty">
{{if eq .Scope "current"}}
Nothing current. Pin a project from its detail page, or
<a href="?scope=all"
hx-get="?scope=all"
hx-target="#dashboard-section"
hx-swap="outerHTML"
hx-push-url="true">show all active</a>.
{{else}}
No projects to show.
{{end}}
</p>
{{end}}
{{if .P.ProjectsQuiet}}
<details class="dash-quiet">
<summary class="dash-quiet-summary muted">
Quiet ({{len .P.ProjectsQuiet}}) — older than {{.P.QuietWindowLabel}}{{if .P.QuietStaleCount}} · {{.P.QuietStaleCount}} stale{{end}}
</summary>
<section class="dash-tiles dash-tiles-quiet">
{{range .P.ProjectsQuiet}}
{{template "tile" .}}
{{end}}
</section>
</details>
{{end}}
{{end}}
{{define "tile"}}
{{$path := .Item.PrimaryPath}}
{{$id := .Item.ID}}
<article class="tile{{if .Item.Pinned}} tile-pinned{{end}}{{if .Stale}} tile-stale{{end}}" data-item-path="{{$path}}" data-item-id="{{$id}}">
<header class="tile-head">
<form class="tile-pin-form"
hx-post="/dashboard/pin"
hx-target="#dashboard-section"
hx-swap="outerHTML">
<input type="hidden" name="id" value="{{$id}}">
<input type="hidden" name="pin" value="{{if .Item.Pinned}}false{{else}}true{{end}}">
<button type="submit" class="tile-pin{{if .Item.Pinned}} pinned{{end}}"
title="{{if .Item.Pinned}}Unpin{{else}}Pin{{end}}"
aria-label="{{if .Item.Pinned}}Unpin{{else}}Pin{{end}}">{{if .Item.Pinned}}★{{else}}☆{{end}}</button>
</form>
<a class="tile-title" href="/i/{{$path}}">{{.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}}
{{if .Stale}}<span class="tile-stale-flag muted" title="mai-managed · quiet repo · no open work">stale</span>{{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}}