/** * Time Range Calculation Utility * * Builds continuous work sessions from discrete timeline events. * Events within the gap threshold are merged into the same range so * overlapping git/file/chat evidence for the SAME project is not * triple-counted. * * Product rule (billable time): concurrent work across different projects * is independently billable. Each project gap-merges its own stream; totals * are the SUM of per-project session minutes. That sum may exceed wall-clock * when agents (or humans) work multiple projects in parallel — intentional. */ /** Default session-tail / gap-closer (minutes). Single product constant. */ export const DEFAULT_GAP_THRESHOLD_MINUTES = 15; export interface TimeRange { start: Date; end: Date; durationMinutes: number; eventCount: number; eventIds: string[]; } export interface TimeRangeEvent { id: string; timestamp: Date; } /** Minimal event shape for billable-minute totals across surfaces. */ export interface BillableTimeEvent { id?: string; timestamp: Date | number | string; /** Project id; null/undefined groups as unassigned. */ projectId?: string | null; source?: string | null; /** Used only for `source === "manual"` (explicit duration). */ estimatedMinutes?: number | null; } /** * Coerce a timestamp to a Date. Accepts Date, ms epoch, second epoch, or ISO. * Returns null when unparseable. */ export function coerceEventTimestamp( value: Date | number | string, ): Date | null { if (value instanceof Date) { return Number.isNaN(value.getTime()) ? null : value; } if (typeof value === "number") { if (!Number.isFinite(value)) return null; const ms = value < 1_000_000_000_000 ? value * 1000 : value; const date = new Date(ms); return Number.isNaN(date.getTime()) ? null : date; } const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; } /** * Build continuous time ranges from discrete events. * * Each event extends the current work session by `gapThresholdMinutes`. * If the next event falls within the current range, it extends the range. * If there's a gap > threshold, a new range starts. * * @param events - Array of events with id and timestamp * @param options - Configuration options * @returns Array of merged time ranges */ export function buildTimeRanges( events: TimeRangeEvent[], options?: { gapThresholdMinutes?: number }, ): TimeRange[] { if (events.length === 0) return []; const gapMs = (options?.gapThresholdMinutes ?? DEFAULT_GAP_THRESHOLD_MINUTES) * 60 * 1000; // Sort by timestamp const sorted = [...events].sort( (a, b) => a.timestamp.getTime() - b.timestamp.getTime(), ); const ranges: TimeRange[] = []; let currentRange = { start: sorted[0].timestamp, end: new Date(sorted[0].timestamp.getTime() + gapMs), eventCount: 1, eventIds: [sorted[0].id], }; for (let i = 1; i < sorted.length; i++) { const event = sorted[i]; const eventEnd = new Date(event.timestamp.getTime() + gapMs); if (event.timestamp.getTime() <= currentRange.end.getTime()) { // Event falls within current range - extend it if (eventEnd.getTime() > currentRange.end.getTime()) { currentRange.end = eventEnd; } currentRange.eventCount++; currentRange.eventIds.push(event.id); } else { // Gap detected - finalize current range and start new one ranges.push({ ...currentRange, durationMinutes: Math.round( (currentRange.end.getTime() - currentRange.start.getTime()) / 60000, ), }); currentRange = { start: event.timestamp, end: eventEnd, eventCount: 1, eventIds: [event.id], }; } } // Don't forget the last range ranges.push({ ...currentRange, durationMinutes: Math.round( (currentRange.end.getTime() - currentRange.start.getTime()) / 60000, ), }); return ranges; } /** * Calculate total minutes from an array of time ranges. */ export function calculateTotalMinutes(ranges: TimeRange[]): number { return ranges.reduce((sum, range) => sum + range.durationMinutes, 0); } /** * Billable minutes for a set of events — the single clock for invoices, * timeline stats, reports, and agent summaries. * * - Non-manual events: gap-merge **per project**, then **sum** projects. * Concurrent multi-project sessions are all billable (sum may exceed * wall-clock). * - Manual events: add `estimatedMinutes` (explicit user duration). * - Cross-source overlap *within* a project collapses via gap-merge. */ /** * Per-project billable minutes (gap-merge within each project). * Keys are `projectId` or `"__unassigned__"`. Manual minutes are included * under the event's project key. */ export function calculateBillableMinutesByProject( events: BillableTimeEvent[], options?: { gapThresholdMinutes?: number }, ): Map { const gapThresholdMinutes = options?.gapThresholdMinutes ?? DEFAULT_GAP_THRESHOLD_MINUTES; const byProject = new Map(); const manualByProject = new Map(); for (let i = 0; i < events.length; i++) { const event = events[i]; const key = event.projectId ?? "__unassigned__"; if (event.source === "manual") { manualByProject.set( key, (manualByProject.get(key) ?? 0) + (event.estimatedMinutes || 0), ); continue; } const timestamp = coerceEventTimestamp(event.timestamp); if (!timestamp) continue; let bucket = byProject.get(key); if (!bucket) { bucket = []; byProject.set(key, bucket); } bucket.push({ id: event.id ?? `evt-${i}`, timestamp, }); } const result = new Map(); for (const [key, projectEvents] of byProject) { result.set( key, calculateTotalMinutes( buildTimeRanges(projectEvents, { gapThresholdMinutes }), ) + (manualByProject.get(key) ?? 0), ); } for (const [key, minutes] of manualByProject) { if (!result.has(key)) result.set(key, minutes); } return result; } /** * Total billable minutes = sum of {@link calculateBillableMinutesByProject}. * Concurrent multi-project work is fully billable. */ export function calculateBillableMinutes( events: BillableTimeEvent[], options?: { gapThresholdMinutes?: number }, ): number { let total = 0; for (const minutes of calculateBillableMinutesByProject( events, options, ).values()) { total += minutes; } return total; } export interface ActivitySourceSummary { source: string; events: number; minutes: number; } export interface ActivitySummary { /** * Billable minutes: per-project gap-merge, then sum. Concurrent multi-project * work is counted fully. Matches invoice / timeline stats when `projectId` * is provided on events. */ totalMinutes: number; /** * Per-source breakdown. `minutes` is each source's own gap-merged window * (not split by project), so the per-source totals can sum to more than * `totalMinutes` when sources overlap within a project. */ bySource: ActivitySourceSummary[]; } /** * Summarize timeline events into a total + per-source activity view. * Pure and DB-free. Prefer including `projectId` so concurrent multi-project * work is billable (sum of per-project sessions). */ export function summarizeActivity( events: Array<{ id: string; source: string; timestamp: Date | number | string; projectId?: string | null; estimatedMinutes?: number | null; }>, options?: { gapThresholdMinutes?: number }, ): ActivitySummary { const totalMinutes = calculateBillableMinutes(events, options); const bySourceEvents = new Map(); for (const e of events) { if (e.source === "manual") continue; const timestamp = coerceEventTimestamp(e.timestamp); if (!timestamp) continue; const arr = bySourceEvents.get(e.source) ?? []; arr.push({ id: e.id, timestamp }); bySourceEvents.set(e.source, arr); } const bySource: ActivitySourceSummary[] = [...bySourceEvents.entries()].map( ([source, evs]) => ({ source, events: evs.length, minutes: calculateTotalMinutes(buildTimeRanges(evs, options)), }), ); // Manual sources: count events and sum explicit minutes into bySource. const manualEvents = events.filter((e) => e.source === "manual"); if (manualEvents.length > 0) { bySource.push({ source: "manual", events: manualEvents.length, minutes: manualEvents.reduce( (sum, e) => sum + (e.estimatedMinutes || 0), 0, ), }); } return { totalMinutes, bySource }; } /** * Group events by project and build time ranges for each. * Returns a map of projectId -> TimeRange[]. */ export function buildTimeRangesByProject( events: Array, options?: { gapThresholdMinutes?: number }, ): Map { // Group events by project const byProject = new Map(); for (const event of events) { const projectId = event.projectId ?? null; if (!byProject.has(projectId)) { byProject.set(projectId, []); } byProject.get(projectId)?.push(event); } // Build ranges for each project const result = new Map(); for (const [projectId, projectEvents] of byProject) { result.set(projectId, buildTimeRanges(projectEvents, options)); } return result; }