feat(phase 3c per-events): event_date on item_links, Documents UI, PER URL resolver, MCP date-aware add_link

migration 0011_item_links_event_date.sql: ADD event_date date + partial
index (idempotent). Day granularity by design per the PER spec; the
column lands NULL on every existing row, no backfill.

store:
- ItemLink gains an EventDate *time.Time (every read path scans it).
- AddLinkDated(ctx, item, refType, refID, rel, note, date, metadata)
  upserts with COALESCE(new, old) for note + event_date so partial
  callers don't clobber prior state.
- DatedLinks(item) returns event_date IS NOT NULL ordered DESC.

web:
- per.go: parsePER strips a trailing .YYMMDD (rejects invalid dates like
  Feb 30); collisionTag yields a/b/.../z/aa/ab/...; computePERs walks
  DatedLinks output and assigns render-time collision tags inside each
  date group. Tags are never stored.
- handleDetail: 404 retry with PER stripped — /i/mfin.house1.260515
  resolves to the house1 item with HighlightDate=2026-05-15.
- documents_section.tmpl: add-form (ref_type/date/ref_id/note),
  date-sorted rows with computed PER, ref-type badge, remove × with
  anti-forgery item-id check, highlight row when HighlightDate matches.
- POST /i/{path}/links/add and /links/remove handlers; HTMX swap on the
  fragment, redirect for non-HTMX callers.

mcp:
- add_link accepts event_date: "YYYY-MM-DD" (parsed strict, hands back
  fmt.Errorf on bad form). linkView.event_date surfaces it on responses.
- Existing add_link callers without event_date keep working unchanged.

docs:
- docs/standards/per.md gains an Implementation section pointing at
  item_links.event_date + ref_types + render-time collision policy.
- docs/design.md adds a Documents/dated artifacts section with the
  schema delta, conflict policy, and URL routing rules.

tests:
- per_test.go: parsePER (valid/invalid dates, non-numeric, wrong
  length); collisionTag (1..53); computePERs (bare-then-.a, skips
  undated, multi-date grouping).
This commit is contained in:
mAi
2026-05-15 18:35:21 +02:00
parent 836054be63
commit e055e4607e
12 changed files with 627 additions and 25 deletions

127
web/links.go Normal file
View File

@@ -0,0 +1,127 @@
package web
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/m/projax/store"
)
// handleLinksAdd processes POST /i/{path}/links/add. Accepts ref_type, ref_id,
// note, event_date (YYYY-MM-DD). Anti-forgery isn't a concern at v1 since the
// trust model is Tailscale-only + cookie auth.
func (s *Server) handleLinksAdd(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 err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
refType := strings.TrimSpace(r.FormValue("ref_type"))
refID := strings.TrimSpace(r.FormValue("ref_id"))
noteVal := strings.TrimSpace(r.FormValue("note"))
dateStr := strings.TrimSpace(r.FormValue("event_date"))
banner := ""
if refType == "" || refID == "" {
banner = "ref_type and ref_id are required."
}
var date *time.Time
if banner == "" && dateStr != "" {
t, err := time.Parse("2006-01-02", dateStr)
if err != nil {
banner = "event_date must be YYYY-MM-DD."
} else {
date = &t
}
}
if banner == "" {
var notePtr *string
if noteVal != "" {
notePtr = &noteVal
}
if _, err := s.Store.AddLinkDated(r.Context(), it.ID, refType, refID, "", notePtr, date, nil); err != nil {
banner = fmt.Sprintf("Could not add link: %v", err)
}
}
s.renderDocumentsSection(w, r, it, nil, banner)
}
// handleLinksRemove processes POST /i/{path}/links/remove.
func (s *Server) handleLinksRemove(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 err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
linkID := strings.TrimSpace(r.FormValue("link_id"))
banner := ""
if linkID == "" {
banner = "link_id required"
} else {
// Belt-and-braces: ensure the link belongs to this item before
// deleting, so a crafted form can't snipe an unrelated row.
owns, err := s.linkBelongsToItem(r.Context(), linkID, it.ID)
if err != nil {
s.fail(w, r, err)
return
}
if !owns {
banner = "Link does not belong to this item."
} else if err := s.Store.DeleteLink(r.Context(), linkID); err != nil {
banner = fmt.Sprintf("Could not remove link: %v", err)
}
}
s.renderDocumentsSection(w, r, it, nil, banner)
}
// renderDocumentsSection re-pulls dated links, computes PERs, and renders the
// Documents fragment for HTMX swaps. Non-HTMX requests fall back to a full
// detail-page redirect.
func (s *Server) renderDocumentsSection(w http.ResponseWriter, r *http.Request, it *store.Item, highlight *time.Time, banner string) {
if r.Header.Get("HX-Request") != "true" {
http.Redirect(w, r, "/i/"+it.PrimaryPath()+"#documents-section", http.StatusSeeOther)
return
}
docs, err := s.Store.DatedLinks(r.Context(), it.ID)
if err != nil {
s.fail(w, r, err)
return
}
documents := computePERs(it.PrimaryPath(), docs)
s.render(w, "documents_section", map[string]any{
"Item": it,
"Documents": documents,
"HighlightDate": highlight,
"DocBanner": banner,
})
}
// linkBelongsToItem returns true when the link's item_id equals the supplied
// item id. Used as an anti-forgery check before delete.
func (s *Server) linkBelongsToItem(ctx context.Context, linkID, itemID string) (bool, error) {
var owner string
err := s.Store.Pool.QueryRow(ctx,
`select item_id from projax.item_links where id = $1`, linkID).Scan(&owner)
if err != nil {
if isNoRows(err) {
return false, nil
}
return false, err
}
return owner == itemID, nil
}
func isNoRows(err error) bool {
return err != nil && (errors.Is(err, store.ErrNotFound) || err.Error() == "no rows in result set")
}

