VIBN Frontend for Coolify deployment
This commit is contained in:
397
app/api/projects/[projectId]/timeline/route.ts
Normal file
397
app/api/projects/[projectId]/timeline/route.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user