Phase 5h slice 2 — adds the three-tab dashboard chrome (Tiles / Tasks /
Events) and lands the Tiles view as the default landing surface per
m's §7 pick.
URL contract:
/dashboard — Tiles (default, elided)
/dashboard?view=tasks — today's 5-card layout
/dashboard?view=events — Events card promoted to a full-tab view
Unknown ?view= falls back to Tiles.
Refactor: aggregator calls (Todos / Events / Issues) hoisted up into
buildDashboard so the rollup can consume the same uncapped rows without
a second DAV/Gitea round-trip. The legacy collect* helpers split into
pure projectTasks / projectEvents / projectIssues / projectDocs that
take pre-fetched rows. collectStale extended to return its per-item
repo-activity map alongside the trimmed stale list — the rollup uses
the map as a LastActivity signal.
Cache: key now composes (filter | view=X) so each tab has its own 60s
TTL slot. Tab switches don't poison the cache for siblings.
Tiles render with: pin star (when pinned), title + path + live badge,
counts row (open / overdue! / issues / quiet), NextSignal one-liner
(task wins over issue), and a tile-foot LastActivity stamp.
CSS:
- .dash-tabs strip with active-state border bridge.
- .dash-tiles grid: 1/2/3 cols at 600/900px breakpoints.
- .dash-events-view scaffolding for the promoted Events surface.
Templates: dashboard_section.tmpl restructured to dispatch by .View.
The cards layout is now {{define "dashboard-cards"}} and the
events-only surface is {{define "dashboard-events-view"}}. New
dashboard_tiles.tmpl defines {{define "dashboard-tiles"}}. Both
templates registered in the dashboard + dashboard_section bundles.
Tests:
- Existing dashboard tests retargeted at ?view=tasks for the legacy
Tasks-tab expectations (5-card layout, inline writeback, stale card).
- New dashboard_view_test.go covers: default view = Tiles, three-tab
strip rendering + active marker, view=tasks fallback, view=events
promotion, unknown view fallback, tile rendering for seeded item,
cache-key separation between views.
- TestLayoutNoTopHeader scoped to the body chrome before <main> so it
no longer trips on legitimate <header> elements inside cards/tiles.
Out of scope (later slices): scope chip + Quiet fold (slice 3), pin
toggle handler (slice 4), Events tab dedicated polish (slice 5),
mobile polish (slice 7), design.md addendum (slice 8).
194 lines
7.2 KiB
Go
194 lines
7.2 KiB
Go
package web_test
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestLayoutSidebarOnDesktop confirms the Phase 5g sidebar markup is
|
|
// rendered with all six nav items (Tree / Dashboard / Calendar /
|
|
// Timeline / Graph / Admin). Per-item href + label asserted so a stray
|
|
// edit can't silently lose a section.
|
|
func TestLayoutSidebarOnDesktop(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/dashboard")
|
|
if !strings.Contains(body, `<aside class="projax-sidebar"`) {
|
|
t.Fatalf("expected <aside class=\"projax-sidebar\"> in body, got: %s", truncate(body, 400))
|
|
}
|
|
for _, want := range []struct {
|
|
href, label string
|
|
}{
|
|
{`/`, "Tree"},
|
|
{`/dashboard`, "Dashboard"},
|
|
{`/calendar`, "Calendar"},
|
|
{`/timeline`, "Timeline"},
|
|
{`/graph`, "Graph"},
|
|
{`/admin`, "Admin"},
|
|
} {
|
|
if !strings.Contains(body, `href="`+want.href+`"`) {
|
|
t.Errorf("sidebar missing href=%q", want.href)
|
|
}
|
|
if !strings.Contains(body, `<span class="nav-label">`+want.label+`</span>`) {
|
|
t.Errorf("sidebar missing label %q", want.label)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLayoutActiveClass proves the server-side active marker fires only
|
|
// on the cell whose href matches the request path. Render is driven by
|
|
// the .Path field the render helper injects from r.URL.Path.
|
|
func TestLayoutActiveClass(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/dashboard")
|
|
// Dashboard item should be active.
|
|
if !strings.Contains(body, `class="nav-item active" title="Dashboard"`) {
|
|
t.Errorf("expected Dashboard nav-item to carry .active on /dashboard, body: %s", truncate(body, 400))
|
|
}
|
|
// Tree item (href="/") must NOT be active on the /dashboard page.
|
|
// The Tree anchor opens with the exact-path active match; on /dashboard
|
|
// the substring `class="nav-item" title="Tree"` should be present and
|
|
// not its `active` sibling.
|
|
if !strings.Contains(body, `class="nav-item" title="Tree"`) {
|
|
t.Errorf("expected Tree nav-item to be non-active on /dashboard")
|
|
}
|
|
if strings.Contains(body, `class="nav-item active" title="Tree"`) {
|
|
t.Errorf("Tree nav-item should NOT be active on /dashboard")
|
|
}
|
|
}
|
|
|
|
// TestLayoutCollapseScript proves the inline pre-paint script that
|
|
// restores the sidebar collapsed state from localStorage ships unchanged.
|
|
// Without it the main-content margin would flash from 220px → 56px on
|
|
// every navigation when the user has the sidebar collapsed.
|
|
func TestLayoutCollapseScript(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/dashboard")
|
|
// Pre-paint restore script.
|
|
if !strings.Contains(body, `localStorage.getItem('projax.sidebar.collapsed')`) {
|
|
t.Errorf("expected pre-paint localStorage restore script in layout")
|
|
}
|
|
// The collapse-toggle button + its handler are also part of the chrome.
|
|
if !strings.Contains(body, `id="sidebar-collapse"`) {
|
|
t.Errorf("expected #sidebar-collapse button in layout")
|
|
}
|
|
if !strings.Contains(body, `localStorage.setItem('projax.sidebar.collapsed'`) {
|
|
t.Errorf("expected toggle handler that persists state")
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// TestLayoutBottomNavMarkup pins the Slice-B mobile bottom-nav shape: five
|
|
// slots in the documented order (Tree / Dashboard / + New / Calendar /
|
|
// Menu), the +New slot is a raised .capture-circle pointing at /new, and
|
|
// the Menu opens a <details> drawer with the overflow items inside.
|
|
func TestLayoutBottomNavMarkup(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/dashboard")
|
|
if !strings.Contains(body, `<nav class="projax-bottom-nav"`) {
|
|
t.Fatalf("expected <nav class=\"projax-bottom-nav\"> in body, got: %s", truncate(body, 400))
|
|
}
|
|
// 5-slot anchors / details element.
|
|
for _, want := range []string{
|
|
`<a href="/" class="bottom-nav-item`,
|
|
`<a href="/dashboard" class="bottom-nav-item`,
|
|
`<a href="/new" class="bottom-nav-item capture-btn"`,
|
|
`class="capture-circle"`,
|
|
`<a href="/calendar" class="bottom-nav-item`,
|
|
`<details class="projax-mobile-drawer"`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("bottom-nav body missing %q", want)
|
|
}
|
|
}
|
|
// Drawer overflow items: Timeline, Graph, Admin, theme toggle, sign-out.
|
|
for _, want := range []string{
|
|
`<a href="/timeline" class="drawer-item`,
|
|
`<a href="/graph" class="drawer-item`,
|
|
`<a href="/admin" class="drawer-item`,
|
|
`id="theme-toggle-drawer"`,
|
|
`<form method="post" action="/logout" class="drawer-form">`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("drawer body missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLayoutBottomNavActiveClass mirrors TestLayoutActiveClass — the
|
|
// server-side .Path marker fires on the bottom-nav too so a user on
|
|
// /calendar sees the Calendar slot highlighted (and not Tree/Dashboard).
|
|
func TestLayoutBottomNavActiveClass(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/calendar")
|
|
if !strings.Contains(body, `<a href="/calendar" class="bottom-nav-item active"`) {
|
|
t.Errorf("expected Calendar bottom-nav-item to carry .active on /calendar")
|
|
}
|
|
if strings.Contains(body, `<a href="/" class="bottom-nav-item active"`) {
|
|
t.Errorf("Tree bottom-nav-item should NOT be active on /calendar")
|
|
}
|
|
}
|
|
|
|
// TestLayoutThemeToggleBoundToBothButtons proves the single theme handler
|
|
// is wired to both the sidebar (#theme-toggle) and the drawer
|
|
// (#theme-toggle-drawer) so flipping either swaps the data-theme. Without
|
|
// this the mobile drawer's theme button would be a dead element.
|
|
func TestLayoutThemeToggleBoundToBothButtons(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/dashboard")
|
|
// Both buttons present.
|
|
if !strings.Contains(body, `id="theme-toggle"`) {
|
|
t.Errorf("sidebar theme-toggle button missing")
|
|
}
|
|
if !strings.Contains(body, `id="theme-toggle-drawer"`) {
|
|
t.Errorf("drawer theme-toggle-drawer button missing")
|
|
}
|
|
// Inline handler enumerates BOTH ids — keeps drift detection cheap.
|
|
if !strings.Contains(body, `getElementById('theme-toggle-drawer')`) {
|
|
t.Errorf("expected theme handler to enumerate #theme-toggle-drawer")
|
|
}
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "…"
|
|
}
|