Phase 5e slice A. New surface alongside /timeline (chronological spine) and
/dashboard (today/week buckets) — a 7×N month grid that answers "show me my
month at a glance." Monday-leading weeks per the German convention, with
adjacent-month lead-in/trail-out cells greyed to keep the grid rectangular.
web/calendar.go (new):
- calendarPayload / calendarWeek / calendarDay / calendarRow types.
- parseCalendarQuery: reads ?month=YYYY-MM (defaults to current month),
?kind=event,todo,doc (defaults to all three; creation excluded by design),
inherits the full TreeFilter via ParseTreeFilter so ?tag=work / ?mgmt=mai
scope identically to /timeline.
- handleCalendar: TTL-cached at 60s per (filter, month, kinds).
- buildCalendar: items → TreeFilter narrow → aggregate.{Todos,Events,Docs}
for the grid window → bin by YYYY-MM-DD → stable per-cell sort (timed
first, then by kind rank, then summary).
- layoutCalendarWeeks: pure function building the rectangular grid; lead
days computed from mondayWeekday(monthStart), trailing pad from
(totalCells % 7). Each cell caps visible rows at 3 and surfaces the
remainder via ExtraCount so the template emits a "+N more" drill-down
link to /timeline scoped to that single day.
- formatMonthLabel: German month names (Mai, März, Juni, Dezember).
- docSummary: prefers item_link.note, falls back to last path segment of
ref_id, then ref_id verbatim.
web/templates/calendar.tmpl (new):
- Grid markup as a <table role="grid"> — semantically a calendar grid,
works without JS, and the layout calc already pre-chunks weeks.
- Header carries h1 (German month label), prev/next/today nav, and the
cached/fresh + total-rows counts line.
- Each cell: .calendar-cell, .is-today, .adjacent-month conditional
classes; .today-pill rendered when IsToday.
- Rows: .row-event / .row-todo (+ .overdue) / .row-doc with a leading
time slot and an <a> to /i/<itemPath>.
- "+N more" link drills into /timeline?from=YYYY-MM-DD&to=YYYY-MM-DD.
web/static/style.css:
- ~95 lines of minimal grid styling: 7-column table-fixed, 110px cell
height, today border accent, adjacent-month opacity 0.4, per-kind row
border-left colour. Slice B will refine cell sizing + add the mobile
breakpoint + chip strip.
web/server.go:
- New calendar template parse (layout.tmpl + calendar.tmpl), calendar
field on Server (cache.TTLCache[*calendarPayload]), route registration
GET /calendar.
web/templates/layout.tmpl:
- Nav anchor added between timeline and graph.
web/server_test.go:
- TestLayoutHasViewportMeta now probes /calendar too.
Tests (web/calendar_test.go — pure unit):
- TestCalendarLayoutMondayLead, TestCalendarLayoutTrailingPad: grid math
for Friday-leading (May 2026) and Monday-trailing (June 2026) months.
- TestCalendarTodayCell: IsToday flag lands on the right cell only.
- TestCalendarCellRowOverflow: >3 seeded rows → 3 visible + ExtraCount=2.
- TestMondayWeekday: Sunday→6, Monday→0 conversion.
- TestFormatMonthLabel: German month strings.
- TestParseCalendarQuery{Defaults,MonthParam,KindFilter}: URL parsing.
Tests (web/calendar_integration_test.go — DB integration):
- TestCalendarRendersMonthGrid: empty-data smoke through srv.Routes().
- TestCalendarSurfacesDatedLink: seeds an item_link on today, asserts
the rendered cell carries the note text + .is-today class.
- TestCalendarFilterScopeByTag: seeds two tagged items, confirms
?tag=<work-tag> only renders the work-item rows.
- TestCalendarAdjacentMonthDays: May 2026 (Fri-leading) renders the
Apr 27 lead-in cell with .adjacent-month.
- TestCalendarNavPrevNextLinks: prev → 2026-04, next → 2026-06 links
present.
Slice B follows: refined CSS, mobile breakpoint (≤480px → vertical list
of days), HTMX filter chip strip, docs/design.md §17.
285 lines
8.7 KiB
Go
285 lines
8.7 KiB
Go
package web_test
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/m/projax/db"
|
|
"github.com/m/projax/store"
|
|
"github.com/m/projax/web"
|
|
)
|
|
|
|
var (
|
|
migrateOnce sync.Once
|
|
migrateErr error
|
|
)
|
|
|
|
func mustServer(t *testing.T) (*web.Server, *pgxpool.Pool) {
|
|
t.Helper()
|
|
dbURL := os.Getenv("PROJAX_DB_URL")
|
|
if dbURL == "" {
|
|
dbURL = os.Getenv("SUPABASE_DATABASE_URL")
|
|
}
|
|
if dbURL == "" {
|
|
t.Skip("no PROJAX_DB_URL / SUPABASE_DATABASE_URL set — skipping HTTP integration test")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
pool, err := pgxpool.New(ctx, dbURL)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
t.Skipf("DB unreachable: %v", err)
|
|
}
|
|
if os.Getenv("PROJAX_SKIP_MIGRATE") != "1" {
|
|
migrateOnce.Do(func() { migrateErr = db.ApplyMigrations(ctx, pool) })
|
|
if migrateErr != nil {
|
|
t.Fatalf("migrate: %v", migrateErr)
|
|
}
|
|
}
|
|
srv, err := web.New(store.New(pool), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if err != nil {
|
|
t.Fatalf("server: %v", err)
|
|
}
|
|
return srv, pool
|
|
}
|
|
|
|
func get(t *testing.T, h http.Handler, url string) (int, string) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, url, nil)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
body, _ := io.ReadAll(w.Result().Body)
|
|
return w.Result().StatusCode, string(body)
|
|
}
|
|
|
|
func TestTreeRenders(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
code, body := get(t, h, "/")
|
|
if code != 200 {
|
|
t.Fatalf("GET / status %d body=%s", code, body)
|
|
}
|
|
// /admin/classify used to live in the nav; Phase 3o consolidated all
|
|
// admin links under the new /admin index. Assert /admin instead.
|
|
for _, want := range []string{"<h1>Tree</h1>", "/i/dev", "/i/home", `href="/admin"`} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("body missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLayoutHasViewportMeta proves every chrome-bearing page carries the
|
|
// viewport meta tag added in Phase 3i. Without it iOS Safari renders pages
|
|
// at 980px and the user must pinch-zoom to read anything. We probe one
|
|
// representative GET on each layout-rendered route.
|
|
func TestLayoutHasViewportMeta(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
for _, path := range []string{"/", "/dashboard", "/calendar", "/graph", "/admin/bulk", "/admin/classify", "/new", "/login"} {
|
|
_, body := get(t, h, path)
|
|
if !strings.Contains(body, `name="viewport"`) {
|
|
t.Errorf("GET %s: missing <meta name=\"viewport\">", path)
|
|
}
|
|
if !strings.Contains(body, `width=device-width`) {
|
|
t.Errorf("GET %s: viewport meta does not set width=device-width", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHealthzSurfacesVersion proves /healthz returns the version line as
|
|
// well as the ok marker. Phase 3p — closes the silent-deploy-rot gap so a
|
|
// worker can verify "deploy actually rolled" with an unauthenticated curl
|
|
// (compare against `git rev-parse --short HEAD` before assuming the latest
|
|
// merge is live).
|
|
func TestHealthzSurfacesVersion(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
srv.Version = "abc1234"
|
|
h := srv.Routes()
|
|
code, body := get(t, h, "/healthz")
|
|
if code != 200 {
|
|
t.Fatalf("GET /healthz → %d", code)
|
|
}
|
|
if !strings.Contains(body, "ok") {
|
|
t.Errorf("body should contain 'ok', got %q", body)
|
|
}
|
|
if !strings.Contains(body, "version: abc1234") {
|
|
t.Errorf("body should contain 'version: abc1234', got %q", body)
|
|
}
|
|
}
|
|
|
|
func TestHealthz(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
code, body := get(t, srv.Routes(), "/healthz")
|
|
// Body is two lines now (Phase 3p): "ok\nversion: <sha>\n". Assert the
|
|
// 200 status + "ok" leader, not exact equality, so the version line can
|
|
// grow without breaking this guard.
|
|
if code != 200 || !strings.HasPrefix(body, "ok") {
|
|
t.Fatalf("healthz: %d %q", code, body)
|
|
}
|
|
}
|
|
|
|
func TestDetailRendersEditableForm(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
code, body := get(t, srv.Routes(), "/i/dev")
|
|
if code != 200 {
|
|
t.Fatalf("status %d body=%s", code, body)
|
|
}
|
|
if !strings.Contains(body, `form method="post" action="/i/dev"`) {
|
|
t.Errorf("edit form missing for /i/dev")
|
|
}
|
|
if !strings.Contains(body, `name="tags"`) {
|
|
t.Errorf("tags input missing")
|
|
}
|
|
if !strings.Contains(body, `name="management"`) {
|
|
t.Errorf("management input missing")
|
|
}
|
|
}
|
|
|
|
func TestDetailShowsManagementChips(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
// dev.projax is the manually-promoted item from before Phase 1.5 — should
|
|
// already carry management=['mai'] after the backfill+sync pass.
|
|
code, body := get(t, srv.Routes(), "/i/dev.projax")
|
|
if code != 200 {
|
|
t.Fatalf("status %d", code)
|
|
}
|
|
if !strings.Contains(body, "mgmt-mai") {
|
|
t.Errorf("expected mgmt-mai chip on /i/dev.projax, body did not include it")
|
|
}
|
|
}
|
|
|
|
func TestClassifyListsMaiRoots(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
code, body := get(t, srv.Routes(), "/admin/classify")
|
|
if code != 200 {
|
|
t.Fatalf("status %d", code)
|
|
}
|
|
if !strings.Contains(body, "Classify root mai-managed items") &&
|
|
!strings.Contains(body, "No unclassified roots") {
|
|
t.Errorf("classify page body unexpected: %q", body)
|
|
}
|
|
}
|
|
|
|
func TestReparentRoundTrip(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
// Create a fresh root mai-managed item via the reverse-sync path so the
|
|
// test never collides with another project. The sync trigger drops the
|
|
// mirror at parent_id=NULL — exactly the case /admin/classify handles.
|
|
maiID := "phase15-test-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
|
defer func() {
|
|
_, _ = pool.Exec(context.Background(), `delete from mai.projects where id=$1`, maiID)
|
|
_, _ = pool.Exec(context.Background(), `delete from projax.items where slug=$1`, maiID)
|
|
}()
|
|
|
|
if _, err := pool.Exec(ctx,
|
|
`insert into mai.projects (id, name, status) values ($1, $2, 'active')`,
|
|
maiID, "Reparent test "+maiID,
|
|
); err != nil {
|
|
t.Fatalf("seed mai.projects: %v", err)
|
|
}
|
|
|
|
var nParents int
|
|
if err := pool.QueryRow(ctx,
|
|
`select cardinality(parent_ids) from projax.items where slug=$1`, maiID,
|
|
).Scan(&nParents); err != nil {
|
|
t.Fatalf("read mirror: %v", err)
|
|
}
|
|
if nParents != 0 {
|
|
t.Fatalf("expected mirror at root (no parents), got %d parents", nParents)
|
|
}
|
|
|
|
var devID string
|
|
if err := pool.QueryRow(ctx,
|
|
`select id from projax.items where slug='dev' and cardinality(parent_ids) = 0`,
|
|
).Scan(&devID); err != nil {
|
|
t.Fatalf("dev: %v", err)
|
|
}
|
|
|
|
form := url.Values{}
|
|
form.Set("parent_id", devID)
|
|
req := httptest.NewRequest(http.MethodPost, "/i/"+maiID+"/reparent", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
if w.Result().StatusCode != http.StatusSeeOther {
|
|
body, _ := io.ReadAll(w.Result().Body)
|
|
t.Fatalf("reparent status %d body=%s", w.Result().StatusCode, body)
|
|
}
|
|
if loc := w.Result().Header.Get("Location"); loc != "/i/dev."+maiID {
|
|
t.Errorf("Location = %q, want /i/dev.%s", loc, maiID)
|
|
}
|
|
|
|
var parents []string
|
|
if err := pool.QueryRow(ctx,
|
|
`select array(select unnest(parent_ids)::text) from projax.items where slug=$1`, maiID,
|
|
).Scan(&parents); err != nil {
|
|
t.Fatalf("post-reparent read: %v", err)
|
|
}
|
|
if len(parents) != 1 || parents[0] != devID {
|
|
t.Errorf("parent_ids after reparent = %v, want [%s]", parents, devID)
|
|
}
|
|
}
|
|
|
|
func TestMultiParentBothPathsRouteToSameRow(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
slug := "p15-multi-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
|
defer func() {
|
|
_, _ = pool.Exec(context.Background(), `delete from projax.items where slug=$1`, slug)
|
|
}()
|
|
|
|
var dev, work 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='work' and cardinality(parent_ids)=0`).Scan(&work); err != nil {
|
|
t.Fatalf("work: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'Multi', $1, ARRAY[$2,$3]::uuid[])`,
|
|
slug, dev, work,
|
|
); err != nil {
|
|
t.Fatalf("insert multi: %v", err)
|
|
}
|
|
|
|
for _, p := range []string{"dev." + slug, "work." + slug} {
|
|
code, body := get(t, h, "/i/"+p)
|
|
if code != 200 {
|
|
t.Fatalf("GET /i/%s → %d", p, code)
|
|
}
|
|
if !strings.Contains(body, "Multi") {
|
|
t.Errorf("body for /i/%s missing item title 'Multi'", p)
|
|
}
|
|
}
|
|
}
|