110
web/per.go Normal file
View File

@@ -0,0 +1,110 @@
package web
import (
"strconv"
"strings"
"time"
"github.com/m/projax/store"
)
// parsePER strips a trailing YYMMDD segment off the path and returns the
// shorter path + the parsed date. If the last segment doesn't match the
// 6-digit form, the input is returned unchanged with a nil date. We
// deliberately ignore collision-tag suffixes (`.a`/`.b`) at v0.1 — collision
// is a display concern only per the PER standard, so a URL ending in `.a`
// just won't strip and will 404 on its own.
func parsePER(path string) (basePath string, eventDate *time.Time) {
idx := strings.LastIndex(path, ".")
if idx < 0 || idx == len(path)-1 {
return path, nil
}
last := path[idx+1:]
if len(last) != 6 {
return path, nil
}
for _, c := range last {
if c < '0' || c > '9' {
return path, nil
}
}
yy, _ := strconv.Atoi(last[0:2])
mm, _ := strconv.Atoi(last[2:4])
dd, _ := strconv.Atoi(last[4:6])
// YY → 20YY (PER spec applies to m's century).
t := time.Date(2000+yy, time.Month(mm), dd, 0, 0, 0, 0, time.UTC)
// Reject impossible dates: time.Date normalises (e.g. Feb 30 → Mar 2),
// so a round-trip mismatch signals "not a real date".
if t.Year() != 2000+yy || int(t.Month()) != mm || t.Day() != dd {
return path, nil
}
return path[:idx], &t
}
// formatPERDate is the inverse of the YYMMDD slice of parsePER. Used for
// rendering computed PERs in the Documents section.
func formatPERDate(t time.Time) string {
return t.UTC().Format("060102")
}
// computePERs annotates each dated link with the canonical PER under which
// it should display, including a collision tag (`.a`/`.b`/…) when multiple
// links share the same `event_date`. Inputs must already be ordered by
// (event_date DESC, created_at ASC) — matches store.DatedLinks output. The
// tag is render-time only per the PER v0.1 spec; we never store it.
type DocumentRow struct {
Link *store.ItemLink
PER string // basePath + . + YYMMDD + optional .a/.b
Tag string // "" | "a" | "b" | …
}
func computePERs(basePath string, links []*store.ItemLink) []DocumentRow {
// Group by date so we can assign collision tags inside each group.
type group struct {
date time.Time
links []*store.ItemLink
}
groups := []group{}
for _, l := range links {
if l.EventDate == nil {
continue
}
d := *l.EventDate
// New group when date changes (input is already sorted by event_date DESC).
if len(groups) == 0 || !groups[len(groups)-1].date.Equal(d) {
groups = append(groups, group{date: d})
}
groups[len(groups)-1].links = append(groups[len(groups)-1].links, l)
}
out := make([]DocumentRow, 0, len(links))
for _, g := range groups {
// Within a date, the first link is bare; the rest get .a, .b, …
// (input is sorted by created_at ASC within the same date).
for i, l := range g.links {
row := DocumentRow{Link: l, PER: basePath + "." + formatPERDate(g.date)}
if i > 0 {
tag := collisionTag(i)
row.Tag = tag
row.PER = row.PER + "." + tag
}
out = append(out, row)
}
}
return out
}
// collisionTag returns the alpha-only suffix for the n-th colliding link
// (1-indexed: 1→"a", 2→"b", …, 26→"z", 27→"aa", 28→"ab", …). Matches the
// rule documented in docs/standards/per.md §"Collision handling".
func collisionTag(n int) string {
if n <= 0 {
return ""
}
out := ""
for n > 0 {
n--
out = string(rune('a'+(n%26))) + out
n /= 26
}
return out
}

