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.
353 lines
13 KiB
Go
353 lines
13 KiB
Go
package web_test
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/m/projax/caldav"
|
|
"github.com/m/projax/web"
|
|
)
|
|
|
|
// TestTimelineRendersEmpty checks GET /timeline renders cleanly without any
|
|
// linked DAV / Gitea integration and surfaces the "Nothing on this timeline
|
|
// yet" copy when no dated content exists in the default window.
|
|
func TestTimelineRendersEmpty(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
code, body := get(t, h, "/views/timeline")
|
|
if code != 200 {
|
|
t.Fatalf("GET /timeline → %d body=%s", code, body)
|
|
}
|
|
for _, want := range []string{
|
|
`id="timeline-section"`,
|
|
`<h1>Timeline</h1>`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("timeline missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTimelineSurfacesDatedDocs seeds a dated item_link inside the default
|
|
// window and asserts the timeline page renders the corresponding PER.
|
|
func TestTimelineSurfacesDatedDocs(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
|
slug := "tl-doc-" + stamp
|
|
var dev, id string
|
|
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
|
t.Fatalf("dev: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_ids)
|
|
values (array['project']::text[], 'TL doc', $1, ARRAY[$2]::uuid[])
|
|
returning id`,
|
|
slug, dev,
|
|
).Scan(&id); err != nil {
|
|
t.Fatalf("seed item: %v", err)
|
|
}
|
|
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
|
|
|
if _, err := pool.Exec(ctx,
|
|
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
|
|
values ($1, 'document', $2, 'contains', $3, current_date)`,
|
|
id, "https://example.com/tl-doc-"+stamp, "tl test "+stamp,
|
|
); err != nil {
|
|
t.Fatalf("seed link: %v", err)
|
|
}
|
|
|
|
code, body := get(t, h, "/views/timeline")
|
|
if code != 200 {
|
|
t.Fatalf("GET /timeline → %d", code)
|
|
}
|
|
wantPER := "dev." + slug + "." + time.Now().UTC().Format("060102")
|
|
if !strings.Contains(body, wantPER) {
|
|
t.Errorf("timeline body missing PER %q", wantPER)
|
|
}
|
|
// Today header should fire on the seeded date.
|
|
if !strings.Contains(body, "Today") {
|
|
t.Errorf("timeline body missing 'Today' header for today's row")
|
|
}
|
|
}
|
|
|
|
// TestTimelineFilterByKindNarrowsRows seeds an item-creation marker (item just
|
|
// created in the seed step) and a dated doc; ?kind=doc should hide the
|
|
// creation marker but keep the doc row.
|
|
func TestTimelineFilterByKindNarrowsRows(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
|
slug := "tl-k-" + stamp
|
|
var dev, id string
|
|
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
|
t.Fatalf("dev: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_ids)
|
|
values (array['project']::text[], 'TL kind', $1, ARRAY[$2]::uuid[])
|
|
returning id`,
|
|
slug, dev,
|
|
).Scan(&id); err != nil {
|
|
t.Fatalf("seed item: %v", err)
|
|
}
|
|
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
|
if _, err := pool.Exec(ctx,
|
|
`insert into projax.item_links (item_id, ref_type, ref_id, rel, event_date)
|
|
values ($1, 'document', $2, 'contains', current_date)`,
|
|
id, "https://example.com/tl-k-"+stamp,
|
|
); err != nil {
|
|
t.Fatalf("seed link: %v", err)
|
|
}
|
|
|
|
// Unfiltered: both the creation marker and the dated doc should be present.
|
|
_, allBody := get(t, h, "/views/timeline")
|
|
if !strings.Contains(allBody, "added <a class=\"proj\" href=\"/i/dev."+slug) {
|
|
t.Errorf("expected creation marker in unfiltered timeline body")
|
|
}
|
|
if !strings.Contains(allBody, "dev."+slug+"."+time.Now().UTC().Format("060102")) {
|
|
t.Errorf("expected doc PER in unfiltered timeline body")
|
|
}
|
|
|
|
// kind=doc only: the doc row stays; the creation marker drops.
|
|
_, docOnly := get(t, h, "/views/timeline?kind=doc")
|
|
if strings.Contains(docOnly, "added <a class=\"proj\" href=\"/i/dev."+slug) {
|
|
t.Errorf("kind=doc should hide creation marker")
|
|
}
|
|
if !strings.Contains(docOnly, "dev."+slug+"."+time.Now().UTC().Format("060102")) {
|
|
t.Errorf("kind=doc should still surface doc row")
|
|
}
|
|
}
|
|
|
|
// TestTimelineOrderToggleReversesDays seeds dated docs across two distinct
|
|
// dates and asserts the rendered order swaps between ?order=asc and the
|
|
// (default) ?order=desc.
|
|
func TestTimelineOrderToggleReversesDays(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
|
slug := "tl-ord-" + stamp
|
|
var dev, id string
|
|
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
|
t.Fatalf("dev: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_ids)
|
|
values (array['project']::text[], 'TL ord', $1, ARRAY[$2]::uuid[])
|
|
returning id`,
|
|
slug, dev,
|
|
).Scan(&id); err != nil {
|
|
t.Fatalf("seed item: %v", err)
|
|
}
|
|
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
|
// Two distinct dates: today (older marker) and today+5 (newer marker)
|
|
if _, err := pool.Exec(ctx,
|
|
`insert into projax.item_links (item_id, ref_type, ref_id, rel, event_date)
|
|
values ($1, 'document', $2, 'contains', current_date - interval '3 days'),
|
|
($1, 'document', $3, 'contains', current_date + interval '5 days')`,
|
|
id, "https://example.com/tl-ord-a-"+stamp, "https://example.com/tl-ord-b-"+stamp,
|
|
); err != nil {
|
|
t.Fatalf("seed links: %v", err)
|
|
}
|
|
|
|
older := "dev." + slug + "." + time.Now().UTC().AddDate(0, 0, -3).Format("060102")
|
|
newer := "dev." + slug + "." + time.Now().UTC().AddDate(0, 0, 5).Format("060102")
|
|
|
|
_, desc := get(t, h, "/views/timeline")
|
|
idxNewerDesc := strings.Index(desc, newer)
|
|
idxOlderDesc := strings.Index(desc, older)
|
|
if idxNewerDesc < 0 || idxOlderDesc < 0 {
|
|
t.Fatalf("default timeline missing one of the PERs (newer=%d, older=%d)", idxNewerDesc, idxOlderDesc)
|
|
}
|
|
if !(idxNewerDesc < idxOlderDesc) {
|
|
t.Errorf("default order should be desc (newest first); newer at %d, older at %d", idxNewerDesc, idxOlderDesc)
|
|
}
|
|
|
|
_, asc := get(t, h, "/views/timeline?order=asc")
|
|
idxNewerAsc := strings.Index(asc, newer)
|
|
idxOlderAsc := strings.Index(asc, older)
|
|
if !(idxOlderAsc < idxNewerAsc) {
|
|
t.Errorf("?order=asc should put older first; newer at %d, older at %d", idxNewerAsc, idxOlderAsc)
|
|
}
|
|
}
|
|
|
|
// TestTimelineSurfacesCalDAVTodosAndAllDayEvents wires a fake CalDAV server
|
|
// that returns one open VTODO with DUE=today and one all-day VEVENT
|
|
// starting today + spanning two days. Asserts both surface and that the
|
|
// all-day event renders without a HH:MM prefix while the multi-day duration
|
|
// hint appears.
|
|
func TestTimelineSurfacesCalDAVTodosAndAllDayEvents(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
|
|
now := time.Now().UTC()
|
|
today := now
|
|
dueDate := today.Format("20060102")
|
|
endDate := today.AddDate(0, 0, 2).Format("20060102")
|
|
startDate := today.Format("20060102")
|
|
|
|
icsTodo := `BEGIN:VCALENDAR
|
|
BEGIN:VTODO
|
|
UID:tl-todo-1@fake
|
|
SUMMARY:Timeline todo today
|
|
STATUS:NEEDS-ACTION
|
|
DUE;VALUE=DATE:` + dueDate + `
|
|
END:VTODO
|
|
END:VCALENDAR`
|
|
icsEvent := `BEGIN:VCALENDAR
|
|
BEGIN:VEVENT
|
|
UID:tl-evt-1@fake
|
|
SUMMARY:Two-day offsite
|
|
DTSTART;VALUE=DATE:` + startDate + `
|
|
DTEND;VALUE=DATE:` + endDate + `
|
|
END:VEVENT
|
|
END:VCALENDAR`
|
|
|
|
multi := func(href, etag, ics string) string {
|
|
return `<d:response><d:href>` + href + `</d:href><d:propstat><d:prop>` +
|
|
`<d:getetag>"` + etag + `"</d:getetag>` +
|
|
`<cal:calendar-data>` + ics + `</cal:calendar-data>` +
|
|
`</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/dav/calendars/m/TL/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "REPORT" {
|
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
body, _ := io.ReadAll(r.Body)
|
|
w.WriteHeader(207)
|
|
if strings.Contains(string(body), "VTODO") {
|
|
_, _ = io.WriteString(w, `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">`+
|
|
multi("/dav/calendars/m/TL/td-1.ics", "t1", icsTodo)+
|
|
`</d:multistatus>`)
|
|
return
|
|
}
|
|
_, _ = io.WriteString(w, `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">`+
|
|
multi("/dav/calendars/m/TL/ev-1.ics", "e1", icsEvent)+
|
|
`</d:multistatus>`)
|
|
})
|
|
fake := httptest.NewServer(mux)
|
|
defer fake.Close()
|
|
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.URL+"/dav/calendars/m/", "u", "p")}
|
|
|
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
|
slug := "tl-mix-" + stamp
|
|
calURL := fake.URL + "/dav/calendars/m/TL/"
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
var dev, id string
|
|
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
|
t.Fatalf("dev: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_ids)
|
|
values (array['project']::text[], 'TL mix', $1, ARRAY[$2]::uuid[])
|
|
returning id`,
|
|
slug, dev,
|
|
).Scan(&id); err != nil {
|
|
t.Fatalf("seed item: %v", err)
|
|
}
|
|
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
|
if _, err := pool.Exec(ctx,
|
|
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
|
|
values ($1, 'caldav-list', $2, 'tracks')`,
|
|
id, calURL,
|
|
); err != nil {
|
|
t.Fatalf("seed link: %v", err)
|
|
}
|
|
|
|
h := srv.Routes()
|
|
code, body := get(t, h, "/views/timeline")
|
|
if code != 200 {
|
|
t.Fatalf("GET /timeline → %d", code)
|
|
}
|
|
for _, want := range []string{
|
|
"Timeline todo today",
|
|
"Two-day offsite",
|
|
"(2 days)", // duration hint
|
|
`hx-post="/dashboard/task/edit"`, // edit affordance on timeline VTODO row
|
|
`hx-post="/dashboard/task/delete"`, // delete affordance
|
|
`hx-post="/dashboard/task/done"`, // complete affordance
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("timeline body missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTimelineFilterByTagAppliesAcrossKinds seeds two items in different areas,
|
|
// each with a dated link; ?tag=work should narrow the timeline to the work-
|
|
// tagged item only.
|
|
func TestTimelineFilterByTagAppliesAcrossKinds(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
|
var dev, home string
|
|
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
|
t.Fatalf("dev: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx, `select id from projax.items where slug='home' and cardinality(parent_ids)=0`).Scan(&home); err != nil {
|
|
t.Fatalf("home: %v", err)
|
|
}
|
|
mkItem := func(parent, slug, tag string) string {
|
|
var id string
|
|
if err := pool.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_ids, tags)
|
|
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[], ARRAY[$4]::text[])
|
|
returning id`,
|
|
"X "+slug, slug, parent, tag,
|
|
).Scan(&id); err != nil {
|
|
t.Fatalf("seed %s: %v", slug, err)
|
|
}
|
|
if _, err := pool.Exec(ctx,
|
|
`insert into projax.item_links (item_id, ref_type, ref_id, rel, event_date)
|
|
values ($1, 'document', $2, 'contains', current_date)`,
|
|
id, "https://example.com/"+slug,
|
|
); err != nil {
|
|
t.Fatalf("link %s: %v", slug, err)
|
|
}
|
|
return id
|
|
}
|
|
devID := mkItem(dev, "tl-tag-d-"+stamp, "tl-tag-work-"+stamp)
|
|
homeID := mkItem(home, "tl-tag-h-"+stamp, "tl-tag-life-"+stamp)
|
|
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, devID, homeID)
|
|
|
|
tag := "tl-tag-work-" + stamp
|
|
_, body := get(t, h, "/views/timeline?tag="+tag)
|
|
// Phase 5i Slice A: the project picker renders every item path as a
|
|
// <select> option, so a naive substring match also sees filtered-out
|
|
// items inside the dropdown. Anchor on the timeline-row link instead.
|
|
devLink := `href="/i/dev.tl-tag-d-` + stamp + `"`
|
|
homeLink := `href="/i/home.tl-tag-h-` + stamp + `"`
|
|
if !strings.Contains(body, devLink) {
|
|
t.Errorf("?tag=%s should surface dev-tagged item", tag)
|
|
}
|
|
if strings.Contains(body, homeLink) {
|
|
t.Errorf("?tag=%s should hide home-tagged item", tag)
|
|
}
|
|
}
|