311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import admin from '@/lib/firebase/admin';
|
|
import { getApiUrl } from '@/lib/utils/api-url';
|
|
|
|
/**
|
|
* Timeline View Data
|
|
* Structures MVP checklist pages with their development sessions on a timeline
|
|
*/
|
|
export async function GET(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ projectId: string }> }
|
|
) {
|
|
try {
|
|
const { projectId } = await params;
|
|
|
|
// Load project data, MVP checklist, git history, and activity in parallel
|
|
const db = admin.firestore();
|
|
const projectRef = db.collection('projects').doc(projectId);
|
|
|
|
const [projectDoc, checklistResponse, gitResponse, activityResponse] = await Promise.all([
|
|
projectRef.get(),
|
|
fetch(getApiUrl(`/api/projects/${projectId}/mvp-checklist`, request)),
|
|
fetch(getApiUrl(`/api/projects/${projectId}/git-history`, request)),
|
|
fetch(getApiUrl(`/api/projects/${projectId}/activity`, request))
|
|
]);
|
|
|
|
const projectData = projectDoc.exists ? projectDoc.data() : null;
|
|
const checklist = await checklistResponse.json();
|
|
const git = await gitResponse.json();
|
|
const activity = await activityResponse.json();
|
|
|
|
// Check if checklist exists and has the expected structure
|
|
if (!checklist || checklist.error || !checklist.mvpChecklist || !Array.isArray(checklist.mvpChecklist)) {
|
|
return NextResponse.json({
|
|
workItems: [],
|
|
timeline: {
|
|
start: new Date().toISOString(),
|
|
end: new Date().toISOString(),
|
|
totalDays: 0
|
|
},
|
|
summary: {
|
|
totalWorkItems: 0,
|
|
withActivity: 0,
|
|
noActivity: 0,
|
|
built: 0,
|
|
missing: 0
|
|
},
|
|
projectCreator: projectData?.createdBy || projectData?.owner || 'You',
|
|
message: 'No MVP checklist generated yet. Click "Regenerate Plan" to create one.'
|
|
});
|
|
}
|
|
|
|
// Build lightweight history object with just what we need
|
|
const history = {
|
|
chronologicalEvents: [
|
|
// Add git commits
|
|
...(git.commits || []).map((commit: any) => ({
|
|
type: 'git_commit',
|
|
timestamp: new Date(commit.date).toISOString(),
|
|
data: {
|
|
hash: commit.hash,
|
|
message: commit.message,
|
|
filesChanged: commit.filesChanged,
|
|
insertions: commit.insertions,
|
|
deletions: commit.deletions
|
|
}
|
|
})),
|
|
// Add extension sessions
|
|
...(activity.sessions || []).map((session: any) => ({
|
|
type: 'extension_session',
|
|
timestamp: session.startTime,
|
|
data: {
|
|
duration: session.duration,
|
|
filesModified: session.filesModified
|
|
}
|
|
}))
|
|
]
|
|
};
|
|
|
|
// Map pages to work items with session data
|
|
const workItems = [];
|
|
|
|
for (const category of checklist.mvpChecklist) {
|
|
for (const item of category.pages) {
|
|
const relatedSessions = findRelatedSessions(item, history);
|
|
const relatedCommits = findRelatedCommits(item, history);
|
|
|
|
const hasActivity = relatedSessions.length > 0 || relatedCommits.length > 0;
|
|
const startDate = hasActivity
|
|
? getEarliestDate([...relatedSessions, ...relatedCommits])
|
|
: null;
|
|
const endDate = hasActivity
|
|
? getLatestDate([...relatedSessions, ...relatedCommits])
|
|
: null;
|
|
|
|
workItems.push({
|
|
id: `${category.category.toLowerCase().replace(/\s+/g, '-')}-${item.title.toLowerCase().replace(/\s+/g, '-')}`,
|
|
title: item.title,
|
|
category: category.category,
|
|
path: item.path,
|
|
status: item.status,
|
|
priority: item.priority,
|
|
|
|
startDate,
|
|
endDate,
|
|
duration: calculateDuration(startDate, endDate),
|
|
|
|
sessionsCount: relatedSessions.length,
|
|
commitsCount: relatedCommits.length,
|
|
totalActivity: relatedSessions.length + relatedCommits.length,
|
|
|
|
sessions: relatedSessions,
|
|
commits: relatedCommits,
|
|
|
|
requirements: generateRequirements(item, { name: category.category }),
|
|
|
|
evidence: item.evidence || [],
|
|
note: item.note
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort by category order and status
|
|
// Priority: Core Features -> Marketing -> Social -> Content -> Settings
|
|
const categoryOrder = [
|
|
'Core Features',
|
|
'Marketing',
|
|
'Social',
|
|
'Content',
|
|
'Settings'
|
|
];
|
|
|
|
workItems.sort((a, b) => {
|
|
// First by category
|
|
const catCompare = categoryOrder.indexOf(a.category) - categoryOrder.indexOf(b.category);
|
|
if (catCompare !== 0) return catCompare;
|
|
|
|
// Then by status (built first, then in_progress, then missing)
|
|
const statusOrder = { 'built': 0, 'in_progress': 1, 'missing': 2 };
|
|
return (statusOrder[a.status as keyof typeof statusOrder] || 3) -
|
|
(statusOrder[b.status as keyof typeof statusOrder] || 3);
|
|
});
|
|
|
|
// Calculate timeline range
|
|
const allDates = workItems
|
|
.filter(w => w.startDate)
|
|
.flatMap(w => [w.startDate, w.endDate].filter(Boolean))
|
|
.map(d => new Date(d!));
|
|
|
|
const timelineStart = allDates.length > 0
|
|
? new Date(Math.min(...allDates.map(d => d.getTime())))
|
|
: new Date();
|
|
const timelineEnd = new Date(); // Today
|
|
|
|
return NextResponse.json({
|
|
workItems,
|
|
timeline: {
|
|
start: timelineStart.toISOString(),
|
|
end: timelineEnd.toISOString(),
|
|
totalDays: Math.ceil((timelineEnd.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24))
|
|
},
|
|
summary: {
|
|
totalWorkItems: workItems.length,
|
|
withActivity: workItems.filter(w => w.totalActivity > 0).length,
|
|
noActivity: workItems.filter(w => w.totalActivity === 0).length,
|
|
built: workItems.filter(w => w.status === 'built').length,
|
|
missing: workItems.filter(w => w.status === 'missing').length
|
|
},
|
|
projectCreator: projectData?.createdBy || projectData?.owner || 'You'
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error generating timeline view:', error);
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Failed to generate timeline view',
|
|
details: error instanceof Error ? error.message : String(error)
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
function findRelatedSessions(page: any, history: any) {
|
|
const pagePath = page.path.toLowerCase();
|
|
const pageTitle = page.title.toLowerCase();
|
|
|
|
return history.chronologicalEvents
|
|
.filter((e: any) => e.type === 'extension_session')
|
|
.filter((e: any) => {
|
|
const filesModified = e.data.filesModified || [];
|
|
return filesModified.some((f: string) => {
|
|
const lowerFile = f.toLowerCase();
|
|
return lowerFile.includes(pagePath) ||
|
|
lowerFile.includes(pageTitle.replace(/\s+/g, '-')) ||
|
|
(page.evidence && page.evidence.some((ev: string) => lowerFile.includes(ev.toLowerCase())));
|
|
});
|
|
})
|
|
.map((e: any) => ({
|
|
timestamp: e.timestamp,
|
|
duration: e.data.duration,
|
|
filesModified: e.data.filesModified
|
|
}));
|
|
}
|
|
|
|
function findRelatedCommits(page: any, history: any) {
|
|
const pagePath = page.path.toLowerCase();
|
|
const pageTitle = page.title.toLowerCase();
|
|
|
|
return history.chronologicalEvents
|
|
.filter((e: any) => e.type === 'git_commit')
|
|
.filter((e: any) => {
|
|
const message = e.data.message.toLowerCase();
|
|
return message.includes(pagePath) ||
|
|
message.includes(pageTitle.replace(/\s+/g, ' ')) ||
|
|
(page.evidence && page.evidence.some((ev: string) => message.includes(ev.toLowerCase())));
|
|
})
|
|
.map((e: any) => ({
|
|
timestamp: e.timestamp,
|
|
hash: e.data.hash,
|
|
message: e.data.message,
|
|
insertions: e.data.insertions,
|
|
deletions: e.data.deletions
|
|
}));
|
|
}
|
|
|
|
function getEarliestDate(events: any[]) {
|
|
if (events.length === 0) return null;
|
|
const dates = events.map(e => new Date(e.timestamp).getTime());
|
|
return new Date(Math.min(...dates)).toISOString();
|
|
}
|
|
|
|
function getLatestDate(events: any[]) {
|
|
if (events.length === 0) return null;
|
|
const dates = events.map(e => new Date(e.timestamp).getTime());
|
|
return new Date(Math.max(...dates)).toISOString();
|
|
}
|
|
|
|
function calculateDuration(startDate: string | null, endDate: string | null): number {
|
|
if (!startDate || !endDate) return 0;
|
|
const diff = new Date(endDate).getTime() - new Date(startDate).getTime();
|
|
return Math.ceil(diff / (1000 * 60 * 60 * 24));
|
|
}
|
|
|
|
function generateRequirements(page: any, category: any): any[] {
|
|
const requirements = [];
|
|
|
|
// Generate specific requirements based on page type
|
|
if (page.title.includes('Sign In') || page.title.includes('Sign Up')) {
|
|
requirements.push(
|
|
{ id: 1, text: 'Email/password authentication', status: 'built' },
|
|
{ id: 2, text: 'GitHub OAuth integration', status: 'built' },
|
|
{ id: 3, text: 'Password reset flow', status: 'missing' },
|
|
{ id: 4, text: 'Session management', status: 'built' }
|
|
);
|
|
} else if (page.title.includes('Checklist')) {
|
|
requirements.push(
|
|
{ id: 1, text: 'Display generated tasks from API', status: 'missing' },
|
|
{ id: 2, text: 'Mark tasks as complete', status: 'missing' },
|
|
{ id: 3, text: 'Drag-and-drop reordering', status: 'missing' },
|
|
{ id: 4, text: 'Save checklist state', status: 'missing' },
|
|
{ id: 5, text: 'Export to markdown/PDF', status: 'missing' }
|
|
);
|
|
} else if (page.title.includes('Vision') || page.title.includes('Mission')) {
|
|
requirements.push(
|
|
{ id: 1, text: 'Capture product vision text', status: 'missing' },
|
|
{ id: 2, text: 'AI-assisted vision refinement', status: 'missing' },
|
|
{ id: 3, text: 'Upload supporting documents', status: 'missing' },
|
|
{ id: 4, text: 'Save vision to project metadata', status: 'built' }
|
|
);
|
|
} else if (page.title.includes('Marketing Automation')) {
|
|
requirements.push(
|
|
{ id: 1, text: 'Connect to /plan/marketing API', status: 'missing' },
|
|
{ id: 2, text: 'Generate landing page copy', status: 'missing' },
|
|
{ id: 3, text: 'Generate email sequences', status: 'missing' },
|
|
{ id: 4, text: 'Export marketing materials', status: 'missing' }
|
|
);
|
|
} else if (page.title.includes('Communication Automation')) {
|
|
requirements.push(
|
|
{ id: 1, text: 'Email template builder', status: 'missing' },
|
|
{ id: 2, text: 'Slack integration', status: 'missing' },
|
|
{ id: 3, text: 'Automated project updates', status: 'missing' },
|
|
{ id: 4, text: 'Team notifications', status: 'missing' }
|
|
);
|
|
} else if (page.title.includes('Import') && page.title.includes('Modal')) {
|
|
requirements.push(
|
|
{ id: 1, text: 'Start from scratch option', status: 'built' },
|
|
{ id: 2, text: 'Import from GitHub', status: 'built' },
|
|
{ id: 3, text: 'Import from local folder', status: 'missing' },
|
|
{ id: 4, text: 'Auto-detect project type', status: 'missing' },
|
|
{ id: 5, text: 'Trigger Cursor import', status: 'built' },
|
|
{ id: 6, text: 'Create .vibn file', status: 'built' }
|
|
);
|
|
} else if (page.status === 'built') {
|
|
requirements.push(
|
|
{ id: 1, text: 'Page built and accessible', status: 'built' },
|
|
{ id: 2, text: 'Connected to backend API', status: 'built' }
|
|
);
|
|
} else {
|
|
requirements.push(
|
|
{ id: 1, text: 'Design page layout', status: 'missing' },
|
|
{ id: 2, text: 'Implement core functionality', status: 'missing' },
|
|
{ id: 3, text: 'Connect to backend API', status: 'missing' },
|
|
{ id: 4, text: 'Add error handling', status: 'missing' }
|
|
);
|
|
}
|
|
|
|
return requirements;
|
|
}
|
|
|