Files
pwa/src/hooks/useLearningJourney.ts
T
ar.badieiandCursor 8016b8f45e Polish Novya UI with brand colors, first-load skeletons, and session transition overlay.
Keep chat pinned to the latest message while AI thinks or streams, and align buttons/inputs with the logo teal/blue focus treatment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 01:31:24 +03:30

289 lines
9.1 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, hasSessionToken } from '@/services/backend';
import { getLatestByPhase } from '@/services/assessmentService';
import { getProgramProgress } from '@/services/programService';
import { PROGRAM_SESSION_COUNT } from '@/data/program/sessions';
/** Sessions 1..(N-1) done ⇒ learner is on final session; post-test may open manually. */
const POST_TEST_UNLOCK_MIN_COMPLETED_SESSIONS = PROGRAM_SESSION_COUNT - 1;
export type LearningStep =
| 'registered'
| 'pre_test_done'
| 'training_in_progress'
| 'training_completed'
| 'post_test_done'
| 'completed';
export interface LearningProgress {
user_id: string;
current_step: LearningStep;
pre_test_completed_at: string | null;
training_started_at: string | null;
training_completed_at: string | null;
post_test_completed_at: string | null;
updated_at?: string;
}
const getStorageKey = (userId: string) => `learning-progress:${userId}`;
const defaultProgress = (userId: string): LearningProgress => ({
user_id: userId,
current_step: 'registered',
pre_test_completed_at: null,
training_started_at: null,
training_completed_at: null,
post_test_completed_at: null
});
const readLocalProgress = (userId: string): LearningProgress => {
try {
const saved = localStorage.getItem(getStorageKey(userId));
return saved ? (JSON.parse(saved) as LearningProgress) : defaultProgress(userId);
} catch {
return defaultProgress(userId);
}
};
const writeLocalProgress = (progress: LearningProgress) => {
localStorage.setItem(getStorageKey(progress.user_id), JSON.stringify(progress));
};
/** Mirrors backend deriveCurrentStep (timestamps are authoritative). */
const deriveStep = (progress: LearningProgress): LearningStep => {
if (progress.post_test_completed_at) return 'completed';
if (progress.training_completed_at) return 'training_completed';
if (progress.training_started_at && progress.pre_test_completed_at) return 'training_in_progress';
if (progress.pre_test_completed_at) return 'pre_test_done';
return 'registered';
};
export function useLearningJourney(userId?: string) {
/** Offline guest ids are `guest-…` and have no backend session. */
const useLocalOnly = !hasSessionToken() || Boolean(userId?.startsWith('guest-'));
const [progress, setProgress] = useState<LearningProgress | null>(null);
const [backendPermissions, setBackendPermissions] = useState<{
canTakePreTest: boolean;
canOpenTraining: boolean;
canTakePostTest: boolean;
isCompleted: boolean;
} | null>(null);
const [loading, setLoading] = useState(true);
/** Skeleton / blocking load only on first fetch — silent on reloadProgress. */
const hasLoadedOnceRef = useRef(false);
const mapBackendJourney = useCallback((journey: {
currentStep: LearningStep;
preTestCompletedAt: string | null;
trainingStartedAt: string | null;
trainingCompletedAt: string | null;
postTestCompletedAt: string | null;
lastActivityAt: string | null;
}): LearningProgress => ({
user_id: userId ?? '',
current_step: journey.currentStep,
pre_test_completed_at: journey.preTestCompletedAt,
training_started_at: journey.trainingStartedAt,
training_completed_at: journey.trainingCompletedAt,
post_test_completed_at: journey.postTestCompletedAt,
updated_at: journey.lastActivityAt ?? undefined,
}), [userId]);
const mergeAssessmentCompletion = useCallback(
async (base: LearningProgress): Promise<LearningProgress> => {
try {
const [preAttempt, postAttempt] = await Promise.all([
getLatestByPhase(base.user_id, 'pre'),
getLatestByPhase(base.user_id, 'post'),
]);
const merged: LearningProgress = {
...base,
pre_test_completed_at: base.pre_test_completed_at ?? preAttempt?.completedAt ?? null,
training_started_at:
base.training_started_at ??
base.pre_test_completed_at ??
preAttempt?.completedAt ??
null,
post_test_completed_at: base.post_test_completed_at ?? postAttempt?.completedAt ?? null,
};
merged.current_step = deriveStep(merged);
return merged;
} catch {
return base;
}
},
[]
);
const loadProgress = useCallback(async () => {
if (!userId) {
setProgress(null);
setBackendPermissions(null);
setLoading(false);
return;
}
const isInitialLoad = !hasLoadedOnceRef.current;
if (isInitialLoad) {
setLoading(true);
}
if (useLocalOnly) {
const local = await mergeAssessmentCompletion(readLocalProgress(userId));
setProgress(local);
setBackendPermissions(null);
hasLoadedOnceRef.current = true;
setLoading(false);
return;
}
try {
const journey = await api.get<{
currentStep: LearningStep;
preTestCompletedAt: string | null;
trainingStartedAt: string | null;
trainingCompletedAt: string | null;
postTestCompletedAt: string | null;
lastActivityAt: string | null;
permissions: {
canTakePreTest: boolean;
canOpenTraining: boolean;
canTakePostTest: boolean;
isCompleted: boolean;
};
}>('/journey');
const mapped = mapBackendJourney(journey);
const merged = await mergeAssessmentCompletion(mapped);
setProgress(merged);
setBackendPermissions(journey.permissions);
} catch (error) {
console.error('Error loading learning progress:', error);
const local = readLocalProgress(userId);
const merged = await mergeAssessmentCompletion(local);
setProgress(merged);
setBackendPermissions(null);
} finally {
hasLoadedOnceRef.current = true;
setLoading(false);
}
}, [mapBackendJourney, mergeAssessmentCompletion, userId, useLocalOnly]);
useEffect(() => {
hasLoadedOnceRef.current = false;
}, [userId]);
useEffect(() => {
loadProgress();
}, [loadProgress]);
const saveProgress = useCallback(
async (next: LearningProgress) => {
// Optimistic update - فوری state را update کن
setProgress(next);
if (useLocalOnly) {
writeLocalProgress(next);
return;
}
// Backend is source of truth for authenticated users.
// Do not write journey state directly from frontend.
writeLocalProgress(next);
},
[useLocalOnly]
);
const markPreTestDone = useCallback(async (completedAt?: string) => {
const now = completedAt ?? new Date().toISOString();
const base = progress ?? {
user_id: userId ?? '',
current_step: 'registered' as const,
pre_test_completed_at: null,
training_started_at: null,
training_completed_at: null,
post_test_completed_at: null
};
const next = {
...base,
current_step: 'training_in_progress' as const,
pre_test_completed_at: base.pre_test_completed_at || now,
training_started_at: base.training_started_at || now
};
await saveProgress(next);
return true;
}, [progress, saveProgress, userId]);
const markTrainingDone = useCallback(async () => {
if (!userId) return false;
if (!useLocalOnly) {
await loadProgress();
return true;
}
const now = new Date().toISOString();
const base = progress ?? defaultProgress(userId);
if (base.training_completed_at) {
return true;
}
const next = {
...base,
user_id: userId,
current_step: 'training_completed' as const,
training_started_at: base.training_started_at || now,
training_completed_at: now
};
await saveProgress(next);
return true;
}, [loadProgress, progress, saveProgress, useLocalOnly, userId]);
const markPostTestDone = useCallback(async (completedAt?: string) => {
if (!userId) return false;
const now = completedAt ?? new Date().toISOString();
const base = progress ?? defaultProgress(userId);
if (base.post_test_completed_at) {
return true;
}
const next = {
...base,
user_id: userId,
current_step: 'completed' as const,
post_test_completed_at: now
};
await saveProgress(next);
return true;
}, [progress, saveProgress, userId]);
const derivedPermissions = useMemo(() => {
const completedSessions = userId
? getProgramProgress(userId).completedSessions.length
: 0;
const reachedFinalSession =
Boolean(progress?.training_completed_at) ||
completedSessions >= POST_TEST_UNLOCK_MIN_COMPLETED_SESSIONS;
return {
canTakePreTest: !progress?.pre_test_completed_at,
canOpenTraining: Boolean(progress?.pre_test_completed_at),
canTakePostTest: Boolean(
progress?.pre_test_completed_at &&
reachedFinalSession &&
!progress?.post_test_completed_at
),
isCompleted: Boolean(progress?.post_test_completed_at)
};
}, [progress, userId]);
const permissions = backendPermissions ?? derivedPermissions;
return {
progress,
loading,
permissions,
reloadProgress: loadProgress,
markPreTestDone,
markTrainingDone,
markPostTestDone
};
}