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.
245 lines
8.7 KiB
Go
245 lines
8.7 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/m/projax/store"
|
|
)
|
|
|
|
// TestCalendarLayoutMondayLead seeds a synthetic month whose first day is a
|
|
// Friday (May 2026) and confirms the layout function:
|
|
// - First cell of week 0 is Monday April 27 2026 (adjacent-month).
|
|
// - Last cell of the final week is Sunday May 31 2026 — the May 31st
|
|
// itself falls on a Sunday, so no trailing pad days are needed.
|
|
// - Total weeks = 5 (4 spillover Mondays + 31 days = 35 cells / 7).
|
|
func TestCalendarLayoutMondayLead(t *testing.T) {
|
|
loc := time.Local
|
|
may := time.Date(2026, 5, 1, 0, 0, 0, 0, loc)
|
|
gridStart := may.AddDate(0, 0, -mondayWeekday(may)) // Apr 27 2026
|
|
gridEnd := time.Date(2026, 6, 1, 0, 0, 0, 0, loc) // Jun 1 2026 (exclusive — May 31 is the final cell)
|
|
|
|
weeks := layoutCalendarWeeks(may, gridStart, gridEnd, may, nil)
|
|
if len(weeks) != 5 {
|
|
t.Fatalf("expected 5 weeks for May 2026 (Fri-leading, Sun-trailing), got %d", len(weeks))
|
|
}
|
|
first := weeks[0].Days[0]
|
|
if first.DateKey != "2026-04-27" {
|
|
t.Errorf("first cell DateKey = %q, want 2026-04-27", first.DateKey)
|
|
}
|
|
if !first.IsAdjacent {
|
|
t.Errorf("first cell should be IsAdjacent (Apr 27 lives in April)")
|
|
}
|
|
last := weeks[4].Days[6]
|
|
if last.DateKey != "2026-05-31" {
|
|
t.Errorf("last cell DateKey = %q, want 2026-05-31", last.DateKey)
|
|
}
|
|
if last.IsAdjacent {
|
|
t.Errorf("last cell should NOT be adjacent — May 31 is within May")
|
|
}
|
|
}
|
|
|
|
// TestCalendarLayoutTrailingPad picks a month whose last day is a Monday
|
|
// (June 2026) — the rectangular grid requires six trailing pad days to
|
|
// close the week, yielding six rows total.
|
|
func TestCalendarLayoutTrailingPad(t *testing.T) {
|
|
loc := time.Local
|
|
june := time.Date(2026, 6, 1, 0, 0, 0, 0, loc) // Monday
|
|
gridStart := june // monthStart already a Monday → no lead
|
|
// June has 30 days, ends on Tuesday. Pad to Sunday = +5 days → grid runs through Jul 5.
|
|
gridEnd := time.Date(2026, 7, 6, 0, 0, 0, 0, loc)
|
|
|
|
weeks := layoutCalendarWeeks(june, gridStart, gridEnd, june, nil)
|
|
if len(weeks) != 5 {
|
|
t.Fatalf("expected 5 weeks for June 2026 (Mon-leading), got %d", len(weeks))
|
|
}
|
|
if weeks[0].Days[0].DateKey != "2026-06-01" {
|
|
t.Errorf("first cell = %q, want 2026-06-01", weeks[0].Days[0].DateKey)
|
|
}
|
|
if weeks[4].Days[6].DateKey != "2026-07-05" {
|
|
t.Errorf("last trailing cell = %q, want 2026-07-05", weeks[4].Days[6].DateKey)
|
|
}
|
|
if !weeks[4].Days[6].IsAdjacent {
|
|
t.Errorf("July 5 cell should be IsAdjacent (lives in next month)")
|
|
}
|
|
}
|
|
|
|
// TestCalendarTodayCell proves the IsToday flag fires on the right cell.
|
|
func TestCalendarTodayCell(t *testing.T) {
|
|
loc := time.Local
|
|
may := time.Date(2026, 5, 1, 0, 0, 0, 0, loc)
|
|
today := time.Date(2026, 5, 14, 0, 0, 0, 0, loc)
|
|
gridStart := may.AddDate(0, 0, -mondayWeekday(may))
|
|
gridEnd := time.Date(2026, 6, 1, 0, 0, 0, 0, loc)
|
|
weeks := layoutCalendarWeeks(may, gridStart, gridEnd, today, nil)
|
|
found := false
|
|
for _, wk := range weeks {
|
|
for _, d := range wk.Days {
|
|
if d.DateKey == "2026-05-14" {
|
|
found = true
|
|
if !d.IsToday {
|
|
t.Errorf("cell for today (2026-05-14) should be IsToday")
|
|
}
|
|
} else if d.IsToday {
|
|
t.Errorf("cell %s should NOT be IsToday", d.DateKey)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("did not find cell for today in the grid")
|
|
}
|
|
}
|
|
|
|
// TestCalendarCellRowOverflow proves that a day with more than three rows
|
|
// renders three visible rows and surfaces the remainder via ExtraCount so
|
|
// the template can emit a "+N more" link.
|
|
func TestCalendarCellRowOverflow(t *testing.T) {
|
|
loc := time.Local
|
|
may := time.Date(2026, 5, 1, 0, 0, 0, 0, loc)
|
|
gridStart := may.AddDate(0, 0, -mondayWeekday(may))
|
|
gridEnd := time.Date(2026, 6, 1, 0, 0, 0, 0, loc)
|
|
dummy := &store.Item{ID: "id", Title: "t", Slug: "t"}
|
|
rows := []calendarRow{
|
|
{Kind: "event", Item: dummy, ItemPath: "t", Summary: "a"},
|
|
{Kind: "event", Item: dummy, ItemPath: "t", Summary: "b"},
|
|
{Kind: "event", Item: dummy, ItemPath: "t", Summary: "c"},
|
|
{Kind: "event", Item: dummy, ItemPath: "t", Summary: "d"},
|
|
{Kind: "event", Item: dummy, ItemPath: "t", Summary: "e"},
|
|
}
|
|
byDay := map[string][]calendarRow{"2026-05-15": rows}
|
|
weeks := layoutCalendarWeeks(may, gridStart, gridEnd, may, byDay)
|
|
var cell *calendarDay
|
|
for _, wk := range weeks {
|
|
for i, d := range wk.Days {
|
|
if d.DateKey == "2026-05-15" {
|
|
cell = &wk.Days[i]
|
|
}
|
|
}
|
|
}
|
|
if cell == nil {
|
|
t.Fatalf("did not find May 15 cell")
|
|
}
|
|
if len(cell.Rows) != calendarMaxRowsPerCell {
|
|
t.Errorf("len(Rows) = %d, want %d (cap)", len(cell.Rows), calendarMaxRowsPerCell)
|
|
}
|
|
if cell.ExtraCount != 2 {
|
|
t.Errorf("ExtraCount = %d, want 2 (5 seeded - 3 visible)", cell.ExtraCount)
|
|
}
|
|
if cell.TotalRows != 5 {
|
|
t.Errorf("TotalRows = %d, want 5", cell.TotalRows)
|
|
}
|
|
}
|
|
|
|
// TestMondayWeekday pins the Monday=0 weekday conversion that drives the
|
|
// lead-day count. The Sunday=0 default in Go's time package would put
|
|
// Sunday at index 0 of the grid — wrong for the German week convention.
|
|
func TestMondayWeekday(t *testing.T) {
|
|
cases := []struct {
|
|
day time.Weekday
|
|
want int
|
|
}{
|
|
{time.Monday, 0}, {time.Tuesday, 1}, {time.Wednesday, 2},
|
|
{time.Thursday, 3}, {time.Friday, 4}, {time.Saturday, 5}, {time.Sunday, 6},
|
|
}
|
|
for _, c := range cases {
|
|
// Pick a reference Monday and offset to get the weekday under test.
|
|
ref := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC) // Mon
|
|
probe := ref.AddDate(0, 0, int(c.day-time.Monday+7)%7)
|
|
if probe.Weekday() != c.day {
|
|
t.Fatalf("setup bug: probe weekday = %v, want %v", probe.Weekday(), c.day)
|
|
}
|
|
if got := mondayWeekday(probe); got != c.want {
|
|
t.Errorf("mondayWeekday(%v) = %d, want %d", c.day, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFormatCalendarLongLabel exercises the per-cell long German label
|
|
// used by the mobile breakpoint (hidden on desktop via CSS). The label
|
|
// matters because on a 360px-wide phone the bare day number no longer
|
|
// carries weekday context — the CSS collapses the column header.
|
|
func TestFormatCalendarLongLabel(t *testing.T) {
|
|
cases := map[string]string{
|
|
"2026-05-04": "Mo., 4. Mai", // Monday
|
|
"2026-05-14": "Do., 14. Mai", // Thursday
|
|
"2026-03-15": "So., 15. März", // Sunday in March (uses März)
|
|
"2026-12-31": "Do., 31. Dez",
|
|
}
|
|
for in, want := range cases {
|
|
t.Run(in, func(t *testing.T) {
|
|
d, _ := time.Parse("2006-01-02", in)
|
|
if got := formatCalendarLongLabel(d); got != want {
|
|
t.Errorf("formatCalendarLongLabel(%s) = %q, want %q", in, got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFormatMonthLabel confirms the German month names render — m reads
|
|
// /calendar primarily in the German register and the label is the most
|
|
// visible piece of chrome.
|
|
func TestFormatMonthLabel(t *testing.T) {
|
|
cases := map[string]string{
|
|
"2026-01-01": "Januar 2026",
|
|
"2026-03-15": "März 2026",
|
|
"2026-05-01": "Mai 2026",
|
|
"2026-12-31": "Dezember 2026",
|
|
}
|
|
for in, want := range cases {
|
|
t.Run(in, func(t *testing.T) {
|
|
d, _ := time.Parse("2006-01-02", in)
|
|
if got := formatMonthLabel(d); got != want {
|
|
t.Errorf("formatMonthLabel(%s) = %q, want %q", in, got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseCalendarQueryDefaults proves month + kinds default to current
|
|
// month + all-three.
|
|
func TestParseCalendarQueryDefaults(t *testing.T) {
|
|
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
|
r := httptest.NewRequest("GET", "/views/calendar", nil)
|
|
q := parseCalendarQuery(r, now)
|
|
if q.Month.Format("2006-01") != "2026-05" {
|
|
t.Errorf("default month = %s, want 2026-05", q.Month.Format("2006-01"))
|
|
}
|
|
kinds := q.activeKinds()
|
|
if len(kinds) != 3 {
|
|
t.Errorf("default activeKinds len = %d, want 3 (event/todo/doc)", len(kinds))
|
|
}
|
|
wantKinds := strings.Join(kinds, ",")
|
|
for _, k := range []string{"event", "todo", "doc"} {
|
|
if !strings.Contains(wantKinds, k) {
|
|
t.Errorf("activeKinds missing %s: %v", k, kinds)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestParseCalendarQueryMonthParam proves a `?month=YYYY-MM` URL param
|
|
// overrides the default. Bookmarkability matters because the prev/next
|
|
// nav writes to this exact key.
|
|
func TestParseCalendarQueryMonthParam(t *testing.T) {
|
|
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
|
r := httptest.NewRequest("GET", "/views/calendar?month=2026-08", nil)
|
|
q := parseCalendarQuery(r, now)
|
|
if q.Month.Format("2006-01") != "2026-08" {
|
|
t.Errorf("parsed month = %s, want 2026-08", q.Month.Format("2006-01"))
|
|
}
|
|
}
|
|
|
|
// TestParseCalendarQueryKindFilter proves `?kind=event,doc` narrows the
|
|
// kind set and drops unknown values.
|
|
func TestParseCalendarQueryKindFilter(t *testing.T) {
|
|
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
|
r := httptest.NewRequest("GET", "/views/calendar?kind=event,doc,junk,creation", nil)
|
|
q := parseCalendarQuery(r, now)
|
|
got := strings.Join(q.activeKinds(), ",")
|
|
want := "doc,event" // sorted alphabetically; creation is excluded by design, junk dropped
|
|
if got != want {
|
|
t.Errorf("activeKinds = %q, want %q", got, want)
|
|
}
|
|
}
|