/** * Derived timeline statistics — shared view model for desktop and hub. * * Computes higher-level activity metrics (streaks, momentum, peak patterns, * per-source counts) from a list of timeline events. This is the single source * of truth so desktop and hub render identical stat rows for the same data. * * Pure and framework-free: the React `useDerivedStats` hooks in each app are * thin `useMemo` wrappers around `computeDerivedStats`. * * Time is measured with the shared billable clock (`calculateBillableMinutes`): * per-project gap-merge, then sum — concurrent multi-project work is fully * billable. Cross-source overlap within a project is not double-counted. */ import { CHAT_PROVIDER_IDS, isFileMetadata, isGitMetadata, type TimelineEventDisplay, } from "../types/timeline"; import { calculateBillableMinutes, DEFAULT_GAP_THRESHOLD_MINUTES, } from "./time-ranges"; export interface DerivedStats { // Core metrics linesChanged: number; conversations: number; filesModified: number; gitCommits: number; // Day/time metrics activeDays: number; weekdayDays: number; weekendDays: number; // Time distribution avgHoursPerWeekday: number; avgHoursPerWeekend: number; weekendBonusHours: number; // Streak metrics currentStreak: number; longestStreak: number; // Time patterns peakHour: number; peakDay: string; // "Mon", "Tue", etc. // Momentum (week-over-week) thisWeekHours: number; lastWeekHours: number; momentumPercent: number; // positive = up, negative = down } const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; /** * Calculate derived statistics from timeline events. * Includes metrics like lines changed, conversations, streaks, peak hours, and momentum. * * Returns null when there are no events (callers render an empty state). * * @param now Optional "current time" anchor for week-over-week momentum. Defaults * to the real current time; injectable so the calculation is deterministic in tests. */ export function computeDerivedStats( events: TimelineEventDisplay[], now: Date = new Date(), ): DerivedStats | null { if (events.length === 0) return null; // Core metrics let linesChanged = 0; let gitCommits = 0; const conversations = new Set(); const filesModified = new Set(); // Group events by day for billable-minute calculation (per-project sessions) type DayEvent = { id: string; timestamp: Date; projectId: string | null; source: string; estimatedMinutes: number | null; }; const eventsByDay: Record = {}; const hourCounts: Record = {}; const dayOfWeekCounts: Record = {}; // 0-6 -> count // Week tracking for momentum const startOfThisWeek = new Date(now); startOfThisWeek.setDate(now.getDate() - now.getDay()); // Sunday startOfThisWeek.setHours(0, 0, 0, 0); const startOfLastWeek = new Date(startOfThisWeek); startOfLastWeek.setDate(startOfLastWeek.getDate() - 7); const thisWeekEvents: DayEvent[] = []; const lastWeekEvents: DayEvent[] = []; for (const event of events) { const date = new Date(event.timestamp); const dayKey = date.toISOString().split("T")[0]; const dayEvent: DayEvent = { id: event.id, timestamp: date, projectId: event.projectId ?? null, source: event.source, estimatedMinutes: event.estimatedMinutes ?? null, }; if (!eventsByDay[dayKey]) { eventsByDay[dayKey] = []; } eventsByDay[dayKey].push(dayEvent); // Track hours const hour = date.getHours(); hourCounts[hour] = (hourCounts[hour] || 0) + 1; // Track day of week const dow = date.getDay(); dayOfWeekCounts[dow] = (dayOfWeekCounts[dow] || 0) + 1; if (date >= startOfThisWeek) { thisWeekEvents.push(dayEvent); } else if (date >= startOfLastWeek) { lastWeekEvents.push(dayEvent); } // Source-specific tracking if (event.source === "git" && isGitMetadata(event.metadata)) { linesChanged += event.metadata.additions + event.metadata.deletions; gitCommits++; } else if ( (CHAT_PROVIDER_IDS as readonly string[]).includes(event.source) ) { const metadata = event.metadata as { sessionId?: unknown } | null; if (typeof metadata?.sessionId === "string") { conversations.add(`${event.source}:${metadata.sessionId}`); } } else if (event.source === "file" && isFileMetadata(event.metadata)) { filesModified.add(event.metadata.path); } } const gapOpts = { gapThresholdMinutes: DEFAULT_GAP_THRESHOLD_MINUTES }; // Per-day billable minutes: per-project gap-merge, sum (concurrent OK) const dayMinutes: Record = {}; for (const [dayKey, dayEvents] of Object.entries(eventsByDay)) { dayMinutes[dayKey] = calculateBillableMinutes(dayEvents, gapOpts); } const thisWeekMinutes = calculateBillableMinutes(thisWeekEvents, gapOpts); const lastWeekMinutes = calculateBillableMinutes(lastWeekEvents, gapOpts); // Split days into weekday/weekend const weekdayKeys: string[] = []; const weekendKeys: string[] = []; let weekdayTotalMinutes = 0; let weekendTotalMinutes = 0; for (const [dayKey, minutes] of Object.entries(dayMinutes)) { const date = new Date(dayKey); const dow = date.getDay(); if (dow === 0 || dow === 6) { weekendKeys.push(dayKey); weekendTotalMinutes += minutes; } else { weekdayKeys.push(dayKey); weekdayTotalMinutes += minutes; } } // Calculate streaks const sortedDays = Object.keys(dayMinutes).sort().reverse(); let currentStreak = 0; let longestStreak = 0; let tempStreak = 0; if (sortedDays.length > 0) { // Current streak (from most recent day backwards) let prevDate = new Date(sortedDays[0]); for (const dayKey of sortedDays) { const dayDate = new Date(dayKey); const diffDays = Math.round( (prevDate.getTime() - dayDate.getTime()) / (1000 * 60 * 60 * 24), ); if (diffDays <= 1) { currentStreak++; prevDate = dayDate; } else { break; } } // Longest streak (scan entire history) const chronologicalDays = [...sortedDays].reverse(); let lastDate: Date | null = null; for (const dayKey of chronologicalDays) { const dayDate = new Date(dayKey); if (lastDate) { const diffDays = Math.round( (dayDate.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24), ); if (diffDays <= 1) { tempStreak++; } else { longestStreak = Math.max(longestStreak, tempStreak); tempStreak = 1; } } else { tempStreak = 1; } lastDate = dayDate; } longestStreak = Math.max(longestStreak, tempStreak); } // Peak hour let peakHour = 12; let peakHourCount = 0; for (const [hour, count] of Object.entries(hourCounts)) { if (count > peakHourCount) { peakHourCount = count; peakHour = Number.parseInt(hour, 10); } } // Peak day of week let peakDow = 1; // Monday default let peakDowCount = 0; for (const [dow, count] of Object.entries(dayOfWeekCounts)) { if (count > peakDowCount) { peakDowCount = count; peakDow = Number.parseInt(dow, 10); } } // Calculate momentum (week-over-week percentage change) let momentumPercent = 0; if (lastWeekMinutes > 0) { momentumPercent = Math.round( ((thisWeekMinutes - lastWeekMinutes) / lastWeekMinutes) * 100, ); } else if (thisWeekMinutes > 0) { momentumPercent = 100; // New activity this week } return { // Core metrics linesChanged, conversations: conversations.size, filesModified: filesModified.size, gitCommits, // Day metrics activeDays: Object.keys(dayMinutes).length, weekdayDays: weekdayKeys.length, weekendDays: weekendKeys.length, // Time distribution avgHoursPerWeekday: weekdayKeys.length > 0 ? Math.round((weekdayTotalMinutes / weekdayKeys.length / 60) * 10) / 10 : 0, avgHoursPerWeekend: weekendKeys.length > 0 ? Math.round((weekendTotalMinutes / weekendKeys.length / 60) * 10) / 10 : 0, weekendBonusHours: Math.round((weekendTotalMinutes / 60) * 10) / 10, // Streaks currentStreak, longestStreak, // Time patterns peakHour, peakDay: DAY_NAMES[peakDow], // Momentum thisWeekHours: Math.round((thisWeekMinutes / 60) * 10) / 10, lastWeekHours: Math.round((lastWeekMinutes / 60) * 10) / 10, momentumPercent, }; }