~bigbes/lethe

dae9e25f7020a566979b2b93fa1b11bcaef23a11 — Eugene Blikh a month ago 12df3f4
web: sectioned /settings with saved-searches CRUD

Add four TanStack Query hooks (useSavedSearches, useCreateSavedSearch,
useUpdateSavedSearch, useDeleteSavedSearch) backed by IF3 contract.
Introduce apiFetchVoid in client.ts for the 204 No Content DELETE path.
Replace the placeholder /settings route with a two-column sectioned shell
(SectionRail + SavedSearchesSection); Display section is disabled pending #8.
M internal/server/web/dist/index.html => internal/server/web/dist/index.html +2 -2
@@ 13,8 13,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-Ch2vbLAm.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-BjmMFw4W.css">
    <script type="module" crossorigin src="/assets/index-CObHBPUh.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-BqeYZ54s.css">
  </head>
  <body class="density-compact">
    <div id="root"></div>

M web/src/api/client.ts => web/src/api/client.ts +31 -0
@@ 43,3 43,34 @@ export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> 

  return resp.json() as Promise<T>
}

/**
 * apiFetchVoid is a variant of apiFetch for endpoints that return no body
 * (e.g. DELETE → 204 No Content). It shares the same auth and error handling
 * as apiFetch but does not attempt to parse the response body.
 */
export async function apiFetchVoid(path: string, init?: RequestInit): Promise<void> {
  const resp = await fetch(path, {
    ...init,
    headers: {
      Accept: 'application/json',
      ...init?.headers,
    },
  })

  if (resp.status === 401) {
    throw new AuthError('not authenticated')
  }

  if (!resp.ok) {
    const ct = resp.headers.get('Content-Type') ?? ''
    if (ct.includes('application/problem+json')) {
      const body = await resp.json() as { detail?: string; title?: string; status?: number; code?: string }
      throw new APIError(body.detail ?? body.title ?? 'error', body.status ?? resp.status, body.code ?? '')
    }
    if (resp.status >= 500) {
      throw new APIError('server error', resp.status, '')
    }
    throw new APIError(`request failed: ${resp.status}`, resp.status, '')
  }
}

A web/src/features/settings/SavedSearchesSection.tsx => web/src/features/settings/SavedSearchesSection.tsx +227 -0
@@ 0,0 1,227 @@
import React, { useState } from 'react'
import { EmptyState, Sub } from '../../primitives'
import { AuthError, APIError } from '../../api/client'
import type { SavedSearch } from '../../api/adapters'
import {
  useSavedSearches,
  useCreateSavedSearch,
  useUpdateSavedSearch,
  useDeleteSavedSearch,
} from './useSavedSearches'

// ── Inline "Add new" form ─────────────────────────────────────────────────────

function AddForm(): React.JSX.Element {
  const [name, setName] = useState('')
  const [query, setQuery] = useState('')
  const createMutation = useCreateSavedSearch()

  function handleSave(e: React.FormEvent) {
    e.preventDefault()
    if (!name.trim() || !query.trim()) return
    createMutation.mutate(
      { name: name.trim(), query: query.trim() },
      {
        onSuccess: () => {
          setName('')
          setQuery('')
        },
      },
    )
  }

  return (
    <form className="saved-search-form" onSubmit={handleSave}>
      <input
        className="saved-search-input"
        placeholder="name"
        value={name}
        onChange={(e) => setName(e.target.value)}
        autoComplete="off"
      />
      <input
        className="saved-search-input saved-search-input-mono"
        placeholder="query"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        autoComplete="off"
      />
      <button
        className="saved-search-btn"
        type="submit"
        disabled={createMutation.isPending}
      >
        save
      </button>
      {createMutation.isError && (
        <span className="saved-search-error">
          {createMutation.error instanceof APIError
            ? createMutation.error.message
            : String(createMutation.error)}
        </span>
      )}
    </form>
  )
}

// ── Per-row inline edit form ──────────────────────────────────────────────────

interface RowProps {
  row: SavedSearch
}

