Files
projax/docs/design.md
mAi 63f5ed115c docs(design): Phase 5h Dashboard overhaul addendum
Final slice of Phase 5h — documents the Tiles + view switcher
contract in design.md as the source of truth so future workers
understand what Phase 5h shipped without archaeology.

New section §19 covers:
- URL contract (view, scope, refresh, filter param interaction)
- dashboardProject rollup fields + LastActivity max-across-sources rule
- IsCurrent predicate (the 14d window)
- Tiles layout + the minmax(0,1fr) + min-width:0 + overflow-wrap
  containment recipe (explaining the mid-rollout horizontal-scroll fix)
- Quiet (N) ▾ fold replacing the standalone Stale card
- Scope chip mechanics (Tiles-only)
- Tasks tab (today minus Stale) and Events tab (promoted with summary
  header + bigger day headings)
- Cache key composition + pin-flip InvalidateAll
- Mobile breakpoints + touch targets
- Explicit non-goals for Phase 5h (Activity tab, sortable rows, project
  filter dim, saved views — those are 5i with kahn)

References block updated to point at docs/plans/dashboard-overhaul.md
for the design rationale + m's chip picks.
2026-05-26 12:37:56 +02:00

828 lines
73 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# projax — PRD
**Status:** v1 draft, 2026-05-15
**Authors:** m, head (dialogue)
**Scope:** Phase-1 build sufficient to live with the system; phases 23 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`.
**Phase 3o consolidation**: `/admin/classify`, `/admin/caldav`, `/admin/bulk` now live under a single `/admin` index page (one card per tool with quick stats — orphan count, calendar count, item count). The header nav exposes one `admin` link rather than three separate ones. A system panel below the cards surfaces version, last applied migration, MCP status, and a parallel-probed health view (DAV / Gitea / Supabase) cached for 30 s.
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):
- Recurring dated artifacts (RRULE-style). Flatten for now.
- Cross-PER linking syntax / forward-jump anchors. Phase 3d+ if m needs it.
Out of scope (permanent — decided 2026-05-17):
- **File uploads / in-projax file storage.** projax `item_links` are *references*, not files. The PER (`{path}.{YYMMDD}`) names where a document lives elsewhere — mFin/, the filesystem, a URL — and the source-of-truth stays there. Do not propose adding multipart uploads, a documents bucket, or an attachments table; m killed that on 2026-05-17. The "documents" surface is intentionally a list of `(ref_type, ref_id, event_date, note)` rows and nothing more.
## 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.
## 12. Timeline view (Phase 4a)
A chronological "what's happening in my life by date" surface at `/timeline`, nav-linked next to `/dashboard`, `/graph`, `/admin`. Different shape from the dashboard (which is "right now") — the timeline is a scrollable spine that braids every dated thing in projax under its anchor date.
**Sources** (each row carries a `(date, kind, item, label, link)` tuple):
1. **CalDAV VTODOs** with a `DUE` date — open ones plus completed/cancelled in the recent past (anchor: `LAST-MODIFIED` for done/cancelled, otherwise `DUE`).
2. **CalDAV VEVENTs** with `DTSTART` in window — past 30 days and future 90 days by default. `DTEND` drives the duration hint (`(2 days)` / `(1h)`).
3. **Dated `projax.item_links`** (`event_date IS NOT NULL`) — letters, notes, documents, URLs anchored to a date. Surfaced under the canonical PER (`{primary_path}.{YYMMDD}`).
4. **Item creation events**`projax.items.created_at::date` rendered as a muted "added X to projax" marker.
Items without a date never appear here — the tree/graph/dashboard cover the rest. Gitea-issue activity is explicitly out of scope for 4a (already on the dashboard).
**Layout**:
- Vertical spine. Each day is a `<li class="spine-day">` with a header and per-day row list. Days with zero rows collapse — no header emitted.
- Default order is newest first; `?order=asc` reverses both the outer day list and (implicitly) the reader's mental model. Within a day rows sort: timed events → all-day events → VTODOs → docs → creation markers, ties broken by summary / PER / slug.
- Today / Tomorrow get sticky pills next to the day header; the spine border-left highlights those days in the accent / ok colour.
- Far-future rows (`Date > today+30d`) get `.far-future` opacity 0.5 so the foreseeable future stands out from the speculative future.
**Time window**:
- Default: past 30 days through next 90 days.
- `?from=YYYY-MM-DD&to=YYYY-MM-DD` overrides both bounds (`to` is inclusive in URL terms; the handler shifts to exclusive internally).
- `?before=YYYY-MM-DD` advances the window into the past for "older" pagination.
- `?when=past` / `?when=future` clamps to the half-line containing today.
**Filters**:
- Reuses the tree filter strip — `?q=`, `?tag=`, `?mgmt=`, `?has=` — so the timeline can be scoped to "work things" or "anything mentioning paliad" with the same vocabulary as `/` and `/dashboard`.
- Timeline-specific narrowing: `?kind=todo,event,doc,creation` (multi-select; default all four). `?when=` for past/future split.
**Affordances on rows** (per the 4a scope-expansion message):
- **VTODOs**: ✓ complete, ✎ edit (summary + due), × delete — using the existing `/dashboard/task/done|edit|delete` handlers. The dashboard's Tasks card grew the same edit + delete on the same day.
- **Dated `item_links` (docs)**: × delete via `/i/{path}/links/remove`. The link-remove handler now re-renders the timeline section when the swap target is `#timeline-section` (it still re-renders the Documents fragment when called from the detail page).
- **VEVENTs**: read-only at v1 (consistent with the 3l decision). Multi-day events render once on the first day with the duration hint.
- **Item creation markers**: read-only display only.
**Cache**: 90 s in-memory map keyed by `(filter, from, to, order, kinds)`. Looser than the dashboard's 60 s because timeline is browse-y, not action-y. The cache is invalidated wholesale on VTODO writeback (`/dashboard/task/*`, `/i/{path}/caldav/todo/*`) and on dated-link add/remove — any of those could move rows on or off the spine and the cost of a re-aggregation is cheap.
**Per-item exclusion (Phase 4f)**:
Each item carries a `timeline_exclude text[]` whose values name kinds to hide from the spine: `'todos'`, `'events'`, `'docs'`, `'creation'` (empty array = default = nothing hidden). The aggregator drops the matching source for each flagged item *before* fanning out — no CalDAV call is made for an item whose VTODOs are excluded, no creation marker is emitted for an item whose `'creation'` kind is excluded, and so on.
The detail page (`/i/{path}`) still surfaces everything regardless — exclusion is a render-time concern for the timeline view only, so m doesn't lose visibility into his data, he just stops seeing it braided into the chronological spine.
URL override: `?include_excluded=1` (and the MCP `include_excluded: true` arg) ignore the per-item arrays and surface everything — useful for "show me what I'm hiding" peek.
Bulk action: `/admin/bulk` offers an "Exclude todos from timeline" / "Re-include todos on timeline" pair (the most common use case — m's home shopping list). The other three kinds (events / docs / creation) are editable per-item only.
**Out of scope for 4a**:
- Drag-to-create-on-date (would require write paths from a non-detail page).
- iCal export of the timeline.
- Per-row inline edit of VEVENT (use the source calendar app — the 3l read-only stance still holds for v1).
- Gitea issue created/closed activity (deferred until m asks; dashboard already covers it).
## 13. Theming (Phase 4b)
projax ships with an explicit dark / light toggle and a 1-year cookie that remembers the choice. Default is dark — m asked for dark-by-default; the toggle exists so light mode is one click away when the ambient lighting calls for it.
**Toggle approach**:
- `<html data-theme="dark">` (default) or `<html data-theme="light">`. CSS palettes live under `:root, :root[data-theme="dark"] { … }` and `:root[data-theme="light"] { … }` in `web/static/style.css`. Every panel colour is a `var(--foo)` — the only hardcoded hex values left in the codebase are inside those two `:root` blocks. A future palette tweak edits the variables, not 30 selectors.
- The header nav grows a small `☀` / `☾` button (sun glyph in dark mode = "switch to light", moon glyph in light mode = "switch to dark"). Inline JS in `layout.tmpl` flips `data-theme` + the apple `<meta name="theme-color">` and writes the cookie via `document.cookie =` — no server roundtrip on toggle. The cookie is `projax_theme=dark|light`, `Max-Age=31536000`, `Path=/`, `SameSite=Lax`, NOT HttpOnly (the toggle JS reads it).
- Layout reads the cookie server-side via `themeFromRequest(r)` and emits `data-theme` + `<meta name="theme-color">` at first paint — no flash-of-wrong-theme before the script runs. The render helper injects `Theme` + `ThemeColor` into every template's data map before execution, so individual handlers don't need to know about the theme.
**Palette structure**:
| Variable | Dark | Light | Used for |
|---|---|---|---|
| `--fg` | `#e6e6e0` | `#1a1a1a` | primary text |
| `--muted` | `#8a8880` | `#6a6a6a` | secondary text, slugs |
| `--bg` | `#0e0e0e` | `#fafafa` | page background |
| `--bg-alt` | `#1a1a1a` | `#f0efe8` | nav bar, code blocks, tag pills |
| `--surface` | `#161616` | `#ffffff` | card backgrounds, form inputs |
| `--surface-hover` | `#1f1f1f` | `#f7f7f7` | row hover |
| `--border` | `#2c2c2c` | `#d8d4c8` | hairline borders |
| `--accent` | `#6fa7e8` | `#2f5d9e` | links, primary buttons |
| `--accent-fg` | `#0a0a0a` | `#ffffff` | text on accent backgrounds |
| `--warn` / `--ok` / `--bad` | brighter pastels | original earthy | status colours |
| `--highlight*` | warm dark | cream | PER highlight, banner warn, overdue row |
| `--kind-*-bg` / `--kind-*-fg` | dark muted | original pastels | timeline kind-badges |
| `--graph-*` | brighter | original | graph node strokes & legend |
**Theme-color meta** flips with the page theme: `#161616` (dark — matches `--surface`, the nav bar) or `#f0efe8` (light — matches `--bg-alt`). Apple Safari uses this to tint the iOS status bar in the installed PWA.
**Standalone SVG download** (`/graph?download=svg`) bakes the LIGHT palette inline because the downloaded asset has no parent `:root` providing variables, and m's existing snapshots (presentations, mBrian) expect the original print-friendly look. The `Standalone` flag on `graphPayload` flips a `{{if .Standalone}}` block in `graph_svg.tmpl` to inject the inline `:root` declarations only on the download path.
**Login page** keeps its embedded dark CSS — it's the gateway and is intentionally always dark (mirrors the lockscreen feel). The theme toggle is hidden from users until they're signed in; switching the login template's palette would only matter for a never-signed-in user.
**Out of scope for 4b**:
- `prefers-color-scheme` auto-detect (could add: read it on first visit if no cookie). v1 is manual.
- Per-page theme overrides — one global theme is enough.
- CSS transitions on the swap. The flip is instant; that's intentional.
## 14. Timeline MCP tool (Phase 4c-B Slice 1)
The chronological view (§12) is now reachable from MCP. The PWA's Otto-projax surface (mAi#228) consumes it to render `/projax/timeline` on m's phone without needing a session cookie against `projax.msbls.de`.
**Tool name:** `timeline`. Registered in `mcp/tools.go` when `RegisterProjaxTools` receives a non-nil `TimelineBuilder` (the running `*web.Server` satisfies it via `BuildTimelinePayloadFromArgs`). When the third arg is nil — e.g. in package tests that don't need timeline — the tool is silently omitted; the rest of the surface stays usable.
**Input schema** mirrors the URL query string of the web `/timeline` route, lifted to a typed JSON object:
```json
{
"from": "YYYY-MM-DD (optional, default now-30d)",
"to": "YYYY-MM-DD (optional, default now+90d)",
"order": "asc|desc (optional, default desc)",
"kinds": ["todo","event","doc","creation"],
"tags": ["work","dev"],
"mgmt": ["mai"],
"has": ["caldav-list"],
"status": ["active"],
"q": "paliad"
}
```
**Output shape** (see `mcp.timelineView` in `tools.go`):
```json
{
"days": [{
"date": "2026-05-17",
"label": "Today",
"sticky": "today",
"rows": [{
"kind": "todo|event|doc|creation",
"item_path": "work.paliad",
"item_title": "paliad",
"far_future": false,
"todo": {"uid","calendar_url","summary","status","due","priority"},
"event": {"uid","summary","start","start_label","end","all_day","location","recurring","duration_hint"},
"link": {linkView},
"per": "work.paliad.260517"
}]
}],
"from": "2026-04-17",
"to": "2026-08-16",
"to_inclusive": "2026-08-15",
"order": "desc",
"kinds": ["todo","event","doc","creation"],
"total_rows": 42,
"built_at": "2026-05-17T16:30:00Z"
}
```
Times stringify into either YYYY-MM-DD (date-only) or full RFC 3339 UTC (timed), matching the convention the existing `list_items` / `get_item` tools use. Polymorphic — JSON consumers can treat the timestamp strings as opaque or parse them per-locale.
**Cache:** the MCP path bypasses the web-side 90 s in-memory cache. Re-aggregation per RPC call is cheap (single DB pass + the same 4-worker CalDAV fan-out), and the two cache keying schemes diverge (URL filter-state vs. JSON args). Skipping the cache here keeps invalidation simple and the data fresher.
**Auth:** same `Authorization: Bearer ${PROJAX_MCP_TOKEN}` as the rest of `/mcp/rpc`. No CORS allowlist needed — consumers (PWA backend, future agents) call projax server-to-server.
## Collapsible detail-page sections (Phase 4e)
The `/i/{path}` detail page wraps each major section in a native `<details>` element so long Issues / Documents lists don't dominate a project page. Three section keys today (`tasks`, `issues`, `documents`, `public`); `<details class="proj-section" data-section data-item-id>` survives HTMX swaps because the wrappers live in `detail.tmpl`, not inside the swap targets.
**Smart defaults** (server-side `open` attribute):
| Section | Open when |
|---|---|
| Tasks | any linked calendar has at least one open VTODO |
| Issues | total open issues ≤ 10 |
| Documents | dated link count ≤ 5 |
| Public listing | always closed (toggle is rarely flipped) |
**Persistence**: inline JS reads `localStorage["projax.section." + itemID + "." + section]` on boot — `"open"` or `"closed"` — and writes it back on every `toggle`. User choice wins over the server default. A `reset section state` link in the form actions wipes every `projax.section.<itemID>.*` key for the current item and reloads, restoring the smart-default behaviour.
**What's NOT collapsible**: title + status/tag/management chip line (always visible breadcrumb), the inline edit form's standard fields (title/slug/parents/content). Only the auxiliary sections collapse — m always needs to see what an item *is* without expanding anything.
## 15. Public listing (Phase 4d)
projax becomes the source of truth for which items go on m's public portfolio (flexsiebels.de today; any future renderer via MCP). Five new columns on `projax.items`, all default-safe — 95% of items stay private and the partial index keeps the "show me everything public" query cheap.
**Schema (migration 0014):**
| Column | Type | Default | Meaning |
|---|---|---|---|
| `public` | `boolean` | `false` | The toggle |
| `public_description` | `text` | `''` | Public-facing prose (markdown); written separately from `content_md` so internal notes never leak |
| `public_live_url` | `text` | `''` | Production / demo URL |
| `public_source_url` | `text` | `''` | Repo URL when m wants to expose source |
| `public_screenshots` | `text[]` | `'{}'` | Ordered list of image URLs (projax stores pointers, never bytes — same PER discipline as §3c) |
Partial index `items_public_idx ON projax.items (public) WHERE public = true` covers the public-only query. items_unified flows the columns through automatically.
**MCP contract:**
- `update_item` accepts any subset of the five new fields as a partial-update patch (nil pointers leave the existing value alone).
- `list_items` gains a `public: boolean` filter. `public=true` returns only public items, `public=false` only private, absent returns all (current behaviour).
- `get_item` returns all five fields automatically — itemView always includes them, even when `public=false`, so consumers can preview "what would publish" without a second round-trip.
**UI surfaces:**
- `/i/{path}` detail page grows a "Public listing" fieldset: toggle + description textarea + live/source URL inputs + screenshot list editor (one row per URL, add/remove buttons, server-side empty-row drop). Values persist when public is off so toggling never destroys typed-in content.
- `/admin/bulk` action bar gains a `public-listing` select with "Make public" / "Make private" — bulk apply uses a single UPDATE per action.
- `/?public=1` and `/?public=0` chip parameters on the tree page narrow to public/private respectively. `Active()` and `QueryString()` round-trip the state; `TogglePublic()` cycles nil → true → false → nil.
**Intended flexsiebels consumption pattern:**
flexsiebels' Go (or Deno) backend POSTs to `https://projax.msbls.de/mcp/rpc` with `Authorization: Bearer ${PROJAX_MCP_TOKEN}`, calls `list_items({public: true})`, renders the response server-side. The PROJAX_MCP_TOKEN is the server-to-server credential — m never sees it. No CORS work needed; calls stay server-to-server like the existing PWA bridge (mAi#228).
**What does NOT happen here:**
- Flexsiebels-side rendering — separate task in `m/flexsiebels.de`.
- Asset hosting for screenshots — projax stores URLs; m hosts images wherever already-deployed (Imgur, S3, static-asset endpoint, …).
- A publish workflow with approval stages — single boolean is enough.
## 17. Calendar view (Phase 5e)
Month grid at `/calendar?month=YYYY-MM` — the fourth dated surface, sibling to `/timeline` (chronological spine), `/dashboard` (today/week buckets), and `/graph` (DAG topology). Same `internal/aggregate.Aggregator` data pipeline as timeline; different presentation.
**Sources** (per cell, anchor date is local-zone midnight of the row's date):
1. **CalDAV VEVENTs** in the grid window (`[gridStart, gridEnd)`). Event start used as the anchor; the cell shows `HH:MM Summary` (or just `Summary` for all-day).
2. **CalDAV VTODOs** with `DUE` in the window. Open todos anchor on `DUE`; completed/cancelled todos in the last 14 days anchor on `LastModified`. Overdue (DUE before today, still open) renders with a warn-coloured border accent.
3. **Dated `projax.item_links`** with `event_date` in the window. Note text is the row summary; ref_id's last path segment is the fallback. Muted border accent.
Not surfaced: item-creation markers (too noisy for a month grid), Gitea issues (no date anchor), untimed items (calendar is fundamentally date-scoped).
**Layout**`web/calendar.go layoutCalendarWeeks` builds the rectangular grid:
- 7 columns Mon→Sun. `mondayWeekday(t)` converts Go's Sunday=0 default to the German Monday=0 convention.
- Leading days from the previous month fill the first row's gap before the 1st. Trailing days from the next month pad to the last row's Sunday. Both carry `IsAdjacent` so CSS can grey them out.
- Each cell caps visible rows at `calendarMaxRowsPerCell` (3). Overflow becomes "+N more" linking to `/timeline?from=YYYY-MM-DD&to=YYYY-MM-DD` for a focused single-day view.
- Today's cell carries `IsToday` → CSS adds an accent border + "Heute" pill.
- Per-cell rows sort: timed first (by `HH:MM`), then by kind rank (event < todo < doc), then by summary.
**Filter integration** reuses `TreeFilter` from `web/tree_filter.go`. Same query keys (`q`, `tag`, `mgmt`, `has`) plus a calendar-specific `kind=event,todo,doc` multi-select. The chip strip uses HTMX `hx-target=#calendar-section` for in-place swaps; the page chrome (month label + prev/next nav) stays outside the swap because chip filtering doesn't change month.
**Cache** `cache.TTLCache[*calendarPayload]` keyed by `(filter, month, kinds)` at 60s, matching the dashboard's cadence. `?refresh=1` invalidates the entire calendar cache.
**Mobile breakpoint** (≤480px) the 7-column grid is unreadable on a 360px-wide phone, so the CSS collapses to a vertical list-of-days. The `LongLabel` field (e.g. "Mi., 14. Mai") is hidden on desktop and revealed at the breakpoint to compensate for the absent weekday column header. Adjacent-month cells drop out entirely on mobile so the list is calendar-scoped.
**German register** month and weekday labels in German throughout (`Mai 2026`, `heute`, `Heute`, `Mi., 14. Mai`). Rest of the app stays English; the calendar surface reads more naturally in German for m's usage.
- [ ] `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
## 18. Layout: sidebar + bottom-nav (Phase 5g)
Top-nav `<header>` retired. Layout chrome now mirrors mBrian's surface so the two apps feel consistent on the same device.
**Desktop (≥768px)** fixed-left `<aside class="projax-sidebar">`:
- Width via `--projax-sidebar-width` (default `220px`), collapsed via `--projax-sidebar-collapsed-width` (`56px`). `html[data-sidebar-collapsed="true"]` flips between them with a 200 ms ease transition.
- Three sections: brand nav (Tree / Dashboard / Calendar / Timeline / Graph / Admin, each an inline SVG + label) bottom (theme toggle + sign-out + collapse toggle).
- Active item is marked server-side: `layout.tmpl` compares `.Path` (injected by `web/server.go render()` from `r.URL.Path`) against each item's `href` and emits `class="nav-item active"` on match. No JS needed for the active marker.
- Collapse toggle persists state in `localStorage["projax.sidebar.collapsed"]`. A pre-paint `<script>` block in `<head>` restores the attribute on `<html>` before first paint so the main-content margin doesn't flash 220 px 56 px on every navigation. ~15 lines of vanilla JS, no framework.
- `main.projax-main` carries `margin-left: var(--projax-sidebar-width)` so content lives to the right of the sidebar. The transition matches the sidebar's so they move in sync.
**Mobile (≤767px)** fixed-bottom `<nav class="projax-bottom-nav">` with five slots:
- Tree / Dashboard / **[+ New] center raised circle** / Calendar / Menu.
- Center "+ New" is a 44×44 px raised circle with `margin-top: -10px` (mBrian's capture-button pattern) but points at `/new` because projax has no separate capture flow.
- "Menu" is a `<details>` element with `<summary>` styled as the bottom-nav-item. Tapping pops a small absolute-positioned `drawer-sheet` 8 px above the bottom-nav with the overflow items (Timeline, Graph, Admin, Theme toggle, Sign out). Default browser `<details>` toggle handles open/close + tap-outside-dismiss no JS, no gesture wiring.
- Height = `calc(56px + env(safe-area-inset-bottom, 0px))` and `padding-bottom: env(safe-area-inset-bottom)` so the iOS PWA install doesn't put the nav under the home indicator. Main content gets `padding-bottom: calc(56px + 1rem + env(safe-area-inset-bottom))` so rows aren't hidden under the nav.
- Sidebar is `display: none` at this breakpoint; bottom-nav is `display: none` at `≥768px`. Single surface visible at a time.
**Theme toggle** same handler binds to both the sidebar button (#theme-toggle) and the drawer button (#theme-toggle-drawer); either surface flips `data-theme` on `<html>` and writes the projax_theme cookie. Existing Phase 4b semantics preserved exactly; only the button location changes.
**What was deliberately parked**:
- mBrian's sidebar resize handle. Their `Sidebar.svelte` has a `$effect`-feedback bug they spent a debug session on (see mBrian `docs/sidebar-resize-debug.md`). Static `220 / 56 px` is sufficient revisit only if multiple users push for it.
- Quick-capture modal (mBrian's center circle opens an in-app capture sheet). projax's "+ New" is just a link to `/new`.
- Quick-switcher / saved-searches / Today / Work / Quick-log slots from mBrian none have analogues in projax. The 5-slot bottom-nav stays scoped to projax's actual surfaces.
- Drawer slide-up gesture. `<details>` with a CSS `transform: translateY(8px) → 0` keyframe is enough for v1; a real bottom-sheet gesture is v2 polish.
## 19. Dashboard overhaul: Tiles + view switcher (Phase 5h)
The Phase 3e task-centric stream (5 cards: Open tasks / Events / Open issues / Recent docs / Stale) ships unchanged on the new **Tasks tab**. The default landing surface at `/dashboard` flips to a project-centric **Tiles** view per m's request "easily show a helpful overview over my current projects". Design plan: `docs/plans/dashboard-overhaul.md` (Phase A: 3 candidates surveyed, Tiles greenlit).
**URL contract** defaults elide:
| Param | Values | Default | Notes |
|---|---|---|---|
| `view` | `tiles` \| `tasks` \| `events` | `tiles` | Tab strip lives below the filter bar |
| `scope` | `current` \| `all` | `current` | Tiles-only chip; Tasks + Events ignore it |
| `tag` / `mgmt` / `has` / `q` / `status` / `public` | (unchanged) | | Same `TreeFilter` vocabulary as `/`, `/timeline`, `/calendar`, `/graph` |
| `refresh` | `1` | | Busts the current (filter, view, scope) cache slot |
Unknown `view=` values fall back to `tiles`.
**Per-project rollup (`dashboardProject`)** one row per item.ID across every signal source. Built from the same aggregator rows the existing cards consume, so adding Tiles costs zero extra DAV/Gitea calls:
- `OpenTasks` open VTODOs across every linked calendar (uncapped, unlike the 30-row Tasks card).
- `Overdue` subset of OpenTasks with `Due < startOfDay(now)`.
- `OpenIssues` open Gitea issues across every linked repo (uncapped).
- `LastActivity` `max(repo.updated_at, latest VTODO LastModified, latest event start, latest dated link, latest issue UpdatedAt)`. Zero when no signal is seen.
- `NextSignal` / `NextSignalKind` soonest-due open VTODO summary (kind=`task`); falls back to the latest-updated issue title (kind=`issue`) when no task; empty otherwise. Task wins over issue.
- `IsLive` derived from `Item.PublicLiveURL` being non-empty.
- `Stale` fed by the existing `collectStale` set so the rollup doesn't re-probe Gitea.
Sort: pinned first, then by primary path ascending.
**`IsCurrent(now)` rule** `Pinned OR OpenTasks > 0 OR OpenIssues > 0 OR LastActivity within 14d` (`dashboardActivityWindow`). Drives the Current/Quiet split when `scope=current`.
**Tiles layout** CSS grid `minmax(0, 1fr)` columns at 1/2/3 cols (≤600 / 600900 / 900 px). Each tile:
- Star toggle (`POST /dashboard/pin` with `id` + `pin=true|false`) flips `Item.Pinned`, invalidates the dashboard cache, re-renders the section. Star glyph is unpinned, pinned.
- Title `/i/<path>`, primary path under (mono font), optional `live` badge for `IsLive`, optional `stale` badge for `Stale`.
- Counts row: `N open` / `M!` (overdue, red) / `K issues` / `quiet` fallback.
- NextSignal one-liner with `•` (task) or `◆` (issue) marker; ellipsis on overflow.
- LastActivity stamp in the footer: `now` / `Nm` / `Nh` / `Nd` (see `activityRel`). Distinct from `relativeTime` so the narrow tile column reads cleanly.
`minmax(0, 1fr)` + `min-width: 0` on `.tile` + `overflow-wrap: anywhere` on `.tile-path` are the canonical CSS-grid containment recipe without all three, a long unbreakable slug-path or task summary widens its column past the viewport and forces a horizontal scroll. The hotfix that introduced this triad landed mid-rollout after m flagged the scroll.
**Quiet (N) ▾ fold** `<details>` element below the primary grid containing every rollup with `IsCurrent=false`, including all stale candidates. Summary line: `Quiet (N) — older than 14d · M stale` (the stale count omits when zero). Tiles inside render with the same shape, slightly faded; stale tiles add a `tile-stale` class (dashed border) and a `stale` flag badge. The Quiet fold replaces the standalone Stale card from Phase 3e m's pick: per-tile `LastActivity` stamp carries the staleness signal, "consider archiving?" framing migrates to the fold.
**Scope chip** renders on the Tiles tab only (`◇ current` `○ all`). Tasks + Events tabs have no scope concept. The chip href flips between `?scope=all` and the default URL, preserving the active view + filter.
**Tasks tab** today's 5-card layout MINUS the Stale card. Inline VTODO writeback (`/dashboard/task/done|edit|delete`) stays exactly as Phase 3e wired it.
**Events tab** promoted from the Tasks-tab Events card to its own surface with: top summary header `N events · next 7 days`, three-column day headings (relative label / ISO date / right-aligned count), and an empty-state copy inviting the user to link a CalDAV calendar from a project detail page. Same `aggregate.Aggregator.Events` source as the cards-tab version.
**Cache** composes `(filter | view=X | scope=Y)` so each surface has its own 60s TTL slot. Pin flip calls `InvalidateAll` (Pinned affects sort order across every combination).
**Mobile (≤768px)** tab strip wraps; scope chip drops to its own row centered. Tile padding tightens; pin button has a 36 px touch target (intentionally less than the 44 px elsewhere to keep the header row balanced), live badge has 32 px, Quiet fold summary has 12 px vertical padding. Events tab day-heading wraps; count drops below the label + date.
**What was deliberately parked for a later phase**:
- Activity tab (chronological feed of commits/issues/completions/docs) m's pick was 3 tabs at launch, defer Activity to v2.
- Sortable Project Rows view (Candidate B from the design plan) Tiles + Tasks tabs cover both daily-driver and detailed read-out shapes.
- Project filter dimension on `TreeFilter` and saved views those are Phase 5i (parallel design with kahn).
- Anything that would need new MCP tools or schema columns `Pinned` already existed and that was the only mutation needed.
## 10. References
- Project CLAUDE.md (this repo) purpose, constraints, gated worker flow
- `~/.claude/CLAUDE.md` global conventions (memory, channel routing, git strategy)
- `docs/plans/dashboard-overhaul.md` Phase 5h design plan (3 candidates + recommendation + m's chip picks)
- `mai.projects` schema (msupabase) current state being adapted
- mBrian `nodes`/`edges` schema terminology source
- otto session 2026-05-15 inventory motivating this project