/** * Activity report — shared, less-charty summary view model for desktop and hub. * * The timeline is chart-heavy; this builds a readable, grouped breakdown of what * happened across projects for a date range: total time, source mix, active days, * per-project rows (time, sources, commits/chats/files), the devices involved, * and unassigned work. It is the single source of truth so desktop/local and * hub/hosted render the same report for the same events. * * Pure and framework-free — the React/route layers are thin wrappers. Time is * measured with the shared billable clock: gap-merge **per project**, then sum. * Cross-source overlap within a project is collapsed; concurrent multi-project * work is fully billable (sum of project minutes may exceed wall-clock). Same * convention as invoices and timeline stats (`calculateBillableMinutes`). */ import { UNASSIGNED_PROJECT_ID } from "../db/schema"; import { CHAT_PROVIDER_IDS, isFileMetadata, isGitMetadata, isLinearMetadata, isManualMetadata, type SourceCountMap, type TimelineEventDisplay, type TimelineSource, } from "../types/timeline"; import { buildTimeRanges, calculateBillableMinutes, calculateTotalMinutes, DEFAULT_GAP_THRESHOLD_MINUTES, } from "./time-ranges"; /** A notable item surfaced in the report (most recent commit/chat/file titles). */ export interface ActivityReportHighlight { source: TimelineSource; /** Human-readable label: commit subject, message preview, or file path. */ label: string; timestamp: string; } /** Per-project rollup row for the report, sorted by estimated time desc. */ export interface ActivityReportProject { /** `null` for unassigned work (no project root / `__unassigned__`). */ projectId: string | null; projectName: string; projectColor: string | null; /** * Client id of the project, when the caller supplied it on the events * (`null` = internal/unassigned). `undefined` when client info was not * resolved. Drives the optional `byClientType` breakdown. */ clientId?: string | null; estimatedMinutes: number; eventCount: number; activeDays: number; bySource: SourceCountMap; gitCommits: number; conversations: number; filesModified: number; } /** A device/contributor that produced events in the range. */ export interface ActivityReportDevice { deviceId: string | null; deviceName: string | null; /** * Owning member, when resolved (team scope). When present, rows are grouped * by person rather than device, so one member with multiple devices appears * once. `null` in local/single-tenant mode where events carry no member. */ userId: string | null; userName: string | null; userImage: string | null; eventCount: number; estimatedMinutes: number; } /** Rolled-up totals for one slice (client or internal) of the report. */ export interface ActivityReportClientTypeBucket { /** Sum of per-project `estimatedMinutes` in this slice (no double counting). */ estimatedMinutes: number; eventCount: number; /** Number of distinct project rows in this slice. */ projectCount: number; } /** Client vs internal split of the report, keyed off `projects.client_id`. */ export interface ActivityReportClientTypeBreakdown { /** Projects with a `clientId` (billable client work). */ client: ActivityReportClientTypeBucket; /** Projects without a `clientId`, including unassigned work. */ internal: ActivityReportClientTypeBucket; } /** One point in the report's continuous daily-activity series (chart data). */ export interface ActivityReportDailyEntry { /** Calendar day (local), `YYYY-MM-DD`. */ date: string; /** Gap-merged minutes attributed to this day. Zero on days with no activity. */ minutes: number; } /** * One row in the unassigned-work breakdown (OPL-2810). Present only when the * Unassigned bucket exceeds 15% of total estimated minutes. */ export interface ActivityReportUnassignedBreakdownRow { /** Repo/folder, session/workspace, or source-name label. */ label: string; estimatedMinutes: number; eventCount: number; } /** A ticket referenced by commits or Linear events (OPL-2811). */ export interface ActivityReportTicket { /** Ticket id, e.g. `OPL-1843` or Linear `issueIdentifier`. */ id: string; /** Human title when known; null when only a commit id was seen. */ title: string | null; /** * `true` when Linear (or equivalent) marks the issue done; `null` when * unknown (e.g. commit-derived only). */ completed: boolean | null; } /** Tickets rolled up under one project for the report. */ export interface ActivityReportTicketsByProject { projectId: string | null; projectName: string; tickets: ActivityReportTicket[]; } /** * Deterministic work-outcome bullets for one project (OPL-2812). * Synthesized from tickets, merge commits, notable subjects, and activity — * never from raw chat evidence. Always present when any project has bullets; * omitted (not an empty shell) when nothing can be said. */ export interface ActivityReportOutcome { projectId: string | null; projectName: string; bullets: string[]; } /** The complete activity report view model for a filtered date range. */ export interface ActivityReport { range: { start: string | null; end: string | null }; totalEstimatedMinutes: number; totalEvents: number; /** Distinct calendar days (local) with at least one event. */ activeDays: number; bySource: SourceCountMap; /** Project rows including unassigned, sorted by `estimatedMinutes` desc. */ projects: ActivityReportProject[]; /** Convenience pointer to the unassigned row, if any unassigned work exists. */ unassigned: ActivityReportProject | null; /** Devices/contributors active in the range, sorted by `estimatedMinutes` desc. */ devices: ActivityReportDevice[]; /** Most recent notable items (commits, chats, files), newest first. */ highlights: ActivityReportHighlight[]; /** * Continuous per-day series across the report's range (or, absent an * explicit range, the observed event days), zero-filled on days with no * activity. Drives the daily-activity chart; `sum(daily) ≈ * totalEstimatedMinutes` (within per-range rounding). */ daily: ActivityReportDailyEntry[]; /** * Client vs internal split. Only present when `includeClientBreakdown` is set * (callers that resolved `clientId` on the events); omitted otherwise so * surfaces that never join client info don't imply everything is internal. */ byClientType?: ActivityReportClientTypeBreakdown; /** * Breakdown of unassigned work by best-signal label (repo/folder, session, * or source). Only present when Unassigned exceeds 15% of total minutes; * top 6 by minutes with remainder folded into a final "Other" row. */ unassignedBreakdown?: ActivityReportUnassignedBreakdownRow[]; /** * Tickets/outcomes per project from commit subjects and Linear events. * Omitted when nothing was found. Cap 15 tickets per project. */ ticketsByProject?: ActivityReportTicketsByProject[]; /** * Deterministic work-summary bullets per project (OPL-2812). Primary * headline section for consumers — preferred over raw `highlights`. * Omitted when no project produced bullets. Cap 5 bullets/project, 6 * projects (remainder folded into "Other projects: Xh"). */ outcomes?: ActivityReportOutcome[]; /** * The exact events summarized, echoed back. Only present when `includeEvents` * is set (exhaustive reports); omitted for summary reports to keep payloads * small. */ events?: TimelineEventDisplay[]; } export interface BuildActivityReportOptions { /** Range echoed back into the report (display only; does not filter). */ start?: string | null; end?: string | null; /** Gap threshold for merging events into a continuous session. Default 15. */ gapThresholdMinutes?: number; /** Max highlights to surface. Default 8. */ highlightLimit?: number; /** * Include the `byClientType` breakdown, derived from each event's `clientId`. * Only set this when the caller resolved client info on the events. Default * false. */ includeClientBreakdown?: boolean; /** * Echo the summarized events back on `report.events` (exhaustive depth). * Default false. */ includeEvents?: boolean; } const CHAT_SOURCES = new Set(CHAT_PROVIDER_IDS); /** Ticket-style git subjects, e.g. `OPL-1843: fix invite expiry`. */ const TICKET_SUBJECT = /^[A-Z]+-\d+:/; /** * Ticket id at the start of a commit subject (OPL-2811). * e.g. `OPL-1843: fix invite` or `ENG-12 do the thing`. */ const TICKET_ID_AT_START = /^([A-Z][A-Z0-9]+-\d+)\b/; /** Unassigned breakdown only when Unassigned exceeds this share of total time. */ const UNASSIGNED_BREAKDOWN_THRESHOLD = 0.15; /** Max distinct labels before folding remainder into "Other". */ const UNASSIGNED_BREAKDOWN_TOP_N = 6; /** Max tickets listed per project in the rollup. */ const TICKETS_PER_PROJECT_CAP = 15; /** Hard cap for presentable group labels (basename / session title). */ const GROUP_LABEL_MAX_LEN = 60; /** Outcomes (OPL-2812): bullets and project caps. */ const OUTCOMES_BULLETS_PER_PROJECT = 5; const OUTCOMES_PROJECTS_CAP = 6; const OUTCOMES_COMPLETED_CAP = 3; const OUTCOMES_WORKED_CAP = 2; const OUTCOMES_NOTABLE_CAP = 3; /** Merge PR subjects (GitHub-style). */ const MERGE_PR_SUBJECT = /^Merge pull request/; /** * Conventional-commit type prefix to strip before presenting a subject as a * notable change (e.g. `feat(auth): add login` → `add login`). */ const CONVENTIONAL_COMMIT_PREFIX = /^(feat|fix|chore|docs|style|refactor|perf|test|build|ci|revert)(\([^)]*\))?:\s*/i; /** * Highlight priority tiers (lower = preferred). High-signal curated text first; * raw chat previews only fill remaining slots. */ const HIGHLIGHT_PRIORITY = { gitTicket: 0, git: 1, conversationTitle: 2, manual: 3, chatPreview: 4, } as const; const PATH_MARKERS = [ "/Users/", "/home/", "C:\\", "/private/", "/tmp/", ] as const; const TOOL_JUNK = ["task-notification", "tool-use", "system-reminder"] as const; /** Last-resort human labels when no repo/session signal is available. */ const SOURCE_FALLBACK_LABELS: Record = { git: "git commits", file: "file changes", claude: "claude sessions", claude_desktop: "claude desktop sessions", gemini: "gemini sessions", codex: "codex sessions", opencode: "opencode sessions", cursor: "cursor sessions", cline: "cline sessions", amp: "amp sessions", copilot: "copilot sessions", linear: "linear issues", manual: "manual entries", }; /** Linear / issue states that count as completed for the tickets rollup. */ const COMPLETED_STATE_NAMES = new Set([ "done", "completed", "merged", "closed", "canceled", "cancelled", ]); export interface HighlightCandidate extends ActivityReportHighlight { priority: number; } function dayKey(date: Date): string { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; } function isUnassigned(projectId: string | null): boolean { return projectId === null || projectId === UNASSIGNED_PROJECT_ID; } /** * Hard filters for highlight labels. Junk is dropped entirely — never * truncated-and-kept — so the report never surfaces XML, image attachments, * paths, or tool protocol noise. */ export function isJunkHighlightLabel(label: string): boolean { const trimmed = label.trim(); if (trimmed.length < 20 || trimmed.length > 200) return true; // XML/HTML-like fragments: `<` followed by an ASCII tag-like token. if (/<[A-Za-z/!?]/.test(trimmed)) return true; if (trimmed.includes("[Image") || trimmed.includes("[Attachment")) return true; for (const marker of PATH_MARKERS) { if (trimmed.includes(marker)) return true; } const lower = trimmed.toLowerCase(); for (const token of TOOL_JUNK) { if (lower.includes(token)) return true; } // Backtick-fenced code blocks. if (trimmed.includes("```")) return true; return false; } /** Case-insensitive near-duplicate: exact match or either is a prefix of the other. */ function isNearDuplicateHighlight( candidate: string, selected: readonly string[], ): boolean { const c = candidate.toLowerCase(); for (const existing of selected) { const e = existing.toLowerCase(); if (c === e || c.startsWith(e) || e.startsWith(c)) return true; } return false; } function optionalStringField( meta: Record | null, keys: readonly string[], ): string | null { if (!meta) return null; for (const key of keys) { const value = meta[key]; if (typeof value === "string" && value.trim()) return value.trim(); } return null; } /** * True when a free-text label fails the same hard junk filters highlights use * (XML, paths, tool protocol, fenced code), ignoring the highlight min-length * rule so short repo basenames like "scribe" remain valid group labels. */ function hasJunkLabelMarkers(label: string): boolean { const trimmed = label.trim(); if (!trimmed) return true; if (/<[A-Za-z/!?]/.test(trimmed)) return true; if (trimmed.includes("[Image") || trimmed.includes("[Attachment")) return true; for (const marker of PATH_MARKERS) { if (trimmed.includes(marker)) return true; } const lower = trimmed.toLowerCase(); for (const token of TOOL_JUNK) { if (lower.includes(token)) return true; } if (trimmed.includes("```")) return true; return false; } /** * Last path segment of a path-ish or remote-ish string, without trailing * separators. Handles `/` and `\` so absolute paths never leak into labels. */ function basenameOf(pathish: string): string { const normalized = pathish.replace(/\\/g, "/").replace(/\/+$/, ""); const slash = normalized.lastIndexOf("/"); return slash >= 0 ? normalized.slice(slash + 1) : normalized; } /** * Whether a string is a filesystem path or normalized git remote we should * reduce to a basename. Free-text session titles (and XML/tool junk) must not * be basenamed just because they contain a `/`. */ function looksLikePathOrRemote(value: string): boolean { if (PATH_MARKERS.some((m) => value.includes(m))) return true; if (value.startsWith("/") || value.startsWith("~")) return true; if (/^[A-Za-z]:[\\/]/.test(value)) return true; // Normalized remotes / short repo paths: host/owner/repo or owner/repo. // Reject spaces and markup so "a / b" or XML never match. if ( !/[\s<>]/.test(value) && /^[\w.-]+(?:\/[\w.-]+)+$/.test(value.replace(/\\/g, "/")) ) { return true; } return false; } /** * Turn a raw signal into a human-presentable group label: basename only, length * capped, junk dropped (returns null so the caller can fall through). Reuses the * highlight junk markers (XML, paths, tool protocol) without the highlight * min-length rule so short repo basenames stay valid. */ export function sanitizeGroupLabel( raw: string | null | undefined, ): string | null { if (raw == null) return null; let value = raw.trim(); if (!value) return null; // Absolute paths and remotes → basename only (never surface full paths). if (looksLikePathOrRemote(value)) { value = basenameOf(value); } value = value.trim(); if (!value) return null; if (value.length > GROUP_LABEL_MAX_LEN) { value = `${value.slice(0, GROUP_LABEL_MAX_LEN - 1)}…`; } // Marker-based junk only — highlight min-length would reject short basenames. if (hasJunkLabelMarkers(value)) return null; // Match highlight ceiling for free-text session titles. if (value.length > 200) return null; return value; } /** * Best-signal grouping label for an unassigned event (OPL-2810): * 1. repo/folder for git/file * 2. session/workspace for chat * 3. source name as last resort */ export function unassignedEventLabel(event: TimelineEventDisplay): string { if (event.source === "git" && isGitMetadata(event.metadata)) { const fromRemote = sanitizeGroupLabel(event.metadata.gitRemote ?? null); if (fromRemote) return fromRemote; } if (event.source === "file" && isFileMetadata(event.metadata)) { const fromProject = sanitizeGroupLabel(event.metadata.projectPath ?? null); if (fromProject) return fromProject; // Prefer parent folder of the file over the bare filename when no projectPath. const path = event.metadata.path; if (path.includes("/") || path.includes("\\")) { const parent = path.replace(/\\/g, "/").replace(/\/[^/]+$/, ""); const fromParent = sanitizeGroupLabel(parent); if (fromParent) return fromParent; } } if (CHAT_SOURCES.has(event.source)) { const meta = (event.metadata ?? null) as Record | null; const sessionLabel = optionalStringField(meta, [ "title", "sessionTitle", "summary", "workspaceName", ]); const fromSession = sanitizeGroupLabel(sessionLabel); if (fromSession) return fromSession; const fromProject = sanitizeGroupLabel( typeof meta?.projectPath === "string" ? meta.projectPath : null, ); if (fromProject) return fromProject; } if (event.source === "manual" && isManualMetadata(event.metadata)) { const fromTitle = sanitizeGroupLabel(event.metadata.title); if (fromTitle) return fromTitle; } if (event.source === "linear" && isLinearMetadata(event.metadata)) { const fromProject = sanitizeGroupLabel(event.metadata.projectName ?? null); if (fromProject) return fromProject; const fromTeam = sanitizeGroupLabel(event.metadata.teamName ?? null); if (fromTeam) return fromTeam; } return SOURCE_FALLBACK_LABELS[event.source] ?? event.source; } interface LabelAccumulator { label: string; events: Array<{ id: string; timestamp: Date }>; manualMinutes: number; eventCount: number; } /** * When Unassigned exceeds 15% of total estimated minutes, group those events by * best-signal label (top 6 + "Other"). Otherwise returns undefined. */ function buildUnassignedBreakdown( unassignedEvents: TimelineEventDisplay[], unassignedMinutes: number, totalMinutes: number, gap: { gapThresholdMinutes: number }, ): ActivityReportUnassignedBreakdownRow[] | undefined { if ( unassignedEvents.length === 0 || totalMinutes <= 0 || unassignedMinutes / totalMinutes <= UNASSIGNED_BREAKDOWN_THRESHOLD ) { return undefined; } const groups = new Map(); for (const event of unassignedEvents) { const date = new Date(event.timestamp); if (Number.isNaN(date.getTime())) continue; const label = unassignedEventLabel(event); let acc = groups.get(label); if (!acc) { acc = { label, events: [], manualMinutes: 0, eventCount: 0 }; groups.set(label, acc); } acc.eventCount += 1; if (event.source === "manual") { acc.manualMinutes += event.estimatedMinutes ?? 0; } else { acc.events.push({ id: event.id, timestamp: date }); } } const rows: ActivityReportUnassignedBreakdownRow[] = [...groups.values()] .map((acc) => ({ label: acc.label, estimatedMinutes: calculateTotalMinutes(buildTimeRanges(acc.events, gap)) + acc.manualMinutes, eventCount: acc.eventCount, })) .sort((a, b) => { if (b.estimatedMinutes !== a.estimatedMinutes) { return b.estimatedMinutes - a.estimatedMinutes; } return a.label.localeCompare(b.label); }); if (rows.length <= UNASSIGNED_BREAKDOWN_TOP_N) return rows; const top = rows.slice(0, UNASSIGNED_BREAKDOWN_TOP_N); const rest = rows.slice(UNASSIGNED_BREAKDOWN_TOP_N); top.push({ label: "Other", estimatedMinutes: rest.reduce((sum, r) => sum + r.estimatedMinutes, 0), eventCount: rest.reduce((sum, r) => sum + r.eventCount, 0), }); return top; } interface TicketAccumulator { id: string; title: string | null; completed: boolean | null; /** Prefer Linear-sourced fields over commit-derived when merging. */ fromLinear: boolean; } interface ProjectTicketBucket { projectId: string | null; projectName: string; tickets: Map; } function projectBucketKey(projectId: string | null): string { return projectId === null || projectId === UNASSIGNED_PROJECT_ID ? UNASSIGNED_PROJECT_ID : projectId; } function isCompletedStateName(name: string): boolean { return COMPLETED_STATE_NAMES.has(name.trim().toLowerCase()); } /** * Extract a ticket id + title remainder from a git commit subject line. * Only matches ids at the start of the subject (deterministic, no network). */ export function extractTicketFromSubject( subject: string, ): { id: string; title: string | null } | null { const firstLine = subject.split("\n")[0]?.trim() ?? ""; const match = TICKET_ID_AT_START.exec(firstLine); if (!match) return null; const id = match[1]; const remainder = firstLine .slice(match[0].length) .replace(/^:\s*/, "") .trim(); return { id, title: remainder || null }; } /** * Roll up tickets/outcomes from git subjects and Linear events (OPL-2811). * Linear data wins over commit-derived on merge; cap 15 tickets per project. */ function buildTicketsByProject( events: TimelineEventDisplay[], ): ActivityReportTicketsByProject[] | undefined { const buckets = new Map(); const ensureBucket = ( projectId: string | null, projectName: string | null, ): ProjectTicketBucket => { const unassigned = isUnassigned(projectId); const key = projectBucketKey(projectId); let bucket = buckets.get(key); if (!bucket) { bucket = { projectId: unassigned ? null : projectId, projectName: projectName || (unassigned ? "Unassigned" : (projectId as string)), tickets: new Map(), }; buckets.set(key, bucket); } else if ( projectName && bucket.projectId && bucket.projectName === bucket.projectId ) { bucket.projectName = projectName; } return bucket; }; for (const event of events) { if (event.source === "git" && isGitMetadata(event.metadata)) { const extracted = extractTicketFromSubject(event.metadata.message); if (!extracted) continue; const bucket = ensureBucket(event.projectId, event.projectName); const existing = bucket.tickets.get(extracted.id); // First commit subject wins for title; later commits do not overwrite. if (!existing) { bucket.tickets.set(extracted.id, { id: extracted.id, title: extracted.title, completed: null, fromLinear: false, }); } continue; } if (event.source === "linear" && isLinearMetadata(event.metadata)) { const meta = event.metadata; const id = meta.issueIdentifier?.trim(); if (!id) continue; const bucket = ensureBucket(event.projectId, event.projectName); const completedFromAction = meta.action === "completed"; // Optional free-form state on extended rows (not on LinearMetadata today). const stateField = (meta as unknown as Record).state; const stateName = typeof stateField === "string" ? stateField : null; const completedFromState = stateName != null ? isCompletedStateName(stateName) : false; const completed = completedFromAction || completedFromState; const existing = bucket.tickets.get(id); if (!existing) { bucket.tickets.set(id, { id, title: meta.title?.trim() || null, completed: completed ? true : null, fromLinear: true, }); } else { // Linear wins over commit-derived title/completed. existing.fromLinear = true; if (meta.title?.trim()) existing.title = meta.title.trim(); if (completed) existing.completed = true; else if (existing.completed == null) existing.completed = null; } } } const result: ActivityReportTicketsByProject[] = []; for (const bucket of buckets.values()) { if (bucket.tickets.size === 0) continue; const tickets = [...bucket.tickets.values()] .map(({ id, title, completed }) => ({ id, title, completed })) .sort((a, b) => a.id.localeCompare(b.id)) .slice(0, TICKETS_PER_PROJECT_CAP); result.push({ projectId: bucket.projectId, projectName: bucket.projectName, tickets, }); } if (result.length === 0) return undefined; result.sort((a, b) => a.projectName.localeCompare(b.projectName)); return result; } /** Compact hours for the outcomes fold line, e.g. `2.5h` or `3h`. */ function formatOutcomesHours(minutes: number): string { const hours = Math.round((minutes / 60) * 10) / 10; return `${hours}h`; } /** * Strip a conventional-commit type prefix so notable-change bullets read as * plain work descriptions rather than protocol noise. */ export function stripConventionalCommitPrefix(subject: string): string { return subject.replace(CONVENTIONAL_COMMIT_PREFIX, "").trim(); } /** * Render a ticket bullet without empty titles, "undefined", or bare parens. * Title-less tickets (commit-id only, unresolved Linear) surface as the bare ID. */ function formatTicketBullet( verb: "Completed" | "Worked", ticket: ActivityReportTicket, ): string { const title = ticket.title?.trim(); if (title) return `${verb} ${ticket.id} ${title}`; return `${verb} ${ticket.id}`; } function ticketBucketKey(projectId: string | null): string { return projectId === null || projectId === UNASSIGNED_PROJECT_ID ? UNASSIGNED_PROJECT_ID : projectId; } /** * Synthesize deterministic outcome bullets for one project (OPL-2812). * Priority order: completed tickets → worked tickets → merged PRs → notable * non-ticket subjects → activity floor. Cap 5 bullets. Ticket data only ADDS * bullets; rules 3–5 stand alone when Linear/ticket conventions are absent. */ function synthesizeProjectBullets( project: ActivityReportProject, tickets: ActivityReportTicket[], gitSubjects: string[], ): string[] { const bullets: string[] = []; const completed = tickets.filter((t) => t.completed === true); const worked = tickets.filter((t) => t.completed !== true); // 1. Completed tickets (cap 3, then "+N more completed"). const completedShown = completed.slice(0, OUTCOMES_COMPLETED_CAP); for (const t of completedShown) { if (bullets.length >= OUTCOMES_BULLETS_PER_PROJECT) break; bullets.push(formatTicketBullet("Completed", t)); } const completedOverflow = completed.length - completedShown.length; if (completedOverflow > 0 && bullets.length < OUTCOMES_BULLETS_PER_PROJECT) { bullets.push(`+${completedOverflow} more completed`); } // 2. Worked-but-not-completed tickets (cap 2). for (const t of worked.slice(0, OUTCOMES_WORKED_CAP)) { if (bullets.length >= OUTCOMES_BULLETS_PER_PROJECT) break; bullets.push(formatTicketBullet("Worked", t)); } // 3. Merged pull requests (subjects matching /^Merge pull request/). const mergeCount = gitSubjects.filter((s) => MERGE_PR_SUBJECT.test(s)).length; if (mergeCount > 0 && bullets.length < OUTCOMES_BULLETS_PER_PROJECT) { bullets.push( mergeCount === 1 ? "Merged 1 pull request" : `Merged ${mergeCount} pull requests`, ); } // 4. Notable non-ticket, non-merge subjects: strip conventional prefixes, // junk-filter, dedupe, longest-first significance proxy, top 2–3. if (bullets.length < OUTCOMES_BULLETS_PER_PROJECT) { const notableRaw: string[] = []; const seen = new Set(); for (const subject of gitSubjects) { if (MERGE_PR_SUBJECT.test(subject)) continue; if (TICKET_ID_AT_START.test(subject)) continue; const cleaned = stripConventionalCommitPrefix(subject); if (!cleaned) continue; // Reuse highlight junk filters (min length, XML, paths, tool noise). if (isJunkHighlightLabel(cleaned)) continue; const key = cleaned.toLowerCase(); if (seen.has(key)) continue; if (isNearDuplicateHighlight(cleaned, notableRaw)) continue; seen.add(key); notableRaw.push(cleaned); } notableRaw.sort((a, b) => b.length - a.length || a.localeCompare(b)); const notableSlots = Math.min( OUTCOMES_NOTABLE_CAP, OUTCOMES_BULLETS_PER_PROJECT - bullets.length, ); for (const label of notableRaw.slice(0, notableSlots)) { bullets.push(label); } } // 5. Activity floor when nothing richer exists (still useful without tickets). if (bullets.length === 0 && project.gitCommits > 0) { const days = project.activeDays; const dayWord = days === 1 ? "day" : "days"; const commitWord = project.gitCommits === 1 ? "commit" : "commits"; bullets.push( `${project.gitCommits} ${commitWord} over ${days} active ${dayWord}`, ); } return bullets; } /** * Build the report's deterministic outcomes section (OPL-2812). * Walks projects in estimated-minutes order, synthesizes bullets, caps at 6 * projects, folds the rest into "Other projects: Xh". Returns undefined when * no project produced any bullet (renderer omits the section). */ function buildOutcomes( events: TimelineEventDisplay[], projectRows: ActivityReportProject[], ticketsByProject: ActivityReportTicketsByProject[] | undefined, ): ActivityReportOutcome[] | undefined { // Index tickets by project key for O(1) lookup. const ticketsByKey = new Map(); if (ticketsByProject) { for (const row of ticketsByProject) { ticketsByKey.set(ticketBucketKey(row.projectId), row.tickets); } } // Collect first-line git subjects per project (for merge + notable rules). const subjectsByKey = new Map(); for (const event of events) { if (event.source !== "git" || !isGitMetadata(event.metadata)) continue; const subject = event.metadata.message.split("\n")[0]?.trim() ?? ""; if (!subject) continue; const key = projectBucketKey(event.projectId); let list = subjectsByKey.get(key); if (!list) { list = []; subjectsByKey.set(key, list); } list.push(subject); } const rows: ActivityReportOutcome[] = []; for (const project of projectRows) { const key = projectBucketKey(project.projectId); const bullets = synthesizeProjectBullets( project, ticketsByKey.get(key) ?? [], subjectsByKey.get(key) ?? [], ); if (bullets.length === 0) continue; rows.push({ projectId: project.projectId, projectName: project.projectName, bullets, }); } if (rows.length === 0) return undefined; if (rows.length <= OUTCOMES_PROJECTS_CAP) return rows; const kept = rows.slice(0, OUTCOMES_PROJECTS_CAP); const folded = rows.slice(OUTCOMES_PROJECTS_CAP); // Sum estimated minutes from the matching project rows for the fold line. const foldedKeys = new Set(folded.map((r) => projectBucketKey(r.projectId))); const foldedMinutes = projectRows .filter((p) => foldedKeys.has(projectBucketKey(p.projectId))) .reduce((sum, p) => sum + p.estimatedMinutes, 0); kept.push({ projectId: null, projectName: "Other projects", bullets: [`Other projects: ${formatOutcomesHours(foldedMinutes)}`], }); return kept; } /** * Extract a presentable highlight candidate from an event, or null when the * event has no usable label / fails hard filters. Priority encodes source * preference (git subjects first, raw chat previews last). */ export function highlightCandidate( event: TimelineEventDisplay, timestamp: string, ): HighlightCandidate | null { if (event.source === "git" && isGitMetadata(event.metadata)) { const label = event.metadata.message.split("\n")[0]?.trim() ?? ""; if (!label || isJunkHighlightLabel(label)) return null; return { source: event.source, label, timestamp, priority: TICKET_SUBJECT.test(label) ? HIGHLIGHT_PRIORITY.gitTicket : HIGHLIGHT_PRIORITY.git, }; } if (event.source === "manual" && isManualMetadata(event.metadata)) { const label = event.metadata.title.trim(); if (!label || isJunkHighlightLabel(label)) return null; return { source: event.source, label, timestamp, priority: HIGHLIGHT_PRIORITY.manual, }; } if (CHAT_SOURCES.has(event.source)) { const meta = (event.metadata ?? null) as Record | null; // Prefer session/thread titles or summaries over raw message bodies. // Evidence threads store `title`; some adapters may surface it on metadata. const title = optionalStringField(meta, [ "title", "sessionTitle", "summary", ]); if (title && !isJunkHighlightLabel(title)) { return { source: event.source, label: title, timestamp, priority: HIGHLIGHT_PRIORITY.conversationTitle, }; } const preview = typeof meta?.messagePreview === "string" ? meta.messagePreview.trim() : ""; if (preview && !isJunkHighlightLabel(preview)) { return { source: event.source, label: preview, timestamp, priority: HIGHLIGHT_PRIORITY.chatPreview, }; } } // File paths and linear issues are not high-signal report highlights. return null; } /** * Select up to `limit` highlights: prefer higher-priority sources, newest * within a tier, drop near-duplicates, then emit newest-first (existing order). */ export function selectHighlights( candidates: HighlightCandidate[], limit: number, ): ActivityReportHighlight[] { if (limit <= 0 || candidates.length === 0) return []; const ranked = [...candidates].sort((a, b) => { if (a.priority !== b.priority) return a.priority - b.priority; return b.timestamp.localeCompare(a.timestamp); }); const selected: ActivityReportHighlight[] = []; const selectedLabels: string[] = []; for (const c of ranked) { if (selected.length >= limit) break; if (isNearDuplicateHighlight(c.label, selectedLabels)) continue; selected.push({ source: c.source, label: c.label, timestamp: c.timestamp, }); selectedLabels.push(c.label); } selected.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); return selected; } interface ProjectAccumulator { projectId: string | null; projectName: string; projectColor: string | null; clientId: string | null | undefined; events: Array<{ id: string; timestamp: Date }>; manualMinutes: number; /** Manual `estimatedMinutes`, bucketed by calendar day, for the daily series. */ manualByDay: Map; eventCount: number; days: Set; bySource: SourceCountMap; gitCommits: number; conversations: Set; files: Set; } function parseDayKey(day: string): Date { const [year, month, date] = day.split("-").map(Number); return new Date(year, month - 1, date); } /** * Add a gap-merged range's minutes to the day(s) it falls on, splitting a * range that crosses midnight proportionally by wall-clock overlap so a * session spanning two calendar days attributes minutes to each correctly. */ function allocateRangeToDays( range: { start: Date; end: Date }, dailyMinutes: Map, ): void { let cursor = new Date( range.start.getFullYear(), range.start.getMonth(), range.start.getDate(), ); while (cursor.getTime() < range.end.getTime()) { const dayEnd = new Date( cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1, ); const overlapStart = Math.max(range.start.getTime(), cursor.getTime()); const overlapEnd = Math.min(range.end.getTime(), dayEnd.getTime()); const overlapMinutes = Math.max(0, overlapEnd - overlapStart) / 60000; if (overlapMinutes > 0) { const key = dayKey(cursor); dailyMinutes.set(key, (dailyMinutes.get(key) ?? 0) + overlapMinutes); } cursor = dayEnd; } } /** * Build the continuous per-day series backing the daily-activity chart: one * entry per calendar day spanning the report's `start`/`end` (falling back to * the observed event days when either bound is missing), zero-filled, using * the same gap-merged time ranges as the per-project totals so * `sum(daily) ≈ totalEstimatedMinutes`. */ function buildDailySeries( projectAccs: ProjectAccumulator[], allDays: Set, gap: { gapThresholdMinutes: number }, start: string | null | undefined, end: string | null | undefined, ): ActivityReportDailyEntry[] { const sortedEventDays = [...allDays].sort(); const minEventDay = sortedEventDays[0] ?? null; const maxEventDay = sortedEventDays.at(-1) ?? null; const startDay = start ? dayKey(new Date(start)) : null; const endDay = end ? dayKey(new Date(end)) : null; let domainStart = startDay ?? minEventDay; let domainEnd = endDay ?? maxEventDay; // Guarantee the domain covers every day with activity, even if narrower // bounds were supplied, so `sum(daily)` never drops real minutes. if (minEventDay && (!domainStart || minEventDay < domainStart)) { domainStart = minEventDay; } if (maxEventDay && (!domainEnd || maxEventDay > domainEnd)) { domainEnd = maxEventDay; } if (!domainStart || !domainEnd) return []; if (domainEnd < domainStart) domainEnd = domainStart; const dailyMinutes = new Map(); let cursor = parseDayKey(domainStart); const last = parseDayKey(domainEnd); while (cursor.getTime() <= last.getTime()) { dailyMinutes.set(dayKey(cursor), 0); cursor = new Date( cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1, ); } for (const acc of projectAccs) { for (const range of buildTimeRanges(acc.events, gap)) { allocateRangeToDays(range, dailyMinutes); } for (const [day, minutes] of acc.manualByDay) { dailyMinutes.set(day, (dailyMinutes.get(day) ?? 0) + minutes); } } return [...dailyMinutes.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([date, minutes]) => ({ date, minutes: Math.round(minutes) })); } /** * Build a readable activity report from a list of timeline events. * * @param events Timeline events (already filtered to the desired range/filters * by the caller — this function does not filter, it summarizes). * @returns A grouped, plain-data report view model. Empty events yield an empty * report (zeroed totals, empty arrays) rather than `null`, so callers can * render a "no activity" state uniformly. */ export function buildActivityReport( events: TimelineEventDisplay[], options: BuildActivityReportOptions = {}, ): ActivityReport { const gap = { gapThresholdMinutes: options.gapThresholdMinutes ?? DEFAULT_GAP_THRESHOLD_MINUTES, }; const highlightLimit = options.highlightLimit ?? 8; const projects = new Map(); const devices = new Map< string, { deviceId: string | null; deviceName: string | null; userId: string | null; userName: string | null; userImage: string | null; events: Array<{ id: string; timestamp: Date; projectId: string | null; source: string; estimatedMinutes: number | null; }>; } >(); const allDays = new Set(); const overallBySource: SourceCountMap = {}; const highlightCandidates: HighlightCandidate[] = []; /** Valid unassigned events retained for optional breakdown (OPL-2810). */ const unassignedEvents: TimelineEventDisplay[] = []; let validEventCount = 0; for (const event of events) { const date = new Date(event.timestamp); if (Number.isNaN(date.getTime())) continue; validEventCount += 1; const unassigned = isUnassigned(event.projectId); if (unassigned) unassignedEvents.push(event); const key = unassigned ? UNASSIGNED_PROJECT_ID : (event.projectId as string); let acc = projects.get(key); if (!acc) { acc = { projectId: unassigned ? null : event.projectId, projectName: event.projectName || (unassigned ? "Unassigned" : (event.projectId as string)), projectColor: event.projectColor, clientId: event.clientId, events: [], manualMinutes: 0, manualByDay: new Map(), eventCount: 0, days: new Set(), bySource: {}, gitCommits: 0, conversations: new Set(), files: new Set(), }; projects.set(key, acc); } // Prefer a concrete name if a later event carries one (the first event for // an assigned project may have arrived before its name was joined in). if ( event.projectName && acc.projectId && acc.projectName === acc.projectId ) { acc.projectName = event.projectName; } if (!acc.projectColor && event.projectColor) { acc.projectColor = event.projectColor; } // Fill in client info if a later event for the same project carries it. if (acc.clientId == null && event.clientId != null) { acc.clientId = event.clientId; } const day = dayKey(date); allDays.add(day); acc.days.add(day); acc.eventCount += 1; acc.bySource[event.source] = (acc.bySource[event.source] ?? 0) + 1; overallBySource[event.source] = (overallBySource[event.source] ?? 0) + 1; if (event.source === "manual") { const minutes = event.estimatedMinutes ?? 0; acc.manualMinutes += minutes; acc.manualByDay.set(day, (acc.manualByDay.get(day) ?? 0) + minutes); } else { acc.events.push({ id: event.id, timestamp: date }); } if (event.source === "git" && isGitMetadata(event.metadata)) { acc.gitCommits += 1; } else if (CHAT_SOURCES.has(event.source)) { const meta = event.metadata as { sessionId?: unknown } | null; if (typeof meta?.sessionId === "string") { acc.conversations.add(`${event.source}:${meta.sessionId}`); } } else if (event.source === "file" && isFileMetadata(event.metadata)) { acc.files.add(event.metadata.path); } // Group by member when resolved (team scope) so one person with multiple // devices is a single contributor; fall back to device, then local. const contributorKey = event.userId ?? event.deviceId ?? "__local__"; let dev = devices.get(contributorKey); if (!dev) { dev = { deviceId: event.deviceId, deviceName: event.deviceName, userId: event.userId ?? null, userName: event.userName ?? null, userImage: event.userImage ?? null, events: [], }; devices.set(contributorKey, dev); } if (!dev.deviceName && event.deviceName) dev.deviceName = event.deviceName; if (!dev.userName && event.userName) dev.userName = event.userName; if (!dev.userImage && event.userImage) dev.userImage = event.userImage; dev.events.push({ id: event.id, timestamp: date, projectId: unassigned ? null : (event.projectId as string), source: event.source, estimatedMinutes: event.estimatedMinutes ?? null, }); const candidate = highlightCandidate(event, date.toISOString()); if (candidate) highlightCandidates.push(candidate); } const projectRows: ActivityReportProject[] = [...projects.values()].map( (acc) => ({ projectId: acc.projectId, projectName: acc.projectName, projectColor: acc.projectColor, clientId: acc.clientId, estimatedMinutes: calculateTotalMinutes(buildTimeRanges(acc.events, gap)) + acc.manualMinutes, eventCount: acc.eventCount, activeDays: acc.days.size, bySource: acc.bySource, gitCommits: acc.gitCommits, conversations: acc.conversations.size, filesModified: acc.files.size, }), ); projectRows.sort((a, b) => b.estimatedMinutes - a.estimatedMinutes); // Contributors: billable minutes on that person/device — per-project // sessions summed, so concurrent multi-project work counts fully. const deviceRows: ActivityReportDevice[] = [...devices.values()].map( (dev) => ({ deviceId: dev.deviceId, deviceName: dev.deviceName, userId: dev.userId, userName: dev.userName, userImage: dev.userImage, eventCount: dev.events.length, estimatedMinutes: calculateBillableMinutes(dev.events, gap), }), ); deviceRows.sort((a, b) => b.estimatedMinutes - a.estimatedMinutes); const dailyEntries = buildDailySeries( [...projects.values()], allDays, gap, options.start, options.end, ); const totalEstimatedMinutes = projectRows.reduce( (sum, p) => sum + p.estimatedMinutes, 0, ); const unassignedRow = projectRows.find((p) => p.projectId === null) ?? null; const report: ActivityReport = { range: { start: options.start ?? null, end: options.end ?? null }, totalEstimatedMinutes, totalEvents: validEventCount, activeDays: allDays.size, bySource: overallBySource, projects: projectRows, unassigned: unassignedRow, devices: deviceRows, daily: dailyEntries, highlights: selectHighlights(highlightCandidates, highlightLimit), }; const unassignedBreakdown = buildUnassignedBreakdown( unassignedEvents, unassignedRow?.estimatedMinutes ?? 0, totalEstimatedMinutes, gap, ); if (unassignedBreakdown) { report.unassignedBreakdown = unassignedBreakdown; } const ticketsByProject = buildTicketsByProject(events); if (ticketsByProject) { report.ticketsByProject = ticketsByProject; } // Deterministic outcomes (OPL-2812) — built from tickets + git subjects // already on the report/events. Always runs; only attached when non-empty. const outcomes = buildOutcomes(events, projectRows, ticketsByProject); if (outcomes) { report.outcomes = outcomes; } if (options.includeClientBreakdown) { report.byClientType = buildClientTypeBreakdown(projectRows); } if (options.includeEvents) { report.events = events; } return report; } /** * Split project rows into client (has `clientId`) vs internal (no `clientId`, * including unassigned) buckets. `estimatedMinutes` sums per-project minutes, * which are already gap-merged and non-double-counted, so the two buckets sum * to the report total. */ function buildClientTypeBreakdown( projectRows: ActivityReportProject[], ): ActivityReportClientTypeBreakdown { const client: ActivityReportClientTypeBucket = { estimatedMinutes: 0, eventCount: 0, projectCount: 0, }; const internal: ActivityReportClientTypeBucket = { estimatedMinutes: 0, eventCount: 0, projectCount: 0, }; for (const row of projectRows) { const bucket = row.clientId != null ? client : internal; bucket.estimatedMinutes += row.estimatedMinutes; bucket.eventCount += row.eventCount; bucket.projectCount += 1; } return { client, internal }; }