function SavedSearchRow({ row }: RowProps): React.JSX.Element {
  const [editing, setEditing] = useState(false)
  const [name, setName] = useState(row.name)
  const [query, setQuery] = useState(row.query)
  const updateMutation = useUpdateSavedSearch()
  const deleteMutation = useDeleteSavedSearch()

  function handleSave(e: React.FormEvent) {
    e.preventDefault()
    updateMutation.mutate(
      { oldName: row.name, name: name.trim() || undefined, query: query.trim() || undefined },
      { onSuccess: () => setEditing(false) },
    )
  }

  function handleDelete() {
    deleteMutation.mutate({ name: row.name })
  }

  if (editing) {
    return (
      <div className="saved-searches-row saved-searches-row-editing">
        <form className="saved-search-form" onSubmit={handleSave}>
          <input
            className="saved-search-input"
            value={name}
            onChange={(e) => setName(e.target.value)}
            autoComplete="off"
          />
          <input
            className="saved-search-input saved-search-input-mono"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            autoComplete="off"
          />
          <button className="saved-search-btn" type="submit" disabled={updateMutation.isPending}>
            save
          </button>
          <button
            className="saved-search-btn"
            type="button"
            onClick={() => {
              setEditing(false)
              setName(row.name)
              setQuery(row.query)
            }}
          >
            cancel
          </button>
        </form>
        {updateMutation.isError && (
          <span className="saved-search-error">
            {updateMutation.error instanceof APIError
              ? updateMutation.error.message
              : String(updateMutation.error)}
          </span>
        )}
      </div>
    )
  }

  return (
    <div className="saved-searches-row">
      <span className="saved-searches-cell saved-searches-name">{row.name}</span>
      <span className="saved-searches-cell saved-searches-query mono">{row.query}</span>
      <span className="saved-searches-cell saved-searches-updated muted">
        {new Date(row.updatedAt).toLocaleDateString()}
      </span>
      <span className="saved-searches-cell saved-searches-actions">
        <button
          className="saved-search-btn"
          type="button"
          onClick={() => setEditing(true)}
        >
          edit
        </button>
        <button
          className="saved-search-btn saved-search-btn-danger"
          type="button"
          onClick={handleDelete}
          disabled={deleteMutation.isPending}
        >
          delete
        </button>
      </span>
      {deleteMutation.isError && (
        <span className="saved-search-error">
          {deleteMutation.error instanceof APIError
            ? deleteMutation.error.message
            : String(deleteMutation.error)}
        </span>
      )}
    </div>
  )
}

// ── Main section ──────────────────────────────────────────────────────────────

export function SavedSearchesSection(): React.JSX.Element {
  const { data, isLoading, error } = useSavedSearches()

  if (isLoading) {
    return <Sub>loading…</Sub>
  }

  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 manage your saved searches.</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 (
    <div className="saved-searches-section">
      <div className="saved-searches-heading">Saved searches</div>
      <AddForm />
      {data != null && data.length === 0 ? (
        <EmptyState glyph="∅" copy="no saved searches yet" />
      ) : (
        <div className="saved-searches-table">
          <div className="saved-searches-thead">
            <span>Name</span>
            <span>Query</span>
            <span>Updated</span>
            <span></span>
          </div>
          {data?.map((row) => (
            <SavedSearchRow key={row.name} row={row} />
          ))}
        </div>
      )}
    </div>
  )
}

A web/src/features/settings/SectionRail.tsx => web/src/features/settings/SectionRail.tsx +57 -0
@@ 0,0 1,57 @@
import React from 'react'

interface Section<K extends string> {
  key: K
  label: string
  disabled?: boolean
  tag?: string
}

interface SectionRailProps<K extends string> {
  sections: Section<K>[]
  active: K
  onSelect: (k: K) => void
}