110
web/per_test.go Normal file
View File

@@ -0,0 +1,110 @@
package web
import (
"testing"
"time"
"github.com/m/projax/store"
)
func TestParsePER(t *testing.T) {
cases := []struct {
in string
wantBase string
wantDate string // empty == nil
}{
{"dev.projax", "dev.projax", ""},
{"dev.projax.260515", "dev.projax", "2026-05-15"},
{"mfin.house1.260515", "mfin.house1", "2026-05-15"},
// Six-digit but not a valid date → leave unchanged.
{"foo.260230", "foo.260230", ""}, // Feb 30 doesn't exist
{"foo.260000", "foo.260000", ""}, // month 00
{"foo.261301", "foo.261301", ""}, // month 13
{"foo.999999", "foo.999999", ""}, // not a real date
// Wrong length → leave unchanged.
{"foo.bar", "foo.bar", ""},
{"foo.12345", "foo.12345", ""},
{"foo.1234567", "foo.1234567", ""},
// Empty trailing segment.
{"foo.", "foo.", ""},
// No dot at all.
{"260515", "260515", ""},
}
for _, tc := range cases {
gotBase, gotDate := parsePER(tc.in)
if gotBase != tc.wantBase {
t.Errorf("parsePER(%q) base = %q, want %q", tc.in, gotBase, tc.wantBase)
}
if tc.wantDate == "" {
if gotDate != nil {
t.Errorf("parsePER(%q) date = %v, want nil", tc.in, gotDate)
}
continue
}
want, _ := time.Parse("2006-01-02", tc.wantDate)
if gotDate == nil || !gotDate.Equal(want) {
t.Errorf("parsePER(%q) date = %v, want %v", tc.in, gotDate, want)
}
}
}
func TestCollisionTag(t *testing.T) {
cases := []struct {
n int
want string
}{
{0, ""},
{1, "a"},
{2, "b"},
{26, "z"},
{27, "aa"},
{28, "ab"},
{52, "az"},
{53, "ba"},
}
for _, tc := range cases {
if got := collisionTag(tc.n); got != tc.want {
t.Errorf("collisionTag(%d) = %q, want %q", tc.n, got, tc.want)
}
}
}
func TestComputePERsBareThenAB(t *testing.T) {
d := time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC)
d2 := time.Date(2026, 5, 16, 0, 0, 0, 0, time.UTC)
links := []*store.ItemLink{
// 2026-05-15 group: 2 entries (sorted by created_at ASC; first bare, second .a)
{ID: "x1", EventDate: &d, CreatedAt: time.Date(2026, 5, 15, 9, 0, 0, 0, time.UTC)},
{ID: "x2", EventDate: &d, CreatedAt: time.Date(2026, 5, 15, 10, 0, 0, 0, time.UTC)},
// 2026-05-16 group: 1 entry (bare).
{ID: "x3", EventDate: &d2, CreatedAt: time.Date(2026, 5, 16, 9, 0, 0, 0, time.UTC)},
}
// Caller invariant: DatedLinks returns event_date DESC, created_at ASC.
// Test data above is created_at ASC; reverse the date groups to match.
desc := []*store.ItemLink{links[2], links[0], links[1]}
rows := computePERs("mfin.house1", desc)
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows))
}
if rows[0].PER != "mfin.house1.260516" {
t.Errorf("row 0 PER = %q", rows[0].PER)
}
if rows[1].PER != "mfin.house1.260515" || rows[1].Tag != "" {
t.Errorf("row 1 should be bare, got PER=%q tag=%q", rows[1].PER, rows[1].Tag)
}
if rows[2].PER != "mfin.house1.260515.a" || rows[2].Tag != "a" {
t.Errorf("row 2 should be .a, got PER=%q tag=%q", rows[2].PER, rows[2].Tag)
}
}
func TestComputePERsSkipsUndated(t *testing.T) {
d := time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC)
links := []*store.ItemLink{
{ID: "with", EventDate: &d, CreatedAt: time.Now()},
{ID: "without", EventDate: nil, CreatedAt: time.Now()},
}
rows := computePERs("dev.x", links)
if len(rows) != 1 || rows[0].Link.ID != "with" {
t.Errorf("undated link should be skipped, got %v", rows)
}
}

