Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions packages/archive/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,28 @@ function mean(values: number[]): number | null {
return sum / values.length;
}

/**
* Build one per-session index row from a parsed session. Pure and derived solely
* from `session`, so a row always carries ITS OWN session's numbers — callers can
* pair an entry with a session by identity (e.g. keyed by file name) rather than
* by array position (#170). NaN-guarded like the aggregate.
*/
export function toSessionIndexEntry(session: ParsedSession): SessionIndexEntry {
const { meta, metrics, isRecording } = session;
return {
title: meta.title,
date: meta.headerDate,
startClock: meta.startClock,
durationMin: finite(meta.durationMin),
sourceLang: meta.sourceLang,
targetLang: meta.targetLang,
costUsd: roundCents(finite(meta.costUsd)),
talkRatioMic: metrics ? finite(metrics.talkRatioMic) : null,
smoothScore: metrics ? finite(metrics.smoothScore) : null,
isRecording,
};
}

/** Chronological order: by header date, then start clock, stable for ties. */
function chronological(a: ParsedSession, b: ParsedSession): number {
if (a.meta.headerDate !== b.meta.headerDate) {
Expand Down Expand Up @@ -120,18 +142,7 @@ export function aggregateSessions(sessions: readonly ParsedSession[]): Dashboard
smoothScoreTrend.push({ date: meta.headerDate, startClock: meta.startClock, value: smoothScore });
}

index.push({
title: meta.title,
date: meta.headerDate,
startClock: meta.startClock,
durationMin: finite(meta.durationMin),
sourceLang: meta.sourceLang,
targetLang: meta.targetLang,
costUsd: roundCents(finite(meta.costUsd)),
talkRatioMic,
smoothScore,
isRecording,
});
index.push(toSessionIndexEntry(session));
}