export function SectionRail<K extends string>({
  sections,
  active,
  onSelect,
}: SectionRailProps<K>): React.JSX.Element {
  return (
    <div className="section-rail">
      {sections.map((s) => {
        const isActive = s.key === active
        const cls = [
          'section-row',
          isActive ? 'active' : '',
          s.disabled ? 'disabled' : '',
        ]
          .filter(Boolean)
          .join(' ')

        return (
          <div
            key={s.key}
            className={cls}
            onClick={s.disabled ? undefined : () => onSelect(s.key)}
            role={s.disabled ? undefined : 'button'}
            tabIndex={s.disabled ? undefined : 0}
            onKeyDown={
              s.disabled
                ? undefined
                : (e) => {
                    if (e.key === 'Enter' || e.key === ' ') onSelect(s.key)
                  }
            }
          >
            {s.label}
            {s.tag != null && (
              <span className="section-row-tag">{s.tag}</span>
            )}
          </div>
        )
      })}
    </div>
  )
}

A web/src/features/settings/useSavedSearches.test.ts => web/src/features/settings/useSavedSearches.test.ts +94 -0
@@ 0,0 1,94 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import React from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useSavedSearches, useCreateSavedSearch } from './useSavedSearches'
import type { SavedSearchDTO } from '../../api/adapters'

// ── Mock apiFetch ─────────────────────────────────────────────────────────────

const mockApiFetch = vi.fn()

vi.mock('../../api/client', () => ({
  apiFetch: (...args: unknown[]) => mockApiFetch(...args),
  apiFetchVoid: vi.fn().mockResolvedValue(undefined),
  AuthError: class AuthError extends Error {
    name = 'AuthError'
    constructor(message: string) { super(message) }
  },
  APIError: class APIError extends Error {
    name = 'APIError'
    status: number
    code: string
    constructor(message: string, status: number, code: string) {
      super(message)
      this.status = status
      this.code = code
    }
  },
}))

// ── Test wrapper ──────────────────────────────────────────────────────────────

function makeWrapper(queryClient: QueryClient) {
  return function Wrapper({ children }: { children: React.ReactNode }) {
    return React.createElement(QueryClientProvider, { client: queryClient }, children)
  }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

describe('useSavedSearches + useCreateSavedSearch — cache invalidation', () => {
  let queryClient: QueryClient

  beforeEach(() => {
    queryClient = new QueryClient({
      defaultOptions: {
        queries: { retry: false },
        mutations: { retry: false },
      },
    })
    mockApiFetch.mockReset()
  })

  it('invalidates saved-searches after createSavedSearch succeeds', async () => {
    const emptyResponse = { saved_searches: [] as SavedSearchDTO[] }
    const filledResponse = {
      saved_searches: [
        { name: 'a', query: 'q', created_at: 0, updated_at: 0 } satisfies SavedSearchDTO,
      ],
    }
    // POST returns the created row
    const createdRow: SavedSearchDTO = { name: 'a', query: 'q', created_at: 0, updated_at: 0 }

    // First call → initial list fetch (empty)
    // Second call → re-fetch after invalidation (one row)
    // Third call may come from the mutation POST
    mockApiFetch
      .mockResolvedValueOnce(emptyResponse)   // initial GET
      .mockResolvedValueOnce(createdRow)      // POST create
      .mockResolvedValueOnce(filledResponse)  // re-fetch after invalidation

    const wrapper = makeWrapper(queryClient)

    const { result: listResult } = renderHook(() => useSavedSearches(), { wrapper })
    const { result: mutResult } = renderHook(() => useCreateSavedSearch(), { wrapper })

    // Wait for initial fetch to settle
    await waitFor(() => expect(listResult.current.isSuccess).toBe(true))
    expect(listResult.current.data).toEqual([])

    // Call mutate
    act(() => {
      mutResult.current.mutate({ name: 'a', query: 'q' })
    })

    // Wait for mutation success + re-fetch
    await waitFor(() => expect(mutResult.current.isSuccess).toBe(true))
    await waitFor(() => {
      expect(listResult.current.data?.length).toBe(1)
    })

    expect(listResult.current.data?.[0].name).toBe('a')
  })
})

A web/src/features/settings/useSavedSearches.ts => web/src/features/settings/useSavedSearches.ts +79 -0
@@ 0,0 1,79 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query'
import { apiFetch, apiFetchVoid } from '../../api/client'
import { adaptSavedSearch } from '../../api/adapters'
import type { SavedSearch, SavedSearchDTO } from '../../api/adapters'

interface SavedSearchesResponse {
  saved_searches: SavedSearchDTO[]
}

export function useSavedSearches(): UseQueryResult<SavedSearch[]> {
  return useQuery({
    queryKey: ['saved-searches'],
    queryFn: async () => {
      const data = await apiFetch<SavedSearchesResponse>('/api/v1/saved-searches')
      return data.saved_searches.map(adaptSavedSearch)
    },
    staleTime: 30_000,
  })
}

export function useCreateSavedSearch(): UseMutationResult<SavedSearch, Error, { name: string; query: string }> {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: async ({ name, query }) => {
      const dto = await apiFetch<SavedSearchDTO>('/api/v1/saved-searches', {
        method: 'POST',
        body: JSON.stringify({ name, query }),
        headers: { 'Content-Type': 'application/json' },
      })
      return adaptSavedSearch(dto)
    },
    onSuccess: () => {
      void queryClient.invalidateQueries({ queryKey: ['saved-searches'] })
    },
  })
}

