@@ 98,3 98,142 @@ All changes additive:
- Project detail = scoped sessions list. Resist building a parallel "project entity" model on either side.
- Reuse `Spark`, `Tag`, `ToolDot`, `EmptyState`, `Sub`, `SessionsTable` from the foundation. Add chart primitives only where none of these fits.
- No silent fallbacks. If a stats card's query returns zero rows for the requested range, the response includes that card as an empty array; the SPA renders an `EmptyState` per-card. No "0%" placeholders, no synthesized data.
+
+## Plan
+
+Approach: five phases — Phase 1 ships the projects aggregate plus the `?cwd=` extension to sessions list (one commit, single backend file group); Phase 2 ships the stats endpoint with deterministic date-bucketing helpers; Phases 3-5 swap the three foundation stub routes for real screens (projects index, project detail using the new cwd filter, stats with inline-SVG chart primitives bundled in the stats commit since they are not reused).
+
+Two new Go packages mirror the `internal/domain/session/` shape: `internal/domain/project/` (Repository + Handler over `GET /projects`) and `internal/domain/stats/` (Repository + Handler over `GET /stats`). Both are registered as steward services and mounted under the existing `/api/v1` chi group alongside `Sessions` and `Ingest`. The `?cwd=` extension lives on the existing `session.Repository.List` / `session.Handler.List`.
+
+### Phase 1 — Backend: `/api/v1/projects` + sessions `?cwd=` filter
+
+- **1.1** `internal/domain/session/repository.go:166-174` (modify) — `ListFilter` gains `Cwd *string`. `List` (lines 237-285) appends `working_dir = ?` clause when non-nil, in fixed order before `Since`/`Until` to keep deterministic clause ordering.
+ - Invariant: query stays parameterised; column name not derived from input. Wire/schema unchanged.
+- **1.2** `internal/domain/session/handler.go:68-105` (modify) — `List` reads `q.Get("cwd")` and threads to `filter.Cwd`. Empty string = absent (consistent with other optional filters).
+- **1.3** `internal/domain/session/repository_test.go` (modify) — add `TestList_FilterByCwd`: seed three sessions with two distinct `working_dir` values + one NULL; assert `?cwd=/code/x` matches only the two with that cwd; NULL row excluded.
+- **1.4** `internal/domain/project/repository.go` (create) — new package `project`. Types and signatures:
+ - `type Project struct { Cwd string; Sessions int64; TurnCount int64; TokensInTotal int64; TokensOutTotal int64; LastActive int64; Hosts []string; Tools []string; TopTool string }` with `db` and `json` tags. `Hosts`/`Tools` are populated by post-query aggregation (sqlite `GROUP_CONCAT` returns comma-joined strings; split + dedupe in Go to keep the JSON shape clean).
+ - `type ListFilter struct { Owner session.OwnerScope; Since *int64; Limit int; Offset int }`
+ - `func (r *Repository) List(ctx context.Context, f ListFilter) ([]Project, error)` — single SQL: `SELECT working_dir AS cwd, COUNT(*) AS sessions, SUM(turns…) ... FROM sessions LEFT JOIN turns USING (owner,tool,host,session_id) WHERE working_dir IS NOT NULL [AND owner=?] [AND started_at >= ?] GROUP BY working_dir ORDER BY MAX(ended_at) DESC LIMIT ? OFFSET ?`. `top_tool` is a correlated subquery picking the tool with the highest turn count for that cwd.
+ - Owner clause logic mirrors `session.Repository.List` switch on `OwnerScope` (AllOwners / SpecificOwner / default). Reuse `session.OwnerScope` directly to avoid divergence.
+ - Invariant: `?owner=` admin-override behaves identically to `/sessions`; non-admin override returns 403 at the handler layer.
+- **1.5** `internal/domain/project/handler.go` (create) — `Handler` steward with `Repo *Repository` (project) and `SessionScope *session.Handler` for scope-resolution reuse — actually re-implement `resolveScope` here using `auth.MustIdentity`; copy is small (≈18 lines) and avoids cross-handler coupling.
+ - `Mount(r chi.Router)` registers `r.Get("/projects", h.List)`. Mounted by `server.go` under `/api/v1`.
+ - `listResponse { Projects []Project; Limit int; Offset int }`. Pagination clamps via the same `clampLimit`/`clampOffset` helpers (relocate to `internal/pkg/httputil` or duplicate — duplicate, keeps each handler self-contained; <10 LoC).
+- **1.6** `internal/domain/project/repository_test.go` (create) — TDD targets:
+ - empty DB → `[]` (not nil)
+ - one cwd, two sessions, three turns total → row with `sessions=2`, `turn_count=3`, `last_active = MAX(ended_at)`
+ - NULL `working_dir` excluded entirely
+ - `top_tool` ties broken deterministically (smallest tool name first via `ORDER BY tool ASC` in the subquery)
+ - `Hosts`/`Tools` deduped + sorted
+ - owner scope: AllOwners returns rows from both owners; SpecificOwner pins to one; default = caller
+- **1.7** `internal/domain/project/handler_test.go` (create) — endpoint-level: seed 1 project; `GET /api/v1/projects` → 200 with row; non-admin `?owner=alice` → 403; bad `since` → 400 problem+json.
+- **1.8** `internal/server/server.go:54-100` (modify) — add `Projects *project.Handler` inject field; in `Init`, call `s.Projects.Mount(r)` inside the `/api/v1` Route block alongside `s.Sessions.Mount(r)`.
+- **1.9** `cmd/lethe/main.go` (modify) — register the new `*project.Repository` and `*project.Handler` with steward (mirrors how `session.Repository`/`session.Handler` are registered). Same for stats in Phase 2.
+- Commit: `project: add /api/v1/projects aggregation; sessions: add ?cwd= filter`
+
+### Phase 2 — Backend: `/api/v1/stats`
+
+- **2.1** `internal/domain/stats/repository.go` (create) — types:
+ - `type ToolRollup struct { Tool string; Sessions int64; Turns int64; TokensIn int64; TokensOut int64; DailySparkline []int64 }` (sparkline length = bucket count of the requested range, capped at 60)
+ - `type DailyBucket struct { DateUnix int64; PerTool map[string]int64 }`
+ - `type HeatmapCell struct { DateUnix int64; Count int64 }`
+ - `type CwdRow struct { Cwd string; Count int64 }`
+ - `type HourBucket struct { Hour int; Count int64 }`
+ - `type HostRow struct { Host string; Count int64 }`
+ - `type Stats struct { PerTool []ToolRollup; Daily []DailyBucket; Heatmap []HeatmapCell; TopCwd []CwdRow; HourOfDay []HourBucket; HostSplit []HostRow }`
+ - `type Filter struct { Owner session.OwnerScope; RangeSince *int64 /* nil = all */; Now int64 /* injected for determinism in tests */ }`
+ - `func (r *Repository) Stats(ctx context.Context, f Filter) (*Stats, error)` — runs six small queries against the `turns` table joined to `sessions` for `working_dir`/`tool` projection, fills with deterministic-window helpers (Phase 2.3) so missing days/hours appear as zero rather than absent rows.
+- **2.2** `internal/domain/stats/handler.go` (create) — `Handler.List` reads `?range=7d|30d|90d|all` and translates to `RangeSince` via `time.Now().Unix() - days*86400` (or nil for `all`); injects `Now = time.Now().Unix()` into Filter. Mount: `r.Get("/stats", h.List)`. `listResponse` is just `*Stats`.
+- **2.3** `internal/domain/stats/buckets.go` (create) — pure Go helpers, deterministic, TDD targets:
+ - `func DailyWindow(now int64, days int) []int64` — returns `days+1` unix seconds at midnight UTC, oldest first.
+ - `func FillDaily(window []int64, rows []DailyBucket) []DailyBucket` — left-joins `rows` onto `window`; missing days get empty `PerTool` map.
+ - `func HeatmapWindow(now int64) []int64` — 84 cells = 12 weeks × 7 days, ending today (UTC).
+ - `func HourWindow() []HourBucket` — 24 zero-initialised buckets.
+- **2.4** `internal/domain/stats/buckets_test.go` (create) — TDD targets per helper: window length, ordering, deterministic boundary at UTC midnight, fill behaviour with sparse rows.
+- **2.5** `internal/domain/stats/repository_test.go` (create) — TDD targets:
+ - empty DB → `Stats` with all slices zero-length but non-nil
+ - one tool, three turns over two days → `PerTool[0].Turns = 3`, `Daily` has the two days populated and others zero
+ - heatmap windowed at 12 weeks regardless of `range`
+ - `top_cwd` capped at 20 even when there are more
+ - owner scope: `AllOwners` aggregates across owners; `SpecificOwner` excludes others
+- **2.6** `internal/domain/stats/handler_test.go` (create) — `?range=7d` → 200 with `Stats`; `?range=foo` → 400 problem+json; non-admin `?owner=` → 403.
+- **2.7** `internal/server/server.go` (modify) — register and mount `*stats.Handler` exactly like `*project.Handler` (Phase 1.8 pattern).
+- **2.8** `cmd/lethe/main.go` (modify) — register `*stats.Repository` and `*stats.Handler`.
+- Commit: `stats: add /api/v1/stats aggregate endpoint`
+
+### Phase 3 — Frontend: Projects index route
+
+- **3.1** `web/src/api/adapters.ts` (modify, append) — add types and adapter:
+ - `interface ProjectDTO { cwd: string; sessions: number; turn_count: number; tokens_in_total: number; tokens_out_total: number; last_active: number; hosts: string[]; tools: string[]; top_tool: string }`
+ - `interface Project { cwd: string; sessions: number; turns: number; tokensIn: number; tokensOut: number; lastActive: string /* ISO */; hosts: string[]; tools: string[]; topTool: string }`
+ - `adaptProject(d: ProjectDTO): Project` — last_active=0 → `lastActive=''`; otherwise ISO.
+- **3.2** `web/src/api/adapters.test.ts` (modify) — add `adaptProject` cases: empty arrays passthrough, last_active=0 → empty string, ISO conversion at unix=1700000000.
+- **3.3** `web/src/features/projects/useProjects.ts` (create) — `useProjects(filters: { since?: '7d'|'30d'|'90d'|'all' }): UseQueryResult<Project[]>`. Same `since`→unix translation pattern as `useSessions`.
+- **3.4** `web/src/features/projects/ProjectsTable.tsx` (create) — port from `proto-pages.jsx:3-32`. Columns: `cwd · sessions · tok · last · top tool · activity`. Reuses `Tag`, `ToolDot`, `Spark`. Click row → router push `/project/$<URL-encoded cwd>`. Cursor row styling identical to `SessionsTable` (`--accent-soft` bg, 2px left border).
+ - Signature: `ProjectsTable(props: { projects: Project[]; cursor: number; onCursor: (i: number) => void; onOpen: (p: Project) => void }): JSX.Element`
+- **3.5** `web/src/features/projects/useProjectsCursor.ts` (create) — clones `useHomeCursor` shape (move/activate/jumpTo). Trivially small; could be hoisted into a shared hook in a follow-up.
+- **3.6** `web/src/routes/projects.tsx` (replace) — drop the stub. SubBar shows `<n> projects · ranked by recent activity` + a single `since` FilterChip (clone the home one or inline a simple `<select>`-equivalent button group; pick the inline button group — lighter and the home `FilterChips.tsx` is currently coupled to `HomeFilters`). Body renders `<ProjectsTable>`. Wires `useKeyboardCursor` like Home.
+- **3.7** `web/src/styles/projects.css` (create) — `.projects-thead`, `.projects-row`, `.projects-cols` rules ported from prototype's generic `.thead`/`.row` rules.
+- **3.8** `web/src/routeTree.gen.ts` regenerates automatically when `npm run build` or the dev server picks up the new file.
+- Commit: `web: projects index route with real /projects data`
+
+### Phase 4 — Frontend: Project detail route (`/project/$`)
+
+- **4.1** `web/src/routes/project.$.tsx` (create) — TanStack splat route. The captured path is `params._splat`; URL-decode it to the cwd. Spike during execute: confirm splat captures slash-bearing values. If splat behaves badly, fall back to single param `project.$cwd.tsx` and instruct the linker to URL-encode (replace `/` with `%2F`).
+ - Signature: `ProjectRoute(): JSX.Element`. Calls `useSessions({ cwd })` (after Phase 4.2 widens HomeFilters) and `useProjects({ since: 'all' }).find(p => p.cwd === cwd)` for the header card meta.
+- **4.2** `web/src/features/home/useSessions.ts:7-11` (modify) — `HomeFilters` gains optional `cwd?: string`; `queryFn` appends `cwd` to the URL params if present.
+ - Invariant: existing Home callers unaffected (field is optional, defaults to absent).
+- **4.3** `web/src/features/projects/ProjectHeader.tsx` (create) — port from `proto-pages.jsx:46-56`. Renders parent path muted + last-segment bold, `<n> sessions` accent tag, `<k> tok` tag, host tags, optional `Spark`. Reuses existing `SessionsTable` for the sessions list below — pass the filtered list straight through.
+ - Signature: `ProjectHeader(props: { cwd: string; project: Project | undefined; sessionCount: number }): JSX.Element`
+- **4.4** `web/src/styles/projects.css` (modify) — add `.project-header` rules; rest is shared with the index.
+- **4.5** `web/src/shell/Palette.tsx` (modify if needed) — JUMP entry "projects" already exists; no new entry. (PROJECT items defer to `lethe-web-ui-palette-savedsearch`.)
+- Commit: `web: project detail route scoped via ?cwd= sessions filter`
+
+### Phase 5 — Frontend: Stats route + chart primitives
+
+- **5.1** `web/src/api/adapters.ts` (modify, append) — types + adapter:
+ - `interface StatsDTO` mirroring the Go `Stats` shape with snake_case keys (`per_tool`, `daily`, `heatmap`, `top_cwd`, `hour_of_day`, `host_split`).
+ - `interface Stats` with camelCase + ISO date strings.
+ - `adaptStats(d: StatsDTO): Stats` — converts `date_unix` to ISO; passes counts through.
+- **5.2** `web/src/api/adapters.test.ts` (modify) — TDD: per-card empty arrays preserved; date conversion; missing keys would throw (not by design — server always returns the keys per the no-silent-fallbacks invariant).
+- **5.3** `web/src/features/stats/useStats.ts` (create) — `useStats(range: '7d'|'30d'|'90d'|'all'): UseQueryResult<Stats>`.
+- **5.4** `web/src/primitives/StackedBars.tsx` (create) — inline `<svg>` for the per-tool stacked-bar card. Signature: `StackedBars(props: { days: { tools: Record<string, number> }[]; toolColor: (tool: string) => string; w?: number; h?: number }): JSX.Element`.
+- **5.5** `web/src/primitives/Heatmap.tsx` (create) — 12×7 cell grid. Signature: `Heatmap(props: { cells: { count: number }[]; max: number }): JSX.Element` — `cells.length === 84` invariant; non-84 length renders `EmptyState`.
+- **5.6** `web/src/primitives/HorizontalBars.tsx` (create) — top-cwd list: cwd · bar · count. Signature: `HorizontalBars(props: { rows: { label: string; count: number; href?: string }[]; max: number }): JSX.Element`.
+- **5.7** `web/src/primitives/HourBars.tsx` (create) — 24 columns. Signature: `HourBars(props: { hours: { hour: number; count: number }[] }): JSX.Element` — `hours.length === 24` invariant.
+- **5.8** `web/src/primitives/index.ts` (modify) — export the four new primitives.
+- **5.9** `web/src/routes/stats.tsx` (replace) — drop the stub. SubBar with range pills (`7d`/`30d`/`90d`/`all`) — clicking a pill updates URL search param `?range=…` (single source of truth). Body renders the 6-card grid via `.stats-grid` CSS, each card consuming one slice of `Stats`. Per-card `EmptyState` when its slice is empty.
+- **5.10** `web/src/styles/stats.css` (create) — port `.stats-grid`, `.card`, `.card-head`, `.card-body` rules from `prototype.css:310-544`.
+- **5.11** `web/src/routes/__root.tsx:8-11` (modify) — import `../styles/projects.css` and `../styles/stats.css` so the new pages have their styles.
+- Commit: `web: stats route with backend-driven chart primitives`
+
+### Test strategy
+
+- **Phase 1** (TDD): `project/repository_test.go` covers the six bullets in 1.6; `repository_test.go::TestList_FilterByCwd` covers 1.3; `project/handler_test.go` covers HTTP edges.
+- **Phase 2** (TDD): `stats/buckets_test.go` covers each helper deterministically (inject `now`); `stats/repository_test.go` covers slice population per range and owner scope.
+- **Phase 3** (TDD): `adapters.test.ts` for `adaptProject`; route-level rendering verified manually against the prototype.
+- **Phase 4** (no TDD): visual + manual; the underlying data path (`useSessions` with cwd) is exercised by the existing tests once the optional field is plumbed.
+- **Phase 5** (TDD): `adapters.test.ts` for `adaptStats`; chart primitives verified manually (no logic worth a unit test beyond what the rendered SVG shows).
+
+### Order & dependencies
+
+- Phase 1 ⊥ Phase 2 (different domain packages; can land in parallel).
+- Phase 3 → Phase 4 (Phase 4 reuses `useProjects` for the header card meta).
+- Phase 5 ⊥ Phases 3-4 once Phase 2 is in.
+- Sequence: 1 → 2 → 3 → 4 → 5. Ships incrementally; each phase replaces a foundation stub and is independently smoke-testable.
+
+### Open questions / risks / rollback
+
+- **Splat route compatibility (4.1)**: spike during execute. Fallback: encode `/` in the linker, single-segment route. ~10 LoC swap.
+- **Aggregate query plan**: at the seeded fixture sizes the GROUP BYs are unindexed scans. If `EXPLAIN QUERY PLAN` shows the `top_cwd` query scanning `turns`, add a covering index `(owner, timestamp)` in a sibling commit on Phase 2. Not blocking.
+- **Stats response size**: `daily` × 60 days × per-tool fan-out ≈ a few KB; `top_cwd` capped at 20. Within the design's "few KB" expectation; no streaming.
+- **Rollback**: each phase's commit is independently revertible. Phase 1 revert leaves `?cwd=` unsupported (silently ignored — chi treats unknown query params as inert). Phase 2 revert leaves the stats route 404 from a future revert; until UI lands, nothing reads it. Phases 3-5 are SPA-only.
+
+### Backwards-compat check
+
+All risks restated from Design:
+- Two new endpoints — no existing consumers, no compat risk.
+- `?cwd=` on `/sessions` is additive; old clients ignore the param and get the existing behaviour.
+- No schema migrations; `internal/shared/wire/` untouched (verify with `git diff` at end of execute).
+- No removed/renamed JSON fields anywhere.