From 62dbaf9fc5063d407c272427cfd7e2159613fa61 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 08:01:21 +0300 Subject: [PATCH] docs(lethe-web-ui-aggregates): add implementation plan --- docs/tasks/lethe-web-ui-aggregates.md | 139 ++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/docs/tasks/lethe-web-ui-aggregates.md b/docs/tasks/lethe-web-ui-aggregates.md index 5285704eac13514a75a8a9faf947eef3cd637862..008ce6f329d0461f51ec1634ac18b5b5382091e4 100644 --- a/docs/tasks/lethe-web-ui-aggregates.md +++ b/docs/tasks/lethe-web-ui-aggregates.md @@ -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`. 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/$`. 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 ` projects · ranked by recent activity` + a single `since` FilterChip (clone the home one or inline a simple `