export function useUpdateSavedSearch(): UseMutationResult<
  SavedSearch,
  Error,
  { oldName: string; name?: string; query?: string }
> {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: async ({ oldName, name, query }) => {
      const body: Record<string, string> = {}
      if (name !== undefined) body['name'] = name
      if (query !== undefined) body['query'] = query

      const dto = await apiFetch<SavedSearchDTO>(
        `/api/v1/saved-searches/${encodeURIComponent(oldName)}`,
        {
          method: 'PUT',
          body: JSON.stringify(body),
          headers: { 'Content-Type': 'application/json' },
        },
      )
      return adaptSavedSearch(dto)
    },
    onSuccess: () => {
      void queryClient.invalidateQueries({ queryKey: ['saved-searches'] })
    },
  })
}

export function useDeleteSavedSearch(): UseMutationResult<void, Error, { name: string }> {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: async ({ name }) => {
      await apiFetchVoid(`/api/v1/saved-searches/${encodeURIComponent(name)}`, {
        method: 'DELETE',
      })
    },
    onSuccess: () => {
      void queryClient.invalidateQueries({ queryKey: ['saved-searches'] })
    },
  })
}

M web/src/routes/settings.tsx => web/src/routes/settings.tsx +19 -4
@@ 1,20 1,35 @@
import { createFileRoute } from '@tanstack/react-router'
import React from 'react'
import { EmptyState, Tag } from '../primitives'
import React, { useState } from 'react'
import { Tag } from '../primitives'
import { SubBar } from '../shell/SubBar'
import { SectionRail } from '../features/settings/SectionRail'
import { SavedSearchesSection } from '../features/settings/SavedSearchesSection'
import '../styles/settings.css'

type SectionKey = 'saved-searches' | 'display'

const SECTIONS: { key: SectionKey; label: string; disabled?: boolean; tag?: string }[] = [
  { key: 'saved-searches', label: 'Saved searches' },
  { key: 'display', label: 'Display', disabled: true, tag: 'in #8' },
]

export const Route = createFileRoute('/settings')({
  component: SettingsRoute,
})

