~bigbes/lethe

e075b986729b67f2afc74b4c069d1793e1f63cea — Eugene Blikh a month ago 321125b
web: projects index route with real /projects data
M internal/server/web/dist/index.html => internal/server/web/dist/index.html +2 -2
@@ 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"
    />
    <script type="module" crossorigin src="/assets/index-BerkcgAa.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-D5vXNu5Y.css">
    <script type="module" crossorigin src="/assets/index-CS8MuTLY.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-agYuYU0K.css">
  </head>
  <body class="density-compact">
    <div id="root"></div>

M web/src/api/adapters.test.ts => web/src/api/adapters.test.ts +53 -2
@@ 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> = {}): SessionDTO {
  return {


@@ 57,3 57,54 @@ describe('adaptSession', () => {
    expect(s.ended).toBe(new Date(endedAt * 1000).toISOString())
  })
})

function makeProjectDTO(overrides: Partial<ProjectDTO> = {}): 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'])
  })
})

M web/src/api/adapters.ts => web/src/api/adapters.ts +40 -0
@@ 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,
  }
}

A web/src/features/projects/ProjectsTable.tsx => web/src/features/projects/ProjectsTable.tsx +105 -0
@@ 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 (
      <div className="body body-pad">
        <EmptyState glyph="∅" copy="no projects match these filters" />
      </div>
    )
  }

  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 (
    <div className="projects-table body">
      <div className="projects-thead projects-cols" style={{ gridTemplateColumns: COLS }}>
        <span>cwd</span>
        <span className="right">sessions</span>
        <span className="right">tok</span>
        <span className="right">last</span>
        <span>top tool</span>
        <span className="right">activity</span>
      </div>
      {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 (
          <div
            key={p.cwd}
            className={'projects-row projects-cols' + (isCursor ? ' cursor' : '')}
            style={{ gridTemplateColumns: COLS }}
            onClick={() => { onCursor(i); handleOpen(p) }}
            onMouseEnter={() => onCursor(i)}
          >
            <span className="mono truncate">{p.cwd}</span>
            <span className="right mono">{p.sessions}</span>
            <span className="right mono muted">{formatTok(p.tokensIn + p.tokensOut)}</span>
            <span className="right mono muted">{formatLastActive(p.lastActive)}</span>
            <span className="mono">
              {p.topTool ? (
                <>
                  <ToolDot tool={p.topTool} />
                  {' '}{p.topTool}
                </>
              ) : (
                <span className="muted">—</span>
              )}
            </span>
            <span className="right">
              <Spark points={sparkPoints} w={70} h={12} accent={i === 0} />
            </span>
          </div>
        )
      })}
    </div>
  )
}

A web/src/features/projects/useProjects.ts => web/src/features/projects/useProjects.ts +45 -0
@@ 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<Project[]> {
  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<ProjectsResponse>(url)
      return data.projects.map(adaptProject)
    },
  })
}

A web/src/features/projects/useProjectsCursor.ts => web/src/features/projects/useProjectsCursor.ts +37 -0
@@ 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 }
}

M web/src/routes/projects.tsx => web/src/routes/projects.tsx +141 -9
@@ 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<string>(['7d', '30d', '90d', 'all'])

export const Route = createFileRoute('/projects')({
  validateSearch: (search: Record<string, unknown>): 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 (
    <div className="since-btn-group">
      {options.map(o => (
        <button
          key={o}
          className={'since-btn' + (value === o ? ' active' : '')}
          onClick={() => onChange(o)}
          type="button"
        >
          {o}
        </button>
      ))}
    </div>
  )
}

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 = (
    <SubBar>
      <span className="mono muted">
        {projects != null ? `${projects.length} projects` : '…'} · ranked by recent activity
      </span>
      <span style={{ flex: 1 }} />
      <SinceButtonGroup value={since} onChange={handleSinceChange} />
    </SubBar>
  )

  if (isLoading) {
    return (
      <>
        {subBar}
        <div className="body body-pad">
          <Sub>loading…</Sub>
        </div>
      </>
    )
  }

  if (error != null) {
    if (error instanceof AuthError) {
      return (
        <div className="body body-pad" style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}>
          <div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
            <div className="uppercase-mono" style={{ marginBottom: 8 }}>not authenticated</div>
            <div className="muted">Sign in to view your projects.</div>
          </div>
        </div>
      )
    }
    const detail = error instanceof APIError ? error.message : String(error)
    return (
      <div className="body body-pad" style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}>
        <div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
          <div className="uppercase-mono" style={{ marginBottom: 8 }}>error</div>
          <div className="muted">{detail}</div>
        </div>
      </div>
    )
  }

  return (
    <>
      <SubBar>
        <Tag kind="neutral">projects</Tag>
      </SubBar>
      <div className="body body-pad">
        <EmptyState glyph="∅" copy="coming in a later task" />
      </div>
      {subBar}
      <ProjectsTable
        projects={projects ?? []}
        cursor={cursor}
        onCursor={jumpTo}
        onOpen={handleOpen}
      />
    </>
  )
}

A web/src/styles/projects.css => web/src/styles/projects.css +77 -0
@@ 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);
}