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

View File

@@ -0,0 +1,17 @@
-- 0011_item_links_event_date.sql
--
-- Phase 3c: add an optional event_date to projax.item_links so dated artifacts
-- (PER-cited letters, invoices, meeting notes, …) actually resolve. The PER
-- standard at docs/standards/per.md uses YYMMDD day granularity, so `date` is
-- correct here — time-of-day is intentionally not part of the standard.
--
-- The partial index makes "list every dated artifact for an item, newest
-- first" cheap without bloating the main index for the (vast) majority of
-- existing links that carry no date.
ALTER TABLE projax.item_links
ADD COLUMN IF NOT EXISTS event_date date;
CREATE INDEX IF NOT EXISTS item_links_event_date_idx
ON projax.item_links (event_date)
WHERE event_date IS NOT NULL;

View File

@@ -320,6 +320,32 @@ Out of scope (parked):
- Bulk import/export tools — phase 3b.
- Otto-PWA integration that consumes this surface — separate worker.
## Documents / dated artifacts (Phase 3c)
The PER standard (`docs/standards/per.md`) needs a `(item, event_date)` pair as its backing store. Phase 3c lands it.
- **Schema**: migration `0011_item_links_event_date.sql` adds `projax.item_links.event_date date` (nullable) and a partial index. Day granularity per the PER spec; time-of-day is intentionally out of scope.
- **Ref-type convention**: existing types (`caldav-list`, `gitea-repo`, `gitea-issue`, `mai-project`, …) keep their meaning. Phase 3c adds three convention names for dated artifacts:
- `document` — generic external pointer (URL, local file path, Drive link, …)
- `note` — short text snippet; the body lives in `note` or `metadata.body`
- `url` — bookmarked link (rendered as a clickable anchor)
The schema doesn't enforce these — the column is `text` — but the UI uses them to render differently.
- **Detail page → Documents section** (renders unconditionally on every `/i/{path}` page; empty-state copy when no dated links exist):
- Lists every `item_link` with `event_date IS NOT NULL` for the item, ordered `event_date DESC, created_at ASC`.
- Each row shows the computed PER (`<primary-path>.<YYMMDD>[.<collision-tag>]`), a ref-type badge, the ref_id (clickable for `url`), the optional note, and a small `×` to remove.
- Add form (top of section): `ref_type | event_date | ref_id | note`. POSTs to `/i/{path}/links/add` → HTMX swap.
- Collision tags (`.a`, `.b`, …) are **computed at render time only**, never stored. The first link on a date is bare; the second gets `.a`, the third `.b`. Order is by `created_at` within the same date.
- **URL resolution** for PER-cited paths: `handleDetail` first tries the literal `path`; if it 404s and the trailing segment looks like `YYMMDD`, it retries with the date stripped and surfaces the parsed date as a render hint so the Documents section can scroll to / highlight the matching row. Invalid dates (Feb 30, 99/99/99) are not stripped — they hit the original 404 path.
- **MCP**: `add_link` accepts an optional `event_date: "YYYY-MM-DD"`. Existing callers without it keep working. `linkView.event_date` surfaces the stored value on the response side. The conflict policy on duplicate `(item_id, ref_type, ref_id, rel)` is `COALESCE(new, old)` for note/event_date so partial updates don't clobber an earlier date by accident.
- **Anti-forgery on remove**: the `/links/remove` handler verifies the link's `item_id` matches the URL's item before deleting — a crafted form can't snipe a link that belongs to a different item.
Out of scope (parked):
- File uploads / in-projax storage. v1 references only.
- Recurring dated artifacts (RRULE-style). Flatten for now.
- Cross-PER linking syntax / forward-jump anchors. Phase 3d+ if m needs it.
## 8. Open questions (post-PRD)
- **Path-trigger correctness** under cycle attempts: enforce acyclicity via check in trigger.

View File