function SettingsRoute(): React.JSX.Element {
  const [active, setActive] = useState<SectionKey>('saved-searches')

  return (
    <>
      <SubBar>
        <Tag kind="neutral">settings</Tag>
      </SubBar>
      <div className="body body-pad">
        <EmptyState glyph="∅" copy="coming in a later task" />
      <div className="settings-shell">
        <SectionRail sections={SECTIONS} active={active} onSelect={setActive} />
        <div className="settings-panel">
          {active === 'saved-searches' && <SavedSearchesSection />}
        </div>
      </div>
    </>
  )

A web/src/styles/settings.css => web/src/styles/settings.css +225 -0
@@ 0,0 1,225 @@
/* Settings route */

/* Two-column shell: narrow sidebar + main panel */
.settings-shell {
  display: grid;
  grid-template-columns: 220px 1fr;
  gap: 0;
  flex: 1;
  min-height: 0;
  overflow: hidden;
}

.settings-panel {
  padding: 16px 20px;
  overflow: auto;
  min-height: 0;
}

/* ── Section rail (vertical nav) ────────────────────────────────────────────── */

.section-rail {
  display: flex;
  flex-direction: column;
  border-right: 1px solid var(--rule-2);
  padding: 8px 0;
  overflow: auto;
}

.section-row {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 14px;
  font-size: 12px;
  color: var(--ink-2);
  cursor: pointer;
  transition: background 80ms;
  border-left: 2px solid transparent;
  user-select: none;
}

.section-row:hover:not(.disabled) {
  background: var(--paper-2);
  color: var(--ink);
}

.section-row.active {
  background: var(--accent-soft);
  border-left: 2px solid var(--accent);
  color: var(--ink);
  font-weight: 500;
}

.section-row.disabled {
  opacity: 0.5;
  cursor: default;
}

.section-row-tag {
  font-size: 10px;
  font-family: var(--mono);
  color: var(--ink-3);
  margin-left: auto;
}

/* ── Saved searches section ──────────────────────────────────────────────────── */

.saved-searches-section {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.saved-searches-heading {
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  padding-bottom: 4px;
  border-bottom: 1px solid var(--rule-2);
}

/* Inline add/edit form: inputs + button on one line */
.saved-search-form {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
}

.saved-search-input {
  padding: 4px 8px;
  font-size: 12px;
  font-family: var(--sans);
  border: 1px solid var(--rule);
  border-radius: 4px;
  background: var(--paper-4);
  color: var(--ink);
  outline: none;
  min-width: 120px;
}

.saved-search-input:focus {
  border-color: var(--accent);
}

.saved-search-input-mono {
  font-family: var(--mono);
  min-width: 200px;
}

.saved-search-btn {
  padding: 3px 10px;
  font-size: 11px;
  font-family: var(--mono);
  border: 1px solid var(--rule);
  border-radius: 4px;
  background: transparent;
  color: var(--ink-2);
  cursor: pointer;
  transition: background 80ms;
  white-space: nowrap;
}

.saved-search-btn:hover:not(:disabled) {
  background: var(--paper-2);
  color: var(--ink);
}

.saved-search-btn:disabled {
  opacity: 0.5;
  cursor: default;
}

.saved-search-btn-danger {
  border-color: var(--err);
  color: var(--err);
}

.saved-search-btn-danger:hover:not(:disabled) {
  background: var(--err-bg);
}

.saved-search-error {
  font-size: 11px;
  color: var(--err);
  font-family: var(--mono);
}

/* Table */
.saved-searches-table {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--rule-2);
  border-radius: 4px;
  overflow: hidden;
}

.saved-searches-thead {
  display: grid;
  grid-template-columns: 180px 1fr 90px 120px;
  padding: 5px 10px;
  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);
}

.saved-searches-row {
  display: grid;
  grid-template-columns: 180px 1fr 90px 120px;
  padding: 5px 10px;
  border-bottom: 1px solid var(--rule-2);
  align-items: center;
  gap: 10px;
  font-size: 12px;
  color: var(--ink);
}

.saved-searches-row:last-child {
  border-bottom: none;
}

.saved-searches-row:hover {
  background: var(--paper-2);
}

.saved-searches-row-editing {
  display: block;
  padding: 6px 10px;
}

.saved-searches-cell {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.saved-searches-query {
  font-family: var(--mono);
  font-size: 11px;
}

.saved-searches-updated {
  font-size: 11px;
  color: var(--ink-3);
}

.saved-searches-actions {
  display: flex;
  gap: 4px;
  overflow: visible;
}

.mono {
  font-family: var(--mono);
}

.muted {
  color: var(--ink-3);
}