View File

@@ -11,6 +11,7 @@ import (
"net/http"
"sort"
"strings"
"time"
"github.com/m/projax/store"
)
@@ -109,6 +110,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
"templates/detail.tmpl",
"templates/tasks_section.tmpl",
"templates/issues_section.tmpl",
"templates/documents_section.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse detail: %w", err)
@@ -120,6 +122,12 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
return nil, fmt.Errorf("parse tasks_section: %w", err)
}
pages["tasks_section"] = tasksFragment
// Standalone documents-section template for HTMX fragment responses.
docsFragment, err := template.New("documents_section").Funcs(funcs).ParseFS(templatesFS, "templates/documents_section.tmpl")
if err != nil {
return nil, fmt.Errorf("parse documents_section: %w", err)
}
pages["documents_section"] = docsFragment
loginTmpl, err := template.New("login").Funcs(funcs).ParseFS(templatesFS, "templates/login.tmpl")
if err != nil {
return nil, fmt.Errorf("parse login: %w", err)
@@ -253,7 +261,18 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
// PER URL resolution: try the full path first; if it 404s and the trailing
// segment looks like YYMMDD, retry against the shorter path and surface
// the date as a render hint to scroll/highlight the matching row.
it, err := s.Store.GetByPath(r.Context(), path)
var highlight *time.Time
if errors.Is(err, store.ErrNotFound) {
if base, d := parsePER(path); d != nil {
if it2, err2 := s.Store.GetByPath(r.Context(), base); err2 == nil {
it, err, highlight = it2, nil, d
}
}
}
if err != nil {
s.fail(w, r, err)
return
@@ -275,6 +294,11 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
for _, ri := range issues {
openTotal += ri.OpenCount
}
docs, err := s.Store.DatedLinks(r.Context(), it.ID)
if err != nil {
s.Logger.Warn("detail docs", "path", it.PrimaryPath(), "err", err)
}
documents := computePERs(it.PrimaryPath(), docs)
s.render(w, "detail", map[string]any{
"Title": it.Title,
"Item": it,
@@ -285,6 +309,8 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
"Issues": issues,
"IssuesOpenTotal": openTotal,
"GiteaOn": s.Gitea != nil,
"Documents": documents,
"HighlightDate": highlight,
})
}
@@ -304,6 +330,14 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
return
}
}
if base, ok := strings.CutSuffix(path, "/links/add"); ok {
s.handleLinksAdd(w, r, base)
return
}
if base, ok := strings.CutSuffix(path, "/links/remove"); ok {
s.handleLinksRemove(w, r, base)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
@@ -533,6 +567,8 @@ func (s *Server) render(w http.ResponseWriter, name string, data map[string]any)
entry = "tasks-section"
case "tree_section":
entry = "tree-section"
case "documents_section":
entry = "documents-section"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, entry, data); err != nil {

View File

@@ -127,3 +127,33 @@ table.classify input, table.classify select { width: 100%; }
#tree-filterbar small { opacity: 0.75; margin-left: 2px; }
.tree-section .empty { padding: 24px; color: var(--muted); }
.tree-section .clear { color: var(--bad); }
/* Documents / PER-dated artifacts (phase 3c). */
.documents { margin-top: 24px; }
.documents .doc-add { display: flex; gap: 6px; margin: 8px 0 12px; align-items: center; flex-wrap: wrap; }
.documents .doc-add input[type="text"] { flex: 1; min-width: 8em; }
.documents ul.docs { list-style: none; padding: 0; margin: 0; }
.documents li.doc-row {
display: flex; gap: 8px; align-items: baseline; padding: 6px 0;
border-bottom: 1px dotted var(--border); flex-wrap: wrap;
}
.documents li.doc-row:last-child { border-bottom: none; }
.documents li.doc-row.highlight { background: #fff5d6; padding-left: 8px; border-left: 3px solid var(--warn); }
.documents .per {
font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.88em;
color: var(--accent); background: var(--bg-alt); padding: 1px 6px; border-radius: 4px;
}
.documents .ref-type {
display: inline-block; font-size: 0.72em; padding: 1px 6px; border-radius: 999px;
background: #fff; border: 1px solid var(--border); color: var(--muted);
}
.documents .ref-type-document { color: var(--accent); border-color: var(--accent); }
.documents .ref-type-note { color: var(--ok); border-color: var(--ok); }
.documents .ref-type-url { color: var(--warn); border-color: var(--warn); }
.documents .ref-id { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.85em; flex: 1; min-width: 8em; }
.documents .doc-note { color: var(--muted); font-style: italic; }
.documents .doc-remove .x {
background: #fff; color: var(--muted); border-color: var(--border);
font-size: 1.05em; line-height: 1; padding: 2px 6px;
}
.documents .doc-remove .x:hover { color: var(--bad); border-color: var(--bad); }

View File

@@ -21,6 +21,8 @@
{{template "issues-section" .}}
{{end}}
{{template "documents-section" .}}
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit">
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>

View File

@@ -0,0 +1,50 @@
{{define "documents-section"}}
<section id="documents-section" class="documents">
<h2>Documents</h2>
{{if .DocBanner}}<p class="banner warn" role="alert">{{.DocBanner}}</p>{{end}}
<form class="doc-add"
hx-post="/i/{{.Item.PrimaryPath}}/links/add"
hx-target="#documents-section"
hx-swap="outerHTML">
<select name="ref_type">
<option value="document">document</option>
<option value="note">note</option>
<option value="url">url</option>
</select>
<input type="date" name="event_date" value="{{if .HighlightDate}}{{.HighlightDate.Format "2006-01-02"}}{{end}}" required>
<input type="text" name="ref_id" placeholder="ref (path, URL, hash, …)" required>
<input type="text" name="note" placeholder="note (optional)">
<button type="submit">+ Add</button>
</form>
{{if .Documents}}
<ul class="docs">
{{range .Documents}}
<li class="doc-row {{if and $.HighlightDate (eq (.Link.EventDate.Format "2006-01-02") ($.HighlightDate.Format "2006-01-02"))}}highlight{{end}}"
id="per-{{.PER}}" data-link-id="{{.Link.ID}}">
<span class="per">{{.PER}}</span>
<span class="ref-type ref-type-{{.Link.RefType}}">{{.Link.RefType}}</span>
{{if eq .Link.RefType "url"}}
<a href="{{.Link.RefID}}" target="_blank" rel="noopener noreferrer">{{.Link.RefID}}</a>
{{else}}
<code class="ref-id">{{.Link.RefID}}</code>
{{end}}
{{if .Link.Note}}<span class="doc-note">{{deref .Link.Note}}</span>{{end}}
<small class="muted">added {{.Link.CreatedAt.Format "2006-01-02"}}</small>
<form class="doc-remove inline"
hx-post="/i/{{$.Item.PrimaryPath}}/links/remove"
hx-target="#documents-section"
hx-swap="outerHTML"
hx-confirm="Remove this document reference?">
<input type="hidden" name="link_id" value="{{.Link.ID}}">
<button type="submit" class="x" aria-label="Remove">×</button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">No dated artifacts yet. Add one above — it becomes a PER like <code>{{.Item.PrimaryPath}}.{{if .HighlightDate}}{{.HighlightDate.Format "060102"}}{{else}}YYMMDD{{end}}</code>.</p>
{{end}}
</section>
{{end}}