feat: Go HTTP server with tree / detail / new / classify

cmd/projax/main.go boots a pgxpool against PROJAX_DB_URL (falls back to
SUPABASE_DATABASE_URL), auto-applies embedded migrations on start
(disable with PROJAX_AUTO_MIGRATE=off), and serves on PROJAX_LISTEN_ADDR
(default :8080).

store package wraps the unified view + projax.items writes. Item has
helper methods for templates: IsArea, Editable, SourceRefDeref. The
Promote() flow runs the insert + item_links link inside a single
transaction so the source row drops out of items_unified atomically.

web package: per-page html/template instances parsed against a shared
layout.tmpl, embedded static/style.css, HTMX from CDN. Pages:
  GET  /                   tree of items_unified
  GET  /i/{path}           detail (editable for projax, read-only +
                           promote form for mai.projects)
  POST /i/{path}           update projax-native item
  POST /i/{path}/promote   one-page promote (HTMX-aware fragment for
                           inline classify)
  GET  /new?parent={path}  create form
  POST /new                create projax-native item
  GET  /admin/classify     orphan list with inline HTMX promote
  GET  /healthz            DB ping
  GET  /static/*           embedded assets

Auth is intentionally out of scope for v1 — service binds to whatever
PROJAX_LISTEN_ADDR points at, deploy guidance pins it to the Tailscale
interface (covered in 1d README).

Tests (skip when DB env is unset):
  TestTreeRenders, TestHealthz,
  TestDetailProjaxNativeEditable, TestDetailMaiProjectsReadOnly,
  TestClassifyListsOrphans, TestPromoteRoundTrip.
This commit is contained in:
mAi
2026-05-15 13:24:44 +02:00
parent c0466ade36
commit 9f905de461
11 changed files with 1184 additions and 0 deletions

85
cmd/projax/main.go Normal file
View File

@@ -0,0 +1,85 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/m/projax/db"
"github.com/m/projax/store"
"github.com/m/projax/web"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
dbURL := os.Getenv("PROJAX_DB_URL")
if dbURL == "" {
dbURL = os.Getenv("SUPABASE_DATABASE_URL")
}
if dbURL == "" {
logger.Error("startup: set PROJAX_DB_URL (or SUPABASE_DATABASE_URL)")
os.Exit(1)
}
listen := os.Getenv("PROJAX_LISTEN_ADDR")
if listen == "" {
listen = ":8080"
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
logger.Error("db pool", "err", err)
os.Exit(1)
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
logger.Error("db ping", "err", err)
os.Exit(1)
}
if os.Getenv("PROJAX_AUTO_MIGRATE") != "off" {
if err := db.ApplyMigrations(ctx, pool); err != nil {
logger.Error("apply migrations", "err", err)
os.Exit(1)
}
logger.Info("migrations applied")
}
srv, err := web.New(store.New(pool), logger)
if err != nil {
logger.Error("server init", "err", err)
os.Exit(1)
}
httpServer := &http.Server{
Addr: listen,
Handler: srv.Routes(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = httpServer.Shutdown(shutdownCtx)
}()
logger.Info("listening", "addr", listen)
if err := httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
logger.Error("listen", "err", err)
os.Exit(1)
}
logger.Info("shutdown clean")
}

274
store/store.go Normal file
View File

@@ -0,0 +1,274 @@
package store
import (
"context"
"errors"
"fmt"
"slices"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Item is the unified row shape: projax-native items and mai.projects rows
// both render through this struct.
type Item struct {
ID string
Kind []string
Title string
Slug string
Path string
ParentID *string
ContentMD string
Aliases []string
Metadata map[string]any
Status string
Pinned bool
Archived bool
StartTime *time.Time
EndTime *time.Time
Source string // "projax" or "mai.projects"
SourceRefID *string // mai.projects.id when Source = "mai.projects"
CreatedAt time.Time
UpdatedAt time.Time
}
// IsArea reports whether this item should be treated as a top-level container.
func (it *Item) IsArea() bool { return slices.Contains(it.Kind, "area") }
// Editable reports whether the UI may edit this row directly.
// mai.projects rows are read-only — they must be promoted first.
func (it *Item) Editable() bool { return it.Source == "projax" }
// SourceRefDeref returns the source ref id (empty string if nil) for templates.
func (it *Item) SourceRefDeref() string {
if it.SourceRefID == nil {
return ""
}
return *it.SourceRefID
}
// Store wraps a pgx pool with the queries projax needs.
type Store struct {
Pool *pgxpool.Pool
}
func New(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
var ErrNotFound = errors.New("projax: item not found")
const itemsUnifiedCols = `id, kind, title, slug, path, parent_id, content_md, aliases,
metadata, status, pinned, archived, start_time, end_time, source, source_ref_id,
created_at, updated_at`
func scanItem(row pgx.Row) (*Item, error) {
var it Item
if err := row.Scan(
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Path, &it.ParentID, &it.ContentMD,
&it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
&it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID,
&it.CreatedAt, &it.UpdatedAt,
); err != nil {
return nil, err
}
return &it, nil
}
func scanItems(rows pgx.Rows) ([]*Item, error) {
defer rows.Close()
var out []*Item
for rows.Next() {
var it Item
if err := rows.Scan(
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Path, &it.ParentID, &it.ContentMD,
&it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
&it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID,
&it.CreatedAt, &it.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, &it)
}
return out, rows.Err()
}
// ListAll returns every visible row from items_unified. Caller groups by tree.
func (s *Store) ListAll(ctx context.Context) ([]*Item, error) {
rows, err := s.Pool.Query(ctx,
`select `+itemsUnifiedCols+` from projax.items_unified
order by source = 'projax' desc, path`)
if err != nil {
return nil, err
}
return scanItems(rows)
}
// GetByPath looks up a single item by path. Projax-native wins on collision.
func (s *Store) GetByPath(ctx context.Context, path string) (*Item, error) {
row := s.Pool.QueryRow(ctx,
`select `+itemsUnifiedCols+` from projax.items_unified
where path = $1
order by source = 'projax' desc
limit 1`, path)
it, err := scanItem(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return it, nil
}
// GetByID looks up a single projax-native item by uuid.
func (s *Store) GetByID(ctx context.Context, id string) (*Item, error) {
row := s.Pool.QueryRow(ctx,
`select `+itemsUnifiedCols+` from projax.items_unified where id = $1`, id)
it, err := scanItem(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return it, nil
}
// Areas lists all root-level area items, ordered by slug.
func (s *Store) Areas(ctx context.Context) ([]*Item, error) {
rows, err := s.Pool.Query(ctx,
`select `+itemsUnifiedCols+` from projax.items_unified
where source = 'projax' and parent_id is null and 'area' = any(kind)
order by slug`)
if err != nil {
return nil, err
}
return scanItems(rows)
}
// MaiOrphans lists rows that originated in mai.projects and have not been
// promoted yet. Used by /admin/classify.
func (s *Store) MaiOrphans(ctx context.Context) ([]*Item, error) {
rows, err := s.Pool.Query(ctx,
`select `+itemsUnifiedCols+` from projax.items_unified
where source = 'mai.projects'
order by path`)
if err != nil {
return nil, err
}
return scanItems(rows)
}
// CreateInput captures the editable surface of a projax-native item.
type CreateInput struct {
Kind []string
Title string
Slug string
ParentID *string
ContentMD string
Status string
Pinned bool
StartTime *time.Time
EndTime *time.Time
}
func (s *Store) Create(ctx context.Context, in CreateInput) (*Item, error) {
if len(in.Kind) == 0 {
return nil, errors.New("kind required")
}
if in.Title == "" {
return nil, errors.New("title required")
}
if in.Slug == "" {
return nil, errors.New("slug required")
}
if in.Status == "" {
in.Status = "active"
}
var id string
err := s.Pool.QueryRow(ctx, `
insert into projax.items
(kind, title, slug, parent_id, content_md, status, pinned, start_time, end_time)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
returning id`,
in.Kind, in.Title, in.Slug, in.ParentID, in.ContentMD, in.Status, in.Pinned, in.StartTime, in.EndTime,
).Scan(&id)
if err != nil {
return nil, fmt.Errorf("insert: %w", err)
}
return s.GetByID(ctx, id)
}
// UpdateInput captures the editable surface of an existing projax-native item.
type UpdateInput struct {
Title string
Slug string
ParentID *string
ContentMD string
Status string
Pinned bool
Archived bool
StartTime *time.Time
EndTime *time.Time
}
func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) {
_, err := s.Pool.Exec(ctx, `
update projax.items
set title=$2, slug=$3, parent_id=$4, content_md=$5,
status=$6, pinned=$7, archived=$8, start_time=$9, end_time=$10
where id=$1 and deleted_at is null`,
id, in.Title, in.Slug, in.ParentID, in.ContentMD,
in.Status, in.Pinned, in.Archived, in.StartTime, in.EndTime,
)
if err != nil {
return nil, fmt.Errorf("update: %w", err)
}
return s.GetByID(ctx, id)
}
// Promote turns a mai.projects orphan into a projax-native project under the
// chosen parent. The promotion link causes items_unified to hide the source.
func (s *Store) Promote(ctx context.Context, maiID, parentID, slug, title string) (*Item, error) {
tx, err := s.Pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var newID string
if err := tx.QueryRow(ctx, `
insert into projax.items (kind, title, slug, parent_id, content_md, metadata, status)
select array['project']::text[], $1, $2, $3,
coalesce(goal, ''),
coalesce(metadata, '{}'::jsonb),
case status
when 'sleeping' then 'archived'
when 'archived' then 'archived'
when 'done' then 'done'
else 'active' end
from mai.projects where id = $4
returning id`,
title, slug, parentID, maiID,
).Scan(&newID); err != nil {
return nil, fmt.Errorf("promote insert: %w", err)
}
if _, err := tx.Exec(ctx, `
insert into projax.item_links (item_id, ref_type, ref_id, rel)
values ($1, 'mai-project', $2, 'derived-from')`,
newID, maiID,
); err != nil {
return nil, fmt.Errorf("promote link: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return s.GetByID(ctx, newID)
}
// SoftDelete marks a projax-native item deleted_at = now().
func (s *Store) SoftDelete(ctx context.Context, id string) error {
_, err := s.Pool.Exec(ctx, `update projax.items set deleted_at = now() where id = $1`, id)
return err
}

364
web/server.go Normal file
View File

@@ -0,0 +1,364 @@
package web
import (
"context"
"embed"
"errors"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"sort"
"strings"
"github.com/m/projax/store"
)
//go:embed templates/*.tmpl
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
// Server bundles handlers, templates, and the store.
type Server struct {
Store *store.Store
pages map[string]*template.Template
Logger *slog.Logger
}
// New builds a Server. Each page is parsed alongside the layout into its own
// Template so per-page `define "content"` blocks don't shadow each other.
func New(s *store.Store, logger *slog.Logger) (*Server, error) {
if logger == nil {
logger = slog.Default()
}
funcs := template.FuncMap{
"deref": func(p *string) string {
if p == nil {
return ""
}
return *p
},
}
pages := map[string]*template.Template{}
for _, name := range []string{"tree", "detail", "new", "classify", "error"} {
t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS,
"templates/layout.tmpl",
"templates/"+name+".tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", name, err)
}
pages[name] = t
}
return &Server{Store: s, pages: pages, Logger: logger}, nil
}
// Routes wires every URL to a handler and returns the mux.
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", s.handleTree)
mux.HandleFunc("GET /i/", s.handleDetail)
mux.HandleFunc("POST /i/", s.handleDetailWrite)
mux.HandleFunc("GET /new", s.handleNewForm)
mux.HandleFunc("POST /new", s.handleNewSubmit)
mux.HandleFunc("GET /admin/classify", s.handleClassify)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := s.Store.Pool.Ping(r.Context()); err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
fmt.Fprintln(w, "ok")
})
static, _ := fs.Sub(staticFS, "static")
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
return logging(s.Logger, mux)
}
// --- handlers ---
type treeNode struct {
Item *store.Item
Children []*treeNode
}
func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
items, err := s.Store.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
areas, orphans, projaxN, maiN := buildForest(items)
s.render(w, "tree", map[string]any{
"Title": "tree",
"Areas": areas,
"Orphans": orphans,
"ProjaxCount": projaxN,
"MaiCount": maiN,
})
}
func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/i/")
if path == "" {
http.NotFound(w, r)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
}
parents, err := s.parentOptions(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, "detail", map[string]any{
"Title": it.Title,
"Item": it,
"ParentOptions": parents,
"StatusOptions": []string{"active", "done", "archived"},
})
}
func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/i/")
if base, ok := strings.CutSuffix(path, "/promote"); ok {
s.handlePromote(w, r, base)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
}
if !it.Editable() {
http.Error(w, "read-only source — use /promote", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
var parentID *string
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
parentID = &v
}
in := store.UpdateInput{
Title: strings.TrimSpace(r.FormValue("title")),
Slug: strings.TrimSpace(r.FormValue("slug")),
ParentID: parentID,
ContentMD: r.FormValue("content_md"),
Status: strings.TrimSpace(r.FormValue("status")),
Pinned: r.FormValue("pinned") == "1",
Archived: r.FormValue("archived") == "1",
}
updated, err := s.Store.Update(r.Context(), it.ID, in)
if err != nil {
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/i/"+updated.Path, http.StatusSeeOther)
}
func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request, path string) {
it, err := s.Store.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
}
if it.Source != "mai.projects" || it.SourceRefID == nil {
http.Error(w, "promote: not a mai.projects row", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
parentID := strings.TrimSpace(r.FormValue("parent_id"))
slug := strings.TrimSpace(r.FormValue("slug"))
title := strings.TrimSpace(r.FormValue("title"))
if parentID == "" || slug == "" || title == "" {
http.Error(w, "promote: parent_id, slug, title required", http.StatusBadRequest)
return
}
newItem, err := s.Store.Promote(r.Context(), *it.SourceRefID, parentID, slug, title)
if err != nil {
s.fail(w, r, err)
return
}
// HTMX inline-promote on /admin/classify expects a fragment; full-page promote redirects.
if r.Header.Get("HX-Request") == "true" {
fmt.Fprintf(w, `<tr class="promoted"><td colspan="6">Promoted to <a href="/i/%s">%s</a></td></tr>`,
template.HTMLEscapeString(newItem.Path), template.HTMLEscapeString(newItem.Path))
return
}
http.Redirect(w, r, "/i/"+newItem.Path, http.StatusSeeOther)
}
func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) {
parentPath := r.URL.Query().Get("parent")
var parent *store.Item
if parentPath != "" {
p, err := s.Store.GetByPath(r.Context(), parentPath)
if err != nil {
s.fail(w, r, err)
return
}
if !p.Editable() {
http.Error(w, "parent must be a projax-native item", http.StatusBadRequest)
return
}
parent = p
}
s.render(w, "new", map[string]any{
"Title": "new",
"Parent": parent,
"StatusOptions": []string{"active", "done", "archived"},
})
}
func (s *Server) handleNewSubmit(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
kind := strings.TrimSpace(r.FormValue("kind"))
if kind == "" {
kind = "project"
}
var parentID *string
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
parentID = &v
}
if kind == "project" && parentID == nil {
http.Error(w, "project requires a parent", http.StatusBadRequest)
return
}
in := store.CreateInput{
Kind: []string{kind},
Title: strings.TrimSpace(r.FormValue("title")),
Slug: strings.TrimSpace(r.FormValue("slug")),
ParentID: parentID,
ContentMD: r.FormValue("content_md"),
Status: strings.TrimSpace(r.FormValue("status")),
}
it, err := s.Store.Create(r.Context(), in)
if err != nil {
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/i/"+it.Path, http.StatusSeeOther)
}
func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
orphans, err := s.Store.MaiOrphans(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
parents, err := s.parentOptions(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, "classify", map[string]any{
"Title": "classify",
"Orphans": orphans,
"ParentOptions": parents,
})
}
// --- helpers ---
// ParentOption is a flat option for the parent <select>.
type ParentOption struct {
ID string
Path string
}
func (s *Server) parentOptions(ctx context.Context) ([]ParentOption, error) {
items, err := s.Store.ListAll(ctx)
if err != nil {
return nil, err
}
var out []ParentOption
for _, it := range items {
if it.Source != "projax" {
continue
}
out = append(out, ParentOption{ID: it.ID, Path: it.Path})
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out, nil
}
func buildForest(items []*store.Item) (areas []*treeNode, orphans []*store.Item, projaxN, maiN int) {
byID := make(map[string]*treeNode, len(items))
for _, it := range items {
if it.Source == "projax" {
projaxN++
byID[it.ID] = &treeNode{Item: it}
} else {
maiN++
orphans = append(orphans, it)
}
}
for _, n := range byID {
if n.Item.ParentID == nil {
areas = append(areas, n)
continue
}
if parent, ok := byID[*n.Item.ParentID]; ok {
parent.Children = append(parent.Children, n)
}
}
sort.Slice(areas, func(i, j int) bool { return areas[i].Item.Slug < areas[j].Item.Slug })
for _, n := range byID {
sort.Slice(n.Children, func(i, j int) bool { return n.Children[i].Item.Slug < n.Children[j].Item.Slug })
}
return
}
func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) {
t, ok := s.pages[name]
if !ok {
http.Error(w, "unknown page: "+name, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
s.Logger.Error("render", "page", name, "err", err)
}
}
func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
status := http.StatusInternalServerError
if errors.Is(err, store.ErrNotFound) {
status = http.StatusNotFound
}
w.WriteHeader(status)
s.render(w, "error", map[string]any{
"Title": "error",
"Message": err.Error(),
})
s.Logger.Error("handler", "path", r.URL.Path, "err", err)
}
// logging wraps the mux with a tiny access log.
func logging(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
logger.Info("req", "method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr)
})
}

190
web/server_test.go Normal file
View File

@@ -0,0 +1,190 @@
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)
}
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)
}
for _, want := range []string{"<h1>Tree</h1>", "/i/dev", "/i/home", "/admin/classify"} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
}
func TestHealthz(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
code, body := get(t, srv.Routes(), "/healthz")
if code != 200 || strings.TrimSpace(body) != "ok" {
t.Fatalf("healthz: %d %q", code, body)
}
}
func TestDetailProjaxNativeEditable(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", code)
}
if !strings.Contains(body, `form method="post" action="/i/dev"`) {
t.Errorf("editable form missing for /i/dev")
}
}
func TestDetailMaiProjectsReadOnly(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
code, body := get(t, srv.Routes(), "/i/mai.dotfiles")
if code != 200 {
t.Fatalf("status %d", code)
}
if !strings.Contains(body, "Promote to projax") {
t.Errorf("Promote section missing for mai.projects row")
}
if !strings.Contains(body, `action="/i/mai.dotfiles/promote"`) {
t.Errorf("promote form missing")
}
}
func TestClassifyListsOrphans(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, "unclassified rows") {
t.Errorf("classify missing summary")
}
}
func TestPromoteRoundTrip(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
// Pick an orphan to promote.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var maiID, maiPath string
if err := pool.QueryRow(ctx,
`select source_ref_id, path from projax.items_unified where source='mai.projects' limit 1`,
).Scan(&maiID, &maiPath); err != nil {
t.Fatalf("pick orphan: %v", err)
}
if maiID == "" {
t.Skip("no mai.projects orphans available")
}
var devID string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and parent_id is null`).Scan(&devID); err != nil {
t.Fatalf("dev: %v", err)
}
promoSlug := "test-promo-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
form := url.Values{}
form.Set("parent_id", devID)
form.Set("slug", promoSlug)
form.Set("title", "Promo "+maiID)
req := httptest.NewRequest(http.MethodPost, "/i/"+maiPath+"/promote", 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("promote status %d body=%s", w.Result().StatusCode, body)
}
loc := w.Result().Header.Get("Location")
wantLoc := "/i/dev." + promoSlug
if loc != wantLoc {
t.Errorf("redirect Location = %q, want %q", loc, wantLoc)
}
// The mai row should be hidden from items_unified now.
var still int
if err := pool.QueryRow(ctx,
`select count(*) from projax.items_unified where source='mai.projects' and source_ref_id=$1`, maiID,
).Scan(&still); err != nil {
t.Fatalf("post-promote count: %v", err)
}
if still != 0 {
t.Errorf("expected mai source row hidden after promote, got count=%d", still)
}
// Clean up to keep test idempotent.
if _, err := pool.Exec(ctx, `delete from projax.item_links where ref_type='mai-project' and ref_id=$1`, maiID); err != nil {
t.Fatalf("cleanup link: %v", err)
}
if _, err := pool.Exec(ctx, `delete from projax.items where slug=$1 and parent_id=$2`, promoSlug, devID); err != nil {
t.Fatalf("cleanup item: %v", err)
}
}

58
web/static/style.css Normal file
View File

@@ -0,0 +1,58 @@
/* projax — minimal style. Desktop browser only. */
:root {
--fg: #1a1a1a;
--muted: #6a6a6a;
--bg: #fafafa;
--bg-alt: #f0efe8;
--border: #d8d4c8;
--accent: #2f5d9e;
--warn: #b35900;
--ok: #2b7a4b;
--bad: #a02929;
}
* { box-sizing: border-box; }
html { font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--fg); background: var(--bg); }
body { margin: 0; }
header { background: var(--bg-alt); border-bottom: 1px solid var(--border); padding: 8px 16px; }
header nav { display: flex; gap: 16px; align-items: center; }
header .brand { font-weight: 600; font-size: 1.1em; color: var(--fg); text-decoration: none; }
header a { color: var(--accent); text-decoration: none; }
header a:hover { text-decoration: underline; }
main { padding: 16px 24px; max-width: 1100px; margin: 0 auto; }
h1 { font-size: 1.4em; margin: 0 0 8px; }
h2 { font-size: 1.1em; margin: 24px 0 8px; }
.counts { color: var(--muted); margin: 0 0 16px; }
.tree ul { list-style: none; padding-left: 18px; margin: 4px 0; border-left: 1px dotted var(--border); }
.tree > ul.forest { padding-left: 0; border-left: none; }
.node { margin: 2px 0; }
.node.area > a { font-weight: 600; }
.slug { color: var(--muted); font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.85em; margin-left: 8px; }
.status { display: inline-block; font-size: 0.75em; padding: 1px 6px; border-radius: 999px; border: 1px solid var(--border); background: #fff; margin-left: 8px; }
.status-active { color: var(--ok); }
.status-done { color: var(--accent); }
.status-archived { color: var(--muted); }
.source { display: inline-block; font-size: 0.75em; padding: 1px 6px; border-radius: 4px; background: var(--bg-alt); border: 1px solid var(--border); }
.source-mai\.projects { color: var(--warn); }
.add { margin-left: 6px; color: var(--accent); text-decoration: none; }
.add:hover { text-decoration: underline; }
.orphans { margin-top: 32px; }
.flat { list-style: none; padding: 0; }
.flat li { padding: 4px 0; border-bottom: 1px dashed var(--border); }
.edit, .promote, .inline-promote { display: grid; gap: 12px; max-width: 720px; }
.inline-promote { display: contents; }
form label { display: flex; flex-direction: column; gap: 4px; font-size: 0.9em; color: var(--muted); }
form label.checkbox { flex-direction: row; align-items: center; gap: 8px; }
form input[type="text"], form input:not([type]), form select, form textarea {
font: inherit; padding: 6px 8px; border: 1px solid var(--border); background: #fff; border-radius: 4px;
}
form textarea { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.92em; }
form .actions { display: flex; gap: 12px; align-items: center; }
button { font: inherit; padding: 6px 14px; border: 1px solid var(--accent); background: var(--accent); color: #fff; border-radius: 4px; cursor: pointer; }
button:hover { filter: brightness(0.92); }
.cancel { color: var(--muted); text-decoration: none; }
.cancel:hover { text-decoration: underline; color: var(--bad); }
.readonly pre { background: var(--bg-alt); padding: 12px; border: 1px solid var(--border); border-radius: 4px; white-space: pre-wrap; }
table.classify { width: 100%; border-collapse: collapse; margin-top: 16px; }
table.classify th, table.classify td { padding: 8px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
table.classify input, table.classify select { width: 100%; }
.error { color: var(--bad); }

View File

@@ -0,0 +1,37 @@
{{define "content"}}
<h1>Classify mai.projects orphans</h1>
<p>{{len .Orphans}} unclassified rows. Pick an area (or any projax item) per row and click Promote — keeps the original mai.projects row untouched and links back via item_links.</p>
<table class="classify">
<thead>
<tr><th>Mai ID / Title</th><th>Status</th><th>Parent</th><th>Slug</th><th>Title</th><th></th></tr>
</thead>
<tbody>
{{range .Orphans}}
<tr id="row-{{.SourceRefDeref}}">
<td>
<a href="/i/{{.Path}}">{{.Title}}</a>
<br><small class="slug">{{.SourceRefDeref}}</small>
</td>
<td><span class="status status-{{.Status}}">{{.Status}}</span></td>
<td>
<form
hx-post="/i/{{.Path}}/promote"
hx-target="#row-{{.SourceRefDeref}}"
hx-swap="outerHTML"
class="inline-promote">
<select name="parent_id" required>
<option value="">— pick parent —</option>
{{range $.ParentOptions}}<option value="{{.ID}}">{{.Path}}</option>{{end}}
</select>
</td>
<td><input name="slug" value="{{.Slug}}" required pattern="[^.]+"></td>
<td><input name="title" value="{{.Title}}" required></td>
<td><button type="submit">Promote</button></form></td>
</tr>
{{else}}
<tr><td colspan="6"><em>No orphans. Everything is classified.</em></td></tr>
{{end}}
</tbody>
</table>
{{end}}

71
web/templates/detail.tmpl Normal file
View File

@@ -0,0 +1,71 @@
{{define "content"}}
<h1>{{.Item.Title}}</h1>
<p class="meta">
<span class="source source-{{.Item.Source}}">{{.Item.Source}}</span>
<span class="slug">{{.Item.Path}}</span>
<span class="status status-{{.Item.Status}}">{{.Item.Status}}</span>
{{if .Item.Pinned}}<span class="pin">pinned</span>{{end}}
{{if .Item.Archived}}<span class="archived">archived</span>{{end}}
</p>
{{if .Item.Editable}}
<form method="post" action="/i/{{.Item.Path}}" class="edit">
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
<label>Parent
<select name="parent_id">
{{if .Item.IsArea}}
<option value="" selected>(root area)</option>
{{else}}
{{range .ParentOptions}}
<option value="{{.ID}}" {{if and $.Item.ParentID (eq .ID (deref $.Item.ParentID))}}selected{{end}}>{{.Path}}</option>
{{end}}
{{end}}
</select>
</label>
<label>Status
<select name="status">
{{range $opt := .StatusOptions}}
<option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option>
{{end}}
</select>
</label>
<label class="checkbox">
<input type="checkbox" name="pinned" value="1" {{if .Item.Pinned}}checked{{end}}> pinned
</label>
<label class="checkbox">
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
</label>
<label>Content
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea>
</label>
<div class="actions">
<button type="submit">Save</button>
<a class="cancel" href="/">Cancel</a>
</div>
</form>
{{else}}
<div class="readonly">
<p><em>Read-only: this row is sourced from {{.Item.Source}}.</em></p>
<pre class="content">{{.Item.ContentMD}}</pre>
<h2>Promote to projax</h2>
<p>Pick the area or project this should live under. mai.projects row stays untouched; the projax item links back to it via item_links.</p>
<form method="post" action="/i/{{.Item.Path}}/promote" class="promote">
<label>Parent
<select name="parent_id" required>
{{range .ParentOptions}}
<option value="{{.ID}}">{{.Path}}</option>
{{end}}
</select>
</label>
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
<div class="actions">
<button type="submit">Promote</button>
<a class="cancel" href="/">Cancel</a>
</div>
</form>
</div>
{{end}}
{{end}}

5
web/templates/error.tmpl Normal file
View File

@@ -0,0 +1,5 @@
{{define "content"}}
<h1>Error</h1>
<p class="error">{{.Message}}</p>
<p><a href="/">Back to tree</a></p>
{{end}}

20
web/templates/layout.tmpl Normal file
View File

@@ -0,0 +1,20 @@
{{define "layout"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{.Title}} — projax</title>
<link rel="stylesheet" href="/static/style.css">
<script src="https://unpkg.com/htmx.org@1.9.12" integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2" crossorigin="anonymous"></script>
</head>
<body>
<header>
<nav>
<a href="/" class="brand">projax</a>
<a href="/admin/classify">classify orphans</a>
</nav>
</header>
<main>
{{template "content" .}}
</main>
</body>
</html>{{end}}

28
web/templates/new.tmpl Normal file
View File

@@ -0,0 +1,28 @@
{{define "content"}}
<h1>New item</h1>
<p class="meta">Parent: <strong>{{if .Parent}}{{.Parent.Path}}{{else}}(root area){{end}}</strong></p>
<form method="post" action="/new" class="edit">
{{if .Parent}}<input type="hidden" name="parent_id" value="{{.Parent.ID}}">{{end}}
<label>Kind
<select name="kind">
{{if not .Parent}}<option value="area" selected>area</option>{{end}}
<option value="project" {{if .Parent}}selected{{end}}>project</option>
</select>
</label>
<label>Title <input name="title" required></label>
<label>Slug <input name="slug" required pattern="[^.]+" placeholder="lowercase, no dots"></label>
<label>Status
<select name="status">
{{range $opt := .StatusOptions}}<option value="{{$opt}}">{{$opt}}</option>{{end}}
</select>
</label>
<label>Content
<textarea name="content_md" rows="10"></textarea>
</label>
<div class="actions">
<button type="submit">Create</button>
<a class="cancel" href="{{if .Parent}}/i/{{.Parent.Path}}{{else}}/{{end}}">Cancel</a>
</div>
</form>
{{end}}

52
web/templates/tree.tmpl Normal file
View File

@@ -0,0 +1,52 @@
{{define "content"}}
<h1>Tree</h1>
<p class="counts">
<strong>{{.ProjaxCount}}</strong> projax · <strong>{{.MaiCount}}</strong> mai.projects orphans
{{if .MaiCount}}<a href="/admin/classify">→ classify</a>{{end}}
</p>
<section class="tree">
<h2>Areas + projax items</h2>
<ul class="forest">
{{range .Areas}}
<li class="node area">
<a href="/i/{{.Item.Path}}">{{.Item.Title}}</a>
<span class="slug">{{.Item.Path}}</span>
<a class="add" href="/new?parent={{.Item.Path}}">+</a>
{{template "children" .}}
</li>
{{end}}
</ul>
</section>
{{if .Orphans}}
<section class="orphans">
<h2>mai.projects orphans <small>(unclassified)</small></h2>
<ul class="flat">
{{range .Orphans}}
<li class="node orphan">
<a href="/i/{{.Path}}">{{.Title}}</a>
<span class="slug">{{.Path}}</span>
<span class="status status-{{.Status}}">{{.Status}}</span>
</li>
{{end}}
</ul>
</section>
{{end}}
{{end}}
{{define "children"}}
{{if .Children}}
<ul>
{{range .Children}}
<li class="node project">
<a href="/i/{{.Item.Path}}">{{.Item.Title}}</a>
<span class="slug">{{.Item.Path}}</span>
<span class="status status-{{.Item.Status}}">{{.Item.Status}}</span>
<a class="add" href="/new?parent={{.Item.Path}}">+</a>
{{template "children" .}}
</li>
{{end}}
</ul>
{{end}}
{{end}}