@@ -79,12 +79,27 @@ Examples:
- Form fields with strict character limits below ~2530 chars.
- Anywhere ambiguity is a feature (intentionally vague references).
## Schema implications (Phase 2)
## Schema implications
- `projax.item_links` gains an `event_date timestamptz` column (optional). Dated artifacts linked to an item — CalDAV todos, Gitea issues, document references, PER-cited letters — sit here with a date.
- `projax.item_links` carries an optional `event_date date` column (migration `0011_item_links_event_date.sql`, shipped 2026-05-15). Dated artifacts linked to an item — CalDAV todos, Gitea issues, document references, PER-cited letters — sit here with a date.
- Day granularity is intentional. Time-of-day is not part of the PER standard.
- Existing `aliases text[]` on `projax.items` is the rename-stability backbone. Don't drop on archive.
- PER resolution = parse the string → match `(area-walk-path, optional date)` → return matching `items_unified` row + linked `item_links` rows with `event_date = parsed-date`.
## Implementation (v0.1, shipped Phase 3c)
- **Backing columns**: `(projax.items.paths[], projax.item_links.event_date)`. The path is the canonical lookup key; the date narrows to a specific dated artifact.
- **Ref-type convention** for the artifacts surfaced under a project's Documents section:
- `document` — generic pointer (URL, file path, Drive link, …); ref_id is the pointer
- `note` — short text snippet; ref_id is the body or a hash, full body in `note` column or `metadata.body`
- `url` — bookmarked link; the UI renders ref_id as an `<a>` opening in a new tab
- Existing typed refs (`caldav-list`, `gitea-repo`, `gitea-issue`, `mai-project`, …) keep their meaning and can also carry an `event_date`.
- **Collision tags are render-time only.** When two links share `(item_id, event_date)`, the UI appends `.a`/`.b`/… in `created_at` order. The first one stays bare. We never store the tag — re-rendering after a delete naturally rebalances the assignments.
- **URL routing**: `/i/<path>.<YYMMDD>` first tries the literal path; if 404 and the trailing segment is a valid `YYMMDD`, retries against the stripped path and surfaces the date as a render hint so the Documents row gets `.highlight`. Invalid dates (Feb 30, etc.) hit the original 404 path.
- **MCP**: `mcp__projax__add_link` accepts an optional `event_date: "YYYY-MM-DD"`. `linkView.event_date` surfaces the stored value on responses.
- **Conflict policy**: on `(item_id, ref_type, ref_id, rel)` duplicates the upsert uses `COALESCE(new, old)` for `note` and `event_date`, so a callable that re-adds a link without a date doesn't clobber a pre-set date.
- **Cross-references**: see `docs/design.md` §"Documents / dated artifacts (Phase 3c)" for the schema delta and UI integration.
## Display & UI
- The backend stores lowercase. The frontend renders PERs in m's preferred camelCase by reading `items.title` (or a derived `display_slug` field if titles drift far from slugs).

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/m/projax/store"
)
@@ -115,18 +116,19 @@ func RegisterProjaxTools(s *Server, st *store.Store) {
})
s.Register(Tool{
Name: "add_link",
Description: "Add an external item_link to an item (caldav-list / gitea-repo / mbrian-node / url / …).",
Description: "Add an external item_link to an item (caldav-list / gitea-repo / document / note / url / …). Pass event_date=YYYY-MM-DD to anchor a dated artifact (PER day-granular).",
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["ref_type", "ref_id"],
"properties": {
"id": {"type": "string"},
"path": {"type": "string"},
"ref_type": {"type": "string"},
"ref_id": {"type": "string"},
"rel": {"type": "string", "description": "Relation, default 'contains'"},
"note": {"type": "string"},
"metadata": {"type": "object"}
"id": {"type": "string"},
"path": {"type": "string"},
"ref_type": {"type": "string"},
"ref_id": {"type": "string"},
"rel": {"type": "string", "description": "Relation, default 'contains'"},
"note": {"type": "string"},
"event_date": {"type": "string", "description": "YYYY-MM-DD; day-granular anchor for PER-cited artifacts"},
"metadata": {"type": "object"}
}
}`),
Handler: addLinkTool(st),
@@ -204,6 +206,7 @@ type linkView struct {
Note any `json:"note"`
Metadata map[string]any `json:"metadata"`
CreatedAt string `json:"created_at"`
EventDate any `json:"event_date"`
}
func toItemView(it *store.Item) itemView {
@@ -251,6 +254,9 @@ func toLinkView(l *store.ItemLink) linkView {
if l.Note != nil {
v.Note = *l.Note
}
if l.EventDate != nil {
v.EventDate = l.EventDate.UTC().Format("2006-01-02")
}
return v
}
@@ -580,13 +586,14 @@ func listLinksTool(st *store.Store) ToolHandler {
func addLinkTool(st *store.Store) ToolHandler {
type input struct {
ID string `json:"id"`
Path string `json:"path"`
RefType string `json:"ref_type"`
RefID string `json:"ref_id"`
Rel string `json:"rel"`
Note string `json:"note"`
Metadata map[string]any `json:"metadata"`
ID string `json:"id"`
Path string `json:"path"`
RefType string `json:"ref_type"`
RefID string `json:"ref_id"`
Rel string `json:"rel"`
Note string `json:"note"`
EventDate string `json:"event_date"`
Metadata map[string]any `json:"metadata"`
}
return func(ctx context.Context, raw json.RawMessage) (any, error) {
var in input
@@ -604,10 +611,20 @@ func addLinkTool(st *store.Store) ToolHandler {
if md == nil {
md = map[string]any{}
}
var notePtr *string
if in.Note != "" {
md["note"] = in.Note
n := in.Note
notePtr = &n
}
link, err := st.AddLink(ctx, it.ID, in.RefType, in.RefID, in.Rel, md)
var datePtr *time.Time
if strings.TrimSpace(in.EventDate) != "" {
t, err := time.Parse("2006-01-02", strings.TrimSpace(in.EventDate))
if err != nil {
return nil, fmt.Errorf("event_date must be YYYY-MM-DD: %w", err)
}
datePtr = &t
}
link, err := st.AddLinkDated(ctx, it.ID, in.RefType, in.RefID, in.Rel, notePtr, datePtr, md)
if err != nil {
return nil, err
}

View File

@@ -335,12 +335,16 @@ type ItemLink struct {
Note *string
Metadata map[string]any
CreatedAt time.Time
// EventDate, when non-nil, anchors the link to a calendar day — the
// backing slot for the YYMMDD segment of the PER standard. Day
// granularity by design; time-of-day is intentionally out of scope.
EventDate *time.Time
}
// LinksByType returns every item_link of the given ref_type for one item.
func (s *Store) LinksByType(ctx context.Context, itemID, refType string) ([]*ItemLink, error) {
rows, err := s.Pool.Query(ctx, `
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at, event_date
from projax.item_links
where item_id = $1 and ref_type = $2
order by created_at`, itemID, refType)
@@ -351,7 +355,7 @@ func (s *Store) LinksByType(ctx context.Context, itemID, refType string) ([]*Ite
var out []*ItemLink
for rows.Next() {
var l ItemLink
if err := rows.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt); err != nil {
if err := rows.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt, &l.EventDate); err != nil {
return nil, err
}
out = append(out, &l)
@@ -363,7 +367,7 @@ func (s *Store) LinksByType(ctx context.Context, itemID, refType string) ([]*Ite
// whole schema. Used by /admin/caldav to find already-linked calendars.
func (s *Store) LinksByRefType(ctx context.Context, refType string) ([]*ItemLink, error) {
rows, err := s.Pool.Query(ctx, `
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at, event_date
from projax.item_links
where ref_type = $1
order by created_at`, refType)
@@ -374,7 +378,7 @@ func (s *Store) LinksByRefType(ctx context.Context, refType string) ([]*ItemLink
var out []*ItemLink
for rows.Next() {
var l ItemLink
if err := rows.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt); err != nil {
if err := rows.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt, &l.EventDate); err != nil {
return nil, err
}
out = append(out, &l)
@@ -403,15 +407,73 @@ func (s *Store) AddLink(ctx context.Context, itemID, refType, refID, rel string,
return nil, fmt.Errorf("add link: %w", err)
}
row := s.Pool.QueryRow(ctx, `
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at, event_date
from projax.item_links where id = $1`, id)
var l ItemLink
if err := row.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt); err != nil {
if err := row.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt, &l.EventDate); err != nil {
return nil, err
}
return &l, nil
}
// AddLinkDated is AddLink + an event_date + an explicit note. Existing
// AddLink callers leave the date unset; the new MCP add_link tool and the
// Documents UI pass it through.
func (s *Store) AddLinkDated(ctx context.Context, itemID, refType, refID, rel string, note *string, eventDate *time.Time, metadata map[string]any) (*ItemLink, error) {
if rel == "" {
rel = "contains"
}
if metadata == nil {
metadata = map[string]any{}
}
var id string
err := s.Pool.QueryRow(ctx, `
insert into projax.item_links (item_id, ref_type, ref_id, rel, note, metadata, event_date)
values ($1, $2, $3, $4, $5, $6, $7)
on conflict (item_id, ref_type, ref_id, rel) do update
set metadata = excluded.metadata,
note = coalesce(excluded.note, projax.item_links.note),
event_date = coalesce(excluded.event_date, projax.item_links.event_date)
returning id`,
itemID, refType, refID, rel, note, metadata, eventDate,
).Scan(&id)
if err != nil {
return nil, fmt.Errorf("add link (dated): %w", err)
}
row := s.Pool.QueryRow(ctx, `
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at, event_date
from projax.item_links where id = $1`, id)
var l ItemLink
if err := row.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt, &l.EventDate); err != nil {
return nil, err
}
return &l, nil
}
// DatedLinks returns every item_link with an event_date set, ordered
// newest-first then by insertion order. Used by the detail-page Documents
// section.
func (s *Store) DatedLinks(ctx context.Context, itemID string) ([]*ItemLink, error) {
rows, err := s.Pool.Query(ctx, `
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at, event_date
from projax.item_links
where item_id = $1 and event_date is not null
order by event_date desc, created_at`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*ItemLink
for rows.Next() {
var l ItemLink
if err := rows.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt, &l.EventDate); err != nil {
return nil, err
}
out = append(out, &l)
}
return out, rows.Err()
}
// DeleteLink removes a single item_link by id.
func (s *Store) DeleteLink(ctx context.Context, id string) error {
_, err := s.Pool.Exec(ctx, `delete from projax.item_links where id = $1`, id)

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