Files
vibn-frontend/app/api/projects/[projectId]/timeline/route.ts

398 lines
13 KiB
TypeScript

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<string, TimelineDay>();
// 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 }
);
}
}