Files
projax/web/server_test.go
mAi e5dd31144a feat(calendar): /calendar month-grid view with VEVENT/VTODO/DOC sources
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.
2026-05-22 12:01:03 +02:00

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)
}
}
}