return {
Expand Down
2 changes: 1 addition & 1 deletion packages/archive/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,5 @@ export type {
// Issue #98 — Dashboard data layer: parse saved sessions + aggregate stats (headless).
export { parseSession } from "./parse";
export type { ParsedSession, ParsedSessionMeta } from "./parse";
export { aggregateSessions } from "./dashboard";
export { aggregateSessions, toSessionIndexEntry } from "./dashboard";
export type { DashboardStats, SessionIndexEntry, TrendPoint } from "./dashboard";
33 changes: 18 additions & 15 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { invoke } from "@tauri-apps/api/core";
// Import the #98 data layer DIRECTLY from its source modules rather than the
// package barrel: `parse`/`dashboard` are pure (they only touch `./types`), so
// the webview never pulls the package's Node-only `fs` writer into the bundle.
import { aggregateSessions, type DashboardStats } from "@livecap/archive/src/dashboard.ts";
import { aggregateSessions, toSessionIndexEntry, type DashboardStats } from "@livecap/archive/src/dashboard.ts";
import { parseSession, type ParsedSession } from "@livecap/archive/src/parse.ts";
import type { SessionIndexEntry } from "@livecap/archive/src/dashboard.ts";
import type { CaptionEntry } from "@livecap/archive/src/types.ts";
Expand Down Expand Up @@ -57,6 +57,10 @@ export interface IndexedSession {
name: string;
/** Front-matter parse: meta/summary/board/metrics populated, entries empty. */
session: ParsedSession;
/** The history-row index entry derived from THIS session (#170) — carried with
* the session so a row shows its own duration/langs/cost, never a tied
* session's (no positional pairing). */
entry: SessionIndexEntry;
}

/** A parsed session paired with the dashboard index entry derived from it, so a
Expand Down Expand Up @@ -87,12 +91,16 @@ const BACK_ICON =
*/
export function buildDashboardModel(index: readonly SessionHeader[]): DashboardModel {
const parsed = index
.map((h) => ({ name: h.name, session: parseSession(h.markdown) }))
.map((h) => {
const session = parseSession(h.markdown);
// Derive each session's index entry HERE, alongside its file name (#170),
// so a history row pairs with its OWN entry by identity — not by a fragile
// positional reversal that swaps sessions tied on (date, startClock).
return { name: h.name, session, entry: toSessionIndexEntry(session) };
})
.filter((p) => !p.session.isRecording);
const stats = aggregateSessions(parsed.map((p) => p.session));
// `stats.index` is chronological (oldest first); the history list shows newest
// first. Sort the parsed sessions the same way the aggregator orders the index
// so a row's position maps to the same session.
// History shows newest-first; each session already carries its own entry.
const sessions = [...parsed].sort((a, b) => compareChronological(b.session, a.session));
return { stats, sessions };
}
Expand Down Expand Up @@ -437,16 +445,11 @@ export function buildDashboard(callbacks: DashboardCallbacks): DashboardSurface
h.textContent = "History";
wrap.appendChild(h);

// Pair each header session with its index entry + file name ONCE.
// `m.sessions` is newest-first; the chronological index is oldest-first, so
// read it in reverse. The name backs the lazy detail load + search (#144).
const index = m.stats.index;
const pairs: { name: string; session: ParsedSession; entry: SessionIndexEntry | undefined }[] = [];
for (let i = 0; i < m.sessions.length; i++) {
const indexed = m.sessions[i];
if (indexed === undefined) continue;
pairs.push({ name: indexed.name, session: indexed.session, entry: index[index.length - 1 - i] });
}
// Each session carries its OWN index entry (built alongside it in
// buildDashboardModel, #170), so a row shows its own duration/langs/cost —
// never a tied session's, as the old positional index reversal did. The name
// backs the lazy detail load + search (#144).
const pairs = m.sessions;

// Search box (#131): case-insensitive substring over title/summary/board/
// transcript, debounced. Title/summary/board are already in the header
Expand Down
53 changes: 53 additions & 0 deletions test/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,24 @@ const RECORDING = `# (recording)
## Transcript
`;

// #170: two sessions saved in the SAME clock-minute (back-to-back captures / the
// "(2)" filename-collision case). They TIE on (headerDate, startClock) but have
// distinct duration/languages/cost, so a positional index pairing would swap
// their history-row subtitles.
const TIED_ALPHA = `# Alpha
> 2026-06-10 09:00–09:45 (45 min) · EN → KO · engine: Claude CLI ($0.30)

## Summary
- alpha notes
`;

const TIED_BETA = `# Beta
> 2026-06-10 09:00–09:10 (10 min) · JA → FR · engine: Claude CLI ($0.05)

## Summary
- beta notes
`;

describe("formatDuration", () => {
it("renders minutes, hours, and combinations", () => {
expect(formatDuration(0)).toBe("0m");
Expand Down Expand Up @@ -170,6 +188,41 @@ describe("buildDashboardModel (#144: front-matter index)", () => {
// The transcript entries are absent in a header parse (they load lazily).
expect(indexed?.session.entries).toHaveLength(0);
});

it("pairs each session with its OWN index entry when two tie on date+clock (#170)", () => {
// Regression: renderHistory used to read the chronological index in reverse
// by position; on a (date, startClock) tie both stable sorts keep the SAME
// relative order (not mirrored), so the reversal swapped tied sessions'
// duration/langs/cost subtitles. Each IndexedSession now carries its OWN entry.
const index: SessionHeader[] = [
{ name: "2026-06-10 0900 — Alpha.md", markdown: TIED_ALPHA },
{ name: "2026-06-10 0900 — Beta (2).md", markdown: TIED_BETA },
];
const model = buildDashboardModel(index);
expect(model.sessions).toHaveLength(2);

const byTitle = new Map(model.sessions.map((s) => [s.session.meta.title, s]));
const alpha = byTitle.get("Alpha");
const beta = byTitle.get("Beta");

// Alpha's row shows Alpha's numbers, Beta's shows Beta's — not swapped.
expect(alpha?.entry.durationMin).toBe(45);
expect(alpha?.entry.sourceLang).toBe("EN");
expect(alpha?.entry.targetLang).toBe("KO");
expect(alpha?.entry.costUsd).toBeCloseTo(0.3, 5);
expect(beta?.entry.durationMin).toBe(10);
expect(beta?.entry.sourceLang).toBe("JA");
expect(beta?.entry.targetLang).toBe("FR");
expect(beta?.entry.costUsd).toBeCloseTo(0.05, 5);

// Invariant: every session's entry is derived from that same session's meta.
for (const s of model.sessions) {
expect(s.entry.title).toBe(s.session.meta.title);
expect(s.entry.date).toBe(s.session.meta.headerDate);
expect(s.entry.startClock).toBe(s.session.meta.startClock);
expect(s.entry.durationMin).toBe(s.session.meta.durationMin);
}
});
});

describe("sessionMatches (#131)", () => {
Expand Down
Loading