caldav package:
- Event struct: UID, Summary, Start, End, AllDay, Location, Description,
Recurring, URL — read-only, no writeback
- ListEvents(ctx, calendarURL, ListEventsOpts{TimeMin, TimeMax}) issues
REPORT calendar-query with server-side <c:time-range> filter
- parseVEvents handles DATE vs DATE-TIME (via hasDateOnlyParam since
splitLine strips ;VALUE=DATE), RRULE-present → Recurring=true with NO
expansion (literal DTSTART only)
- 2 unit tests: full parse (DATE-TIME, all-day, recurring), hasDateOnlyParam
web dashboard:
- dashboardEvent / dashboardEventGroup types
- collectEvents fans out 4-worker pool across every caldav-list link,
fixed 7-day window from now, sort start-asc, cap 50, group by day
- dayLabelFor: Today / Tomorrow / weekday-day-month
- Events card on /dashboard between Tasks and Issues, with empty-collapse
- 2 integration tests with stubbed CalDAV: surfaces upcoming + DATE/RRULE
rendering; empty-collapse with no links
design.md §5 (CalDAV) + §Dashboard updated; mgmt-teardown plan's one
blocking gap is now closed.
503 lines
45 KiB
Markdown
503 lines
45 KiB
Markdown
# projax — PRD
|
||
|
||
**Status:** v1 draft, 2026-05-15
|
||
**Authors:** m, head (dialogue)
|
||
**Scope:** Phase-1 build sufficient to live with the system; phases 2–3 listed but deferred.
|
||
|
||
## 1. Purpose
|
||
|
||
projax is m's personal data backbone for self-management — areas of life, projects within them, and aggregated views over tasks that live elsewhere. It subsumes (over time) the scattered state currently held in `mai.projects`, CalDAV task lists, Gitea issues, and mBrian topic hubs. No interface is canonical; each is a view.
|
||
|
||
**Meta-requirement: flexibility.** m's self-model evolves. Identity is by UUID; everything human-readable is renameable. The data model leans on jsonb + array-typed kinds so future re-categorization doesn't require a migration.
|
||
|
||
## 2. Model
|
||
|
||
### 2.1 Items in a DAG
|
||
|
||
Phase 1.5 collapsed the area/project structural distinction. Every node is an `item`; the `kind` array is kept for forward-compatibility (future: milestone, event, person, …) but `area` is no longer a special value. The seven seeded roots are just items with `parent_ids = '{}'`.
|
||
|
||
The hierarchy is a **directed acyclic graph**, not a tree: each item has zero or more parents, and the same item can surface under multiple branches. `work.paliad` and `dev.paliad` resolve to the same row. PER citations can use any valid path.
|
||
|
||
- **Item** — a node in the DAG. Examples: `dev`, `home.spring-clean`, `work.paliad`, `paliad.note`. The `kind` column carries `['project']` today; we may layer other types as needs arise.
|
||
- **Task** — atomic work item. **Lives outside projax** (CalDAV todos, Gitea issues, `mai.tasks`). projax references and aggregates them; it does not own them.
|
||
|
||
Structural rules:
|
||
|
||
- No cycles (enforced by `items_before_write` + `compute_item_paths` recursive-CTE ancestor closure).
|
||
- An item's slug must be unique among its siblings under any common parent (enforced by `items_check_slug_collision` BEFORE trigger, with the partial unique index `items_root_slug_uniq` covering the root case).
|
||
- Soft delete via `deleted_at`. Hard delete cascades through `items_after_delete`, which scrubs the deleted id from every descendant's `parent_ids` array.
|
||
|
||
### 2.2 Identity & naming
|
||
|
||
- `id uuid` — canonical, immutable.
|
||
- `slug text` — local-only segment (no dots). Renameable freely. Examples: `prjx`, `spring-clean`. Multi-word leaves use kebab-case.
|
||
- `parent_ids uuid[]` — zero or more parent ids. Root items have `parent_ids = '{}'`.
|
||
- `paths text[]` — full dot-joined paths, one per ancestor lineage. Trigger-maintained from `parent_ids` + `slug`. Lookup via `'<path>' = ANY(paths)`.
|
||
- Slug convention: lowercase, vowel-elided where natural (`prjx`, `mai`, `mbrn`), kebab-allowed for multi-word leaves (`spring-clean`).
|
||
- Aliases: `aliases text[]` keeps old slugs searchable after rename.
|
||
- `tags text[]` — free-vocabulary cross-cutting labels (`work`, `dev`, `home`, `tech`, …). GIN-indexed. No fixed vocabulary.
|
||
- `management text[]` — how the project is run: `self`, `mai`, `external`. An item can carry multiple modes. Empty array means "no specific management mode declared".
|
||
|
||
### 2.3 Lifecycle (thin)
|
||
|
||
```
|
||
active → done → archived
|
||
```
|
||
|
||
That's it. Free-text in `content_md` covers the nuance ("waiting on Brian," "paused until June"). No rich state machine; m flagged richer schemes as rot-prone.
|
||
|
||
### 2.4 Relationships
|
||
|
||
- **Tree-as-DAG** (parent/child within projax): `items.parent_ids uuid[]`. Root items have `parent_ids = '{}'`. Any item may name multiple parents — the same row then appears under each branch with paths inherited from each lineage.
|
||
- **External refs** (`projax.item_links`): each row links an `item_id` to a typed external resource — `caldav-todo`, `gitea-issue`, `github-repo`, `mai-task`, `mai-project`, `mbrian-node`, `url`, etc. Used both for aggregating tasks and for soft cross-references.
|
||
|
||
## 3. Schema (Postgres, msupabase, schema `projax`)
|
||
|
||
```sql
|
||
create schema if not exists projax;
|
||
|
||
create table projax.items (
|
||
id uuid primary key default gen_random_uuid(),
|
||
kind text[] not null default '{}', -- ['area'] or ['project'] (multi-tag allowed for future)
|
||
title text not null,
|
||
slug text not null, -- local segment, no dots
|
||
path text not null, -- computed, e.g. 'home.spring-clean'
|
||
parent_id uuid references projax.items(id) on delete restrict,
|
||
content_md text default '',
|
||
aliases text[] not null default '{}',
|
||
metadata jsonb not null default '{}'::jsonb,
|
||
status text not null default 'active', -- active | done | archived
|
||
pinned boolean not null default false,
|
||
archived boolean not null default false,
|
||
start_time timestamptz,
|
||
end_time timestamptz,
|
||
parent_ids uuid[] not null default '{}',
|
||
paths text[] not null default '{}',
|
||
tags text[] not null default '{}',
|
||
management text[] not null default '{}',
|
||
created_at timestamptz not null default now(),
|
||
updated_at timestamptz not null default now(),
|
||
deleted_at timestamptz
|
||
);
|
||
|
||
create index items_paths_idx on projax.items using gin (paths);
|
||
create index items_parent_ids_idx on projax.items using gin (parent_ids);
|
||
create index items_kind_idx on projax.items using gin (kind);
|
||
create index items_tags_idx on projax.items using gin (tags);
|
||
create index items_management_idx on projax.items using gin (management);
|
||
create unique index items_root_slug_uniq
|
||
on projax.items (slug) where cardinality(parent_ids) = 0;
|
||
|
||
create table projax.item_links (
|
||
id uuid primary key default gen_random_uuid(),
|
||
item_id uuid not null references projax.items(id) on delete cascade,
|
||
ref_type text not null, -- 'caldav-todo' | 'gitea-issue' | 'github-repo' | 'mai-task' | 'mai-project' | 'mbrian-node' | 'url' | ...
|
||
ref_id text not null, -- opaque external identifier
|
||
rel text not null default 'contains', -- 'contains' | 'related' | 'blocked-by' | 'derived-from'
|
||
note text,
|
||
metadata jsonb not null default '{}'::jsonb,
|
||
created_at timestamptz not null default now(),
|
||
unique (item_id, ref_type, ref_id, rel)
|
||
);
|
||
|
||
create index item_links_item_idx on projax.item_links (item_id);
|
||
create index item_links_ref_idx on projax.item_links (ref_type, ref_id);
|
||
```
|
||
|
||
### 3.1 Path triggers (multi-parent)
|
||
|
||
`paths` is a `text[]` maintained by `compute_item_paths(parent_ids, slug, self_id)`:
|
||
|
||
- For root items (`parent_ids = '{}'`), `paths = [slug]`.
|
||
- Otherwise, look up every parent's `paths`, append `.<slug>` to each, dedupe and sort. The recursion is implicit — parents' paths are kept up to date by the same trigger, so children just consume the precomputed prefixes.
|
||
- Cycle detection: the function rejects when `self_id` appears anywhere in the recursive ancestor closure (`WITH RECURSIVE closure ...`). Plus a defensive direct-self-parent check.
|
||
|
||
Two BEFORE triggers and two AFTER triggers cooperate:
|
||
|
||
- `items_before_write` (BEFORE INSERT/UPDATE) — cycle guard + `new.paths := compute_item_paths(...)`.
|
||
- `items_check_slug_collision` (BEFORE INSERT/UPDATE) — for each parent in `new.parent_ids`, refuse if another row already uses `new.slug` under that parent.
|
||
- `items_after_reparent` (AFTER UPDATE of slug/parent_ids) — DFS over descendants via `refresh_item_paths_recursive`, parent-first ordering. A session GUC `projax.refreshing_paths` short-circuits the inner UPDATEs so the cascade fires exactly once.
|
||
- `items_after_delete` (AFTER DELETE) — scrubs the deleted id from every other row's `parent_ids` array (we have no FK integrity on array elements; this is the manual cascade).
|
||
|
||
### 3.2 The `items_unified` view
|
||
|
||
After Phase 1.5 mai.projects is a derived projection (see §3.4), so the view collapses to a thin projection over `projax.items`:
|
||
|
||
```sql
|
||
create view projax.items_unified as
|
||
select
|
||
i.id, i.kind, i.title, i.slug, i.paths, i.parent_ids, i.content_md,
|
||
i.aliases, i.metadata, i.status, i.pinned, i.archived,
|
||
i.start_time, i.end_time,
|
||
'projax'::text as source,
|
||
(select l.ref_id from projax.item_links l
|
||
where l.item_id = i.id and l.ref_type = 'mai-project' limit 1) as source_ref_id,
|
||
i.tags, i.management, i.created_at, i.updated_at
|
||
from projax.items i
|
||
where i.deleted_at is null;
|
||
```
|
||
|
||
`source` is always `'projax'` (kept for forward compat); `source_ref_id` surfaces the `mai-project` pointer when one exists so the UI can show "mai id: foo".
|
||
|
||
**Soft-delete tightening (migration 0013, Phase 3d).** Every `item_links` row is implicitly tied to its parent item's life: on soft-delete (`projax.items.deleted_at` flips `NULL → not-null`) a BEFORE-UPDATE trigger cascades a `DELETE FROM projax.item_links WHERE item_id = NEW.id` in the same statement. The migration also one-shot-cleans the ~12 orphan `mai-project` rows that predate this trigger. Result: `count(item_links WHERE ref_type=X)` and `count(items_unified WHERE source_ref_id IS NOT NULL)` stay in lock-step — `TestItemsUnifiedSurfacesMaiPointer` regression-guards this.
|
||
|
||
### 3.3 Classification overlay
|
||
|
||
Items can land at root in two ways:
|
||
|
||
- The backfill in migration 0007 heuristic-assigned every existing mai.projects row to one of the seven seeded areas (`dev`, `sports`, `work`, `home`, `health`, `finances`, `social`). None ended up at root in this pass.
|
||
- The reverse sync trigger (§3.4) drops every NEW mai.projects row at root with `management = ['mai']`, leaving m to classify it via `/admin/classify`.
|
||
|
||
`/admin/classify` surfaces items where `cardinality(parent_ids) = 0 AND 'mai' = ANY(management)`. The inline form posts to `/i/{path}/reparent` to move the item under a chosen parent without touching its other fields.
|
||
|
||
### 3.4 mai.projects bidirectional sync (Phase 1.5)
|
||
|
||
`mai.workers`, `mai.tasks`, `mai.sessions`, `mai.messages`, `mai.metrics` and `mai.pwa_head_pins` hold FKs into `mai.projects(id)`, so the table cannot be replaced by a view. Instead it becomes a **derived projection** kept in sync by triggers:
|
||
|
||
- **Forward** (`projax.sync_to_mai`, AFTER INSERT/UPDATE/DELETE on `projax.items`) — when an item has `'mai' = ANY(management)`, upsert/update/delete the matching `mai.projects` row. Slug stays the join key (`mai.projects.id = projax.items.slug` at creation), but FK targets cannot be renamed, so projax slug and mai id may drift after a rename; the cross-system pointer in `item_links(ref_type='mai-project')` remains stable.
|
||
- **Reverse** (`projax.sync_from_mai`, AFTER INSERT/UPDATE/DELETE on `mai.projects`, `SECURITY DEFINER` so `mai` role writes can fan out into `projax.items` which projax_admin owns) — mirror the change into a `projax.items` row, dropping new rows at root with `management = ['mai']` so `/admin/classify` can pick them up.
|
||
- **Cycle prevention** — both functions short-circuit when `pg_trigger_depth() > 1` (the natural recursion case) and additionally honour a `projax.in_sync` session GUC as belt-and-braces.
|
||
|
||
The mai.projects → projax.items reverse trigger requires manual prereqs on msupabase:
|
||
|
||
```sql
|
||
GRANT INSERT, UPDATE, DELETE, TRIGGER ON mai.projects TO projax_admin;
|
||
DROP POLICY IF EXISTS projax_write ON mai.projects;
|
||
CREATE POLICY projax_write ON mai.projects FOR ALL TO projax_admin
|
||
USING (true) WITH CHECK (true);
|
||
```
|
||
|
||
(documented in 0008's header and the README).
|
||
|
||
## 4. Interfaces
|
||
|
||
### 4.1 Phase 1 + 1.5 — Web frontend (this build)
|
||
|
||
**Web frontend at `https://projax.msbls.de`**, single binary, served by the same Go process that talks to msupabase.
|
||
|
||
Pages:
|
||
|
||
1. **Tree view** (`/`) — DAG rendering: every item appears under each of its parents. Status / management / tag chips per row. Filter bar at the top combines a debounced search input with chip rows for tags, management, status, and has-link (caldav / gitea), plus a "show archived" toggle. Filters compose: AND across dimensions, OR within (except tags, which AND). Each chip shows the count it would yield if toggled (so `work (12)` means flipping that chip on lands you at 12 matching items). Status defaults to `active` only; archived rows hide until either the `archived` status chip is selected AND the show-archived toggle is on. The URL is the source of truth — every filter is in the query string (`?q=…&tag=…&mgmt=…&status=…&has=…&show-archived=1`), so any view is bookmarkable. HTMX swaps the tree-section in place on every chip click and on `keyup` of the search input (200ms debounce); `hx-push-url` keeps the browser URL in sync. `×N` badge on multi-parent items shows how many paths they live at.
|
||
2. **Item detail** (`/i/{path}`) — `{path}` matches any entry in `paths`; both `work.paliad` and `dev.paliad` resolve to the same row. The page shows the primary path plus an "Also at: …" breadcrumb for the others. Edit form supports title, slug, multi-select parents, status, tags, management, pinned/archived, content. Save POSTs to `/i/{path}`.
|
||
3. **New item** (`/new?parent={path}`) — same form shape; the `parent` query pre-selects one parent option, m can pick more.
|
||
4. **Classify** (`/admin/classify`) — surfaces items at root with `'mai' = ANY(management)`. Inline HTMX form sets the first parent. POSTs to `/i/{path}/reparent`.
|
||
5. **Bulk edit** (`/admin/bulk`, Phase 3d) — desktop-only multi-row editor. Top: a filter form that reuses the same query params as the tree page (`q`, `tag`, `mgmt`, `status`, `has`, `show-archived`) so URLs translate 1:1 between tree and bulk views. Below: a flat checkbox list of every matching row (slug, primary path, tags, mgmt, status). An action bar at the top supports four operations: add tag, remove tag, set management (mai/self/external/clear), set status (active/done/archived). One POST to `/admin/bulk/apply` runs every change inside a single transaction (rollback-on-error). Inline per-row chip edits use `POST /admin/bulk/chip` for one-off add/remove without ticking a checkbox; only the affected cell re-renders.
|
||
6. **Auth** — projax's own `/login` (mBrian pattern). Same Supabase backend, per-host cookies (no `Domain` attribute).
|
||
|
||
### 4.2 Tags + management
|
||
|
||
- **Tags** (`projax.items.tags text[]`, GIN-indexed) — free vocabulary, no fixed list. Cross-cutting labels for "work-y dev things" (`['work', 'dev']`), "health priorities" (`['health']`), etc. Filter chips at `/` reveal which tag-flavoured slices exist.
|
||
- **Management** (`projax.items.management text[]`, GIN-indexed) — declarative mode flags:
|
||
- `mai` — bidirectional sync with `mai.projects`. Adding/removing `mai` toggles the mai.projects mirror on/off (with FK safety: removal fails if workers/tasks still reference the project).
|
||
- `self` — m runs this manually; otto does not orchestrate.
|
||
- `external` — owned by a third party; projax mirrors metadata only.
|
||
|
||
Mai.projects backfilled rows arrive with `management = ['mai']`. m can layer `self` on top without dropping mai sync.
|
||
|
||
**Area-tag backfill (migration 0012, Phase 3d).** Backfilled mai-managed items landed with `tags = '{}'`, so the tree-page tag filter chips had no signal to filter on. Migration 0012 one-shot-populates `tags` with the slug of each area an item lives under (so an item under `work.flexsiebels` picks up `tag=work`; a multi-parent item under `work.paliad` AND `dev.paliad` picks up `['dev', 'work']`). The migration only touches rows where `tags = '{}'`; once m has edited an item's tags it is left alone. Going-forward bulk recovery uses `/admin/bulk` instead of repeating the migration.
|
||
|
||
### 4.2 Phase 2 — task aggregation
|
||
|
||
- **CalDAV ingest** — read-only mirror of m's CalDAV todo lists into `item_links` with `ref_type=caldav-todo`. Per-area mapping (e.g. `home` aggregates from CalDAV list "Home"). Background sync, no writeback initially.
|
||
- **Gitea ingest** — read-only mirror of issues on linked repos. `mai.projects.repo` field is a hint; per-item override possible.
|
||
|
||
### 4.3 Phase 3 — visualization & integrations
|
||
|
||
- **Excalidraw view** — visual roadmap, dependencies, area-overview boards. Generated from items_unified.
|
||
- **MCP** — `mcp__projax__*` so otto and other workers can read/write projax. Pattern follows mcp__mai__.
|
||
- **Otto-PWA integration** — read-mostly surface for m's day-to-day. Defer until projax has lived long enough to know what otto actually needs.
|
||
|
||
## 5. Tech stack
|
||
|
||
- **Backend**: Go single binary. `pgx` for Postgres. HTMX-driven HTML rendered server-side (Go `html/template`). No frontend build step. Static assets bundled with `embed`. Matches m's dotfile-stated preferences.
|
||
- **Database**: msupabase, schema `projax` (new). View `projax.items_unified` reads across `projax.*` + `mai.projects`. RLS off for v1 (single-user).
|
||
- **Hosting**: Dokploy on mlake, domain `projax.msbls.de`. Tailscale-only network (no public exposure).
|
||
- **Repo**: `m/projax` (already exists). Branch strategy per project CLAUDE.md (main + short-lived feat/fix branches, no dev branch initially).
|
||
|
||
Alternative considered: SvelteKit + Bun (matches flexsiebels). Rejected for v1 — CRUD admin scale doesn't justify the build chain.
|
||
|
||
## 6. Migration plan
|
||
|
||
Three phases, smallest viable each:
|
||
|
||
**1a — Schema + seed**: create `projax.items`, `projax.item_links`, path trigger. Seed the seven day-one areas (`dev`, `sports`, `home`, `work`, `health`, `finances`, `social`) as `kind=['area']`, `parent_id=null`.
|
||
|
||
**1b — Adapter view**: deploy `items_unified`. All 28 mai.projects rows now visible in the tree as top-level orphans.
|
||
|
||
**1c — Classification UI**: the `/admin/classify` page so m can drag mai.projects rows under areas. Drag = create a projax-native item with `kind=['project']` + `parent_id` set + `item_links` row pointing at the mai.projects row. mai.projects untouched; the projax row owns area assignment + projax metadata.
|
||
|
||
After 1c, m can use the system. Test rows in mai.projects either stay as orphans (ignored) or get a `source-filter` to hide them.
|
||
|
||
## 7. Out of scope
|
||
|
||
- Multi-user (single-tenant, m only)
|
||
- Mobile-first responsive (desktop browser is enough)
|
||
- Public exposure (Tailscale only)
|
||
- Generic SaaS instincts (admin panels, billing, audit logs)
|
||
- CLI surface (m has explicitly opted out)
|
||
- Bidirectional Gitea sync in v1 (read-only mirror first; CalDAV is full read/write as of phase 2.b)
|
||
- Real-time collaboration features
|
||
|
||
## 5. CalDAV integration (Phase 2, v1: full read/write)
|
||
|
||
m's CalDAV server lives at `dav.msbls.de/dav/calendars/m/` (SabreDAV, Basic auth via `DAV_USER`/`DAV_PASSWORD`). projax v1 wires the slice m exercises day-to-day:
|
||
|
||
- **Link model**: a `projax.item_links` row with `ref_type='caldav-list'`, `ref_id=<absolute calendar URL>`, `metadata={display_name, calendar_color, linked_at, …}`. Same item_links row pattern as `mai-project` / `gitea-repo`. An item can be linked to multiple calendars; a calendar can be linked to multiple items (rare in practice).
|
||
- **Discovery** (`GET /admin/caldav`): the binary PROPFINDs Depth: 1 against the base URL, filters out non-calendar collections (`inbox`/`outbox`), and pairs each discovered calendar with the projax item whose lowercased title or slug matches the calendar's display name. m confirms or overrides each suggestion.
|
||
- **Linking** (`POST /admin/caldav/link` / `/admin/caldav/unlink`): single-row CRUD on item_links. No background sync.
|
||
- **Task aggregation** (item detail page): for each linked calendar, the binary REPORTs `calendar-query` for VTODOs and renders open + recent-completed tasks. Each row carries its server ETag and raw ICS so the writeback affordances below can do optimistic-concurrency PUTs. Errors per-calendar are logged and skipped — one bad list does not blank the section.
|
||
- **Create-on-demand list** (`POST /i/{path}/caldav/create`): MKCALENDAR at `<base>/<item.slug>/` with display name `<item.title>`. If the URL is already in use (SabreDAV returns 405), the binary links to the existing calendar instead.
|
||
- **Writeback affordances on the detail page (phase 2.b)**: each VTODO row exposes complete (checkbox → `STATUS:COMPLETED` + `COMPLETED:<UTC>`), reopen (`STATUS:NEEDS-ACTION`, COMPLETED cleared), inline edit of SUMMARY + DUE, and hard-delete via `×` with an `hx-confirm` dialog. An "Add task" form at the top of each linked calendar POSTs a fresh VTODO (UID is a server-generated RFC 4122 v4). All five actions are HTMX-driven (`hx-post` + `hx-target="#tasks-section"` + `hx-swap="outerHTML"`): the handler re-renders the tasks fragment so the swap reflects the post-write server state.
|
||
- **Optimistic concurrency**: every edit/complete/reopen/delete request carries an `If-Match: <ETag>` header. The handler first re-`ListTodos`'es the calendar (small calendars → cheap; ETags from the page render may have drifted) and uses the live ETag, so ordinary use never trips 412. When the server still returns 412 — e.g. another client edited between refetch and PUT — the section re-renders with a banner: "Task changed elsewhere since this page was loaded — refresh and retry." The cached ETag table envisioned in Phase 2.c remains parked until live REPORT-querying gets slow.
|
||
- **ICS round-trip**: writes that modify an existing task call `ApplyVTodoEdit` against the server's raw ICS so unknown properties (DESCRIPTION, CATEGORIES, X-extensions, …) survive the round-trip. Only the keys projax knows about (SUMMARY, STATUS, COMPLETED, DUE, PRIORITY, LAST-MODIFIED, DTSTAMP) get rewritten. New tasks go through `BuildVTodoICS` which emits a minimal but valid VCALENDAR wrapper with RFC 5545 folding at 75 octets and CRLF terminators.
|
||
- **Multi-parent items** keep ONE list per item — the URL is derived from the slug, not the path. `paliad` gets `/dav/calendars/m/paliad/` whether it lives at `work.paliad`, `dev.paliad`, or both.
|
||
- **Authorisation**: writeback handlers reject calendar URLs not currently linked to the item, so a crafted form can't route writes to arbitrary collections.
|
||
- **VEVENT reading (Phase 3l)** — read-only event listing parallel to VTODO support, closing the mgmt-parity gap before teardown. `caldav.ListEvents(calendarURL, ListEventsOpts{TimeMin, TimeMax})` issues a REPORT calendar-query with a server-side `<c:time-range>` filter and parses VEVENT blocks into an `Event{UID, Summary, Start, End, AllDay, Location, Description, Recurring, URL}` struct. DATE-only DTSTART values are detected at the raw-line level (the param strip in `splitLine` would otherwise lose `VALUE=DATE`); `hasDateOnlyParam` flips `AllDay=true`. RRULE-bearing events surface with `Recurring=true` and only the literal DTSTART instance — projax does NOT expand RRULE at v1; m clicks through to his calendar app for the recurring picture.
|
||
- **Out of scope (still parked)**: RRULE expansion, VEVENT writeback (create/edit/delete events from projax — calendar app handles), iCal export of projax-managed events, recurring VTODOs, background sync, multi-calendar drag-and-drop. Phase 2.c may add a TTL'd `cached_tasks` table if live REPORT-querying gets slow at m's scale.
|
||
|
||
Env contract: `DAV_URL` (default `https://dav.msbls.de/dav/calendars/m/`), `DAV_USER`, `DAV_PASSWORD`. All three live in Dokploy secrets; missing → `/admin/caldav` renders a "not configured" notice and the detail page hides the Tasks section.
|
||
|
||
## 6. Gitea integration (Phase 2.d read; 3h writeback)
|
||
|
||
m's Gitea instance lives at `mgit.msbls.de` (token auth, automation account `mAi`). Phase 2.d landed read-only; Phase 3h extended it to read + write for the four most common operations:
|
||
|
||
- **Link model**: a `projax.item_links` row with `ref_type='gitea-repo'`, `ref_id='<owner>/<repo>'` (e.g. `m/projax`, `mAi/paliad`, `HL/mWorkRepo`). The Phase 1.5 backfill already populated this row for every `mai.projects` with a `repo` field. An item can carry multiple `gitea-repo` links — projax sums them on the detail page.
|
||
- **Issues section** (item detail page, rendered when at least one `gitea-repo` link exists): per-repo block with open issues (`#N · title · labels · milestone · assignees · updated <rel>`), a `↗ Gitea repo` link in the header, and a disclosure for the last-30-days closed issues (up to 20). Title and number link out to `htmlURL` on Gitea (`target="_blank"`). Failed fetches (404, network) surface as a per-repo banner so one missing repo doesn't blank the section.
|
||
- **Listing**: `GET /api/v1/repos/{owner}/{repo}/issues?state=open&type=issues&limit=50` for the open list; same shape with `state=closed&since=<-30d>&limit=20` for the recent-closed disclosure. `type=issues` filters PRs out server-side on Gitea ≥1.20; the client also drops any `pull_request != null` rawIssue as belt-and-braces.
|
||
- **Caching**: per-process, in-memory TTL cache (~3 min) keyed by `owner/repo|state` so rendering the same detail page back-to-back does not hammer Gitea. No DB cache table at v1; a `projax.cached_issues` would land in 2.f if perf bites.
|
||
- **Auth**: `Authorization: token <GITEA_TOKEN>`. The token is the **mAi** automation account (`GITEA_TOKEN_AI` in `.env.age`) — keeps projax's reads attributed to mAi for audit purposes, same as how every other automated worker talks to Gitea. Missing token + non-empty URL → fail-fast at boot.
|
||
- **Writeback (Phase 3h)** — four operations on the Issues section + dashboard Issues card:
|
||
- **Close** an open issue (`PATCH /repos/{o}/{r}/issues/{n}` with `{"state":"closed"}`) — single click, no confirm modal (cheap to reopen).
|
||
- **Reopen** a closed issue (same endpoint with `{"state":"open"}`).
|
||
- **Comment** on an issue (`POST /repos/{o}/{r}/issues/{n}/comments` with `{"body":...}`).
|
||
- **Create** a new issue under a linked repo (`POST /repos/{o}/{r}/issues` with `{"title":..., "body":...}`).
|
||
- **Authorisation**: writeback handlers reject any `repo` form value that isn't linked to the item via a `gitea-repo` item_link. Prevents form-crafted writes against arbitrary repos.
|
||
- **Token permission**: the mAi token (`GITEA_TOKEN_AI`) needs write scope on m's repos. A 401/403 surfaces as `gitea.ErrForbidden` and renders an inline "Gitea token lacks write access" banner so the page never breaks.
|
||
- **Cache busting**: every successful writeback invalidates both the Gitea per-repo cache entries (`{repo}|open` + `{repo}|closed-recent`) and the dashboard 60s TTL (all keys) so the next render reflects the upstream change.
|
||
- **Parked further**: PR creation, label edit (folded in only if cheap), issue title/body edit, comment edit/delete, webhook live updates, cross-repo bulk ops, issue templates.
|
||
|
||
Env contract: `GITEA_URL` (e.g. `https://mgit.msbls.de`, no `/api/v1` suffix), `GITEA_TOKEN`. Both live in Dokploy secrets; `GITEA_URL` unset → integration off cleanly (Issues section just doesn't render). `GITEA_URL` set but `GITEA_TOKEN` missing → refuse to start.
|
||
|
||
## 7. MCP surface (Phase 3a)
|
||
|
||
projax exposes its data + writes through an MCP server mounted on the same binary at `/mcp/rpc`. Mirrors the conventions of `mcp__mai__*` and `mcp__mai-memory__*` — one tool per coherent operation, snake_case names, structured JSON results carried inside the standard MCP `content[].text` envelope.
|
||
|
||
### Tools
|
||
|
||
| name | summary | key inputs |
|
||
|-------------------|---------|------------|
|
||
| `list_items` | List items with filters | `parent_path`, `tags[]`, `management[]`, `kind[]`, `status`, `q`, `has_repo`, `has_caldav`, `limit` |
|
||
| `get_item` | Fetch one item by id or path | `id` xor `path`, `include_links` (default true) |
|
||
| `create_item` | Create a new item | `slug`, `title`, `parent_paths[]`, `kind[]`, `tags[]`, `management[]`, `content_md`, `status`, `metadata` |
|
||
| `update_item` | Partial update of an existing item | `id` xor `path`, any subset of editable fields |
|
||
| `delete_item` | Soft-delete; refuses on live descendants unless `cascade=true` | `id` xor `path`, `cascade` |
|
||
| `list_links` | List item_links attached to an item | `id` xor `path`, optional `ref_type` |
|
||
| `add_link` | Add an external item_link | `ref_type`, `ref_id`, `rel`, `note`, `metadata` |
|
||
| `remove_link` | Delete an item_link by id | `link_id` |
|
||
| `search` | Ranked substring search across title/slug/aliases/content_md | `query`, `limit` |
|
||
| `tree` | Nested tree (multi-parent items appear under each branch) | `root_path`, `depth` |
|
||
|
||
### Output shape
|
||
|
||
All tools return a JSON object inside a single MCP text-content block. `list_items`, `list_links`, `search`, `tree` return `{count|roots, items|links|tree}`. `get_item` and write tools return a single `itemView` / `linkView` with snake_case fields matching `projax.items_unified`'s columns.
|
||
|
||
### Multi-parent semantics
|
||
|
||
- `list_items` with `parent_path='work'` matches any item whose `paths[]` contains a path equal to `work` or beginning with `work.` — multi-parent items surface from any ancestor.
|
||
- `get_item` resolves either by uuid or by any path the row publishes; `dev.paliad` and `work.paliad` return the same row.
|
||
- `create_item` accepts `parent_paths` as a string array: `[]` for a root, `['work']` for single-parent, `['work', 'dev']` for multi.
|
||
- `update_item` with a non-nil `parent_paths` *replaces* the full parent list; pass the current list plus the new one to add a parent.
|
||
- `tree` honours multi-parent — the same uuid appears under each branch with its inherited path as the node's `path` field.
|
||
|
||
### Transport + auth
|
||
|
||
- HTTP+JSON-RPC 2.0 over `POST /mcp/rpc` (no SSE needed at v1 — every tool returns synchronously).
|
||
- Bearer auth via `Authorization: Bearer <PROJAX_MCP_TOKEN>`. `/mcp/*` paths are exempt from the cookie auth middleware so API callers don't need a Supabase session.
|
||
- A GET on `/mcp/rpc` returns a small descriptor `{server, version, protocolVersion, tools[], authRequired}` for ops smoke-testing.
|
||
|
||
### Bridge for stdio MCP clients
|
||
|
||
`~/.claude/mcp/projax.sh` is a tiny bash bridge: reads NDJSON JSON-RPC frames from stdin, POSTs each to `${PROJAX_MCP_URL}/rpc` with the Bearer header, writes the response back to stdout. The repo-root `.mcp.json` exposes both wirings:
|
||
|
||
- An `http` server entry for clients that speak HTTP+MCP natively.
|
||
- A `command` server entry (referenced separately under `~/.claude/mcp/projax.sh`) for stdio-only clients.
|
||
|
||
Neither encodes a token; both interpolate `${PROJAX_MCP_TOKEN}` at session start.
|
||
|
||
### Env contract
|
||
|
||
- `PROJAX_MCP_TOKEN` — 32-char Bearer secret. Unset → `/mcp/*` returns 404 (off cleanly, the web UI keeps working). Set → routes mount, every request requires the matching Bearer.
|
||
|
||
Out of scope (parked):
|
||
|
||
- Server-pushed notifications / SSE — phase 3b.
|
||
- 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.
|
||
- **`mai.projects` test row hiding**: drop them from the view via name pattern, or surface them with a "test" tag?
|
||
- **Classification promotion semantics**: when a mai.projects row is promoted, does the projax item replace it in the unified view, or do both still appear? Default: projax wins, view filters out adapted mai rows.
|
||
- **Auth**: re-use flexsiebels Supabase auth, or simpler shared-secret cookie? msupabase auth is heavier than v1 needs.
|
||
- **mBrian topic-hub linkage**: do we auto-suggest mbrian topic links when an item is created with a matching slug? Defer to phase 3.
|
||
|
||
## Dashboard / daily-driver view (Phase 3e)
|
||
|
||
A single landing surface at `/dashboard` that aggregates open work and recent activity across every linked project, so projax can be opened first thing in the morning instead of clicking through each project's detail page.
|
||
|
||
**Sections** (each a card, count in header):
|
||
|
||
1. **Open tasks** — every open VTODO (`Status != COMPLETED/CANCELLED`) from every `caldav-list` item_link, fanned out via a 4-worker pool to avoid DAV-server hammering. Bucketed by due date: `Overdue` (red), `Today`, `Tomorrow`, `This week` (≤7d), `No due`. Sort: overdue first, then due asc with no-due last; ties by priority desc, summary asc. Capped at 30 rows; total + per-bucket counts surface in the section header. Each row has a ✓ button that POSTs `/dashboard/task/done` with `calendar_url + uid`, flips the VTODO to `COMPLETED` via the existing PUT path, busts the dashboard cache, and re-renders the full section (so the row vanishes and counts decrement).
|
||
2. **Open issues** — every open Gitea issue from every `gitea-repo` item_link, sorted `updated_at desc`. Read-only (Gitea writeback parked). Reuses the existing `GiteaDeps.Cache` (3-min TTL) so repeated dashboard loads share Gitea hits with the detail page.
|
||
3. **Recent documents** — every dated `item_link` (`event_date IS NOT NULL`) with `event_date >= now - 30d`, joined to its parent item. Sorted newest-first. Each row renders the canonical PER (`{primary_path}.{YYMMDD}`), ref_type badge, note, ref_id link, and project path. Capped at 30.
|
||
|
||
**Filters**: small chip row at the top reuses `tree_filter.go` URL params (`tag`, `mgmt`, `has`) so `/dashboard?tag=work` scopes all three cards to work-tagged items. The same filter has another use as the cache key — `/dashboard` and `/dashboard?tag=work` are independent cache entries.
|
||
|
||
**TTL cache**: 60s in-memory map keyed by the encoded TreeFilter. The cache is single-replica only (no shared state needed at single-user scale). The ✓-mark-done handler explicitly invalidates the cache so the row disappears immediately on the next render.
|
||
|
||
**Out of scope for 3e**: real-time updates, full per-section pagination, dashboard-as-root-landing. Tree at `/` stays the default surface; nav bar adds a "dashboard" link so m chooses when to switch.
|
||
|
||
**Phase 3g additions:**
|
||
|
||
4. **Stale projects** — items with `'mai' = ANY(management)` AND every linked Gitea repo's `updated_at` older than 60d AND zero open VTODOs across linked CalDAV lists AND zero open Gitea issues. Sorted longest-stale first, capped at 20. Each row shows the project path, the quiet repo, and "last active Nd ago" with the absolute date on hover. "Consider archiving?" framing only — no auto-action.
|
||
- Uses the same 4-worker pool as the issues card. Per-item task/issue counts are reused from the already-aggregated Tasks/Issues cards (no second DAV/Gitea pass).
|
||
- Items with NO linked repo are skipped — without a signal there is no way to call them stale.
|
||
- When an item has multiple linked repos, ALL must be older than the cutoff (so an item with one quiet repo and one busy repo is NOT stale).
|
||
5. **Last-refresh indicator** — small "updated Nm ago · cached" / "updated Nm ago · fresh" line at the top of the dashboard chrome, derived from the cached payload's BuiltAt timestamp.
|
||
6. **Force-refresh button** — `↻ refresh` link that adds `?refresh=1` to the current URL. The handler invalidates the matching cache key and re-runs the full aggregation. HTMX swaps the section in-place.
|
||
7. **Empty-card collapse** — when no filter is active AND a card has zero rows, render a one-line `No open tasks.` / `No open issues.` / `No recent documents.` note instead of the full empty-state block. With a filter active the card chrome stays so m can distinguish "filter hid the data" from "no data".
|
||
|
||
**Phase 3l addition — Events card (closes the mgmt-parity gap):**
|
||
|
||
8. **Events** — every VEVENT in the next 7 days across every `caldav-list` item_link, fanned out via the same 4-worker pool. Time-range filter is server-side (RFC 4791 `<c:time-range>`), so the DAV server returns only what the window contains. Grouped by day with German "Today" / "Tomorrow" / weekday labels lifted from the mgmt cockpit's wording. Sort within day: start asc, summary asc as tiebreaker. Cap 50. Each row: start time (or "ganztägig" for all-day DATE events), project path link, summary, location, and a `↻` badge when the source VEVENT has an RRULE (the dashboard never expands recurrences — only the literal DTSTART instance). Empty-collapse: with no filter and zero events, the card renders "No upcoming events." inline.
|
||
|
||
## Graph view (Phase 3f)
|
||
|
||
A read-only top-down DAG render of every projax item at `/graph`, server-rendered inline SVG — no client-side layout library, no Excalidraw file. Trade-offs: m gets a single page that prints, downloads, and reflows in a regular browser; no drag-to-rearrange (read-only is enough for the daily glance).
|
||
|
||
**Layout** (in `internal/graph`):
|
||
|
||
- `LayerByLongestPath(nodes)` → each node's layer is `max(layer(parent)) + 1`, so a multi-parent item like `paliad` (under both `work` and `dev`) sits below whichever lineage is longer. Roots are layer 0. Depth-capped at 64 to bail loudly on cycles (the schema trigger already forbids cycles on write).
|
||
- `OrderInLayer(layers)` — alphabetical by slug inside each layer for deterministic rendering. No barycenter / crossing-minimisation pass — at m's scale (≤ a few hundred items) the readability cost is negligible.
|
||
- `Compute(nodes, opts)` returns positions + edges + canvas size. Pure-Go, no external deps. Unit-tested with multi-parent, longest-path-wins, sort, and cycle-guard fixtures.
|
||
|
||
**Node styling**:
|
||
|
||
- 130×44 px box per item.
|
||
- Border colour = management mode: `mai` blue, `self` green, `external` orange, mixed dashed purple, unmanaged grey.
|
||
- Box opacity = status: active 1.0, done 0.6, archived 0.3.
|
||
- Slug as the main label; first three tags rendered as small pills along the bottom (`+N` overflow); `×N` badge top-right for multi-parent items.
|
||
- `<title>` element gives a hover tooltip with title + status + management.
|
||
- Each node wrapped in an `<a href="/i/{path}">` so a click navigates to the detail page.
|
||
|
||
**Filter chips**: same `tree_filter.go` URL params (`q`, `tag`, `mgmt`, `status`, `has`). Default behaviour is to *dim* non-matching nodes (opacity 0.15) so the structural relationships stay visible. `?isolate=1` switches to hide-non-matching mode and drops every edge whose endpoint is hidden.
|
||
|
||
**Print + download**: SVG is inline so the browser's "Print" produces a real vector page. `?download=svg` serves the raw SVG with `Content-Disposition: attachment; filename="projax-graph.svg"` — useful for stashing a snapshot in slides or in mBrian.
|
||
|
||
**Out of scope for 3f**: editable layout (drag-to-rearrange), Excalidraw file export, auto-refresh on item changes.
|
||
|
||
## Mobile responsiveness (Phase 3i)
|
||
|
||
Pure CSS + minimal template tweaks make every projax page legible and tappable on m's phone (via Tailscale). The original "Otto-PWA covers mobile" plan is dropped — projax's own pages need to render on phone even if Otto-PWA later embeds or links to them.
|
||
|
||
**Breakpoints** (CSS media queries on `web/static/style.css`):
|
||
|
||
- ≤ 768px (tablet): chip strips become horizontal-scrollable, tables (`/admin/bulk`, `/admin/classify`) flip to card lists, dashboard cards widen, edit forms drop to single column. Touch targets bump to ≥ 44px on buttons.
|
||
- ≤ 480px (phone): base font nudges to 15px so default body text is legible without pinch-zoom. Header nav wraps. The bulk-table primary-path column hides on phone (slug + tags + actions are enough).
|
||
- ≥ 1280px (wide laptop): main column widens to 1200px and the dashboard grid splits to 2 columns with the stale card spanning both.
|
||
|
||
**Layout principles:**
|
||
|
||
- Viewport meta on every page (`<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">`); enforced by `TestLayoutHasViewportMeta`.
|
||
- Touch targets ≥ 44px on phone for buttons + chip toggles. Chip strips use `flex-wrap: nowrap` + `overflow-x: auto` so the row scrolls instead of wrapping into a tall block.
|
||
- Tables → cards: `display: block` on tbody/tr/td with `td::before { content: attr(data-label); }` — when m wants per-column labels visible on mobile, add `data-label="…"` to the `<td>`.
|
||
- Forms: native `<input type=date>` etc. so iOS pickers fire (already in place).
|
||
|
||
**SVG `/graph` special case:**
|
||
|
||
- Wrapped in `.graph-canvas { max-width: 100vw; max-height: 75vh; overflow: auto; }` so the inline SVG scrolls + pinch-zooms inside its container instead of overflowing the page.
|
||
- "Fit to screen" toggle (`.graph-canvas.fit .graph-svg { width: 100%; }`) flips between natural-size and viewport-sized renders. Native pinch-zoom remains available via the viewport meta.
|
||
|
||
**Out of scope:**
|
||
|
||
- Push notifications (Otto-PWA's domain).
|
||
- Dark mode toggle.
|
||
- Right-to-left layout.
|
||
|
||
## PWA install (Phase 3j)
|
||
|
||
projax is now installable as a real PWA — iOS Safari "Add to Home Screen" produces a standalone-launching icon and Android Chrome offers the same install flow. Scope is "install affordance," NOT full offline mode.
|
||
|
||
**Manifest** (`/static/manifest.webmanifest`, served with `application/manifest+json` via `mime.AddExtensionType` in `web.init`):
|
||
|
||
- `name` / `short_name`: "projax"
|
||
- `start_url`: `/dashboard` (the daily-driver surface m wants on tap)
|
||
- `scope`: `/`
|
||
- `display`: `standalone`
|
||
- `theme_color` / `background_color`: `#1a1a1a` (matches login-page palette)
|
||
- `icons`: 192×192, 512×512, and a 512×512 maskable variant with ~12% safe-zone padding
|
||
|
||
**Icons** (`/static/icon-{192,512,maskable}.png`): stdlib-only generation via `cmd/icongen` — `go run ./cmd/icongen` rewrites them from the source design. Stylised "p" monogram on a dark ground with an accent stripe. Re-run when the brand or palette changes; the PNGs are checked in.
|
||
|
||
**Service worker** (`/static/sw.js`):
|
||
|
||
- On `install`: pre-cache `/static/style.css` + manifest + icons.
|
||
- On `activate`: prune stale caches from earlier `CACHE_NAME` versions.
|
||
- On `fetch` (GET only): network-first with cache fallback on failure. Successful GETs are stashed for the next offline render.
|
||
- Explicitly skipped: any non-GET request, anything under `/mcp/`.
|
||
|
||
This is deliberately the smallest correct service worker. Mutations (CalDAV/Gitea writeback, MCP) require the network; m airplane-modes → he sees the last cached read view but can't change anything until reconnected.
|
||
|
||
**Layout meta**: `theme-color`, `apple-mobile-web-app-capable=yes` (iOS treats as standalone), `apple-mobile-web-app-status-bar-style=black-translucent`, `apple-mobile-web-app-title=projax`, plus the `<link rel="manifest">` + `<link rel="apple-touch-icon">` chain. Login template carries the same set so a first-time install from `/login` works the same as from `/dashboard`.
|
||
|
||
**Tests**: `TestManifestServedWithCorrectMIME`, `TestServiceWorkerServed`, `TestIconsServed`, `TestLayoutHasManifestAndAppleTouchIcon`.
|
||
|
||
**Out of scope for 3j**: push notifications, background sync, full offline write mode, splash-screen tuning.
|
||
|
||
## 9. Phase-1 deliverable checklist
|
||
|
||
- [ ] `projax.items` + `projax.item_links` migrations in `db/migrations/`
|
||
- [ ] Path trigger + tests
|
||
- [ ] `projax.items_unified` view
|
||
- [ ] Go binary: HTTP server, pgx pool, html/template + HTMX, embed static
|
||
- [ ] Pages: tree, detail, new, classify
|
||
- [ ] Auth: msupabase session cookie OR shared-secret (decide in 1a)
|
||
- [ ] Dockerfile + Dokploy config for `projax.msbls.de`
|
||
- [ ] Seed migration for the seven day-one areas
|
||
- [ ] README + run instructions
|
||
|
||
## 10. References
|
||
|
||
- Project CLAUDE.md (this repo) — purpose, constraints, gated worker flow
|
||
- `~/.claude/CLAUDE.md` — global conventions (memory, channel routing, git strategy)
|
||
- `mai.projects` schema (msupabase) — current state being adapted
|
||
- mBrian `nodes`/`edges` schema — terminology source
|
||
- otto session 2026-05-15 — inventory motivating this project
|