# Code Review: Dashboard & Infrastructure

**Reviewer:** Claude Opus 4.6
**Date:** 2026-02-07
**Scope:** Dashboard (React/TypeScript), shell scripts, pyproject.toml, CI/CD, issue templates

---

## Critical

### C1. SessionDetail fetches ALL sessions to find one session

**File:** `dashboard/src/pages/SessionDetail.tsx:22-25`

```tsx
const sessionFetcher = useCallback(
  () => api.sessions().then((sessions) => sessions.find((s) => s.id === sessionId) ?? null),
  [sessionId],
);
```

There is no `/api/sessions/:id` endpoint, so the dashboard downloads the entire sessions list (default limit 50) every 5 seconds just to find a single session by ID. This is wasteful on both the network and database side. The server should expose a `GET /api/sessions?id=<id>` or `GET /api/sessions/:id` endpoint, and the client should call it directly.

### C2. Plist uses `/usr/bin/python3` -- will not find installed package

**File:** `scripts/com.quickcall.traced.plist:9-13`

```xml
<key>ProgramArguments</key>
<array>
    <string>/usr/bin/python3</string>
    <string>-m</string>
    <string>qc_trace.daemon</string>
</array>
```

The system Python at `/usr/bin/python3` almost certainly will not have `qc_trace` installed. The `pyproject.toml` defines a `qc-traced` entry point, but the plist does not use it. The same issue exists in the systemd unit (`scripts/qc-traced.service:8`). Both service definitions should either use the installed `qc-traced` binary, or allow the user to configure the Python path during installation. As-is, the daemon will fail to start with `ModuleNotFoundError` on every fresh install.

### C3. Port mismatch between CLI and server

**File:** `qc_trace/cli/traced.py:28`

```python
INGEST_URL = "http://localhost:7777"
```

**File:** `dashboard/vite.config.ts:9-10`

```ts
'/api': 'http://localhost:19777',
'/health': 'http://localhost:19777',
```

The CLI assumes the ingest server is on port 7777, while the dashboard dev proxy targets port 19777. If these are two different services, this should be documented. If they are the same service, there is a port mismatch that will cause one or the other to fail to connect.

---

## High

### H1. `usePolling` creates infinite re-render loop risk with unstable `fetcher` references

**File:** `dashboard/src/hooks/usePolling.ts:38`

```ts
}, [fetcher, intervalMs]);
```

The `useEffect` depends on the `fetcher` reference. If any consumer forgets to wrap `fetcher` in `useCallback`, a new function reference is created on every render, which tears down and re-creates the polling interval on every render cycle. This is a latent correctness/performance bug. Currently all consumers do use `useCallback`, but the hook itself is fragile and should either accept a ref-stable pattern (e.g., store fetcher in a `useRef`) or document/enforce the requirement.

### H2. Multiple overlapping polling intervals on the Overview page

**File:** `dashboard/src/pages/Overview.tsx:1-18`

The Overview page mounts three components that each independently poll:
- `StatsCards` polls `/api/stats` every 3s
- `LiveFeed` polls `/api/feed` every 2s
- `SourceChart` polls `/api/stats` every 5s

`StatsCards` and `SourceChart` both fetch `/api/stats` independently, meaning the same endpoint gets hit every ~1.9 seconds (harmonic of 3s and 5s). These should share a single data source, e.g., via context or lifting the fetch to the parent. At minimum, 8 requests in 10 seconds is heavy for a dashboard.

### H3. `install.sh` uses deprecated `launchctl load`

**File:** `scripts/install.sh:49`

```bash
launchctl load "$PLIST_DST"
```

`launchctl load` has been deprecated since macOS 10.10 in favor of `launchctl bootstrap`. On newer macOS versions, this may print deprecation warnings or behave unexpectedly. The uninstall script has the same issue with `launchctl unload` (`scripts/uninstall.sh:38`).

### H4. `pyproject.toml` has no runtime dependencies but the server/daemon needs them

**File:** `pyproject.toml:7`

```toml
dependencies = []
```

The project declares zero runtime dependencies. The `daemon` optional-dependency group includes `psycopg`, but the server, handlers, and daemon code import from `psycopg` and `psycopg_pool` unconditionally. Anyone installing with `pip install qc-trace` (without `[daemon]`) will get import errors at runtime. The `psycopg` dependencies should be in the main `dependencies` list, or the entry point should be gated behind the optional dependency group with a clear error message.

### H5. `replace('_', ' ')` only replaces the first underscore

**File:** `dashboard/src/components/Badges.tsx:20`

```tsx
{source.replace('_', ' ')}
```

Also at `dashboard/src/components/SessionsTable.tsx:34` and `dashboard/src/components/SourceChart.tsx:15`.

