10 Commits

Author SHA1 Message Date
mAi
173d7ddbb2 feat(views): Phase 5j slice A — paliad-shape schema redesign
Hard-replaces the 5i projax.views table per m's Q10 pick (2026-05-29):
no real data to preserve after a few hours, and the shape changes are
big enough that a clean recreate beats a 6-step ALTER.

Schema (migration 0017_views_redesign.sql):
- id (uuid), slug (text, format-CHECK'd, UNIQUE), name, icon,
  filter_json (jsonb — INCLUDES view_type per m's Q2), sort_field,
  sort_dir, group_by, sort_order, show_count, last_used_at,
  created_at, updated_at.
- DROPPED: pinned, is_default_for, view_type column. m's Q9 picked
  MRU (last_used_at) over per-page-default; Q2 placed view_type
  inside filter_json so the JSON owns the canonical render spec.
- Constraints: slug regex, sort_dir enum. NO view_type CHECK — the
  JSON-shape validator owns it now.
- Indexes: slug UNIQUE, (sort_order, name), (last_used_at DESC).
- updated_at trigger reused; projax_admin ownership preserved.

Store (store/views.go rewrite):
- View struct: Slug as the user-facing key; uuid kept on ID for the
  legacy `?view=<uuid>` 302-redirect path that lands in slice C.
- ListViews ordered by sort_order, name (matches sidebar).
- GetView(slug) + GetViewByID(uuid). MostRecentView() drives the
  /views landing redirect (slice B).
- TouchView(slug) bumps last_used_at fire-and-forget.
- ReorderViews([]slugs) wires the column for slice G's drag UI.
- CreateView server-assigns sort_order = MAX+1 inside the tx.
- UpdateView replaces every writeable field; renames are supported.
- Validation: slug format regex + reserved-list rejection +
  filter_json JSON well-formed check before round-trip.
- ErrViewNotFound / ErrViewSlugTaken / ErrViewSlugReserved /
  ErrViewSlugFormat surface to handlers as the typed error set.

Cleanup of the 5i overlay (drops what the new shape obsoletes):
- web/views.go: gutted to a stub. applySavedView, applyDefaultView,
  overlayURLFields, filterQueryToJSON, filterJSONToQuery,
  filterFromJSONPayload, anySliceToStrings + every old handler
  (handleViewsIndex, handleViewCreate, handleViewWrite, handleViewEdit,
  handleViewRedirect, handleViewDelete) deleted.
- web/server.go: dropped the /views route registrations and the
  applySavedView + applyDefaultView calls in handleTree.
  DefaultBanner data-map field removed.
- web/tree_filter.go: TreeFilter.ViewID field removed; ParseTreeFilter
  and QueryString stop reading/emitting ?view=.
- web/templates/views.tmpl and view_edit.tmpl deleted.
- web/templates/tree_section.tmpl: default-banner block deleted.
- web/views_test.go: deleted (every test was against the 5i shape).

Between slice A and slice B, /views/* URLs return 404 by design.
Slice B reintroduces the route family in paliad-shape:
  GET /views          → MRU landing
  GET /views/{slug}   → render
  GET /views/new      → editor
  GET /views/{slug}/edit → editor
  POST /views, /views/{slug}, /views/{slug}/delete → CRUD

Tests (store/views_test.go, new):
- TestViewSlugCRUD — create / get-by-slug / get-by-id / rename /
  delete round-trip, including rename-leaves-old-slug-gone.
- TestViewSlugFormatRejected — uppercase, underscore, leading dash,
  length-cap, empty all surface ErrViewSlugFormat.
- TestViewReservedSlugRejected — tree/dashboard/calendar/timeline/graph
  and friends all reject with ErrViewSlugReserved.
- TestViewSlugCollision — duplicate slug surfaces ErrViewSlugTaken.
- TestViewMRU — TouchView + MostRecentView ordering against a
  controlled pair of slugs (resilient to other suites' touched views).
- TestViewReorder — ReorderViews rewrites sort_order ascending.

Web tests stay green (the 5i overlay tests are gone, the rest don't
touch the views shape).
2026-05-29 11:41:28 +02:00
mAi
2f47b28f39 feat(views): Phase 5i slice D — saved views table + CRUD + sidebar entry
Persists named bundles of (filter + view_type + sort + group_by). Per m's
Q2 pick (2026-05-26), views are page-agnostic — `is_default_for` lets a
view become the auto-applied default for a page, otherwise views render
on whichever page accepts their view_type.

Schema (db/migrations/0016_views.sql):
- projax.views table with check constraints on view_type (5-value enum),
  sort_dir, is_default_for, and the kanban-needs-group rule.
- Case-insensitive unique name index (live rows only).
- One-default-per-page partial unique index.
- updated_at trigger; projax_admin ownership / grants.

Store (store/views.go):
- View struct + ViewInput; ListViews / GetView / CreateView / UpdateView
  / SoftDeleteView / DefaultViewFor.
- CreateView and UpdateView clear the prior default for a page in the
  same transaction when IsDefaultFor is set — defends against the
  partial unique index outside the SECURITY DEFINER path.
- Validation mirrors the DB check constraints so handlers can surface
  friendlier errors before round-tripping.

Handlers (web/views.go) + routes (web/server.go):
- GET  /views            list + create form (templates/views.tmpl).
- POST /views            create (filter_query form field is parsed into
                         canonical filter_json shape — design.md §2).
- GET  /views/<id>       redirect to the target page + ?view=<id>.
- POST /views/<id>       update.
- POST /views/<id>/delete soft delete.

Resolution path:
- handleTree now calls applySavedView when ?view=<uuid> is present;
  fields the saved filter_json + view_type back into the TreeFilter and
  the view-type slot. view_type then revalidates against the route
  catalog so a saved kanban-view URL on / lands on list with kanban
  shown locked until slice C ships it. Failures fall back gracefully
  (log + URL-derived filter), no 500.

UI:
- Sidebar gains a Views entry (4-square icon) next to Admin in
  layout.tmpl.
- /views renders a flat table + inline create form. The form accepts a
  URL-query filter string (e.g. `tag=work&mgmt=mai`) which is canonised
  into filter_json on save.

Tests:
- TestViewsCRUDRoundTrip — full create / list / open-redirect / soft-
  delete cycle via HTTP, plus filter_json shape assertion.
- TestSavedViewAppliedOnQueryParam — seed a card view scoped to dev,
  hit /?view=<id>, assert the page renders card grid + scoped chip-on.

Out of scope for slice D (per design.md §7):
- HTMX modal save UI from any page (the inline-create-on-/views/ form
  works; a modal lands in a polish pass).
- MCP read tools for views (deferred to a follow-up — m manages views
  via the UI).
2026-05-26 13:42:51 +02:00
mAi
0bea9c1ba4 feat(phase 4f): per-item timeline_exclude flag (hide noise from /timeline)
m's stated use case: home VTODOs (shopping list) shouldn't pollute the
chronological /timeline by default, but they should stay visible on the
home detail page itself. This adds an item-level switch with four kinds
and a URL override to peek at everything when wanted.

## Schema (migration 0015)
- timeline_exclude text[] NOT NULL DEFAULT '{}'
- items_timeline_exclude_idx GIN
- items_unified view rebuilt to surface the new column
- Behaviour-neutral: empty array = unchanged from today. m flips the
  toggle himself via /admin/bulk or the detail-page form.

## Aggregation
- web/timeline.go: pre-compute the per-kind keep-list via keepFor(kind)
  before fanning out — items with the kind in their exclude array are
  dropped entirely (no CalDAV call wasted on excluded sources). Doc and
  creation rows check the per-item flag inline. `?include_excluded=1`
  (URL) and `include_excluded:true` (MCP arg) override the filter.
- store.Item.ExcludesTimelineKind(kind) helper accepts either singular
  ("todo") or plural ("todos") to bridge the kind-constant / persisted-
  value naming choice — see comment for the why.

## UI
- /i/{path} grows a "Timeline behaviour" collapsible section with four
  checkboxes (todos / events / docs / creation) and helper text. Open by
  default when any kind is excluded, so m can see at a glance what's
  hidden for this item.
- /admin/bulk gains a "timeline todos" select with "Exclude from timeline"
  and "Re-include on timeline" — the other three kinds stay editable
  per-item only per the task brief (most common use case is just todos).

## MCP
- update_item accepts timeline_exclude as a partial-update field with an
  enum-restricted whitelist; unknown values dropped silently.
- itemView always emits timeline_exclude (defaults to []) so consumers
  can render the toggle state without a second round-trip.

## Tests
- Migration + GIN index landed
- Item with timeline_exclude=['todos'] hides the VTODO from /timeline
- ?include_excluded=1 brings it back
- Bulk action toggles the array idempotently in both directions
- Detail page renders all 4 checkbox affordances

## docs/design.md
§12 gains a "Per-item exclusion" subsection documenting semantics, the
URL override, the bulk action, and the "detail page still shows everything"
invariant.

## Out of scope (per task brief)
- Per-tag exclusion (per-item is clearer)
- Per-day exclusion (overkill)
- Dashboard exclusion (m only flagged timeline; dashboard's "today" view
  should still show shopping today if it's due today)
- Auto-seeding home with timeline_exclude=['todos'] (m runs once himself
  via /admin/bulk after the deploy — schema change stays behaviour-neutral)
2026-05-17 19:28:49 +02:00
mAi
f6cf050c3f feat(phase 4d): public-listing fields so projax becomes the portfolio source of truth
Adds five additive columns on projax.items and propagates them through
every read/write path. flexsiebels.de (and any future portfolio renderer)
can now pull the public set via the MCP `list_items(public=true)` filter
and stop hard-coding project lists.

## Schema (migration 0014)
- public               boolean       default false (partial index when true)
- public_description   text          default ''
- public_live_url      text          default ''
- public_source_url    text          default ''
- public_screenshots   text[]        default '{}'
- items_unified view rebuilt to include the five new columns
- items_public_idx     PARTIAL INDEX WHERE public = true (5% of rows)

## Store
- Item struct + scan/scanItems extended (5 cols)
- UpdateInput accepts the new fields with full-replace semantics
- new SetPublic(ids, bool) for bulk write
- SearchFilters gains Public *bool — nil = no filter

## MCP
- list_items: new `public` boolean filter (input schema + handler)
- update_item: 5 new partial-update fields (nil pointer = leave alone)
- itemView always emits the 5 fields (even when public=false) so consumers
  can preview "what would publish" without a second round-trip
- 2 new integration tests against the DB

## Web
- /i/{path} grows a "Public listing" fieldset: toggle + textarea + 2 URL
  inputs + screenshot list editor with add/remove rows + inline JS for
  the editor. Values persist when public is off so toggling never
  destroys typed-in content.
- /admin/bulk action bar gains "Make public" / "Make private" via a new
  select; SQL update is a single statement per action.
- /?public=1 and /?public=0 chip parameters narrow the tree page.
  Active() + QueryString() + TogglePublic() round-trip the state.
- parseScreenshotList helper trims + drops empties + preserves order
- 5 integration tests: migration landed, form round-trip, bulk action
  round-trip, detail-page affordances, tree-filter narrowing

## docs/design.md §15
Documents the schema, MCP contract, UI surfaces, flexsiebels consumption
pattern, and what's NOT in scope (flexsiebels-side render, asset hosting,
approval workflows).

## Out of scope (per task brief)
- Flexsiebels rendering — separate task in m/flexsiebels.de after this ships
- Asset hosting (projax stores URLs, never bytes — same PER discipline)
- Multi-stage publish workflow (boolean is enough)
2026-05-17 19:11:26 +02:00
mAi
0e490bb600 feat(phase 3d auto-tag): backfill area tags, bulk-edit UI, soft-delete cleanup
- migration 0012: one-shot populate empty tags from each item's area-roots
  (so chips on /?tag=work etc. actually filter the 40+ mai-backfilled rows)
- migration 0013: cleanup 12 orphan item_links + BEFORE-UPDATE trigger that
  cascades soft-delete to item_links going forward — closes the data drift
  that made TestItemsUnifiedSurfacesMaiPointer fail since 3c
- /admin/bulk page: flat filter+checkbox list with one-tx Apply for add/
  remove tag, set management, set status. Per-row inline chip add/remove
  via /admin/bulk/chip. Reuses tree_filter URL params 1:1.
- design.md §3.2 + §4.1 updated; tag+management section notes 0012
- bulk + tag-backfill + soft-delete-cascade tests cover the new surface
2026-05-15 18:49:58 +02:00
mAi
e055e4607e 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).
2026-05-15 18:35:21 +02:00
mAi
41c1eaadaa feat(phase 1.5): tags + management + DAG + mai.projects sync
Big task. Five migrations, full store + web rewrite, and a model upgrade
that turns the parent_id tree into a parent_ids[] DAG.

Schema (db/migrations)
- 0006_tags_management_unify: adds tags + management text[] (GIN-indexed),
  collapses the area/project distinction (kind keeps the slot but 'area'
  is no longer a special value), drops the structural rules from the
  path trigger so root projects + non-root projects are both legal.
- 0007_backfill_mai_projects: one-shot, idempotent — for every row in
  mai.projects without a 'mai-project' item_link, create a projax.items
  row under a heuristic-chosen area (mhealth→health, msports/manjin→
  sports, kanzlai/hlckm/work/mworkrepo/paliad or HL/* repo→work,
  mhome→home, default→dev), insert the item_link, and tag the row
  management=['mai']. Also flips management='mai' on any already-linked
  pre-Phase-1.5 promotions.
- 0008_mai_projects_sync: bidirectional triggers. sync_to_mai runs as
  projax_admin and writes mai.projects directly (after the operator-run
  grant + RLS policy widening — documented in the migration header).
  sync_from_mai is SECURITY DEFINER so writes by the mai role fan out
  into projax.items. pg_trigger_depth() + projax.in_sync GUC keep the
  cycle suppressed. Slug stays the join key for new rows; the
  item_link pointer survives renames.
- 0009_items_unified_simplify: view collapses to a thin projection over
  projax.items now that mai.projects is a derived projection.
- 0010_multi_parent: parent_id → parent_ids uuid[], path → paths text[].
  compute_item_paths walks via parents' precomputed paths (no recursive
  CTE in the hot path; cycle detection uses one). New triggers:
  items_check_slug_collision (multi-parent uniqueness),
  items_after_delete (manual cascade since arrays don't carry FK).
  Trigger refresh_item_paths_recursive does parent-first DFS over
  descendants, guarded by projax.refreshing_paths GUC.

Go store + handlers
- Item gains ParentIDs []string + Paths []string. PrimaryPath /
  OtherPaths helpers feed the detail breadcrumb. Source always
  'projax' now; SourceRefDeref still surfaces the mai-id pointer.
- Update / Reparent / Create take ParentIDs []string. AddParent helper
  for the multi-parent UI's "also list under" action.
- GetByPath uses '$1 = any(paths)' so /i/work.paliad and /i/dev.paliad
  resolve to the same row.
- buildForest renders a multi-parent item under each of its parents
  (duplicated nodes in distinct branches). Tag-filter prune is
  branch-preserving.

Templates
- detail.tmpl: multi-select parents, tags + management chip inputs,
  "Also at: …" breadcrumb for multi-parent items.
- new.tmpl: same multi-select + chip inputs.
- tree.tmpl: tag-filter chip bar, "×N" badge on multi-parent rows,
  management chips visible on every row.
- classify.tmpl: re-parent workflow (no more promote-to-projax — the
  bidirectional sync removed the dichotomy).

Tests (DB + HTTP, all skip without env)
- TestMultiParentResolvesBothPaths   inserts an item with two parents,
                                     asserts both inherited paths.
- TestSlugCollisionUnderCommonParent  refuses a sibling clash.
- TestMultiParentBothPathsRouteToSameRow  HTTP-level: /i/dev.X and
                                          /i/work.X both 200, same row.
- TestReparentRoundTrip rewritten for parent_ids[] semantics.
- TestPathTriggerNestAndRename / Reparent rewritten to query paths[].

Docs (docs/design.md)
- §2 rewritten: items in a DAG, no area/project distinction.
- §3 schema: parent_ids + paths + tags + management + indices.
- §3.1 path-trigger overhaul incl. cycle detection via recursive CTE
  and slug-collision-under-common-parent guard.
- §3.2 view simplified.
- §3.4 NEW: mai.projects bidirectional sync, including the manual
  prereq.
- §4.1 + §4.2: classify becomes re-parent, tags+management UI section.

mai head start / mai hire / mai status / mai instruct keep working
because mai.projects retains its FK-target shape; the projax sync just
mirrors the row in lock-step.
2026-05-15 16:33:52 +02:00
mAi
092a56cf24 feat(db): pivot to dedicated projax_admin role
Mirrors the mbrian_admin pattern: the binary connects as a role bounded
to the projax schema, so even a compromised projax process cannot read
mai.workers, otto.*, vault.*, etc.

- 0001: switch grants block from postgres → projax_admin (conditional
  on the role existing — bootstrap still works as superuser before the
  role is created). Wrap `create schema` in a guard so the migration
  is idempotent when re-run as a non-superuser app role that lacks
  database-level CREATE.
- 0005_reown_to_projax_admin.sql: enumerate every projax-namespaced
  object via pg_namespace + pg_class / pg_proc and ALTER OWNER to
  projax_admin. Explicitly scoped — no global REASSIGN OWNED that
  would yank ownership from other projects sharing the postgres role.
  Strips residual postgres grants. No-ops with a NOTICE when the role
  is missing.
- README: new "Manual prerequisite" deploy section. Documents the
  CREATE ROLE statement, the cross-schema USAGE + SELECT grants, AND
  the RLS policy `projax_read ON mai.projects` that's required because
  mai.projects has row-level security with policies scoped to `mai`
  and `anon` only. Without the policy, items_unified silently returns
  zero mai-source rows.
- deploy/dokploy.yaml: DSN comment now reflects projax_admin and
  points at the README prereq.

Verified locally against msupabase with a throwaway projax_admin role:
- 13/13 tests green
- mai.workers SELECT → permission denied
- mai.sessions SELECT → permission denied
- mai.projects SELECT → 59 rows (RLS policy in effect)
- projax.items_unified SELECT → 66 rows (7 projax + 59 mai)
2026-05-15 13:32:56 +02:00
mAi
c0466ade36 feat(db): items_unified adapter view + promotion hiding
projax.items_unified joins projax.items (deleted_at IS NULL) with
mai.projects so a single query feeds the tree UI. mai.projects.id is a
text key, so a deterministic placeholder UUID is derived from md5(p.id);
projax-native rows keep their gen_random_uuid().

When a projax item is created with an item_links row pointing back to a
mai.projects id (ref_type='mai-project'), the corresponding mai.projects
row drops out of the view — that's how the "Promote to projax" flow
makes the duplicate disappear without ever touching mai.projects.

Test coverage:
- both sources appear in the view
- promotion link hides the mai source row and surfaces the projax row
2026-05-15 13:17:51 +02:00
mAi
b8d3418876 feat(db): projax schema, path trigger, seed areas
- 0001_init.sql: projax.items + projax.item_links tables with indices,
  partial-unique root slug, updated_at trigger, schema grants to the
  application role.
- 0002_path_trigger.sql: BEFORE-write trigger maintains items.path via
  recursive parent walk; rejects cycles and structural-rule violations
  (areas at root, projects not at root). AFTER trigger rewrites
  descendant paths on slug rename or re-parent.
- 0003_seed_areas.sql: dev, sports, home, work, health, finances, social.
- db/migrate.go: embed.FS-backed sequential runner.
- db/migrate_test.go: integration suite covering idempotency, nest,
  rename propagation, re-parent propagation, cycle rejection, and
  structural rules. Skips when no DB env var is set.

Also ignores .m/events.log and .m/locks (per-worker scratch).
2026-05-15 13:16:24 +02:00