diff --git a/packages/archive/src/dashboard.ts b/packages/archive/src/dashboard.ts index 51895a1..b95ca71 100644 --- a/packages/archive/src/dashboard.ts +++ b/packages/archive/src/dashboard.ts @@ -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) { @@ -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 { diff --git a/packages/archive/src/index.ts b/packages/archive/src/index.ts index 71e0732..620a644 100644 --- a/packages/archive/src/index.ts +++ b/packages/archive/src/index.ts @@ -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"; diff --git a/src/dashboard.ts b/src/dashboard.ts index 99388d0..cb328bf 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -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"; @@ -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 @@ -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 }; } @@ -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 diff --git a/test/dashboard.test.ts b/test/dashboard.test.ts index 3982814..c5dc976 100644 --- a/test/dashboard.test.ts +++ b/test/dashboard.test.ts @@ -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"); @@ -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)", () => {