Merge branch 'mai/fuller/phase-5h-phase-a-design' (phase 5h slice 3: scope chip + Quiet fold + Stale folded into Tiles)
This commit is contained in:
116
web/dashboard.go
116
web/dashboard.go
@@ -47,6 +47,15 @@ type dashboardPayload struct {
|
||||
// primary path ascending.
|
||||
Projects []dashboardProject
|
||||
|
||||
// ProjectsCurrent / ProjectsQuiet are the per-request scope-split of
|
||||
// Projects — populated by the handler, not the cache, so the scope
|
||||
// chip can toggle without recomputing. The Tiles view renders Current
|
||||
// in the primary grid and Quiet behind a "Quiet (N) ▾" fold.
|
||||
ProjectsCurrent []dashboardProject `json:"-"`
|
||||
ProjectsQuiet []dashboardProject `json:"-"`
|
||||
QuietStaleCount int `json:"-"` // subset of ProjectsQuiet flagged stale, for the fold label
|
||||
QuietWindowLabel string `json:"-"` // e.g. "14d" — derived from dashboardActivityWindow
|
||||
|
||||
BuiltAt time.Time
|
||||
Cached bool
|
||||
}
|
||||
@@ -123,6 +132,14 @@ const (
|
||||
dashboardViewEvents = "events"
|
||||
)
|
||||
|
||||
// Dashboard scope (Phase 5h Tiles view) — narrows the tile grid to the
|
||||
// projects m is plausibly working on (the IsCurrent set), folding the
|
||||
// rest into a "Quiet (N) ▾" section. Default = current.
|
||||
const (
|
||||
dashboardScopeCurrent = "current"
|
||||
dashboardScopeAll = "all"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
@@ -136,21 +153,31 @@ func parseDashboardView(raw string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// parseDashboardScope normalizes the ?scope= query, falling back to
|
||||
// "current" (the daily-driver default per m's §7 pick).
|
||||
func parseDashboardScope(raw string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(raw), dashboardScopeAll) {
|
||||
return dashboardScopeAll
|
||||
}
|
||||
return dashboardScopeCurrent
|
||||
}
|
||||
|
||||
// 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"))
|
||||
scope := parseDashboardScope(r.URL.Query().Get("scope"))
|
||||
// Dashboard treats status=active as the meaningful default — same as tree.
|
||||
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
|
||||
// Cache key composes filter + view + scope so each surface has its own
|
||||
// 60s TTL entry. Scope only changes the Tiles split; we still key it
|
||||
// in for every view so the future Activity tab can ride the same path.
|
||||
cacheKey := filterKey + "|view=" + view + "|scope=" + scope
|
||||
|
||||
// ?refresh=1 busts this filter's cache entry so the next aggregation
|
||||
// runs fresh — used by the ↻ button on the dashboard chrome.
|
||||
@@ -173,7 +200,23 @@ 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 preserves the active view + filter.
|
||||
// Split the rollup into Current vs. Quiet according to the active
|
||||
// scope. For scope=all we keep everything Current; for scope=current
|
||||
// (default) we use IsCurrent + the Stale set to populate Quiet.
|
||||
now := time.Now()
|
||||
current, quiet := splitProjectsByScope(displayPayload.Projects, scope, now)
|
||||
displayPayload.ProjectsCurrent = current
|
||||
displayPayload.ProjectsQuiet = quiet
|
||||
staleCount := 0
|
||||
for _, p := range quiet {
|
||||
if p.Stale {
|
||||
staleCount++
|
||||
}
|
||||
}
|
||||
displayPayload.QuietStaleCount = staleCount
|
||||
displayPayload.QuietWindowLabel = strconv.Itoa(int(dashboardActivityWindow/(24*time.Hour))) + "d"
|
||||
|
||||
// Refresh URL preserves the active view + scope + filter.
|
||||
refreshQuery := filterKey
|
||||
if refreshQuery == "__empty__" {
|
||||
refreshQuery = ""
|
||||
@@ -184,6 +227,12 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
refreshQuery += "view=" + view
|
||||
}
|
||||
if scope != dashboardScopeCurrent {
|
||||
if refreshQuery != "" {
|
||||
refreshQuery += "&"
|
||||
}
|
||||
refreshQuery += "scope=" + scope
|
||||
}
|
||||
refreshURL := "/dashboard?"
|
||||
if refreshQuery != "" {
|
||||
refreshURL += refreshQuery + "&"
|
||||
@@ -195,7 +244,9 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
"P": displayPayload,
|
||||
"Filter": filter,
|
||||
"View": view,
|
||||
"Tabs": dashboardTabs(view, filterKey),
|
||||
"Scope": scope,
|
||||
"Tabs": dashboardTabs(view, filterKey, scope),
|
||||
"ScopeURL": dashboardScopeToggleURL(view, scope, filterKey),
|
||||
"UpdatedRel": updatedRel,
|
||||
"RefreshURL": refreshURL,
|
||||
"FilterActive": filter.Active(),
|
||||
@@ -207,6 +258,49 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, r, "dashboard", data)
|
||||
}
|
||||
|
||||
// splitProjectsByScope partitions the rollup into the primary grid
|
||||
// (ProjectsCurrent) and the Quiet fold (ProjectsQuiet) per m's §7 pick.
|
||||
// scope=all → everything counts as Current; Quiet is empty.
|
||||
// scope=current → IsCurrent(now) selects Current; the rest (including
|
||||
// stale candidates) move to Quiet.
|
||||
func splitProjectsByScope(projects []dashboardProject, scope string, now time.Time) (current, quiet []dashboardProject) {
|
||||
if scope == dashboardScopeAll {
|
||||
return projects, nil
|
||||
}
|
||||
for _, p := range projects {
|
||||
if p.IsCurrent(now) {
|
||||
current = append(current, p)
|
||||
} else {
|
||||
quiet = append(quiet, p)
|
||||
}
|
||||
}
|
||||
return current, quiet
|
||||
}
|
||||
|
||||
// dashboardScopeToggleURL builds the URL that flips the scope chip —
|
||||
// /dashboard with the alternate scope and the current view + filter
|
||||
// preserved.
|
||||
func dashboardScopeToggleURL(view, scope, filterKey string) string {
|
||||
next := dashboardScopeAll
|
||||
if scope == dashboardScopeAll {
|
||||
next = dashboardScopeCurrent
|
||||
}
|
||||
parts := []string{}
|
||||
if filterKey != "__empty__" && filterKey != "" {
|
||||
parts = append(parts, filterKey)
|
||||
}
|
||||
if view != dashboardViewTiles {
|
||||
parts = append(parts, "view="+view)
|
||||
}
|
||||
if next != dashboardScopeCurrent {
|
||||
parts = append(parts, "scope="+next)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "/dashboard"
|
||||
}
|
||||
return "/dashboard?" + strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
// dashboardTab is a single entry in the view-switcher strip.
|
||||
type dashboardTab struct {
|
||||
View string // tiles | tasks | events
|
||||
@@ -216,9 +310,10 @@ type dashboardTab struct {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// preserving the active filter + scope. The default view (tiles) and
|
||||
// scope (current) elide from the URL so the address bar stays clean
|
||||
// on the daily-driver path.
|
||||
func dashboardTabs(active, filterKey, scope string) []dashboardTab {
|
||||
prefix := "/dashboard"
|
||||
filterQuery := ""
|
||||
if filterKey != "__empty__" && filterKey != "" {
|
||||
@@ -232,6 +327,9 @@ func dashboardTabs(active, filterKey string) []dashboardTab {
|
||||
if view != dashboardViewTiles {
|
||||
parts = append(parts, "view="+view)
|
||||
}
|
||||
if scope != dashboardScopeCurrent {
|
||||
parts = append(parts, "scope="+scope)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return prefix
|
||||
}
|
||||
|
||||
@@ -265,17 +265,20 @@ func TestDashboardStaleCardSurfacesDormantMaiProject(t *testing.T) {
|
||||
}
|
||||
|
||||
h := srv.Routes()
|
||||
// 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")
|
||||
// Phase 5h: the Stale card retired. The stale project now appears
|
||||
// inside the Tiles Quiet fold with a tile-stale flag on the tile.
|
||||
code, body := get(t, h, "/dashboard")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /dashboard?view=tasks → %d", code)
|
||||
t.Fatalf("GET /dashboard → %d", code)
|
||||
}
|
||||
if !strings.Contains(body, "card-stale") {
|
||||
t.Fatalf("expected stale card to render — body lacks 'card-stale'")
|
||||
if !strings.Contains(body, `class="dash-quiet"`) {
|
||||
t.Fatalf("expected dash-quiet fold to render — body lacks 'class=\"dash-quiet\"'")
|
||||
}
|
||||
if !strings.Contains(body, `tile tile-stale`) {
|
||||
t.Errorf("expected stale tile with 'tile tile-stale' class — body missing it")
|
||||
}
|
||||
if !strings.Contains(body, "/i/dev."+slug) {
|
||||
t.Errorf("expected stale list to include /i/dev.%s", slug)
|
||||
t.Errorf("expected stale tile to link to /i/dev.%s", slug)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,13 +328,26 @@ func TestDashboardStaleCardSkipsRecentRepo(t *testing.T) {
|
||||
}
|
||||
|
||||
h := srv.Routes()
|
||||
// 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)
|
||||
// Phase 5h: assert the tile for this slug is NOT flagged stale.
|
||||
// Recent repo activity (3d old) puts it solidly inside the activity
|
||||
// window AND fails the staleness probe, so no tile-stale class.
|
||||
_, body := get(t, h, "/dashboard")
|
||||
// Find the tile for this slug and check its class attribute.
|
||||
marker := `data-item-path="dev.` + slug + `"`
|
||||
idx := strings.Index(body, marker)
|
||||
if idx < 0 {
|
||||
// Tile may not render at all if the project has no signal at
|
||||
// all; that's also fine — the negative assertion holds vacuously.
|
||||
return
|
||||
}
|
||||
// Walk back to the start of the <article tag and inspect class attr.
|
||||
start := strings.LastIndex(body[:idx], "<article")
|
||||
if start < 0 {
|
||||
t.Fatalf("could not locate article opening for /i/dev.%s", slug)
|
||||
}
|
||||
openTag := body[start:idx]
|
||||
if strings.Contains(openTag, "tile-stale") {
|
||||
t.Errorf("recent repo (3d old) should NOT be flagged stale — class=%q", openTag)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -182,3 +182,71 @@ func TestDashboardCacheKeySeparatesViews(t *testing.T) {
|
||||
t.Errorf("first /dashboard?view=tasks load should be fresh — sharing a cache slot with Tiles would mark it cached")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashboardScopeChipRendersOnTilesOnly asserts the scope chip
|
||||
// (◇ current / ○ all) renders next to the tab strip on Tiles view
|
||||
// only — Tasks and Events tabs don't have a scope concept.
|
||||
func TestDashboardScopeChipRendersOnTilesOnly(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, tiles := get(t, h, "/dashboard")
|
||||
if !strings.Contains(tiles, `class="dash-scope-chip"`) {
|
||||
t.Errorf("Tiles view should render the scope chip")
|
||||
}
|
||||
if !strings.Contains(tiles, "◇ current") {
|
||||
t.Errorf("default scope chip should show '◇ current'")
|
||||
}
|
||||
_, tasks := get(t, h, "/dashboard?view=tasks")
|
||||
if strings.Contains(tasks, `class="dash-scope-chip"`) {
|
||||
t.Errorf("Tasks view should NOT render the scope chip")
|
||||
}
|
||||
_, events := get(t, h, "/dashboard?view=events")
|
||||
if strings.Contains(events, `class="dash-scope-chip"`) {
|
||||
t.Errorf("Events view should NOT render the scope chip")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashboardScopeAllChipFlipsLabel asserts that scope=all renders
|
||||
// the chip with the alternate label so m can flip back.
|
||||
func TestDashboardScopeAllChipFlipsLabel(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard?scope=all")
|
||||
if !strings.Contains(body, "○ all") {
|
||||
t.Errorf("scope=all should render '○ all' chip label")
|
||||
}
|
||||
if strings.Contains(body, "◇ current") {
|
||||
t.Errorf("scope=all should NOT render the '◇ current' label")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashboardScopeAllHidesQuietFold asserts that scope=all puts
|
||||
// everything in the primary grid; no Quiet fold should render because
|
||||
// nothing is "quiet" under that scope.
|
||||
func TestDashboardScopeAllHidesQuietFold(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard?scope=all")
|
||||
if strings.Contains(body, `class="dash-quiet"`) {
|
||||
t.Errorf("scope=all should NOT render the Quiet fold — everything is in the primary grid")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashboardScopeChipURLFlips asserts the chip's href flips between
|
||||
// ?scope=all and the default /dashboard each toggle.
|
||||
func TestDashboardScopeChipURLFlips(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, defaultBody := get(t, h, "/dashboard")
|
||||
if !strings.Contains(defaultBody, `href="/dashboard?scope=all"`) {
|
||||
t.Errorf("default scope chip should link to ?scope=all")
|
||||
}
|
||||
_, allBody := get(t, h, "/dashboard?scope=all")
|
||||
if !strings.Contains(allBody, `href="/dashboard"`) {
|
||||
t.Errorf("scope=all chip should link back to /dashboard (scope=current is default+elided)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +331,14 @@ table.bulk .chip-add-btn:hover { background: var(--accent); color: var(--accent-
|
||||
color: var(--fg); background: var(--surface);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.dashboard .dash-scope-chip {
|
||||
margin-left: auto; padding: 4px 12px;
|
||||
font-size: 0.85em; color: var(--muted);
|
||||
border: 1px solid var(--border); border-radius: 999px;
|
||||
text-decoration: none; align-self: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.dashboard .dash-scope-chip:hover { color: var(--fg); border-color: var(--accent); }
|
||||
|
||||
.dashboard .dash-tiles {
|
||||
display: grid; gap: 12px; margin-top: 8px;
|
||||
@@ -375,6 +383,22 @@ table.bulk .chip-add-btn:hover { background: var(--accent); color: var(--accent-
|
||||
margin-top: auto; font-size: 0.78em;
|
||||
display: flex; justify-content: flex-end;
|
||||
}
|
||||
.dashboard .tile .tile-stale-flag {
|
||||
margin-left: auto; font-size: 0.7em;
|
||||
padding: 1px 6px; border-radius: 999px;
|
||||
border: 1px dashed var(--warn); color: var(--warn);
|
||||
}
|
||||
|
||||
.dashboard .dash-tiles-empty { margin: 24px 0; }
|
||||
|
||||
.dashboard .dash-quiet { margin-top: 18px; }
|
||||
.dashboard .dash-quiet > summary.dash-quiet-summary {
|
||||
cursor: pointer; padding: 6px 0;
|
||||
border-top: 1px dotted var(--border);
|
||||
font-size: 0.9em; list-style: revert;
|
||||
}
|
||||
.dashboard .dash-quiet[open] > summary.dash-quiet-summary { margin-bottom: 8px; }
|
||||
.dashboard .dash-tiles-quiet .tile { opacity: 0.85; }
|
||||
|
||||
.dashboard .dash-events-view { margin-top: 8px; }
|
||||
.dashboard .dash-events-view .event-day-large { margin: 16px 0; }
|
||||
|
||||
@@ -51,6 +51,16 @@
|
||||
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"}}
|
||||
@@ -216,22 +226,9 @@
|
||||
<p class="card-collapsed muted">No recent documents.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .P.Stale}}
|
||||
<article class="card card-stale">
|
||||
<header>
|
||||
<h2>Stale projects <small class="muted">({{.P.StaleTotal}}) · consider archiving?</small></h2>
|
||||
</header>
|
||||
<ul class="stale-list">
|
||||
{{range .P.Stale}}
|
||||
<li class="stale-row">
|
||||
<a class="proj" href="/i/{{.Item.PrimaryPath}}">{{.Item.PrimaryPath}}</a>
|
||||
<span class="repo muted">{{.Repo}}</span>
|
||||
<span class="last-active" title="{{.LastActive.Format "2006-01-02"}}">last active {{.StaleRel}} ago</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</article>
|
||||
{{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}}
|
||||
|
||||
@@ -1,40 +1,69 @@
|
||||
{{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>
|
||||
{{if .P.ProjectsCurrent}}
|
||||
<section class="dash-tiles">
|
||||
{{range .P.ProjectsCurrent}}
|
||||
{{template "tile" .}}
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="empty muted">No projects to show.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
</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}}
|
||||
<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}}
|
||||
{{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}}
|
||||
|
||||
Reference in New Issue
Block a user