From e075b986729b67f2afc74b4c069d1793e1f63cea Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 08:34:01 +0300 Subject: [PATCH] web: projects index route with real /projects data --- internal/server/web/dist/index.html | 4 +- web/src/api/adapters.test.ts | 55 ++++++- web/src/api/adapters.ts | 40 +++++ web/src/features/projects/ProjectsTable.tsx | 105 ++++++++++++ web/src/features/projects/useProjects.ts | 45 ++++++ .../features/projects/useProjectsCursor.ts | 37 +++++ web/src/routes/projects.tsx | 150 ++++++++++++++++-- web/src/styles/projects.css | 77 +++++++++ 8 files changed, 500 insertions(+), 13 deletions(-) create mode 100644 web/src/features/projects/ProjectsTable.tsx create mode 100644 web/src/features/projects/useProjects.ts create mode 100644 web/src/features/projects/useProjectsCursor.ts create mode 100644 web/src/styles/projects.css diff --git a/internal/server/web/dist/index.html b/internal/server/web/dist/index.html index 64bea57b0caf1f33eec905f82ecb9c50e6e4e0fb..29249f12b2cf6264081f86de4a153a75fe03cb50 100644 --- a/internal/server/web/dist/index.html +++ b/internal/server/web/dist/index.html @@ -10,8 +10,8 @@ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" /> - - + +
diff --git a/web/src/api/adapters.test.ts b/web/src/api/adapters.test.ts index 574eb741c4cb2398d5dcd6d3826acde1038a6047..a7d7b9f2716861eb33b7e681ea20144c1abcaa13 100644 --- a/web/src/api/adapters.test.ts +++ b/web/src/api/adapters.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' -import { adaptSession } from './adapters' -import type { SessionDTO } from './adapters' +import { adaptSession, adaptProject } from './adapters' +import type { SessionDTO, ProjectDTO } from './adapters' function makeDTO(overrides: Partial = {}): SessionDTO { return { @@ -57,3 +57,54 @@ describe('adaptSession', () => { expect(s.ended).toBe(new Date(endedAt * 1000).toISOString()) }) }) + +function makeProjectDTO(overrides: Partial = {}): ProjectDTO { + return { + cwd: '/home/user/code/project', + sessions: 3, + turn_count: 12, + tokens_in_total: 5000, + tokens_out_total: 8000, + last_active: 1_700_000_000, + hosts: ['laptop', 'workpc'], + tools: ['claude-code', 'opencode'], + top_tool: 'claude-code', + ...overrides, + } +} + +describe('adaptProject', () => { + it('passes through empty arrays unchanged', () => { + const p = adaptProject(makeProjectDTO({ hosts: [], tools: [] })) + expect(p.hosts).toEqual([]) + expect(p.tools).toEqual([]) + }) + + it('last_active=0 → lastActive === ""', () => { + const p = adaptProject(makeProjectDTO({ last_active: 0 })) + expect(p.lastActive).toBe('') + }) + + it('last_active=1700000000 → ISO string "2023-11-14T22:13:20.000Z"', () => { + const p = adaptProject(makeProjectDTO({ last_active: 1_700_000_000 })) + expect(p.lastActive).toBe(new Date(1_700_000_000 * 1000).toISOString()) + // Verify the concrete value for determinism + expect(p.lastActive).toBe('2023-11-14T22:13:20.000Z') + }) + + it('maps snake_case fields to camelCase', () => { + const p = adaptProject(makeProjectDTO()) + expect(p.turns).toBe(12) + expect(p.tokensIn).toBe(5000) + expect(p.tokensOut).toBe(8000) + expect(p.topTool).toBe('claude-code') + expect(p.sessions).toBe(3) + expect(p.cwd).toBe('/home/user/code/project') + }) + + it('passes through hosts and tools arrays', () => { + const p = adaptProject(makeProjectDTO({ hosts: ['laptop'], tools: ['claude-code', 'opencode'] })) + expect(p.hosts).toEqual(['laptop']) + expect(p.tools).toEqual(['claude-code', 'opencode']) + }) +}) diff --git a/web/src/api/adapters.ts b/web/src/api/adapters.ts index 3a73c1c0ce6fd5ede16ed4a6ee06e14ece854f0b..1fc1368f7a8bfd47aa589be2041496215ed1b2b3 100644 --- a/web/src/api/adapters.ts +++ b/web/src/api/adapters.ts @@ -49,3 +49,43 @@ export function adaptSession(d: SessionDTO): Session { hasError: false, } } + +// ── Projects ───────────────────────────────────────────────────────────────── + +export interface ProjectDTO { + cwd: string + sessions: number + turn_count: number + tokens_in_total: number + tokens_out_total: number + last_active: number // unix epoch seconds; 0 = never + hosts: string[] + tools: string[] + top_tool: string +} + +export interface Project { + cwd: string + sessions: number + turns: number + tokensIn: number + tokensOut: number + lastActive: string // ISO 8601, or '' when last_active === 0 + hosts: string[] + tools: string[] + topTool: string +} + +export function adaptProject(d: ProjectDTO): Project { + return { + cwd: d.cwd, + sessions: d.sessions, + turns: d.turn_count, + tokensIn: d.tokens_in_total, + tokensOut: d.tokens_out_total, + lastActive: d.last_active === 0 ? '' : new Date(d.last_active * 1000).toISOString(), + hosts: d.hosts, + tools: d.tools, + topTool: d.top_tool, + } +} diff --git a/web/src/features/projects/ProjectsTable.tsx b/web/src/features/projects/ProjectsTable.tsx new file mode 100644 index 0000000000000000000000000000000000000000..47b36e16d710502368922e3eb8bb54034657a883 --- /dev/null +++ b/web/src/features/projects/ProjectsTable.tsx @@ -0,0 +1,105 @@ +import React from 'react' +import { useNavigate } from '@tanstack/react-router' +import { EmptyState, ToolDot, Spark } from '../../primitives' +import type { Project } from '../../api/adapters' + +interface ProjectsTableProps { + projects: Project[] + cursor: number + onCursor: (i: number) => void + onOpen: (p: Project) => void +} + +const COLS = '1fr 90px 90px 110px 110px 90px' + +// Format TOK: 12400 → "12.4k"; small numbers unchanged +function formatTok(n: number): string { + if (n >= 1000) { + return (n / 1000).toFixed(1) + 'k' + } + return String(n) +} + +// Format lastActive ISO string: recent = "Apr 25 11:08"; empty = "—" +function formatLastActive(iso: string): string { + if (!iso) return '—' + const d = new Date(iso) + const now = new Date() + const diffMs = now.getTime() - d.getTime() + const h24 = 24 * 60 * 60 * 1000 + + if (diffMs <= h24) { + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) + } + return ( + d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + + ' ' + + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) + ) +} + +export function ProjectsTable({ projects, cursor, onCursor, onOpen }: ProjectsTableProps): React.JSX.Element { + const navigate = useNavigate() + + if (projects.length === 0) { + return ( +
+ +
+ ) + } + + function handleOpen(p: Project) { + onOpen(p) + // Route /project/$ is added in Phase 4; cast needed until routeTree is regenerated. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void (navigate as any)({ to: '/project/$', params: { _splat: encodeURIComponent(p.cwd) } }) + } + + const maxSessions = Math.max(...projects.map(p => p.sessions), 1) + + return ( +
+
+ cwd + sessions + tok + last + top tool + activity +
+ {projects.map((p, i) => { + const isCursor = i === cursor + // Build a minimal sparkline: normalise sessions relative to max, scaled to 0–12px height + const sparkPoints = [0, (p.sessions / maxSessions) * 12] + return ( +
{ onCursor(i); handleOpen(p) }} + onMouseEnter={() => onCursor(i)} + > + {p.cwd} + {p.sessions} + {formatTok(p.tokensIn + p.tokensOut)} + {formatLastActive(p.lastActive)} + + {p.topTool ? ( + <> + + {' '}{p.topTool} + + ) : ( + + )} + + + + +
+ ) + })} +
+ ) +} diff --git a/web/src/features/projects/useProjects.ts b/web/src/features/projects/useProjects.ts new file mode 100644 index 0000000000000000000000000000000000000000..29354e1bd4320c08773bc381e1bf92b7499440ec --- /dev/null +++ b/web/src/features/projects/useProjects.ts @@ -0,0 +1,45 @@ +import { useQuery } from '@tanstack/react-query' +import type { UseQueryResult } from '@tanstack/react-query' +import { apiFetch } from '../../api/client' +import { adaptProject } from '../../api/adapters' +import type { Project, ProjectDTO } from '../../api/adapters' + +export interface ProjectFilters { + since?: '7d' | '30d' | '90d' | 'all' +} + +interface ProjectsResponse { + projects: ProjectDTO[] + limit: number + offset: number +} + +function sinceToEpoch(since: string): number { + const now = Math.floor(Date.now() / 1000) + switch (since) { + case '7d': return now - 7 * 86400 + case '30d': return now - 30 * 86400 + case '90d': return now - 90 * 86400 + default: return 0 + } +} + +export function useProjects(filters: ProjectFilters): UseQueryResult { + const since = filters.since ?? '30d' + + return useQuery({ + queryKey: ['projects', filters], + queryFn: async () => { + const params = new URLSearchParams() + + if (since !== 'all') { + params.set('since', String(sinceToEpoch(since))) + } + + const qs = params.toString() + const url = `/api/v1/projects${qs ? `?${qs}` : ''}` + const data = await apiFetch(url) + return data.projects.map(adaptProject) + }, + }) +} diff --git a/web/src/features/projects/useProjectsCursor.ts b/web/src/features/projects/useProjectsCursor.ts new file mode 100644 index 0000000000000000000000000000000000000000..101ab0bc85068dc1e5893dedd87b42a5dc620178 --- /dev/null +++ b/web/src/features/projects/useProjectsCursor.ts @@ -0,0 +1,37 @@ +import { useState, useCallback } from 'react' + +export interface ProjectsCursor { + cursor: number + move(d: 1 | -1): void + activate(): void + jumpTo(i: number): void +} + +export function useProjectsCursor( + projectCount: number, + onActivate: (idx: number) => void, +): ProjectsCursor { + const [cursor, setCursor] = useState(0) + + const move = useCallback( + (d: 1 | -1) => { + if (projectCount === 0) return + setCursor(c => Math.max(0, Math.min(projectCount - 1, c + d))) + }, + [projectCount], + ) + + const activate = useCallback(() => { + onActivate(cursor) + }, [cursor, onActivate]) + + const jumpTo = useCallback( + (i: number) => { + if (projectCount === 0) return + setCursor(Math.max(0, Math.min(projectCount - 1, i))) + }, + [projectCount], + ) + + return { cursor, move, activate, jumpTo } +} diff --git a/web/src/routes/projects.tsx b/web/src/routes/projects.tsx index 17885363f0f77fae34dcb87baba572cbe88065da..57a7d77b5bdd32fb5bd7ffa3578e095df9628cb3 100644 --- a/web/src/routes/projects.tsx +++ b/web/src/routes/projects.tsx @@ -1,21 +1,153 @@ -import { createFileRoute } from '@tanstack/react-router' -import React from 'react' -import { EmptyState, Tag } from '../primitives' +import React, { useEffect, useCallback } from 'react' +import { createFileRoute, useNavigate } from '@tanstack/react-router' import { SubBar } from '../shell/SubBar' +import { Sub } from '../primitives' +import { ProjectsTable } from '../features/projects/ProjectsTable' +import { useProjects } from '../features/projects/useProjects' +import { useProjectsCursor } from '../features/projects/useProjectsCursor' +import { useKeyboardCursor } from './__root' +import { AuthError, APIError } from '../api/client' +import type { ProjectFilters } from '../features/projects/useProjects' +import type { Project } from '../api/adapters' +import '../styles/projects.css' + +type ProjectsSearch = { + since?: '7d' | '30d' | '90d' | 'all' +} + +const VALID_SINCE = new Set(['7d', '30d', '90d', 'all']) export const Route = createFileRoute('/projects')({ + validateSearch: (search: Record): ProjectsSearch => { + const since = + typeof search['since'] === 'string' && VALID_SINCE.has(search['since']) + ? (search['since'] as ProjectsSearch['since']) + : undefined + return { since } + }, component: ProjectsRoute, }) +function SinceButtonGroup({ + value, + onChange, +}: { + value: string + onChange: (v: ProjectsSearch['since']) => void +}): React.JSX.Element { + const options = ['7d', '30d', '90d', 'all'] as const + return ( +
+ {options.map(o => ( + + ))} +
+ ) +} + function ProjectsRoute(): React.JSX.Element { + const navigate = useNavigate() + const search = Route.useSearch() + + const since = search.since ?? '30d' + + const filters: ProjectFilters = { since } + + const { data: projects, isLoading, error } = useProjects(filters) + + const handleOpen = useCallback( + (p: Project) => { + // Route /project/$ is added in Phase 4; cast needed until routeTree is regenerated. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void (navigate as any)({ to: '/project/$', params: { _splat: encodeURIComponent(p.cwd) } }) + }, + [navigate], + ) + + const { cursor, move, activate, jumpTo } = useProjectsCursor( + projects?.length ?? 0, + useCallback( + (idx: number) => { + if (projects && projects[idx]) handleOpen(projects[idx]) + }, + [projects, handleOpen], + ), + ) + + const cursorRef = useKeyboardCursor() + useEffect(() => { + cursorRef.current = { move, activate } + return () => { + cursorRef.current = { + move: (_d: 1 | -1) => { /* no-op */ }, + activate: () => { /* no-op */ }, + } + } + }, [cursorRef, move, activate]) + + function handleSinceChange(next: ProjectsSearch['since']) { + void navigate({ to: '/projects', search: { since: next } }) + } + + const subBar = ( + + + {projects != null ? `${projects.length} projects` : '…'} · ranked by recent activity + + + + + ) + + if (isLoading) { + return ( + <> + {subBar} +
+ loading… +
+ + ) + } + + if (error != null) { + if (error instanceof AuthError) { + return ( +
+
+
not authenticated
+
Sign in to view your projects.
+
+
+ ) + } + const detail = error instanceof APIError ? error.message : String(error) + return ( +
+
+
error
+
{detail}
+
+
+ ) + } + return ( <> - - projects - -
- -
+ {subBar} + ) } diff --git a/web/src/styles/projects.css b/web/src/styles/projects.css new file mode 100644 index 0000000000000000000000000000000000000000..7e1948584a0391707cd54bf9d3fd8c47b9f80de7 --- /dev/null +++ b/web/src/styles/projects.css @@ -0,0 +1,77 @@ +/* Projects route — projects table */ + +.projects-table { + flex: 1; + overflow: auto; + min-height: 0; +} + +.projects-thead { + display: grid; + padding: 5px 14px; + background: var(--paper-3); + font-size: 10px; + color: var(--ink-3); + text-transform: uppercase; + letter-spacing: 0.05em; + font-family: var(--mono); + align-items: center; + gap: 10px; + border-bottom: 1px solid var(--rule-2); + flex: none; +} + +.projects-row { + display: grid; + padding: 4px 14px; + border-bottom: 1px solid var(--rule-2); + align-items: center; + gap: 10px; + font-size: 11.5px; + cursor: pointer; + color: var(--ink); +} + +.projects-row:hover { + background: var(--paper-2); +} + +.projects-row.cursor { + background: var(--accent-soft); + border-left: 2px solid var(--accent); + padding-left: 12px; +} + +/* Column grid: CWD · SESSIONS · TOK · LAST · TOP TOOL · ACTIVITY */ +.projects-cols { + grid-template-columns: 1fr 90px 90px 110px 110px 90px; +} + +/* Since filter button group in the SubBar */ +.since-btn-group { + display: flex; + gap: 2px; +} + +.since-btn { + padding: 2px 8px; + font-size: 10.5px; + font-family: var(--mono); + border: 1px solid var(--rule); + background: transparent; + color: var(--ink-3); + cursor: pointer; + border-radius: 3px; + line-height: 1.6; +} + +.since-btn:hover { + background: var(--paper-2); + color: var(--ink); +} + +.since-btn.active { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent-ink); +}