JavaScript's `String.replace()` with a string argument only replaces the first occurrence. A source like `codex_cli` works fine, but a hypothetical future source like `some_new_source` would render as `"some new_source"`. Use `.replaceAll('_', ' ')` or a regex `/.replace(/_/g, ' ')`.

### H6. Release workflow does not build or test anything

**File:** `.github/workflows/release.yml:12-28`

The release workflow only creates a GitHub Release from the tag. It does not:
- Run tests
- Lint
- Build the Python package
- Build the dashboard
- Publish to PyPI
- Attach build artifacts

For a project with both a Python backend and a React frontend, this is a significant gap. At minimum, the workflow should run the test suite before creating a release.

---

## Medium

### M1. `App.css` is dead code

**File:** `dashboard/src/App.css:1`

```css
/* Replaced by Tailwind */
```

This file contains only a comment and is not imported anywhere. It should be deleted.

### M2. Dashboard has no 404/catch-all route

**File:** `dashboard/src/App.tsx:10-15`

```tsx
<Routes>
  <Route element={<Layout />}>
    <Route path="/" element={<Overview />} />
    <Route path="/sessions" element={<Sessions />} />
    <Route path="/sessions/:id" element={<SessionDetail />} />
  </Route>
</Routes>
```

Navigating to any undefined path (e.g., `/settings`) shows a blank page inside the layout. A catch-all `<Route path="*" element={<NotFound />} />` route would improve the user experience.

### M3. No error boundary in the React tree

**File:** `dashboard/src/main.tsx:1-10`

If any component throws during render, the entire app will crash to a white screen. There is no `ErrorBoundary` wrapping the app or any of its sub-trees. In a polling dashboard, transient errors from failed fetches could cascade.

### M4. `index.html` references `/vite.svg` which does not exist

**File:** `dashboard/index.html:5`

```html
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
```

There is no `vite.svg` in the `public/` directory. This will result in a 404 for the favicon. Either add the file or replace with a project-specific favicon.

### M5. Dark mode uses `prefers-color-scheme` media query inside `@theme` override, but `@theme` variables are not media-query-scoped by default in Tailwind v4

**File:** `dashboard/src/index.css:21-39`

```css
@media (prefers-color-scheme: dark) {
  :root {
    --color-surface: #0a0a0f;
    ...
  }
}
```

The light theme is defined inside `@theme { }`, which registers values at the Tailwind config level. The dark overrides are in a plain `@media` block on `:root`. This works because CSS custom properties cascade, but there is a subtlety: the `@theme` block values are used at build time for utility generation, while the `:root` overrides only work at runtime. This means Tailwind's JIT will only see the light values when generating spacing/color utilities. If any Tailwind utility directly references these tokens (e.g., `bg-[var(--color-surface)]`), the dark mode values will work at runtime but Tailwind might not generate the classes. Currently the code uses semantic class names like `bg-surface` so this works, but it is fragile.

### M6. `uninstall.sh` kills process by PID without verifying it is actually `qc-traced`

**File:** `scripts/uninstall.sh:53-58`

```bash
PID=$(cat "$PID_FILE")
kill "$PID" 2>/dev/null || true
```

If the PID has been recycled (process exited and a new unrelated process got the same PID), this will kill an innocent process. The script should verify the process name before killing, e.g., `kill -0 "$PID" && pgrep -F "$PID_FILE" qc_trace`.

### M7. No pagination in SessionsTable or SessionDetail

**File:** `dashboard/src/components/SessionsTable.tsx:14` and `dashboard/src/pages/SessionDetail.tsx:29`

The sessions table fetches with `limit: 50` and the session detail fetches messages with `limit: 200`. There is no UI for pagination (next/previous page) or infinite scroll. If a user has more than 50 sessions or 200 messages in a session, data is silently truncated.

### M8. `dev-db.sh` does not check if Docker/docker compose is available

**File:** `scripts/dev-db.sh:23`

The script invokes `docker compose` directly but never checks if Docker is installed or running. A missing-command error from the shell would be confusing. A quick `command -v docker` check with a helpful error message would improve usability.

### M9. `install.sh` does not check if `qc-trace` Python package is installed

**File:** `scripts/install.sh:1-67`

The install script sets up the service manager configuration but never verifies that the `qc_trace` Python package is actually importable. The service will be enabled and started, then immediately fail. A pre-flight check like `python3 -c "import qc_trace"` with a clear error message would prevent confusion.

---

## Low

### L1. Inconsistent `latest_message` field name

**File:** `dashboard/src/api.ts:18` (interface `Session`)

```ts
latest_message: string | null;
```

**File:** `dashboard/src/components/SessionsTable.tsx:90`

```tsx
{s.latest_message ? new Date(s.latest_message).toLocaleString() : '---'}
```

The field is named `latest_message` but it stores a timestamp, not a message. A name like `last_message_at` or `last_active` would be clearer and less confusing.

### L2. `SourceBadge` and `SessionsTable` source filter list are not co-located

