import { describe, expect, test } from "bun:test"; import { UNASSIGNED_PROJECT_ID } from "../db/schema"; import type { LinearMetadata, TimelineEventDisplay, TimelineEventMetadata, } from "../types/timeline"; import { buildActivityReport, extractTicketFromSubject, isJunkHighlightLabel, sanitizeGroupLabel, stripConventionalCommitPrefix, unassignedEventLabel, } from "./activity-report"; let idCounter = 0; function evt(partial: { source: TimelineEventDisplay["source"]; timestamp: string; projectId?: string | null; projectName?: string | null; projectColor?: string | null; deviceId?: string | null; deviceName?: string | null; userId?: string | null; userName?: string | null; userImage?: string | null; metadata?: TimelineEventMetadata | null; estimatedMinutes?: number | null; }): TimelineEventDisplay { idCounter += 1; return { id: `e${idCounter}`, timestamp: partial.timestamp, source: partial.source, sourceId: null, projectRootId: null, projectId: partial.projectId ?? null, metadata: partial.metadata ?? null, estimatedMinutes: partial.estimatedMinutes ?? null, projectName: partial.projectName ?? null, projectColor: partial.projectColor ?? null, deviceId: partial.deviceId ?? null, deviceName: partial.deviceName ?? null, userId: partial.userId ?? null, userName: partial.userName ?? null, userImage: partial.userImage ?? null, }; } function git( timestamp: string, project: { id: string; name: string } | null, message: string, gitRemote?: string, ): TimelineEventDisplay { return evt({ source: "git", timestamp, projectId: project?.id ?? null, projectName: project?.name ?? null, metadata: { message, author: "tester", filesChanged: ["a.ts"], additions: 10, deletions: 2, ...(gitRemote ? { gitRemote } : {}), }, }); } function linear( timestamp: string, project: { id: string; name: string } | null, meta: Partial & Pick, ): TimelineEventDisplay { return evt({ source: "linear", timestamp, projectId: project?.id ?? null, projectName: project?.name ?? null, metadata: { issueId: meta.issueId ?? `iss-${meta.issueIdentifier}`, issueIdentifier: meta.issueIdentifier, title: meta.title, action: meta.action, teamName: meta.teamName, projectName: meta.projectName, assigneeName: meta.assigneeName, url: meta.url, }, }); } describe("buildActivityReport", () => { test("empty input yields a zeroed report, not null", () => { const report = buildActivityReport([]); expect(report.totalEvents).toBe(0); expect(report.totalEstimatedMinutes).toBe(0); expect(report.activeDays).toBe(0); expect(report.projects).toEqual([]); expect(report.devices).toEqual([]); expect(report.highlights).toEqual([]); expect(report.unassigned).toBeNull(); expect(report.unassignedBreakdown).toBeUndefined(); expect(report.ticketsByProject).toBeUndefined(); expect(report.outcomes).toBeUndefined(); }); test("echoes the provided range without filtering", () => { const report = buildActivityReport( [git("2026-06-01T10:00:00Z", { id: "p1", name: "Alpha" }, "init")], { start: "2026-06-01", end: "2026-06-07" }, ); expect(report.range).toEqual({ start: "2026-06-01", end: "2026-06-07" }); expect(report.totalEvents).toBe(1); }); test("groups by project and sorts rows by estimated time desc", () => { const alpha = { id: "p1", name: "Alpha" }; const beta = { id: "p2", name: "Beta" }; const report = buildActivityReport([ // Alpha: two events ~2h apart -> two 15min windows = 30min git("2026-06-01T10:00:00Z", alpha, "a1"), git("2026-06-01T12:30:00Z", alpha, "a2"), // Beta: single event -> one 15min window = 15min git("2026-06-01T10:00:00Z", beta, "b1"), ]); expect(report.projects.map((p) => p.projectId)).toEqual(["p1", "p2"]); expect(report.projects[0].estimatedMinutes).toBe(30); expect(report.projects[1].estimatedMinutes).toBe(15); expect(report.totalEstimatedMinutes).toBe(45); expect(report.projects[0].gitCommits).toBe(2); }); test("unassigned work is tracked separately and exposed via `unassigned`", () => { const report = buildActivityReport([ evt({ source: "git", timestamp: "2026-06-01T09:00:00Z", projectId: null, }), evt({ source: "git", timestamp: "2026-06-01T09:00:00Z", projectId: UNASSIGNED_PROJECT_ID, }), ]); expect(report.unassigned).not.toBeNull(); expect(report.unassigned?.projectId).toBeNull(); expect(report.unassigned?.projectName).toBe("Unassigned"); expect(report.unassigned?.eventCount).toBe(2); }); test("counts active days, source mix, conversations and files per project", () => { const proj = { id: "p1", name: "Alpha" }; const report = buildActivityReport([ git("2026-06-01T10:00:00Z", proj, "day one"), git("2026-06-02T10:00:00Z", proj, "day two"), evt({ source: "claude", timestamp: "2026-06-02T11:00:00Z", projectId: "p1", projectName: "Alpha", metadata: { sessionId: "s1", messagePreview: "hello", conversationFile: "/c.jsonl", }, }), evt({ source: "file", timestamp: "2026-06-02T11:05:00Z", projectId: "p1", projectName: "Alpha", metadata: { path: "src/x.ts", size: 1, extension: ".ts" }, }), ]); const row = report.projects[0]; expect(row.activeDays).toBe(2); expect(row.gitCommits).toBe(2); expect(row.conversations).toBe(1); expect(row.filesModified).toBe(1); expect(row.bySource).toEqual({ git: 2, claude: 1, file: 1 }); expect(report.activeDays).toBe(2); expect(report.bySource).toEqual({ git: 2, claude: 1, file: 1 }); }); test("manual estimatedMinutes add to project time on top of windowed events", () => { const report = buildActivityReport([ evt({ source: "manual", timestamp: "2026-06-01T10:00:00Z", projectId: "p1", projectName: "Alpha", estimatedMinutes: 90, metadata: { title: "Design review" }, }), ]); expect(report.projects[0].estimatedMinutes).toBe(90); expect(report.totalEstimatedMinutes).toBe(90); }); test("highlights surface newest items first, capped by highlightLimit", () => { const proj = { id: "p1", name: "Alpha" }; const report = buildActivityReport( [ git( "2026-06-01T10:00:00Z", proj, "oldest commit subject that is long enough", ), git( "2026-06-03T10:00:00Z", proj, "newest commit subject that is long enough", ), git( "2026-06-02T10:00:00Z", proj, "middle commit subject that is long enough", ), ], { highlightLimit: 2 }, ); expect(report.highlights.map((h) => h.label)).toEqual([ "newest commit subject that is long enough", "middle commit subject that is long enough", ]); }); test("isJunkHighlightLabel drops XML, images, paths, tool junk, and length outliers", () => { expect( isJunkHighlightLabel( " abc123 done", ), ).toBe(true); expect( isJunkHighlightLabel( "[Image: source: /var/folders/xx/Screenshot 2026-06-30.png]", ), ).toBe(true); expect( isJunkHighlightLabel( "Opened /Users/satchmo/Desktop/notes.md for review today", ), ).toBe(true); expect( isJunkHighlightLabel( "Please handle the tool-use response before continuing", ), ).toBe(true); expect( isJunkHighlightLabel( "```ts\nconst x = 1;\n``` and some surrounding text", ), ).toBe(true); expect(isJunkHighlightLabel("too short")).toBe(true); expect(isJunkHighlightLabel(`x`.repeat(201))).toBe(true); expect( isJunkHighlightLabel("Ship the invite expiry fix for the team report"), ).toBe(false); }); test("highlights filter junk lines and never surface them", () => { const proj = { id: "p1", name: "Alpha" }; const report = buildActivityReport([ git( "2026-06-01T10:00:00Z", proj, "Ship the invite expiry fix for team reports", ), evt({ source: "claude", timestamp: "2026-06-02T10:00:00Z", projectId: "p1", projectName: "Alpha", metadata: { sessionId: "s-junk", messagePreview: " abc finished work", conversationFile: "/c.jsonl", }, }), evt({ source: "claude", timestamp: "2026-06-02T11:00:00Z", projectId: "p1", projectName: "Alpha", metadata: { sessionId: "s-img", messagePreview: "[Image: source: /Users/satchmo/Desktop/Screenshot.png]", conversationFile: "/c.jsonl", }, }), evt({ source: "claude", timestamp: "2026-06-02T12:00:00Z", projectId: "p1", projectName: "Alpha", metadata: { sessionId: "s-short", // Under 20 chars after trim — hard-filtered as too short. messagePreview: "we want p[l;ugin", conversationFile: "/c.jsonl", }, }), ]); expect(report.highlights).toHaveLength(1); expect(report.highlights[0].label).toBe( "Ship the invite expiry fix for team reports", ); }); test("highlights prefer ticket git subjects, then other git, titles, manual, chat last", () => { const proj = { id: "p1", name: "Alpha" }; const report = buildActivityReport( [ // Lowest priority: raw chat preview (newest timestamp would win if priority ignored) evt({ source: "claude", timestamp: "2026-06-05T10:00:00Z", projectId: "p1", projectName: "Alpha", metadata: { sessionId: "s1", messagePreview: "we want our plugin to handle the invite flow cleanly", conversationFile: "/c.jsonl", }, }), // Manual mid-tier evt({ source: "manual", timestamp: "2026-06-04T10:00:00Z", projectId: "p1", projectName: "Alpha", estimatedMinutes: 30, metadata: { title: "Design review for the invite expiry UX flow" }, }), // Conversation title (higher than raw chat / manual) evt({ source: "claude", timestamp: "2026-06-03T10:00:00Z", projectId: "p1", projectName: "Alpha", metadata: { sessionId: "s2", messagePreview: "raw body that should lose to the session title field", conversationFile: "/c.jsonl", title: "Session title: plan invite expiry and report polish", } as TimelineEventMetadata, }), // Plain git git( "2026-06-02T10:00:00Z", proj, "Improve report highlight filtering heuristics", ), // Ticket-prefixed git (highest priority) git( "2026-06-01T10:00:00Z", proj, "OPL-1843: fix invite link expiry for team joins", ), ], { highlightLimit: 3 }, ); // Selection is priority-first, output remains newest-first among selected. expect(report.highlights.map((h) => h.label)).toEqual([ "Session title: plan invite expiry and report polish", "Improve report highlight filtering heuristics", "OPL-1843: fix invite link expiry for team joins", ]); // Raw chat preview and manual must not displace higher-priority items. expect( report.highlights.some((h) => h.label.includes("we want our plugin")), ).toBe(false); expect( report.highlights.some((h) => h.label.includes("Design review")), ).toBe(false); }); test("highlights dedupe near-duplicate labels", () => { const proj = { id: "p1", name: "Alpha" }; const report = buildActivityReport([ git( "2026-06-01T10:00:00Z", proj, "Fix the login bug in the auth middleware layer", ), git( "2026-06-02T10:00:00Z", proj, "Fix the login bug in the auth middleware layer again", ), git( "2026-06-03T10:00:00Z", proj, "Ship invoice PDF branding for client deliverables", ), ]); expect(report.highlights).toHaveLength(2); expect(report.highlights.map((h) => h.label)).toEqual([ "Ship invoice PDF branding for client deliverables", "Fix the login bug in the auth middleware layer again", ]); }); test("all-junk input yields an empty highlights array", () => { const report = buildActivityReport([ evt({ source: "claude", timestamp: "2026-06-01T10:00:00Z", projectId: "p1", projectName: "Alpha", metadata: { sessionId: "s1", messagePreview: " done with task", conversationFile: "/c.jsonl", }, }), evt({ source: "claude", timestamp: "2026-06-01T11:00:00Z", projectId: "p1", projectName: "Alpha", metadata: { sessionId: "s2", messagePreview: "[Attachment: notes.pdf] please review", conversationFile: "/c.jsonl", }, }), git("2026-06-01T12:00:00Z", { id: "p1", name: "Alpha" }, "short"), evt({ source: "file", timestamp: "2026-06-01T13:00:00Z", projectId: "p1", projectName: "Alpha", metadata: { path: "/Users/satchmo/code/scribe/src/x.ts", size: 1, extension: ".ts", }, }), ]); expect(report.highlights).toEqual([]); }); test("aggregates devices/contributors by event and time", () => { const proj = { id: "p1", name: "Alpha" }; const report = buildActivityReport([ { ...git("2026-06-01T10:00:00Z", proj, "x"), deviceId: "d1", deviceName: "Laptop", }, { ...git("2026-06-01T12:30:00Z", proj, "y"), deviceId: "d1", deviceName: "Laptop", }, { ...git("2026-06-01T10:00:00Z", proj, "z"), deviceId: "d2", deviceName: "Desktop", }, ]); expect(report.devices).toHaveLength(2); expect(report.devices[0].deviceId).toBe("d1"); expect(report.devices[0].eventCount).toBe(2); expect(report.devices[0].estimatedMinutes).toBe(30); // No member resolved (local mode): person fields are null. expect(report.devices[0].userId).toBeNull(); expect(report.devices[0].userName).toBeNull(); }); test("groups contributors by member across devices (team scope)", () => { const proj = { id: "p1", name: "Alpha" }; const report = buildActivityReport([ { ...git("2026-06-01T10:00:00Z", proj, "a"), deviceId: "d1", deviceName: "Laptop", userId: "u1", userName: "Ada", userImage: "https://img/ada.png", }, // Same member, different device: must fold into one contributor row. { ...git("2026-06-01T12:30:00Z", proj, "b"), deviceId: "d2", deviceName: "Desktop", userId: "u1", userName: "Ada", }, { ...git("2026-06-01T10:00:00Z", proj, "c"), deviceId: "d3", deviceName: "Server", userId: "u2", userName: "Grace", }, ]); expect(report.devices).toHaveLength(2); const ada = report.devices.find((d) => d.userId === "u1"); expect(ada).toBeDefined(); expect(ada?.userName).toBe("Ada"); expect(ada?.userImage).toBe("https://img/ada.png"); expect(ada?.eventCount).toBe(2); expect(report.devices.find((d) => d.userId === "u2")?.userName).toBe( "Grace", ); }); test("skips events with invalid timestamps", () => { const report = buildActivityReport([ git("2026-06-01T10:00:00Z", { id: "p1", name: "Alpha" }, "valid"), git("not-a-date", { id: "p1", name: "Alpha" }, "invalid"), ]); expect(report.totalEvents).toBe(1); }); test("includeEvents echoes the input; omitted by default", () => { const events = [git("2026-06-01T10:00:00Z", { id: "p1", name: "A" }, "x")]; expect(buildActivityReport(events).events).toBeUndefined(); expect(buildActivityReport(events, { includeEvents: true }).events).toBe( events, ); }); test("byClientType is omitted unless includeClientBreakdown is set", () => { const events = [git("2026-06-01T10:00:00Z", { id: "p1", name: "A" }, "x")]; expect(buildActivityReport(events).byClientType).toBeUndefined(); }); test("byClientType splits projects by clientId presence", () => { const report = buildActivityReport( [ { ...git("2026-06-01T10:00:00Z", { id: "p1", name: "Client" }, "c"), clientId: "c1", }, { ...git("2026-06-01T10:00:00Z", { id: "p2", name: "Internal" }, "i"), clientId: null, }, evt({ source: "git", timestamp: "2026-06-01T10:00:00Z", projectId: null, }), ], { includeClientBreakdown: true }, ); const breakdown = report.byClientType; if (!breakdown) throw new Error("expected byClientType"); expect(breakdown.client.projectCount).toBe(1); expect(breakdown.client.eventCount).toBe(1); // Internal project + unassigned row. expect(breakdown.internal.projectCount).toBe(2); expect(breakdown.internal.eventCount).toBe(2); expect( breakdown.client.estimatedMinutes + breakdown.internal.estimatedMinutes, ).toBe(report.totalEstimatedMinutes); }); test("project rows expose clientId when events carry it", () => { const report = buildActivityReport([ { ...git("2026-06-01T10:00:00Z", { id: "p1", name: "Client" }, "c"), clientId: "c1", }, ]); expect(report.projects[0].clientId).toBe("c1"); }); }); describe("buildActivityReport daily series", () => { test("empty input yields an empty daily series", () => { expect(buildActivityReport([]).daily).toEqual([]); }); test("zero-fills days with no activity across the explicit range", () => { const proj = { id: "p1", name: "Alpha" }; const report = buildActivityReport( [ git("2026-06-01T10:00:00Z", proj, "start"), git("2026-06-04T10:00:00Z", proj, "end"), ], { start: "2026-06-01T10:00:00Z", end: "2026-06-04T10:00:00Z" }, ); expect(report.daily.map((d) => d.date)).toEqual([ "2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", ]); const middleDays = report.daily.filter( (d) => d.date === "2026-06-02" || d.date === "2026-06-03", ); expect(middleDays.every((d) => d.minutes === 0)).toBe(true); const activeDays = report.daily.filter( (d) => d.date === "2026-06-01" || d.date === "2026-06-04", ); expect(activeDays.every((d) => d.minutes > 0)).toBe(true); }); test("includes the boundary day when start and end fall on the same day", () => { const report = buildActivityReport( [git("2026-06-01T10:00:00Z", { id: "p1", name: "Alpha" }, "same day")], { start: "2026-06-01T09:00:00Z", end: "2026-06-01T11:00:00Z" }, ); expect(report.daily).toHaveLength(1); expect(report.daily[0].date).toBe("2026-06-01"); expect(report.daily[0].minutes).toBeGreaterThan(0); }); test("daily minutes sum to the report total within rounding", () => { const proj = { id: "p1", name: "Alpha" }; const report = buildActivityReport([ git("2026-06-01T10:00:00Z", proj, "a"), git("2026-06-01T12:30:00Z", proj, "b"), git("2026-06-02T09:00:00Z", proj, "c"), ]); const dailySum = report.daily.reduce((sum, d) => sum + d.minutes, 0); expect( Math.abs(dailySum - report.totalEstimatedMinutes), ).toBeLessThanOrEqual(report.daily.length); }); test("manual entries are attributed to their own calendar day", () => { const report = buildActivityReport([ evt({ source: "manual", timestamp: "2026-06-01T10:00:00Z", projectId: "p1", projectName: "Alpha", estimatedMinutes: 90, metadata: { title: "Design review" }, }), evt({ source: "manual", timestamp: "2026-06-02T10:00:00Z", projectId: "p1", projectName: "Alpha", estimatedMinutes: 30, metadata: { title: "Client call" }, }), ]); expect(report.daily.find((d) => d.date === "2026-06-01")?.minutes).toBe(90); expect(report.daily.find((d) => d.date === "2026-06-02")?.minutes).toBe(30); }); }); describe("sanitizeGroupLabel / unassignedEventLabel", () => { test("paths become basenames; absolute paths never leak", () => { expect(sanitizeGroupLabel("/Users/satchmo/code/scribe")).toBe("scribe"); expect(sanitizeGroupLabel("github.com/b-open-io/scribe")).toBe("scribe"); expect(sanitizeGroupLabel("C:\\Users\\dev\\repo\\app")).toBe("app"); expect(sanitizeGroupLabel("/Users/satchmo/code/scribe")).not.toContain( "/Users/", ); }); test("junk free-text is dropped; clean labels pass", () => { expect( sanitizeGroupLabel( " abc123 finished work", ), ).toBeNull(); expect( sanitizeGroupLabel( "Please handle the tool-use response before continuing", ), ).toBeNull(); expect(sanitizeGroupLabel("scribe")).toBe("scribe"); expect( sanitizeGroupLabel("Plan invite expiry and report polish work"), ).toBe("Plan invite expiry and report polish work"); }); test("caps overlong labels", () => { const long = `repo-${"x".repeat(80)}`; const sanitized = sanitizeGroupLabel(long); if (sanitized == null) throw new Error("expected sanitized label"); expect(sanitized.length).toBeLessThanOrEqual(60); }); test("prefers git remote basename, file projectPath, chat title, then source", () => { expect( unassignedEventLabel( git("2026-06-01T10:00:00Z", null, "msg", "github.com/acme/widget"), ), ).toBe("widget"); expect( unassignedEventLabel( evt({ source: "file", timestamp: "2026-06-01T10:00:00Z", projectId: null, metadata: { path: "/Users/satchmo/code/widget/src/a.ts", size: 1, extension: ".ts", projectPath: "/Users/satchmo/code/widget", }, }), ), ).toBe("widget"); expect( unassignedEventLabel( evt({ source: "claude", timestamp: "2026-06-01T10:00:00Z", projectId: null, metadata: { sessionId: "s1", messagePreview: "hello there this is a long enough preview text", conversationFile: "/c.jsonl", title: "Session: plan the widget refactor carefully", } as TimelineEventMetadata, }), ), ).toBe("Session: plan the widget refactor carefully"); expect( unassignedEventLabel( evt({ source: "claude", timestamp: "2026-06-01T10:00:00Z", projectId: null, metadata: { sessionId: "s1", messagePreview: "x", conversationFile: "/c.jsonl", }, }), ), ).toBe("claude sessions"); expect( unassignedEventLabel( evt({ source: "file", timestamp: "2026-06-01T10:00:00Z", projectId: null, metadata: { path: "a.ts", size: 1, extension: ".ts" }, }), ), ).toBe("file changes"); }); }); describe("buildActivityReport unassignedBreakdown (OPL-2810)", () => { test("omits breakdown at or below 15% unassigned share (14%)", () => { const report = buildActivityReport([ evt({ source: "manual", timestamp: "2026-06-01T10:00:00Z", projectId: "p1", projectName: "Alpha", estimatedMinutes: 86, metadata: { title: "Assigned design work for the client sprint" }, }), evt({ source: "manual", timestamp: "2026-06-01T11:00:00Z", projectId: null, estimatedMinutes: 14, metadata: { title: "Unassigned scratchpad notes for later sorting" }, }), ]); expect(report.totalEstimatedMinutes).toBe(100); expect(report.unassigned?.estimatedMinutes).toBe(14); expect(report.unassignedBreakdown).toBeUndefined(); }); test("emits breakdown when unassigned exceeds 15% (16%)", () => { const report = buildActivityReport([ evt({ source: "manual", timestamp: "2026-06-01T10:00:00Z", projectId: "p1", projectName: "Alpha", estimatedMinutes: 84, metadata: { title: "Assigned design work for the client sprint" }, }), git( "2026-06-01T11:00:00Z", null, "wip on helper", "github.com/acme/widget", ), // Force unassigned minutes via manual so share is exact 16%. evt({ source: "manual", timestamp: "2026-06-01T12:00:00Z", projectId: null, estimatedMinutes: 16, metadata: { title: "Unassigned scratchpad notes for later sorting" }, }), ]); // Gap-merged git adds 15min to unassigned on top of 16 manual → share > 15%. if (!report.unassigned) throw new Error("expected unassigned row"); expect( (report.unassigned.estimatedMinutes / report.totalEstimatedMinutes) * 100, ).toBeGreaterThan(15); if (!report.unassignedBreakdown) { throw new Error("expected unassignedBreakdown"); } expect(report.unassignedBreakdown.length).toBeGreaterThan(0); // Manual title (sanitized) and git remote basename should appear. const labels = report.unassignedBreakdown.map((r) => r.label); expect(labels).toContain("widget"); expect( labels.some((l) => l.includes("scratchpad") || l === "manual entries"), ).toBe(true); }); test("omits breakdown when there is no unassigned bucket", () => { const report = buildActivityReport([ git("2026-06-01T10:00:00Z", { id: "p1", name: "Alpha" }, "only assigned"), ]); expect(report.unassigned).toBeNull(); expect(report.unassignedBreakdown).toBeUndefined(); }); test("folds labels beyond top 6 into a final Other row", () => { // 8 distinct unassigned repos, each one isolated 15min window → equal minutes. // Plus a large assigned manual so total is high but unassigned still > 15%. // 8 * 15 = 120 unassigned; assigned 100 → 120/220 ≈ 54% > 15%. const events: TimelineEventDisplay[] = [ evt({ source: "manual", timestamp: "2026-06-01T08:00:00Z", projectId: "p1", projectName: "Alpha", estimatedMinutes: 100, metadata: { title: "Assigned baseline work for the week ahead" }, }), ]; for (let i = 0; i < 8; i++) { events.push( git( `2026-06-0${(i % 9) + 1}T${String(10 + i).padStart(2, "0")}:00:00Z`, null, `commit in repo ${i}`, `github.com/acme/repo-${i}`, ), ); } const report = buildActivityReport(events); const rows = report.unassignedBreakdown; if (!rows) throw new Error("expected unassignedBreakdown"); expect(rows).toHaveLength(7); // 6 + Other expect(rows[6].label).toBe("Other"); expect(rows[6].eventCount).toBe(2); expect(rows.slice(0, 6).every((r) => r.label !== "Other")).toBe(true); }); test("groups unassigned file and chat events by folder/session label", () => { const report = buildActivityReport([ evt({ source: "manual", timestamp: "2026-06-01T08:00:00Z", projectId: "p1", projectName: "Alpha", estimatedMinutes: 10, metadata: { title: "Tiny assigned anchor so unassigned share is high" }, }), evt({ source: "file", timestamp: "2026-06-01T10:00:00Z", projectId: null, metadata: { path: "/Users/satchmo/code/widget/src/a.ts", size: 1, extension: ".ts", projectPath: "/Users/satchmo/code/widget", }, }), evt({ source: "file", timestamp: "2026-06-01T10:05:00Z", projectId: null, metadata: { path: "/Users/satchmo/code/widget/src/b.ts", size: 1, extension: ".ts", projectPath: "/Users/satchmo/code/widget", }, }), evt({ source: "claude", timestamp: "2026-06-01T12:00:00Z", projectId: null, metadata: { sessionId: "s1", messagePreview: "working on the auth flow for the new invite links", conversationFile: "/c.jsonl", title: "Auth invite flow planning session for the team", } as TimelineEventMetadata, }), ]); if (!report.unassignedBreakdown) { throw new Error("expected unassignedBreakdown"); } const byLabel = Object.fromEntries( report.unassignedBreakdown.map((r) => [r.label, r]), ); expect(byLabel.widget?.eventCount).toBe(2); expect( byLabel["Auth invite flow planning session for the team"]?.eventCount, ).toBe(1); }); }); describe("extractTicketFromSubject / ticketsByProject (OPL-2811)", () => { test("extracts ticket id and title remainder from commit subjects", () => { expect(extractTicketFromSubject("OPL-1843: fix invite expiry")).toEqual({ id: "OPL-1843", title: "fix invite expiry", }); expect(extractTicketFromSubject("ENG-12 do the thing")).toEqual({ id: "ENG-12", title: "do the thing", }); expect(extractTicketFromSubject("OPL-1")).toEqual({ id: "OPL-1", title: null, }); expect(extractTicketFromSubject("fix OPL-1843 later")).toBeNull(); expect(extractTicketFromSubject("no ticket here at all")).toBeNull(); }); test("rolls up commit-derived tickets per project with completed=null", () => { const alpha = { id: "p1", name: "Alpha" }; const report = buildActivityReport([ git("2026-06-01T10:00:00Z", alpha, "OPL-1843: fix invite expiry"), git("2026-06-01T11:00:00Z", alpha, "OPL-1843: another mention ignored"), git("2026-06-01T12:00:00Z", alpha, "OPL-2000: add report breakdown"), git("2026-06-01T13:00:00Z", alpha, "chore: no ticket"), ]); expect(report.ticketsByProject).toBeDefined(); expect(report.ticketsByProject).toHaveLength(1); const row = report.ticketsByProject?.[0]; if (!row) throw new Error("expected ticketsByProject row"); expect(row.projectId).toBe("p1"); expect(row.projectName).toBe("Alpha"); expect(row.tickets.map((t) => t.id)).toEqual(["OPL-1843", "OPL-2000"]); expect(row.tickets[0]).toEqual({ id: "OPL-1843", title: "fix invite expiry", completed: null, }); expect(row.tickets[1].completed).toBeNull(); }); test("linear events set title/completed and win over commit-derived", () => { const alpha = { id: "p1", name: "Alpha" }; const report = buildActivityReport([ git("2026-06-01T10:00:00Z", alpha, "OPL-1843: commit title loses"), linear("2026-06-02T10:00:00Z", alpha, { issueIdentifier: "OPL-1843", title: "Linear canonical title for invite expiry", action: "completed", }), linear("2026-06-03T10:00:00Z", alpha, { issueIdentifier: "OPL-2100", title: "Only from Linear", action: "created", }), ]); const tickets = report.ticketsByProject?.[0]?.tickets; if (!tickets) throw new Error("expected tickets"); expect(tickets.find((t) => t.id === "OPL-1843")).toEqual({ id: "OPL-1843", title: "Linear canonical title for invite expiry", completed: true, }); expect(tickets.find((t) => t.id === "OPL-2100")).toEqual({ id: "OPL-2100", title: "Only from Linear", completed: null, }); }); test("caps tickets at 15 per project and omits projects with none", () => { const alpha = { id: "p1", name: "Alpha" }; const beta = { id: "p2", name: "Beta" }; const events: TimelineEventDisplay[] = [ git("2026-06-01T10:00:00Z", beta, "chore: no tickets on beta"), ]; for (let i = 1; i <= 20; i++) { events.push( git( `2026-06-01T${String(10 + Math.floor(i / 60)).padStart(2, "0")}:${String(i % 60).padStart(2, "0")}:00Z`, alpha, `OPL-${1000 + i}: ticket number ${i}`, ), ); } const report = buildActivityReport(events); expect(report.ticketsByProject).toHaveLength(1); const alphaTickets = report.ticketsByProject?.[0]; if (!alphaTickets) throw new Error("expected Alpha tickets row"); expect(alphaTickets.projectId).toBe("p1"); expect(alphaTickets.tickets).toHaveLength(15); // Sorted by id — first 15 after sort of OPL-1001..OPL-1020. expect(alphaTickets.tickets[0].id).toBe("OPL-1001"); expect(alphaTickets.tickets[14].id).toBe("OPL-1015"); }); test("ticketsByProject is undefined when nothing was found", () => { const report = buildActivityReport([ git("2026-06-01T10:00:00Z", { id: "p1", name: "Alpha" }, "no ticket"), evt({ source: "file", timestamp: "2026-06-01T11:00:00Z", projectId: "p1", projectName: "Alpha", metadata: { path: "a.ts", size: 1, extension: ".ts" }, }), ]); expect(report.ticketsByProject).toBeUndefined(); }); test("attributes tickets to unassigned when projectId is null", () => { const report = buildActivityReport([ git("2026-06-01T10:00:00Z", null, "OPL-9: unassigned ticket work"), ]); expect(report.ticketsByProject).toEqual([ { projectId: null, projectName: "Unassigned", tickets: [ { id: "OPL-9", title: "unassigned ticket work", completed: null }, ], }, ]); }); }); describe("buildActivityReport outcomes (OPL-2812)", () => { test("stripConventionalCommitPrefix removes type/scope prefixes", () => { expect( stripConventionalCommitPrefix("feat: add login button handler"), ).toBe("add login button handler"); expect( stripConventionalCommitPrefix("fix(auth): repair session expiry path"), ).toBe("repair session expiry path"); expect( stripConventionalCommitPrefix("plain subject without conventional type"), ).toBe("plain subject without conventional type"); }); test("completed tickets become Completed bullets (cap 3 + overflow)", () => { const alpha = { id: "p1", name: "Alpha" }; const events: TimelineEventDisplay[] = []; for (let i = 1; i <= 5; i++) { events.push( linear(`2026-06-0${i}T10:00:00Z`, alpha, { issueIdentifier: `OPL-${100 + i}`, title: `Completed ticket number ${i} with enough text`, action: "completed", }), ); } // Floor of git so the project is not ticket-only edge noise. events.push( git("2026-06-01T11:00:00Z", alpha, "chore: keep project active here"), ); const report = buildActivityReport(events); const outcome = report.outcomes?.find((o) => o.projectId === "p1"); if (!outcome) throw new Error("expected Alpha outcomes"); const completed = outcome.bullets.filter((b) => b.startsWith("Completed ")); expect(completed).toHaveLength(3); expect(outcome.bullets).toContain("+2 more completed"); expect(outcome.bullets[0]).toMatch(/^Completed OPL-101 /); }); test("worked (not completed) tickets become Worked bullets (cap 2)", () => { const alpha = { id: "p1", name: "Alpha" }; const report = buildActivityReport([ linear("2026-06-01T10:00:00Z", alpha, { issueIdentifier: "OPL-1", title: "First in-progress ticket with a long title", action: "updated", }), linear("2026-06-02T10:00:00Z", alpha, { issueIdentifier: "OPL-2", title: "Second in-progress ticket with a long title", action: "created", }), linear("2026-06-03T10:00:00Z", alpha, { issueIdentifier: "OPL-3", title: "Third in-progress ticket should be dropped", action: "updated", }), git("2026-06-01T11:00:00Z", alpha, "feat: touch the branch for activity"), ]); const bullets = report.outcomes?.find((o) => o.projectId === "p1")?.bullets; if (!bullets) throw new Error("expected bullets"); const worked = bullets.filter((b) => b.startsWith("Worked ")); expect(worked).toHaveLength(2); expect(worked[0]).toContain("OPL-1"); expect(worked[1]).toContain("OPL-2"); expect(worked.some((b) => b.includes("OPL-3"))).toBe(false); }); test("ticket IDs without titles render as bare ID (no undefined / empty parens)", () => { const alpha = { id: "p1", name: "Alpha" }; const report = buildActivityReport([ // Commit-derived ticket with no title remainder. git("2026-06-01T10:00:00Z", alpha, "OPL-42"), linear("2026-06-02T10:00:00Z", alpha, { issueIdentifier: "OPL-99", title: "", action: "completed", }), ]); const bullets = report.outcomes?.find((o) => o.projectId === "p1")?.bullets; if (!bullets) throw new Error("expected bullets"); // completed empty-title Linear issue. expect(bullets).toContain("Completed OPL-99"); // worked commit-derived bare id. expect(bullets).toContain("Worked OPL-42"); for (const b of bullets) { expect(b).not.toContain("undefined"); expect(b).not.toContain("()"); expect(b).not.toMatch(/\s{2,}/); } }); test("merged pull requests produce a Merged N pull requests bullet", () => { const alpha = { id: "p1", name: "Alpha" }; const report = buildActivityReport([ git( "2026-06-01T10:00:00Z", alpha, "Merge pull request #12 from team/feature-a", ), git( "2026-06-01T11:00:00Z", alpha, "Merge pull request #13 from team/feature-b", ), git( "2026-06-01T12:00:00Z", alpha, "feat: a sufficiently long non-merge subject line here", ), ]); const bullets = report.outcomes?.find((o) => o.projectId === "p1")?.bullets; if (!bullets) throw new Error("expected bullets"); expect(bullets).toContain("Merged 2 pull requests"); }); test("notable non-ticket subjects: strip conventional prefixes, longest-first", () => { const alpha = { id: "p1", name: "Alpha" }; const report = buildActivityReport([ git( "2026-06-01T10:00:00Z", alpha, "feat: short notable will be filtered by junk min length", ), git( "2026-06-01T11:00:00Z", alpha, "fix: repair the session expiry handling path completely", ), git( "2026-06-01T12:00:00Z", alpha, "chore: rewrite the invoice PDF pipeline for multi-page stability", ), // Ticket subject must not appear as a notable change. git( "2026-06-01T13:00:00Z", alpha, "OPL-9: this ticket subject is not a notable change bullet", ), ]); const bullets = report.outcomes?.find((o) => o.projectId === "p1")?.bullets; if (!bullets) throw new Error("expected bullets"); // Worked ticket from the OPL-9 subject (rule 2). expect(bullets.some((b) => b.startsWith("Worked OPL-9"))).toBe(true); // Notable subjects have conventional prefixes stripped. expect( bullets.some((b) => b.includes("rewrite the invoice PDF pipeline for multi-page stability"), ), ).toBe(true); expect( bullets.some((b) => b.includes("repair the session expiry handling path completely"), ), ).toBe(true); // No raw conventional prefix left on notable bullets. expect(bullets.some((b) => /^feat:|^fix:|^chore:/.test(b))).toBe(false); }); test("activity floor when nothing richer exists", () => { const alpha = { id: "p1", name: "Alpha" }; // Short subjects fail the highlight min-length junk filter → no notables. const report = buildActivityReport([ git("2026-06-01T10:00:00Z", alpha, "wip"), git("2026-06-02T10:00:00Z", alpha, "tmp"), git("2026-06-03T10:00:00Z", alpha, "x"), ]); const bullets = report.outcomes?.find((o) => o.projectId === "p1")?.bullets; expect(bullets).toEqual(["3 commits over 3 active days"]); }); test("HARD CONSTRAINT: zero ticket data still yields rich outcomes (PRs + notable + floor)", () => { const alpha = { id: "p1", name: "Alpha" }; // No Linear, no ticket-id subjects — rules 3–5 only. const report = buildActivityReport([ git( "2026-06-01T10:00:00Z", alpha, "Merge pull request #7 from team/ship-it", ), git( "2026-06-01T11:00:00Z", alpha, "feat: implement the work summary outcomes engine carefully", ), git( "2026-06-02T10:00:00Z", alpha, "fix: stabilize the report narrative failure path for hosts", ), ]); expect(report.ticketsByProject).toBeUndefined(); const outcome = report.outcomes?.find((o) => o.projectId === "p1"); if (!outcome) throw new Error("expected outcomes without tickets"); expect(outcome.bullets).toContain("Merged 1 pull request"); expect( outcome.bullets.some((b) => b.includes("implement the work summary outcomes engine carefully"), ), ).toBe(true); expect( outcome.bullets.some((b) => b.includes("stabilize the report narrative failure path for hosts"), ), ).toBe(true); // Floor is not needed because richer bullets exist. expect(outcome.bullets.some((b) => /commits over/.test(b))).toBe(false); }); test("caps at 5 bullets per project", () => { const alpha = { id: "p1", name: "Alpha" }; const events: TimelineEventDisplay[] = [ git( "2026-06-01T09:00:00Z", alpha, "Merge pull request #1 from team/a-long-enough-merge-subject", ), ]; for (let i = 1; i <= 4; i++) { events.push( linear(`2026-06-0${i}T10:00:00Z`, alpha, { issueIdentifier: `OPL-${i}`, title: `Done ticket ${i} with a sufficiently long title text`, action: "completed", }), ); } for (let i = 1; i <= 3; i++) { events.push( linear(`2026-06-0${i}T12:00:00Z`, alpha, { issueIdentifier: `OPL-2${i}`, title: `Worked ticket ${i} with a sufficiently long title text`, action: "updated", }), ); } const report = buildActivityReport(events); const bullets = report.outcomes?.find((o) => o.projectId === "p1")?.bullets; if (!bullets) throw new Error("expected bullets"); expect(bullets.length).toBeLessThanOrEqual(5); // Priority: completed first (3) + overflow (+1 more completed) + 1 worked = 5. expect(bullets.filter((b) => b.startsWith("Completed ")).length).toBe(3); expect(bullets).toContain("+1 more completed"); }); test("caps at 6 projects; rest folded into Other projects: Xh", () => { const events: TimelineEventDisplay[] = []; for (let i = 1; i <= 8; i++) { const project = { id: `p${i}`, name: `Project ${i}` }; // Stagger minutes so sort order is stable: more events for lower i. for (let j = 0; j < 9 - i; j++) { events.push( git( `2026-06-${String(j + 1).padStart(2, "0")}T10:00:00Z`, project, `wip-${i}-${j}`, ), ); } } const report = buildActivityReport(events); if (!report.outcomes) throw new Error("expected outcomes"); // 6 real projects + 1 "Other projects" fold row. expect(report.outcomes).toHaveLength(7); const other = report.outcomes[report.outcomes.length - 1]; expect(other.projectName).toBe("Other projects"); expect(other.bullets[0]).toMatch(/^Other projects: \d+(\.\d+)?h$/); // Top projects by minutes are Project 1..6 (more commits = more days). const names = report.outcomes.slice(0, 6).map((o) => o.projectName); expect(names).toEqual([ "Project 1", "Project 2", "Project 3", "Project 4", "Project 5", "Project 6", ]); }); test("outcomes omitted when no project produces bullets", () => { // File-only activity: no git commits, no tickets → no outcomes. const report = buildActivityReport([ evt({ source: "file", timestamp: "2026-06-01T10:00:00Z", projectId: "p1", projectName: "Alpha", metadata: { path: "a.ts", size: 1, extension: ".ts" }, }), ]); expect(report.outcomes).toBeUndefined(); }); });