Skip to content

Commit b53b78e

Browse files
Merge pull request #273 from Abfa41/feat/interview-progress-sync
feat(progress): implement server-backed interview progress system with migration layer (fixes #70, #73)
2 parents 3552efa + c99a8c0 commit b53b78e

12 files changed

Lines changed: 449 additions & 40 deletions

File tree

client/src/lib/auth.store.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ export const useAuthStore = create<AuthState>((set) => {
3131

3232
logout: () => {
3333
localStorage.removeItem("user");
34+
localStorage.removeItem("interview-progress-migrated");
35+
localStorage.removeItem("interview-progress");
36+
3437
set({ user: null, isAuthenticated: false });
3538
_queryClient?.clear();
3639
// Clear httpOnly cookie server-side (fire-and-forget)

client/src/module/student/interview-prep/InterviewLessonsPage.tsx

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,27 @@ import { courseSchema, breadcrumbSchema } from "../../../lib/structured-data";
1010
import { useAuthStore } from "../../../lib/auth.store";
1111
import { LoginGate } from "../../../components/LoginGate";
1212
import { CircularProgress } from "../../../components/ui/CircularProgress";
13-
import { useInterviewProgress } from "./interviewProgress";
13+
import api from "../../../lib/axios"
14+
15+
const STORAGE_KEY = "interview-progress";
16+
17+
function getLocalProgress(): InterviewProgress {
18+
try {
19+
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
20+
21+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
22+
return {};
23+
}
24+
25+
return raw as InterviewProgress;
26+
} catch {
27+
return {};
28+
}
29+
}
30+
31+
function saveLocalProgress(progress: InterviewProgress) {
32+
localStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
33+
}
1434

1535
const LEVEL_STYLE: Record<string, string> = {
1636
Beginner: "text-green-700 dark:text-green-400 border-green-300 dark:border-green-900/60",
@@ -29,7 +49,56 @@ function MetaChip({ children, className = "" }: { children: React.ReactNode; cla
2949
export default function InterviewLessonsPage() {
3050
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
3151
const [showGate, setShowGate] = useState(false);
32-
const { progress, isLoading } = useInterviewProgress();
52+
const [progress, setProgress] = useState<InterviewProgress>({});
53+
54+
useEffect(() => {
55+
if (!isAuthenticated) {
56+
setProgress(getLocalProgress());
57+
return;
58+
}
59+
60+
const loadProgress = async () => {
61+
try {
62+
const localProgress = getLocalProgress();
63+
64+
const response = await api.get("/interview-progress");
65+
66+
const serverData = response.data;
67+
68+
const merged: InterviewProgress = {
69+
...localProgress,
70+
};
71+
72+
serverData.completedIds.forEach((id: string) => {
73+
merged[id] = { completed: true };
74+
});
75+
76+
setProgress(merged);
77+
78+
saveLocalProgress(merged);
79+
80+
const migrated = localStorage.getItem("interview-progress-migrated");
81+
82+
if (!migrated && Object.keys(localProgress).length > 0) {
83+
for (const [questionId, value] of Object.entries(localProgress)) {
84+
if (value.completed) {
85+
await api.patch(`/interview-progress`, {
86+
questionId: questionId, action: "complete"
87+
});
88+
}
89+
}
90+
91+
localStorage.setItem("interview-progress-migrated", "true");
92+
}
93+
} catch (error) {
94+
console.error("Failed to load interview progress", error);
95+
96+
setProgress(getLocalProgress());
97+
}
98+
};
99+
100+
loadProgress();
101+
}, [isAuthenticated]);
33102

34103
const sectionStats = useMemo(() => {
35104
return sections.map((section) => {

client/src/module/student/interview-prep/InterviewQuestionPage.tsx

Lines changed: 122 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useMemo, useState } from "react";
1+
import { useState, useCallback, useMemo, useEffect } from "react";
22
import { useParams, Link, useNavigate, Navigate } from "react-router";
33
import { motion } from "framer-motion";
44
import {
@@ -13,12 +13,44 @@ import {
1313
MessageSquare,
1414
} from "lucide-react";
1515
import { sections, questions } from "./data";
16-
import type { InterviewProgress, CodeExample } from "./data/types";
16+
import type { CodeExample } from "./data/types";
1717
import { SEO } from "../../../components/SEO";
1818
import { canonicalUrl } from "../../../lib/seo.utils";
1919
import { useAuthStore } from "../../../lib/auth.store";
2020
import { reportMilestone } from "../../../lib/milestone.utils";
21-
import { useInterviewProgress } from "./interviewProgress";
21+
22+
async function getServerProgress() {
23+
const res = await fetch("/api/interview-progress", {
24+
method: "GET",
25+
credentials: "include",
26+
});
27+
28+
if (!res.ok) {
29+
throw new Error("Failed to fetch progress");
30+
}
31+
32+
return res.json();
33+
}
34+
35+
async function updateServerProgress(
36+
questionId: string,
37+
action: "complete" | "uncomplete" | "visit"
38+
) {
39+
const res = await fetch(`/api/interview-progress`, {
40+
method: "PATCH",
41+
credentials: "include",
42+
headers: {
43+
"Content-Type": "application/json",
44+
},
45+
body: JSON.stringify({ questionId, action }),
46+
});
47+
48+
if (!res.ok) {
49+
throw new Error("Failed to update progress");
50+
}
51+
52+
return res.json();
53+
}
2254

2355
const DIFF_STYLE: Record<string, string> = {
2456
Beginner: "text-green-700 dark:text-green-400 border-green-300 dark:border-green-900/60",
@@ -115,7 +147,8 @@ export default function InterviewQuestionPage() {
115147
const navigate = useNavigate();
116148
const basePath = "/learn/interview";
117149
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
118-
const { progress, toggleComplete, recordVisit } = useInterviewProgress();
150+
151+
const [completed, setCompleted] = useState(false);
119152

120153
const section = sections.find((s) => s.id === sectionSlug);
121154
const sectionQuestions = useMemo(
@@ -124,27 +157,93 @@ export default function InterviewQuestionPage() {
124157
);
125158

126159
const question = sectionQuestions.find((q) => q.id === questionId);
127-
const completed = !!(questionId && progress[questionId]?.completed);
128-
const currentIndex = question ? sectionQuestions.findIndex((q) => q.id === question.id) : -1;
129-
const prevQuestion = currentIndex > 0 ? sectionQuestions[currentIndex - 1] : null;
130-
const nextQuestion = currentIndex < sectionQuestions.length - 1 ? sectionQuestions[currentIndex + 1] : null;
131-
132-
const handleToggleComplete = useCallback(() => {
133-
if (!questionId) return;
134-
void toggleComplete(questionId)
135-
.then((newVal) => {
136-
if (newVal && isAuthenticated && sectionSlug) {
137-
const nextProgress: InterviewProgress = { ...progress, [questionId]: { completed: true } };
138-
const allDone = sectionQuestions.every((q) => nextProgress[q.id]?.completed);
139-
if (allDone) reportMilestone("INTERVIEW_SECTION_COMPLETE", sectionSlug);
140-
}
141-
})
142-
.catch(() => {});
143-
}, [questionId, toggleComplete, isAuthenticated, sectionSlug, sectionQuestions, progress]);
160+
161+
const currentIndex = question
162+
? sectionQuestions.findIndex((q) => q.id === question.id)
163+
: -1;
164+
165+
const prevQuestion =
166+
currentIndex > 0
167+
? sectionQuestions[currentIndex - 1]
168+
: null;
169+
170+
const nextQuestion =
171+
currentIndex < sectionQuestions.length - 1
172+
? sectionQuestions[currentIndex + 1]
173+
: null;
144174

145175
useEffect(() => {
146-
if (questionId) recordVisit(questionId);
147-
}, [questionId, recordVisit]);
176+
if (!isAuthenticated || !questionId) return;
177+
178+
const loadProgress = async () => {
179+
try {
180+
const progress = await getServerProgress();
181+
182+
setCompleted(
183+
progress.completedIds?.includes(questionId) ?? false
184+
);
185+
} catch (err) {
186+
console.error(err);
187+
}
188+
};
189+
190+
loadProgress();
191+
}, [isAuthenticated, questionId]);
192+
193+
useEffect(() => {
194+
if (!isAuthenticated || !questionId) return;
195+
196+
const timeout = setTimeout(() => {
197+
updateServerProgress(questionId, "visit")
198+
.catch(console.error);
199+
}, 500);
200+
201+
return () => clearTimeout(timeout);
202+
}, [isAuthenticated, questionId]);
203+
204+
const handleToggleComplete = useCallback(async () => {
205+
if (!questionId || !isAuthenticated) return;
206+
207+
try {
208+
const action =
209+
completed ? "uncomplete" : "complete";
210+
211+
const updatedProgress =
212+
await updateServerProgress(
213+
questionId,
214+
action
215+
);
216+
217+
const isNowCompleted =
218+
updatedProgress.completedIds.includes(questionId);
219+
220+
setCompleted(isNowCompleted);
221+
222+
if (
223+
isNowCompleted &&
224+
sectionSlug
225+
) {
226+
const allDone = sectionQuestions.every((q) =>
227+
updatedProgress.completedIds.includes(q.id)
228+
);
229+
230+
if (allDone) {
231+
reportMilestone(
232+
"INTERVIEW_SECTION_COMPLETE",
233+
sectionSlug
234+
);
235+
}
236+
}
237+
} catch (err) {
238+
console.error(err);
239+
}
240+
}, [
241+
questionId,
242+
completed,
243+
isAuthenticated,
244+
sectionSlug,
245+
sectionQuestions,
246+
]);
148247

149248
if (section && !section.freeTier && !isAuthenticated) {
150249
return <Navigate to={basePath} replace />;

client/src/module/student/interview-prep/InterviewSectionPage.tsx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import { useMemo } from "react";
1+
import { useEffect, useMemo, useState } from "react";
22
import { useParams, Link, Navigate } from "react-router";
33
import { motion } from "framer-motion";
44
import { CheckCircle2, ArrowUpRight } from "lucide-react";
55
import { sections, questions } from "./data";
66
import { SEO } from "../../../components/SEO";
77
import { canonicalUrl } from "../../../lib/seo.utils";
88
import { useAuthStore } from "../../../lib/auth.store";
9-
import { useInterviewProgress } from "./interviewProgress";
9+
import api from "../../../lib/axios";
1010

1111
const DIFF_STYLE: Record<string, string> = {
1212
Beginner: "text-green-700 dark:text-green-400 border-green-300 dark:border-green-900/60",
@@ -34,7 +34,25 @@ export default function InterviewSectionPage() {
3434
const { sectionSlug } = useParams();
3535
const basePath = "/learn/interview";
3636
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
37-
const { progress, isLoading } = useInterviewProgress();
37+
38+
const [completedIds, setCompletedIds] = useState<string[]>([]);
39+
40+
useEffect(() => {
41+
if (!isAuthenticated) return;
42+
43+
const loadProgress = async () => {
44+
try {
45+
const res = await api.get("/interview-progress");
46+
47+
setCompletedIds(res.data.completedIds || []);
48+
} catch (err) {
49+
console.error(err);
50+
}
51+
};
52+
53+
loadProgress();
54+
}, [isAuthenticated]);
55+
3856
const section = sections.find((s) => s.id === sectionSlug);
3957

4058
const sectionQuestions = useMemo(
@@ -60,7 +78,7 @@ export default function InterviewSectionPage() {
6078
);
6179
}
6280

63-
const completedCount = sectionQuestions.filter((q) => progress[q.id]?.completed).length;
81+
const completedCount = sectionQuestions.filter((q) => completedIds.includes(q.id)).length;
6482
const pct = sectionQuestions.length > 0 ? Math.round((completedCount / sectionQuestions.length) * 100) : 0;
6583

6684
return (
@@ -170,7 +188,7 @@ export default function InterviewSectionPage() {
170188
) : (
171189
<div className="flex flex-col gap-2">
172190
{sectionQuestions.map((question, i) => {
173-
const isCompleted = progress[question.id]?.completed;
191+
const isCompleted = completedIds.includes(question.id);
174192
return (
175193
<motion.div
176194
key={question.id}

client/vite.config.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,25 @@ export default defineConfig({
8282
loader: { '.keep': 'text' },
8383
},
8484
},
85-
server: {
86-
headers: {
87-
'Cross-Origin-Opener-Policy': 'same-origin-allow-popups',
88-
'Cross-Origin-Embedder-Policy': 'unsafe-none',
85+
server: {
86+
headers: {
87+
'Cross-Origin-Opener-Policy': 'same-origin-allow-popups',
88+
'Cross-Origin-Embedder-Policy': 'unsafe-none',
89+
},
90+
proxy: {
91+
// Proxy sitemap.xml to backend so it works in development
92+
'/sitemap.xml': {
93+
target: 'http://localhost:3000',
94+
changeOrigin: true,
8995
},
90-
proxy: {
91-
// Proxy sitemap.xml to backend so it works in development
92-
'/sitemap.xml': {
93-
target: dockerInternalApiOrigin,
94-
changeOrigin: true,
95-
},
96+
// Proxy API requests to backend in development.
97+
'/api': {
98+
target: 'http://localhost:3000',
99+
changeOrigin: true,
100+
secure: false,
96101
},
97102
},
103+
},
98104
build: {
99105
chunkSizeWarningLimit: 600,
100106
rollupOptions: {

server/src/database/prisma/schema/ai.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ model jobAgentConversation {
118118
119119
@@index([userId, isActive])
120120
}
121+
121122
model generatedResume {
122123
id Int @id @default(autoincrement())
123124
userId Int

0 commit comments

Comments
 (0)