**File:** `dashboard/src/components/Badges.tsx:1-6` -- defines `SOURCE_COLORS` for `claude_code`, `codex_cli`, `gemini_cli`, `cursor`.

**File:** `dashboard/src/components/SessionsTable.tsx:7` -- defines `SOURCES = ['all', 'claude_code', 'codex_cli', 'gemini_cli', 'cursor']`.

These two lists must be kept in sync manually. If a new source is added to one but not the other, the badge will fall back to gray or the filter will be incomplete. Consider extracting a shared `KNOWN_SOURCES` constant.

### L3. `api.ts` `get()` returns `res.json()` without awaiting

**File:** `dashboard/src/api.ts:59`

```ts
return res.json();
```

This works because `res.json()` returns a `Promise<any>` and the function is `async`, so the promise is implicitly returned. However, errors thrown during JSON parsing will become unhandled rejections rather than being caught by the `try/catch` in `usePolling`. This is not a bug per se, but `return await res.json()` would ensure consistent error handling.

### L4. `CardGrid` renders empty div grid when loading

**File:** `dashboard/src/components/StatsCards.tsx:9`

```tsx
if (loading) return <CardGrid values={[]} />;
```

This renders an empty grid container with no visual indication of loading. A skeleton or spinner would be better UX.

### L5. `MessageList` does not virtualize -- will be slow with 200 messages

**File:** `dashboard/src/components/MessageList.tsx:5-16`

All 200 messages are rendered as DOM elements. Each `MessageRow` has expandable state. For sessions with many messages (especially with long content), this could cause noticeable jank. Consider virtualization (`react-window` or similar) if the 200-message limit is expected to grow.

### L6. Hardcoded `tailwindcss` and `@tailwindcss/vite` in `dependencies` instead of `devDependencies`

**File:** `dashboard/package.json:13,18`

```json
"dependencies": {
    "@tailwindcss/vite": "^4.1.18",
    ...
    "tailwindcss": "^4.1.18"
}
```

`tailwindcss` and `@tailwindcss/vite` are build-time tools. They should be in `devDependencies`. They are only used by the Vite build pipeline and are not needed at runtime.

### L7. Issue templates are fine but `release.yml` duplicates what the CI workflow already does

**File:** `.github/ISSUE_TEMPLATE/release.yml`

The `release.yml` issue template asks the user to manually list changes for a release. The `release.yml` workflow uses `generate_release_notes: true`, which auto-generates release notes from PRs. These two mechanisms overlap, and the issue template will be stale the moment it is created. Consider removing the release issue template and relying solely on the automated release notes.

### L8. `performance.yml` and `docs.yml` issue templates are rarely useful at this project stage

**Files:** `.github/ISSUE_TEMPLATE/performance.yml`, `.github/ISSUE_TEMPLATE/docs.yml`

For a `v0.1.0` project with a single contributor, having 6 issue templates (bug, feature, chore, docs, performance, release) adds friction without proportional value. Most early-stage projects do fine with bug + feature + a blank template. The extras can always be added later when the project grows.

### L9. `@theme` variables define `--color-text-inverse` identically in light and dark

**File:** `dashboard/src/index.css:12,31`

```css
/* light */  --color-text-inverse: #ffffff;
/* dark */   --color-text-inverse: #ffffff;
```

An "inverse" text color that is white in both themes is likely not doing what the name implies. In dark mode, "inverse" text should probably be dark (e.g., `#111827`), since the background is already dark.

### L10. `_json_response` always returns status 200

**File:** `qc_trace/server/handlers.py:31-33`

```python
def _json_response(data: dict[str, Any]) -> tuple[bytes, int]:
    return json.dumps(data).encode(), HTTPStatus.OK
```

This function hardcodes 200 OK. While currently all success responses are 200, if a 201 Created response is ever needed (e.g., for `POST /ingest`), this helper cannot accommodate it without modification. Minor but worth noting.

---

## Summary

| Severity | Count |
|----------|-------|
| Critical | 3     |
| High     | 6     |
| Medium   | 9     |
| Low      | 10    |

**Top priorities to address:**

1. **C2/C3** -- The daemon service definitions are broken out of the box. The plist/systemd files reference system Python which will not have the package, and the port numbers are inconsistent.
2. **C1** -- The SessionDetail page fetches all sessions to find one, which is an N+1-style anti-pattern that will scale poorly.
3. **H2** -- Duplicate polling of `/api/stats` from two components on the same page is wasteful.
4. **H4** -- The Python package has no runtime dependencies declared despite needing `psycopg`.
5. **H6** -- The release workflow creates a release without testing, building, or publishing anything.

The dashboard code is generally well-structured with clean component boundaries, good use of TypeScript, and consistent styling. The main issues are around data fetching efficiency, service configuration correctness, and build/deployment gaps.
