import { NextRequest, NextResponse } from 'next/server'; import { adminDb } from '@/lib/firebase/admin'; import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); interface TimelineDay { date: string; // YYYY-MM-DD format dayOfWeek: string; gitCommits: Array<{ hash: string; time: string; author: string; message: string; filesChanged: number; insertions: number; deletions: number; }>; extensionSessions: Array<{ startTime: string; endTime: string; duration: number; // minutes filesModified: string[]; conversationSummary?: string; }>; cursorMessages: Array<{ time: string; type: 'user' | 'assistant'; conversationName: string; preview: string; // First 100 chars }>; summary: { totalGitCommits: number; totalExtensionSessions: number; totalCursorMessages: number; linesAdded: number; linesRemoved: number; uniqueFilesModified: number; }; } interface UnifiedTimeline { projectId: string; dateRange: { earliest: string; latest: string; totalDays: number; }; days: TimelineDay[]; dataSources: { git: { available: boolean; firstDate: string | null; lastDate: string | null; totalRecords: number }; extension: { available: boolean; firstDate: string | null; lastDate: string | null; totalRecords: number }; cursor: { available: boolean; firstDate: string | null; lastDate: string | null; totalRecords: number }; }; } export async function GET( request: NextRequest, { params }: { params: Promise<{ projectId: string }> } ) { try { const { projectId } = await params; // 1. Load Git commits const repoPath = '/Users/markhenderson/ai-proxy'; let gitCommits: any[] = []; let gitFirstDate: string | null = null; let gitLastDate: string | null = null; try { const { stdout: commitsOutput } = await execAsync( `cd "${repoPath}" && git log --all --pretty=format:"%H|%ai|%an|%s" --numstat`, { maxBuffer: 10 * 1024 * 1024 } ); if (commitsOutput.trim()) { const lines = commitsOutput.split('\n'); let currentCommit: any = null; for (const line of lines) { if (line.includes('|')) { if (currentCommit) { gitCommits.push(currentCommit); } const [hash, date, author, message] = line.split('|'); currentCommit = { hash: hash.substring(0, 8), date, author, message, filesChanged: 0, insertions: 0, deletions: 0 }; } else if (line.trim() && currentCommit) { const parts = line.trim().split('\t'); if (parts.length === 3) { const [insertStr, delStr] = parts; const insertions = insertStr === '-' ? 0 : parseInt(insertStr, 10) || 0; const deletions = delStr === '-' ? 0 : parseInt(delStr, 10) || 0; currentCommit.filesChanged++; currentCommit.insertions += insertions; currentCommit.deletions += deletions; } } } if (currentCommit) { gitCommits.push(currentCommit); } gitCommits.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); if (gitCommits.length > 0) { gitFirstDate = gitCommits[0].date; gitLastDate = gitCommits[gitCommits.length - 1].date; } } } catch (error) { console.log('⚠️ Could not load Git commits:', error); } // 2. Load Extension sessions let extensionSessions: any[] = []; let extensionFirstDate: string | null = null; let extensionLastDate: string | null = null; try { // Try to find sessions by projectId first let sessionsSnapshot = await adminDb .collection('sessions') .where('projectId', '==', projectId) .get(); // If no sessions found by projectId, try by workspacePath if (sessionsSnapshot.empty) { const workspacePath = '/Users/markhenderson/ai-proxy'; sessionsSnapshot = await adminDb .collection('sessions') .where('workspacePath', '==', workspacePath) .get(); } extensionSessions = sessionsSnapshot.docs.map(doc => { const data = doc.data(); const startTime = data.startTime?.toDate?.() || new Date(data.startTime); const endTime = data.endTime?.toDate?.() || new Date(data.endTime); return { startTime, endTime, filesModified: data.filesModified || [], conversationSummary: data.conversationSummary || '', conversation: data.conversation || [] }; }); extensionSessions.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() ); if (extensionSessions.length > 0) { extensionFirstDate = extensionSessions[0].startTime.toISOString(); extensionLastDate = extensionSessions[extensionSessions.length - 1].endTime.toISOString(); } } catch (error) { console.log('⚠️ Could not load extension sessions:', error); } // 3. Load Cursor messages (from both cursorConversations and extension sessions) let cursorMessages: any[] = []; let cursorFirstDate: string | null = null; let cursorLastDate: string | null = null; try { // Load from cursorConversations (backfilled historical data) const conversationsSnapshot = await adminDb .collection('projects') .doc(projectId) .collection('cursorConversations') .get(); for (const convDoc of conversationsSnapshot.docs) { const conv = convDoc.data(); const messagesSnapshot = await adminDb .collection('projects') .doc(projectId) .collection('cursorConversations') .doc(convDoc.id) .collection('messages') .orderBy('createdAt', 'asc') .get(); const messages = messagesSnapshot.docs.map(msgDoc => { const msg = msgDoc.data(); return { createdAt: msg.createdAt, type: msg.type === 1 ? 'user' : 'assistant', text: msg.text || '', conversationName: conv.name || 'Untitled' }; }); cursorMessages = cursorMessages.concat(messages); } // Also load from extension sessions conversation data for (const session of extensionSessions) { if (session.conversation && Array.isArray(session.conversation)) { for (const msg of session.conversation) { cursorMessages.push({ createdAt: msg.timestamp || session.startTime.toISOString(), type: msg.role === 'user' ? 'user' : 'assistant', text: msg.message || '', conversationName: 'Extension Session' }); } } } cursorMessages.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); if (cursorMessages.length > 0) { cursorFirstDate = cursorMessages[0].createdAt; cursorLastDate = cursorMessages[cursorMessages.length - 1].createdAt; } } catch (error) { console.log('⚠️ Could not load Cursor messages:', error); } // 4. Find overall date range const allFirstDates = [ gitFirstDate ? new Date(gitFirstDate) : null, extensionFirstDate ? new Date(extensionFirstDate) : null, cursorFirstDate ? new Date(cursorFirstDate) : null ].filter(d => d !== null) as Date[]; const allLastDates = [ gitLastDate ? new Date(gitLastDate) : null, extensionLastDate ? new Date(extensionLastDate) : null, cursorLastDate ? new Date(cursorLastDate) : null ].filter(d => d !== null) as Date[]; if (allFirstDates.length === 0 && allLastDates.length === 0) { return NextResponse.json({ error: 'No timeline data available', projectId, dateRange: { earliest: null, latest: null, totalDays: 0 }, days: [], dataSources: { git: { available: false, firstDate: null, lastDate: null, totalRecords: 0 }, extension: { available: false, firstDate: null, lastDate: null, totalRecords: 0 }, cursor: { available: false, firstDate: null, lastDate: null, totalRecords: 0 } } }); } const earliestDate = new Date(Math.min(...allFirstDates.map(d => d.getTime()))); const latestDate = new Date(Math.max(...allLastDates.map(d => d.getTime()))); const totalDays = Math.ceil((latestDate.getTime() - earliestDate.getTime()) / (1000 * 60 * 60 * 24)) + 1; // 5. Group data by day const dayMap = new Map(); // Initialize all days for (let i = 0; i < totalDays; i++) { const date = new Date(earliestDate); date.setDate(date.getDate() + i); const dateKey = date.toISOString().split('T')[0]; const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'long' }); dayMap.set(dateKey, { date: dateKey, dayOfWeek, gitCommits: [], extensionSessions: [], cursorMessages: [], summary: { totalGitCommits: 0, totalExtensionSessions: 0, totalCursorMessages: 0, linesAdded: 0, linesRemoved: 0, uniqueFilesModified: 0 } }); } // Add Git commits to days for (const commit of gitCommits) { const dateKey = commit.date.split(' ')[0]; const day = dayMap.get(dateKey); if (day) { day.gitCommits.push({ hash: commit.hash, time: commit.date, author: commit.author, message: commit.message, filesChanged: commit.filesChanged, insertions: commit.insertions, deletions: commit.deletions }); day.summary.totalGitCommits++; day.summary.linesAdded += commit.insertions; day.summary.linesRemoved += commit.deletions; } } // Add Extension sessions to days for (const session of extensionSessions) { const dateKey = new Date(session.startTime).toISOString().split('T')[0]; const day = dayMap.get(dateKey); if (day) { const startTime = new Date(session.startTime); const endTime = new Date(session.endTime); const duration = Math.round((endTime.getTime() - startTime.getTime()) / (1000 * 60)); day.extensionSessions.push({ startTime: session.startTime.toISOString(), endTime: session.endTime.toISOString(), duration, filesModified: session.filesModified, conversationSummary: session.conversationSummary }); day.summary.totalExtensionSessions++; // Track unique files const uniqueFiles = new Set([...session.filesModified]); day.summary.uniqueFilesModified += uniqueFiles.size; } } // Add Cursor messages to days for (const message of cursorMessages) { const dateKey = new Date(message.createdAt).toISOString().split('T')[0]; const day = dayMap.get(dateKey); if (day) { day.cursorMessages.push({ time: message.createdAt, type: message.type, conversationName: message.conversationName, preview: message.text.substring(0, 100) }); day.summary.totalCursorMessages++; } } // Convert to array and sort by date const days = Array.from(dayMap.values()).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() ); const timeline: UnifiedTimeline = { projectId, dateRange: { earliest: earliestDate.toISOString(), latest: latestDate.toISOString(), totalDays }, days, dataSources: { git: { available: gitCommits.length > 0, firstDate: gitFirstDate, lastDate: gitLastDate, totalRecords: gitCommits.length }, extension: { available: extensionSessions.length > 0, firstDate: extensionFirstDate, lastDate: extensionLastDate, totalRecords: extensionSessions.length }, cursor: { available: cursorMessages.length > 0, firstDate: cursorFirstDate, lastDate: cursorLastDate, totalRecords: cursorMessages.length } } }; return NextResponse.json(timeline); } catch (error) { console.error('Error generating unified timeline:', error); return NextResponse.json( { error: 'Failed to generate unified timeline', details: error instanceof Error ? error.message : String(error) }, { status: 500 } ); } }