Status: Reviewed
Module: sourcecraft.dev/bigbes/lethe
Branch: master
Worktree: none
Parent RFC: Personal AI Assistant Log Aggregator (2026-04-25)
Design source: docs/design_handoff_assistant_log/ (Direction 4 — Dense Data UI)
Sibling tasks:
lethe-server.md (#1, ✓ Verified)lethe-web-ui-foundation.md (#4, ✓ Reviewed)lethe-collector-claude-code.md (#2, deferred)lethe-search-and-opencode.md (#3, deferred)lethe-web-ui-palette-savedsearch.md (#6, deferred)lethe-web-ui-search.md (#7, blocked on #3)lethe-web-ui-settings-display.md (#8, deferred)lethe-web-ui-health-sources.md (#9, blocked on #2)Add the two backend aggregation endpoints (/api/v1/projects, /api/v1/stats) the foundation deferred, plus the three frontend screens that consume them: Projects index, Project detail, Stats. Replaces three of the foundation's stub routes (/projects, /project/$, /stats) with real content.
In scope: Go aggregation queries; the new project URL pattern; chart primitives (stacked-bar, heatmap, horizontal-bar, hour-bar) inline in SVG; range/group-by sub-bar controls. Out of scope: Search route, Health route, Settings → Display, palette PROJECT/SESSION items, saved searches.
Backend — two endpoints, both additive:
GET /api/v1/projects?since=<unix>&limit=&offset= returns rows of { cwd, sessions, turn_count, tokens_in_total, tokens_out_total, last_active, hosts[], tools[], top_tool }. SQL groups by working_dir over the existing sessions+turns join, ordered by MAX(ended_at) DESC. Owner-scoped via existing auth middleware.GET /api/v1/stats?range=7d|30d|90d|all returns a single fat JSON response with six cards in one round-trip:
per_tool: { tool, sessions, turns, tokens_in, tokens_out, daily_sparkline: number[N] }[] for the requested rangedaily: { date_unix, per_tool: { [tool: string]: number /*turns*/ } }[] over the last 60 daysheatmap: { date_unix, count }[] — 84 cells (12 weeks × 7 days), fixed windowtop_cwd: { cwd, count }[] — top 20 cwds by turn count in the requested rangehour_of_day: { hour, count }[] — 24 buckets over the rangehost_split: { host, count }[] over the rangeapierror.Render for error paths; both honor admin ?owner= override exactly like sessions list.Backend — one tiny extension to sessions list: GET /api/v1/sessions?cwd=<exact> accepts a new optional filter on working_dir. Project detail page uses this to pull a cwd's sessions; no separate project-detail endpoint. Wire/schema unchanged.
Frontend:
useProjects(filters) — TanStack Query hook for /api/v1/projects.useStats(range) — TanStack Query hook for /api/v1/stats.web/src/routes/projects.tsx (replaces stub) — projects table.web/src/routes/project.$.tsx (new, $ catch-all so cwd-with-slashes works as a single URL-encoded path segment) — header card + sessions table scoped to that cwd via the new ?cwd= filter.web/src/routes/stats.tsx (replaces stub) — sub-bar with range pills (7d / 30d / 90d / all), 2-column card grid.web/src/primitives/): StackedBars (60×N stacked-bar SVG), Heatmap (12×7 cell grid), HorizontalBars (top-cwd list), HourBars (24-bin column chart). Spark from the foundation is reused for the per-tool sparkline cards. All SVGs inline; no chart library.Project identity: exact working_dir string, no canonicalization. URL is /project/$ with a single segment carrying the URL-encoded cwd. Tradeoff: /Users/x/proj and ~/proj resolved-to-the-same-place show as different projects. Acceptable for v1; canonicalization is a known limitation.
working_dir. Simpler, matches prototype; canonicalization needs OS-level path resolution that's out of scope.GET /api/v1/sessions?cwd= rather than a dedicated endpoint. Avoids duplicating the sessions DTO server-side.7d = now - 7*86400; all omits the range param. Mirrors the existing since pattern on sessions list.top_cwd capped at 20. Card has fixed height; prototype shows ~10 entries.All changes additive:
?cwd=) on /api/v1/sessions; old clients ignore it and get the existing behavior.adaptStats adapter that pipes Go DTOs into the chart-friendly shapes.$ route compatibility with TanStack Router: needs a quick spike — TanStack supports $param (segment) and $ (full splat). Verify the splat captures slash-bearing cwds correctly and Link round-trips them.EXPLAIN QUERY PLAN shows a scan on turns for the top_cwd query, add a covering index (owner, timestamp) in the same execute. Not blocking the phase./api/v1/projects and /api/v1/stats reuse the existing auth middleware. No bypass, no special-casing.?owner= admin override behaves identically across /sessions, /projects, /stats (all-owners when set to *, specific owner when set to a username, default = caller's identity).internal/shared/wire/ changes; no removed/renamed JSON fields.application/problem+json via the existing apierror.Render.working_dir; the server never normalizes paths.apiFetch wrapper. No raw fetch outside api/client.ts.<svg> in TSX; no chart library dependency added.Spark, Tag, ToolDot, EmptyState, Sub, SessionsTable from the foundation. Add chart primitives only where none of these fits.EmptyState per-card. No "0%" placeholders, no synthesized data.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.
/api/v1/projects + sessions ?cwd= filterinternal/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.
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).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.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.session.Repository.List switch on OwnerScope (AllOwners / SpecificOwner / default). Reuse session.OwnerScope directly to avoid divergence.?owner= admin-override behaves identically to /sessions; non-admin override returns 403 at the handler layer.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).internal/domain/project/repository_test.go (create) — TDD targets:
[] (not nil)sessions=2, turn_count=3, last_active = MAX(ended_at)working_dir excluded entirelytop_tool ties broken deterministically (smallest tool name first via ORDER BY tool ASC in the subquery)Hosts/Tools deduped + sortedinternal/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.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).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.project: add /api/v1/projects aggregation; sessions: add ?cwd= filter/api/v1/statsinternal/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.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.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.internal/domain/stats/buckets_test.go (create) — TDD targets per helper: window length, ordering, deterministic boundary at UTC midnight, fill behaviour with sparse rows.internal/domain/stats/repository_test.go (create) — TDD targets:
Stats with all slices zero-length but non-nilPerTool[0].Turns = 3, Daily has the two days populated and others zerorangetop_cwd capped at 20 even when there are moreAllOwners aggregates across owners; SpecificOwner excludes othersinternal/domain/stats/handler_test.go (create) — ?range=7d → 200 with Stats; ?range=foo → 400 problem+json; non-admin ?owner= → 403.internal/server/server.go (modify) — register and mount *stats.Handler exactly like *project.Handler (Phase 1.8 pattern).cmd/lethe/main.go (modify) — register *stats.Repository and *stats.Handler.stats: add /api/v1/stats aggregate endpointweb/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.web/src/api/adapters.test.ts (modify) — add adaptProject cases: empty arrays passthrough, last_active=0 → empty string, ISO conversion at unix=1700000000.web/src/features/projects/useProjects.ts (create) — useProjects(filters: { since?: '7d'|'30d'|'90d'|'all' }): UseQueryResult<Project[]>. Same since→unix translation pattern as useSessions.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).
ProjectsTable(props: { projects: Project[]; cursor: number; onCursor: (i: number) => void; onOpen: (p: Project) => void }): JSX.Elementweb/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.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.web/src/styles/projects.css (create) — .projects-thead, .projects-row, .projects-cols rules ported from prototype's generic .thead/.row rules.web/src/routeTree.gen.ts regenerates automatically when npm run build or the dev server picks up the new file.web: projects index route with real /projects data/project/$)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).
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.web/src/features/home/useSessions.ts:7-11 (modify) — HomeFilters gains optional cwd?: string; queryFn appends cwd to the URL params if present.
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.
ProjectHeader(props: { cwd: string; project: Project | undefined; sessionCount: number }): JSX.Elementweb/src/styles/projects.css (modify) — add .project-header rules; rest is shared with the index.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.)web: project detail route scoped via ?cwd= sessions filterweb/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.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).web/src/features/stats/useStats.ts (create) — useStats(range: '7d'|'30d'|'90d'|'all'): UseQueryResult<Stats>.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.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.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.web/src/primitives/HourBars.tsx (create) — 24 columns. Signature: HourBars(props: { hours: { hour: number; count: number }[] }): JSX.Element — hours.length === 24 invariant.web/src/primitives/index.ts (modify) — export the four new primitives.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.web/src/styles/stats.css (create) — port .stats-grid, .card, .card-head, .card-body rules from prototype.css:310-544.web/src/routes/__root.tsx:8-11 (modify) — import ../styles/projects.css and ../styles/stats.css so the new pages have their styles.web: stats route with backend-driven chart primitivesproject/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.stats/buckets_test.go covers each helper deterministically (inject now); stats/repository_test.go covers slice population per range and owner scope.adapters.test.ts for adaptProject; route-level rendering verified manually against the prototype.useSessions with cwd) is exercised by the existing tests once the optional field is plumbed.adapters.test.ts for adaptStats; chart primitives verified manually (no logic worth a unit test beyond what the rendered SVG shows).useProjects for the header card meta)./ in the linker, single-segment route. ~10 LoC swap.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.daily × 60 days × per-tool fan-out ≈ a few KB; top_cwd capped at 20. Within the design's "few KB" expectation; no streaming.?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.All risks restated from Design:
?cwd= on /sessions is additive; old clients ignore the param and get the existing behaviour.internal/shared/wire/ untouched (verify with git diff at end of execute).Outcome: All five planned phases shipped + two review-fix commits. HEAD = 1818fac. Backend adds /api/v1/projects and /api/v1/stats; SPA replaces three foundation stubs; ?cwd= extension on /api/v1/sessions is additive.
Invariants:
project.Handler.Mount and stats.Handler.Mount register under the existing /api/v1 chi group in server.go, exercised by the same auth.MustIdentity machinery as sessions. Confirmed by handler-level tests covering 403 for non-admin ?owner=.?owner= admin override identical across /sessions, /projects, /stats): each handler resolves scope via session.OwnerScope switch on AllOwners | SpecificOwner | default. Tested per-handler.internal/shared/wire/ changes / no removed JSON fields): git diff 62dbaf9..HEAD -- internal/shared/wire/ schema/ empty.apierror.Render; TestProjectHandler_List_BadSinceReturns400 and TestStatsHandler_BadRange_Returns400 assert the content-type.working_dir): server never normalizes; cwd flows through SQL working_dir = ? with parameter binding.apiFetch only; no raw fetch outside api/client.ts): grep confirmed empty.web/package*.json for recharts|chart.js|d3|victory|nivo empty.StackedBars, Heatmap, HourBars) use inline <svg>; HorizontalBars uses inline <div> bars — see deviation below.EmptyState): routes/stats.tsx renders an EmptyState per empty card; useStats does not synthesize cells.params._splat captures slash-bearing values as a single segment; encodeURIComponent/decodeURIComponent round-trips correctly. Real-browser smoke deferred — see Verified by below.EXPLAIN QUERY PLAN later shows the top_cwd scan dominating at production volume, add covering index (owner, timestamp) on turns.Daily window capped at 60 days when ?range=all: Plan was silent on response size for the all-time case. 60 matches the prototype's daily-card width and the sparkline cap; bounding is the conservative read of the implicit "few KB" expectation in the design.?range defaults to 30d: Plan listed legal values but not absence behavior. Sessions list's analogous ?since defaults to all-time, so a strictly consistent reading would default to all (or 400). Frontend always supplies ?range= explicitly, so the edge never lands in real use. Reviewer to weigh server-default ergonomics if revisited.ProjectsTable: /api/v1/projects returns no per-day series, but the design reuses Spark for an activity column. Renders [0, (sessions/max)*12] — relative-height placeholder, not real time-series. Tension with no-silent-fallbacks; the column is visual ranking, not a time-window claim. Remediation candidates: (a) drop the column, (b) extend /api/v1/projects to emit daily_sparkline []int64 like stats.PerTool does. See Future work.ProjectHeader derives hosts from sessions when project is loading: Plan signature was caller-computes-hosts. Implementer kept the prop but added an internal fallback (use sessions's unique hosts while project is undefined). Avoids header flicker during projects-list fetch; widens component responsibility slightly.__root.tsx: Plan bullet 5.11 said to modify __root.tsx. The established codebase pattern (set by routes/index.tsx's home.css import in the foundation, continued by Phases 3/4) is per-route imports. Sibling-consistency rule wins: stats.css is imported at the top of routes/stats.tsx.toolColor palette inlined in routes/stats.tsx: ToolDot.tsx exports only the component, no palette map. Duplicated a 5-entry palette + hash fallback locally rather than refactoring ToolDot. See Future work.HorizontalBars uses div-based bars, not inline <svg>: Strict-letter reading of the "all chart primitives inline <svg>" invariant flags this. Spirit-of-the-rule reading (no library, all inline TSX, server returns numbers) is preserved. Visually equivalent and simpler than an SVG <rect> implementation. Reviewer to weigh whether to tighten the invariant or convert.internal/server/web/dist/index.html: Vite stamps content-hash asset filenames; bundle changes flip them. Phases 3 and 4 included the regen; Phase 5 didn't. Resolved with follow-up commit f6611d7 (web: regenerate embedded SPA bundle for stats route).ProjectsTable row click — fixed in 3fbbfc8. The inner navigate({ to: '/project/$', ... }) was redundant with the parent's onOpen handler; one click was pushing two history entries (back-button regression). Now matches the SessionsTable single-call pattern..card / .card-head / .card-body rules duplicated in stats.css and shell.css — fixed in 3fbbfc8. Removed the duplicates from stats.css; the rules live in shell.css (loaded globally) only.<Link to={...}> — fixed in 3fbbfc8. HorizontalBars now exposes an onActivate(row) callback instead of href: string; routes/stats.tsx provides the typed navigate({ to: '/project/$', params: { _splat: ... } }). Decouples the primitive from any specific route shape.rawHosts / rawTools fields on Project struct — fixed in 1818fac. The fields had db:"raw_hosts" / db:"raw_tools" tags but the actual scan target is the local rawRow struct; sqlx silently ignored the unexported fields, so they never held data. Earlier deferral ("avoid amending a signed commit") was a weak rationalization; new commit is the right path per repo policy.ProjectsTable, or extend /api/v1/projects with a real daily_sparkline. Justification: the no-silent-fallbacks invariant prefers no column over a fake one. New backend field is the bigger fix and warrants its own task. Cross-references the Phase 3 deviation above.HorizontalBars to inline <svg>. Justification: the literal-vs-spirit ambiguity in the current invariant text invites future drift. The decision belongs to a design revision, not an in-task fix.web/src/lib/toolColors.ts if a third call site for tool colors appears. Today ToolDot and routes/stats.tsx are the only two; one duplication is below the rule-of-three threshold.Result: passed
Positive:
go test ./internal/domain/project/... → okgo test ./internal/domain/stats/... ./internal/domain/session/... → okcd web && npm test → 44/44 vitest cases pass (5 new for adaptStats, 5 for adaptProject)cd web && npm run build → tsc clean + vite 372 modules okgo test ./cmd/lethe/... (multi-user isolation e2e) → ok; exercises steward registration of project.Handler + stats.HandlerNegative:
TestProjectHandler_List_BadSinceReturns400 covers ?since=garbage → 400TestStatsHandler_BadRange_Returns400 covers ?range=foo → 400TestProjectHandler_List_NonAdminOwnerParamReturns403 and TestStatsHandler_NonAdminOwnerParam_Returns403 cover ?owner=*|alice|bob → 403Invariants / assumptions:
internal/shared/wire/ changes") — git diff 62dbaf9..HEAD -- internal/shared/wire/ emptygrep '"(recharts|chart\.js|d3|victory|nivo)"' web/package*.json emptyfetch outside api/client.ts") — grep -rn 'fetch(' web/src finds nothing outside client.ts<svg>") — Spark, Heatmap, StackedBars, HourBars are inline SVG; HorizontalBars uses div-based bars — recorded as a deviation above; spirit-of-the-rule (no library, inline) preservedproject.Handler and stats.Handler mount under the existing /api/v1 chi group via server.go; same auth middleware exercises them as sessions. Confirmed by handler tests using the shared auth.MustIdentity machinery.Interfaces:
useSessions(filters: HomeFilters) accepts cwd?: string) — type-check at routes/project.$.tsx calling useSessions({ cwd, since: 'all' }) passes tsc; ?cwd= round-trips through repository.go queryuseProjects(filters) and useStats(range) shapes match plan) — web/src/features/projects/useProjects.ts and web/src/features/stats/useStats.ts exist with the declared signatures; vitest imports + tsc resolve them/project/$ splat route registered) — grep ProjectSplat web/src/routeTree.gen.ts shows path: '/project/$' and '/project/$': typeof ProjectSplatRouteGET /api/v1/projects returns { projects, limit, offset } JSON) — confirmed via TestProjectHandler_List_OneProject decoding and asserting fields/projects, /project/<encoded cwd>, and /stats?range=… were not exercised in a real browser. The frontend is verified by tsc + vitest + bundle build, and the backend is verified by handler-level Go tests. Standing up the running server requires an auth bearer token + a populated DB; out of reach in autonomous mode without user input. Recommend the user (a) run go run ./cmd/lethe, (b) authenticate, (c) walk through the three new routes, before sending to review. Risk if skipped: SPA-side runtime regressions not surfaced by tsc (e.g. an unhandled async rejection, a missing CSS class) escape to review.