claude-code-transcripts-ingest 0.1.12

CLI that ingests Claude Code transcript JSONL files into a DuckDB database for usage / cost analysis.
import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import {
  DEFAULT_FILTER,
  fetchProjects,
  fetchSessions,
  fetchSessionsMeta,
  filterToQuery,
  queryToFilter,
  type ProjectRow,
  type SessionRow,
  type SessionsFilter,
  type SessionsMeta,
  type SortField,
  type SortOrder,
  type SubagentFilter,
} from "../api/sessions";
import {
  MultiSelectPicker,
  type PickerOption,
} from "../components/MultiSelectPicker";
import "../components/MultiSelectPicker.css";
import { Timeline } from "../components/Timeline";
import "../components/Timeline.css";

const SORT_OPTIONS: Array<{ value: SortField; label: string }> = [
  { value: "last", label: "Last active" },
  { value: "started", label: "Started" },
  { value: "cost", label: "Cost" },
  { value: "tokens", label: "Tokens" },
];

function fmtCost(c: number): string {
  if (!c) return "$0.00";
  if (c >= 1) return `$${c.toFixed(2)}`;
  if (c >= 0.01) return `$${c.toFixed(3)}`;
  return `$${c.toFixed(4)}`;
}
function fmtTok(n: number): string {
  if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`;
  if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
  return String(n);
}
function fmtDate(ts: string | null): string {
  if (!ts) return "?";
  return new Date(ts).toLocaleString(undefined, {
    month: "short",
    day: "numeric",
    hour: "2-digit",
    minute: "2-digit",
  });
}

function sortSessions(rows: SessionRow[], sort: SortField, order: SortOrder): SessionRow[] {
  const dir = order === "asc" ? 1 : -1;
  const key = (s: SessionRow): number => {
    switch (sort) {
      case "cost":
        return s.costUsd;
      case "tokens":
        return s.totalTokens;
      case "started":
        return s.startedAt ? Date.parse(s.startedAt) : 0;
      case "last":
      default:
        return s.lastActive ? Date.parse(s.lastActive) : 0;
    }
  };
  return [...rows].sort((a, b) => (key(a) - key(b)) * dir);
}

export function SessionListPage() {
  const [searchParams, setSearchParams] = useSearchParams();
  const filter = useMemo<SessionsFilter>(
    () => queryToFilter(searchParams),
    [searchParams],
  );

  const [projects, setProjects] = useState<ProjectRow[]>([]);
  const [meta, setMeta] = useState<SessionsMeta | null>(null);
  const [sessions, setSessions] = useState<SessionRow[]>([]);
  const [loading, setLoading] = useState(true);
  const [err, setErr] = useState<string | null>(null);

  useEffect(() => {
    fetchProjects().then(setProjects).catch(() => {});
    fetchSessionsMeta().then(setMeta).catch(() => {});
  }, []);

  // Refetch when server-side filters change. Time range is applied client-side.
  const serverKey = useMemo(
    () =>
      JSON.stringify({
        p: filter.projects,
        t: filter.tools,
        s: filter.subagents,
      }),
    [filter.projects, filter.tools, filter.subagents],
  );

  useEffect(() => {
    setLoading(true);
    setErr(null);
    fetchSessions(filter)
      .then((d) => setSessions(d))
      .catch((e) => setErr(String(e)))
      .finally(() => setLoading(false));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [serverKey]);

  const updateFilter = (next: SessionsFilter) => {
    setSearchParams(filterToQuery(next), { replace: false });
  };

  const setRange = (tStart: string | null, tEnd: string | null) => {
    const next = filterToQuery({ ...filter, tStart, tEnd });
    setSearchParams(next, { replace: true });
  };

  const clearAll = () => updateFilter(DEFAULT_FILTER);

  const hasAnyFilter =
    filter.projects.length > 0 ||
    filter.tools.length > 0 ||
    filter.subagents !== "any" ||
    filter.tStart !== null ||
    filter.tEnd !== null;

  const projectOptions: PickerOption[] = useMemo(
    () =>
      projects.map((p) => ({
        value: p.key,
        label: p.display,
        hint: `${p.sessionCount}`,
      })),
    [projects],
  );

  const toolOptions: PickerOption[] = useMemo(
    () => (meta?.tools ?? []).map((t) => ({ value: t, label: t })),
    [meta],
  );

  const projectLabels = useMemo(() => {
    const m: Record<string, string> = {};
    for (const p of projects) m[p.key] = p.display;
    return m;
  }, [projects]);

  const dataStart = meta?.earliest ?? null;
  const dataEnd = meta?.latest ?? null;

  // Apply time filter + sort client-side.
  const visibleSessions = useMemo(() => {
    const startMs = filter.tStart ? Date.parse(filter.tStart) : -Infinity;
    const endMs = filter.tEnd ? Date.parse(filter.tEnd) : Infinity;
    const within = sessions.filter((s) => {
      const t = s.lastActive ? Date.parse(s.lastActive) : NaN;
      if (isNaN(t)) return false;
      return t >= startMs && t <= endMs;
    });
    return sortSessions(within, filter.sort, filter.order);
  }, [sessions, filter.tStart, filter.tEnd, filter.sort, filter.order]);

  const totalCost = useMemo(
    () => visibleSessions.reduce((s, r) => s + r.costUsd, 0),
    [visibleSessions],
  );
  const totalTokens = useMemo(
    () => visibleSessions.reduce((s, r) => s + r.totalTokens, 0),
    [visibleSessions],
  );

  return (
    <div className="sl-root">
      <Timeline
        sessions={sessions}
        tStart={filter.tStart}
        tEnd={filter.tEnd}
        onRangeChange={setRange}
        projectLabels={projectLabels}
      />

      <div className="sl-filterbar">
        <div className="sl-row">
          <label>Range</label>
          <input
            type="datetime-local"
            value={isoToLocal(filter.tStart ?? dataStart)}
            onChange={(e) =>
              updateFilter({
                ...filter,
                tStart: localToIso(e.target.value),
              })
            }
            min={isoToLocal(dataStart)}
            max={isoToLocal(dataEnd)}
          />
          <span className="sl-arrow">→</span>
          <input
            type="datetime-local"
            value={isoToLocal(filter.tEnd ?? dataEnd)}
            onChange={(e) =>
              updateFilter({ ...filter, tEnd: localToIso(e.target.value) })
            }
            min={isoToLocal(dataStart)}
            max={isoToLocal(dataEnd)}
          />
          {(filter.tStart !== null || filter.tEnd !== null) && (
            <button
              className="sort-btn"
              onClick={() =>
                updateFilter({ ...filter, tStart: null, tEnd: null })
              }
              title="Reset to full range"
            >
              reset
            </button>
          )}
          <span className="sl-data-range">
            data: {fmtDate(dataStart)} — {fmtDate(dataEnd)}
          </span>

          <span className="sl-spacer" />

          <label>Subagents</label>
          <div className="sort-group">
            {(["any", "yes", "no"] as SubagentFilter[]).map((v) => (
              <button
                key={v}
                className={`sort-btn ${filter.subagents === v ? "active" : ""}`}
                onClick={() => updateFilter({ ...filter, subagents: v })}
              >
                {v}
              </button>
            ))}
          </div>

          {hasAnyFilter && (
            <button className="btn sl-clear" onClick={clearAll}>
              Clear filters
            </button>
          )}
        </div>

        <div className="sl-row sl-row-pickers">
          <MultiSelectPicker
            label="Projects"
            options={projectOptions}
            selected={filter.projects}
            onChange={(projects) => updateFilter({ ...filter, projects })}
            placeholder="Search projects…"
          />
          <MultiSelectPicker
            label="Tools"
            options={toolOptions}
            selected={filter.tools}
            onChange={(tools) => updateFilter({ ...filter, tools })}
            pillClassName="tool"
            placeholder="Search tools…"
          />
        </div>
      </div>

      <div className="sl-summary">
        <div className="sl-summary-stats">
          <strong>{visibleSessions.length}</strong>
          <span>sessions</span>
          <span className="sep">·</span>
          <span className="v-cost">{fmtCost(totalCost)}</span>
          <span className="sep">·</span>
          <span>{fmtTok(totalTokens)} tok</span>
          {(filter.tStart || filter.tEnd) && (
            <>
              <span className="sep">·</span>
              <span>
                {fmtDate(filter.tStart ?? dataStart)} — {fmtDate(filter.tEnd ?? dataEnd)}
              </span>
            </>
          )}
          {loading && (
            <>
              <span className="sep">·</span>
              <span>loading…</span>
            </>
          )}
          {err && <span className="err">{err}</span>}
        </div>
        <div className="sl-summary-sort">
          <label>Sort</label>
          <select
            value={filter.sort}
            onChange={(e) =>
              updateFilter({ ...filter, sort: e.target.value as SortField })
            }
          >
            {SORT_OPTIONS.map((o) => (
              <option key={o.value} value={o.value}>
                {o.label}
              </option>
            ))}
          </select>
          <div className="sort-group">
            {(["desc", "asc"] as SortOrder[]).map((o) => (
              <button
                key={o}
                className={`sort-btn ${filter.order === o ? "active" : ""}`}
                onClick={() => updateFilter({ ...filter, order: o })}
                title={o === "desc" ? "Descending" : "Ascending"}
              >
                {o === "desc" ? "↓" : "↑"}
              </button>
            ))}
          </div>
        </div>
      </div>

      <div className="sl-list">
        {visibleSessions.map((s) => (
          <SessionCard key={s.id} session={s} />
        ))}
      </div>
    </div>
  );
}

function SessionCard({ session }: { session: SessionRow }) {
  const qs = new URLSearchParams();
  if (session.project) qs.set("project", session.project);
  const to = `/sessions/${encodeURIComponent(session.id)}?${qs.toString()}`;
  return (
    <Link to={to} className="sl-card">
      <div className="sl-card-head">
        <span className="sl-card-id">{session.id.slice(0, 8)}</span>
        <span className="sl-card-project" title={session.project}>
          {session.project.replace(/^-/, "").replace(/-/g, "/")}
        </span>
        {session.hasSubagents && (
          <span className="sl-tag agent">subagents</span>
        )}
        <span className="sl-card-cost">{fmtCost(session.costUsd)}</span>
      </div>
      <div className="sl-card-meta">
        <span>started {fmtDate(session.startedAt)}</span>
        <span>·</span>
        <span>last {fmtDate(session.lastActive)}</span>
        <span>·</span>
        <span>{fmtTok(session.totalTokens)} tok</span>
      </div>
      {session.tools.length > 0 && (
        <div className="sl-card-tools">
          {session.tools.slice(0, 10).map((t) => (
            <span key={t} className="sl-pill sm tool">
              {t}
            </span>
          ))}
          {session.tools.length > 10 && (
            <span className="sl-pill sm">+{session.tools.length - 10}</span>
          )}
        </div>
      )}
    </Link>
  );
}

function isoToLocal(iso: string | null): string {
  if (!iso) return "";
  const d = new Date(iso);
  if (isNaN(d.getTime())) return "";
  const pad = (n: number) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(
    d.getDate(),
  )}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function localToIso(local: string): string | null {
  if (!local) return null;
  const d = new Date(local);
  if (isNaN(d.getTime())) return null;
  return d.toISOString();
}