Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions app/api/interview/[id]/evaluate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ interface RouteParams {
}

// POST: Trigger evaluation of a submitted interview session
// Phase 4 will add the actual structural rule engine + AI reasoning evaluator
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params;
Expand All @@ -37,20 +36,39 @@ export async function POST(request: NextRequest, { params }: RouteParams) {

// Atomic check-and-set: only claim the session if it's currently 'submitted'
// This prevents race conditions where two concurrent requests both pass the status check
const session = await InterviewSession.findOneAndUpdate(
let session = await InterviewSession.findOneAndUpdate(
{ _id: id, userId: user._id, status: 'submitted' },
{ $set: { status: 'evaluating' } },
{ new: false } // return the pre-update doc so we can inspect canvas
);

// If not found as 'submitted', check if it's stuck in 'evaluating' (stale > 2 min)
if (!session) {
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
session = await InterviewSession.findOneAndUpdate(
{ _id: id, userId: user._id, status: 'evaluating', updatedAt: { $lt: twoMinutesAgo } },
{ $set: { status: 'evaluating' } },
{ new: false }
);
}

// Also allow re-evaluation of already-evaluated sessions (user-triggered)
if (!session) {
session = await InterviewSession.findOneAndUpdate(
{ _id: id, userId: user._id, status: 'evaluated' },
{ $set: { status: 'evaluating' } },
{ new: false }
);
}

if (!session) {
// Distinguish between "not found" and "wrong status"
const exists = await InterviewSession.findOne({ _id: id, userId: user._id }).select('status').lean();
if (!exists) {
return NextResponse.json({ error: 'Interview session not found' }, { status: 404 });
}
return NextResponse.json(
{ error: `Cannot evaluate session with status "${exists.status}". Must be "submitted".` },
{ error: `Cannot evaluate session with status "${exists.status}". Session must be "submitted", "evaluated", or stuck in "evaluating" for >2 minutes to be evaluated.` },
{ status: 409 }
);
}
Expand Down
29 changes: 11 additions & 18 deletions app/interview/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export default function InterviewCanvasPage({ params }: PageProps) {
const [showHints, setShowHints] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isInterviewPanelOpen, setIsInterviewPanelOpen] = useState(false);
const [isQuestionPanelOpen, setIsQuestionPanelOpen] = useState(true);
const [finalValidationTriggered, setFinalValidationTriggered] = useState(false);

// Refs for save logic
Expand Down Expand Up @@ -274,23 +275,13 @@ export default function InterviewCanvasPage({ params }: PageProps) {
setSession(prev => prev ? { ...prev, status: 'submitted', submittedAt: new Date().toISOString() } : null);
setSubmitError(null);

// Trigger evaluation
const evalResponse = await authFetch(`/api/interview/${id}/evaluate`, {
method: 'POST'
});

if (!evalResponse.ok) {
const evalData = await evalResponse.json().catch(() => ({}));
console.error('Evaluation failed:', evalData.error);
// We don't throw here to avoid showing an error after successful submission
// The status will remain 'submitted' and can be re-evaluated later
} else {
const evalData = await evalResponse.json();
setSession(prev => prev ? { ...prev, status: 'evaluated', evaluation: evalData.evaluation } : null);
// Fire-and-forget: trigger evaluation in the background.
// The result page will poll until evaluation completes.
authFetch(`/api/interview/${id}/evaluate`, { method: 'POST' })
.catch(err => console.warn('Background evaluation trigger:', err));

// Redirect to results page now that evaluation is complete
router.push(`/interview/${id}/result`);
}
// Immediately redirect to results page — it will poll for completion
router.push(`/interview/${id}/result`);
} catch (err) {
console.error('Error submitting:', err);
setSubmitError(err instanceof Error ? err.message : 'Failed to submit');
Expand Down Expand Up @@ -330,7 +321,7 @@ export default function InterviewCanvasPage({ params }: PageProps) {
if (data.success && data.messages) {
if (setMessages) setMessages(data.messages);
setSession(prev => prev ? { ...prev, constraintChanges: data.constraintChanges } : null);
setIsInterviewPanelOpen(true); // Automatically slide open the interviewer panel
setIsInterviewPanelOpen(true);
}
} catch (err) {
console.error('Chaos timeout failed:', err);
Expand Down Expand Up @@ -411,13 +402,15 @@ export default function InterviewCanvasPage({ params }: PageProps) {

<div className="flex flex-1 overflow-hidden">
{/* Question Panel - left sidebar */}
<div className="w-[320px] flex-shrink-0">
<div className="flex-shrink-0">
<QuestionPanel
question={session.question}
difficulty={session.difficulty}
constraintChanges={session.constraintChanges || []}
showHints={showHints}
onToggleHints={() => setShowHints(prev => !prev)}
isCollapsed={!isQuestionPanelOpen}
onToggle={() => setIsQuestionPanelOpen(prev => !prev)}
/>
</div>

Expand Down
85 changes: 76 additions & 9 deletions app/interview/[id]/result/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useEffect, use } from 'react';
import { useState, useEffect, useRef, use } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useRequireAuth } from '@/src/hooks/useRequireAuth';
Expand Down Expand Up @@ -39,40 +39,107 @@ export default function InterviewResultPage({ params }: PageProps) {
const [session, setSession] = useState<InterviewSessionData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isEvaluating, setIsEvaluating] = useState(false);
const isEvaluatingRef = useRef(false);

useEffect(() => {
let cancelled = false;
let pollTimer: NodeJS.Timeout;
let pollAttempts = 0;
const MAX_POLLS = 40; // 40 * 3000ms = 2 mins timeout limit

const fetchResult = async () => {
if (!user?.uid || !id) return;
try {
setIsLoading(true);
if (!isEvaluatingRef.current) setIsLoading(true);
const response = await authFetch(`/api/interview/${id}`);
if (!response.ok) {
throw new Error('Failed to load results');
}
const data = await response.json();

if (data.session.status !== 'evaluated') {
// If not evaluated yet, it might be in progress, submitted, or evaluating
if (['in_progress', 'submitted', 'evaluating'].includes(data.session.status)) {
router.replace(`/interview/${id}`);
if (cancelled) return;

if (data.session.status === 'evaluated') {
// Evaluation complete — show results
isEvaluatingRef.current = false;
setIsEvaluating(false);
setSession(data.session);
setIsLoading(false);
return; // stop polling
}

if (['submitted', 'evaluating'].includes(data.session.status)) {
pollAttempts++;
if (pollAttempts >= MAX_POLLS) {
isEvaluatingRef.current = false;
setIsEvaluating(false);
setIsLoading(false);
setError('Evaluation is taking longer than expected. Please go back and try re-evaluating.');
return;
}

// Still evaluating — show spinner and poll again
isEvaluatingRef.current = true;
setIsEvaluating(true);
setIsLoading(false);
pollTimer = setTimeout(fetchResult, 3000);
return;
}

setSession(data.session);
// in_progress — shouldn't be on this page
if (data.session.status === 'in_progress') {
router.replace(`/interview/${id}`);
return;
}
} catch (err) {
if (cancelled) return;
console.error('Error fetching results:', err);
setError(err instanceof Error ? err.message : 'Failed to load results');
} finally {
setIsLoading(false);
isEvaluatingRef.current = false;
setIsEvaluating(false);
}
};

if (isAuthenticated && user) {
fetchResult();
}

return () => {
cancelled = true;
if (pollTimer) clearTimeout(pollTimer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, user, id, router]);

// Evaluating state — show a dedicated loading screen
if (isEvaluating) {
return (
<div className="flex h-screen items-center justify-center bg-background-dark">
<div className="flex flex-col items-center gap-6 max-w-md text-center">
<div className="relative">
<div className="w-16 h-16 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
<span className="absolute inset-0 flex items-center justify-center material-symbols-outlined text-primary text-[24px]">
psychology
</span>
</div>
<div>
<h2 className="text-xl font-bold text-white mb-2">Evaluating Your Design</h2>
<p className="text-slate-400 text-sm leading-relaxed">
Our AI is analyzing your architecture for structural integrity, trade-off quality, and scalability patterns.
This typically takes 15-30 seconds.
</p>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<span className="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
Processing...
</div>
</div>
</div>
);
}

if (authLoading || isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-background-dark">
Expand Down Expand Up @@ -494,4 +561,4 @@ export default function InterviewResultPage({ params }: PageProps) {
</div>
</div>
);
}
}
65 changes: 65 additions & 0 deletions app/interview/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export default function InterviewPage() {
const [isLoadingSessions, setIsLoadingSessions] = useState(true);
const [isStarting, setIsStarting] = useState<string | null>(null); // difficulty being started
const [error, setError] = useState<string | null>(null);
const [reEvaluatingId, setReEvaluatingId] = useState<string | null>(null);
const lastFetchedUid = useRef<string | null>(null);

const fetchSessions = useCallback(async () => {
Expand Down Expand Up @@ -134,6 +135,44 @@ export default function InterviewPage() {
}
};

const handleReEvaluate = async (e: React.MouseEvent, sessionId: string) => {
e.preventDefault();
e.stopPropagation();
if (reEvaluatingId) return;

try {
setReEvaluatingId(sessionId);
// Update the card to show evaluating state immediately
setSessions(prev => prev.map(s =>
s.id === sessionId ? { ...s, status: 'evaluating' as const } : s
));

const response = await authFetch(`/api/interview/${sessionId}/evaluate`, {
method: 'POST'
});

if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Re-evaluation failed');
}

const data = await response.json();
// Update session with new evaluation result
setSessions(prev => prev.map(s =>
s.id === sessionId
? { ...s, status: 'evaluated' as const, finalScore: data.session?.finalScore ?? s.finalScore }
: s
));
} catch (err) {
console.error('Re-evaluate failed:', err);
// Revert status to what it was before (refetch to be safe)
fetchSessions();
setError(err instanceof Error ? err.message : 'Re-evaluation failed');
} finally {
setReEvaluatingId(null);
}
};

const formatRelativeTime = (dateString: string) => {
const date = new Date(dateString);
if (isNaN(date.getTime())) return 'Unknown';
Expand Down Expand Up @@ -334,6 +373,32 @@ export default function InterviewPage() {
</div>
)}

{/* Re-evaluate logic */}
{['submitted', 'evaluating', 'evaluated'].includes(session.status) && (
<button
onClick={(e) => handleReEvaluate(e, session.id)}
disabled={reEvaluatingId === session.id || session.status === 'evaluating'}
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium border transition-all ${
reEvaluatingId === session.id || session.status === 'evaluating'
? 'bg-amber-500/10 text-amber-400 border-amber-500/20 cursor-wait'
: 'bg-primary/10 text-primary border-primary/20 hover:bg-primary/20 cursor-pointer'
}`}
title="Re-evaluate this design"
>
{reEvaluatingId === session.id || session.status === 'evaluating' ? (
<>
<div className="w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
Evaluating...
</>
) : (
<>
<span className="material-symbols-outlined text-[14px]">refresh</span>
Re-evaluate
</>
)}
</button>
)}

<span className="material-symbols-outlined text-[18px] text-slate-400 dark:text-text-muted-dark group-hover:text-primary transition-colors">
arrow_forward
</span>
Expand Down
Loading
Loading