From 7f1da76f3861e7213d9dd44f607de44c23fbeb4e Mon Sep 17 00:00:00 2001 From: Kamran Ahmed Date: Tue, 29 Jul 2025 18:42:30 +0100 Subject: [PATCH] Roadmap personalization --- .astro/settings.json | 2 +- .../PersonalizedRoadmap.tsx | 184 ++++++++++-------- .../PersonalizedRoadmapForm.tsx | 58 ------ .../PersonalizedRoadmapModal.tsx | 55 +++++- .../PersonalizedRoadmapSwitcher.tsx | 99 ++++++++++ src/hooks/use-personalized-roadmap.ts | 12 +- src/lib/resource-progress.ts | 124 ++++-------- src/pages/[roadmapId]/index.astro | 3 +- src/queries/resource-progress.ts | 4 + src/stores/roadmap.ts | 9 +- 10 files changed, 314 insertions(+), 236 deletions(-) delete mode 100644 src/components/PersonalizedRoadmap/PersonalizedRoadmapForm.tsx create mode 100644 src/components/PersonalizedRoadmap/PersonalizedRoadmapSwitcher.tsx diff --git a/.astro/settings.json b/.astro/settings.json index 7e0ebf75c..cde26c11e 100644 --- a/.astro/settings.json +++ b/.astro/settings.json @@ -3,6 +3,6 @@ "enabled": false }, "_variables": { - "lastUpdateCheck": 1750679157111 + "lastUpdateCheck": 1753810743067 } } \ No newline at end of file diff --git a/src/components/PersonalizedRoadmap/PersonalizedRoadmap.tsx b/src/components/PersonalizedRoadmap/PersonalizedRoadmap.tsx index 1b0cef329..1cc5a44fc 100644 --- a/src/components/PersonalizedRoadmap/PersonalizedRoadmap.tsx +++ b/src/components/PersonalizedRoadmap/PersonalizedRoadmap.tsx @@ -1,11 +1,12 @@ import { Loader2Icon, PersonStandingIcon } from 'lucide-react'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { usePersonalizedRoadmap } from '../../hooks/use-personalized-roadmap'; import { refreshProgressCounters, renderTopicProgress, } from '../../lib/resource-progress'; import { PersonalizedRoadmapModal } from './PersonalizedRoadmapModal'; +import { PersonalizedRoadmapSwitcher } from './PersonalizedRoadmapSwitcher'; import { useMutation, useQuery } from '@tanstack/react-query'; import { httpPost } from '../../lib/query-http'; import { useToast } from '../../hooks/use-toast'; @@ -14,13 +15,6 @@ import { userResourceProgressOptions } from '../../queries/resource-progress'; import { useAuth } from '../../hooks/use-auth'; import { roadmapJSONOptions } from '../../queries/roadmap'; -type BulkUpdateResourceProgressBody = { - done: string[]; - learning: string[]; - skipped: string[]; - pending: string[]; -}; - type PersonalizedRoadmapProps = { roadmapId: string; }; @@ -31,6 +25,7 @@ export function PersonalizedRoadmap(props: PersonalizedRoadmapProps) { const toast = useToast(); const currentUser = useAuth(); const [isModalOpen, setIsModalOpen] = useState(false); + const [isPersonalized, setIsPersonalized] = useState(false); const { data: roadmap } = useQuery( roadmapJSONOptions(roadmapId), @@ -42,6 +37,12 @@ export function PersonalizedRoadmap(props: PersonalizedRoadmapProps) { queryClient, ); + useEffect(() => { + if (userProgress?.personalized) { + setIsPersonalized(true); + } + }, [userProgress]); + const alreadyInProgressNodeIds = useMemo(() => { return new Set([ ...(userProgress?.learning ?? []), @@ -68,28 +69,33 @@ export function PersonalizedRoadmap(props: PersonalizedRoadmapProps) { localStorage.removeItem(`roadmap-${roadmapId}-${currentUser?.id}-favorite`); }, [roadmapId, currentUser]); - const { - mutate: bulkUpdateResourceProgress, - isPending: isBulkUpdating, - mutateAsync: bulkUpdateResourceProgressAsync, - } = useMutation( - { - mutationFn: (body: BulkUpdateResourceProgressBody) => { - return httpPost(`/v1-bulk-update-resource-progress/${roadmapId}`, body); + const { mutate: savePersonalization, isPending: isSavingPersonalization } = + useMutation( + { + mutationFn: (data: { topicIds: string[]; information: string }) => { + const remainingTopicIds = allPendingNodeIds.filter( + (nodeId) => !data.topicIds.includes(nodeId), + ); + + return httpPost(`/v1-save-personalization/${roadmapId}`, { + personalized: { + ...data, + topicIds: remainingTopicIds, + }, + }); + }, + onError: (error) => { + toast.error(error?.message ?? 'Failed to save personalization'); + }, + onSuccess: () => { + clearResourceProgressLocalStorage(); + refetchUserProgress(); + refreshProgressCounters(); + toast.success('Personalization saved successfully'); + }, }, - onError: (error) => { - toast.error( - error?.message ?? 'Something went wrong, please try again.', - ); - }, - onSuccess: () => { - clearResourceProgressLocalStorage(); - refetchUserProgress(); - refreshProgressCounters(); - }, - }, - queryClient, - ); + queryClient, + ); const { generatePersonalizedRoadmap, status } = usePersonalizedRoadmap({ roadmapId, @@ -107,55 +113,67 @@ export function PersonalizedRoadmap(props: PersonalizedRoadmapProps) { }); }, onFinish: (data) => { - const { topicIds } = data; - const remainingTopicIds = allPendingNodeIds.filter( - (nodeId) => !topicIds.includes(nodeId), - ); - - bulkUpdateResourceProgress({ - skipped: remainingTopicIds, - learning: [], - done: [], - pending: [], - }); + const { topicIds, information } = data; + savePersonalization({ topicIds, information }); }, }); - const { mutate: clearResourceProgress, isPending: isClearing } = useMutation( + const { mutate: clearPersonalization, isPending: isClearing } = useMutation( { - mutationFn: (pendingTopicIds: string[]) => { - return bulkUpdateResourceProgressAsync({ - skipped: [], - learning: [], - done: [], - pending: pendingTopicIds, - }); + mutationFn: () => { + return httpPost(`/v1-clear-roadmap-personalization/${roadmapId}`, {}); }, onError: (error) => { - toast.error( - error?.message ?? 'Something went wrong, please try again.', - ); + toast.error(error?.message ?? 'Failed to clear personalization'); }, - onSuccess: (_, pendingTopicIds) => { - for (const topicId of pendingTopicIds) { + onSuccess: () => { + // Reset all topics to pending state + allPendingNodeIds.forEach((topicId) => { renderTopicProgress(topicId, 'pending'); - } + }); - toast.success('Progress cleared successfully.'); - clearResourceProgressLocalStorage(); - refreshProgressCounters(); + setIsPersonalized(false); + toast.success('Personalization cleared successfully.'); refetchUserProgress(); }, }, queryClient, ); - const isGenerating = status !== 'idle' || isBulkUpdating || isClearing; + const isGenerating = + status !== 'idle' || isClearing || isSavingPersonalization; + + const handleTogglePersonalization = (showPersonalized: boolean) => { + setIsPersonalized(showPersonalized); + + if (!showPersonalized) { + const allTopicIds = allPendingNodeIds; + allTopicIds.forEach((topicId) => { + renderTopicProgress(topicId, 'pending'); + }); + } else if (userProgress?.personalized) { + const { topicIds } = userProgress.personalized; + const remainingTopicIds = allPendingNodeIds.filter( + (nodeId) => !topicIds.includes(nodeId), + ); + + remainingTopicIds.forEach((topicId) => { + renderTopicProgress(topicId, 'skipped'); + }); + + topicIds.forEach((topicId) => { + if (!alreadyInProgressNodeIds.has(topicId)) { + renderTopicProgress(topicId, 'pending'); + } + }); + } + }; return ( <> {isModalOpen && ( setIsModalOpen(false)} onSubmit={(information) => { for (const nodeId of allPendingNodeIds) { @@ -166,29 +184,41 @@ export function PersonalizedRoadmap(props: PersonalizedRoadmapProps) { }} onClearProgress={() => { setIsModalOpen(false); - const prevSkipped = userProgress?.skipped ?? []; - clearResourceProgress(prevSkipped); + clearPersonalization(); }} /> )} - + {userProgress?.personalized?.information ? ( + setIsModalOpen(true)} + onRemove={() => { + if (confirm('Are you sure you want to remove personalization?')) { + clearPersonalization(); + } + }} + /> + ) : ( + + )} ); } diff --git a/src/components/PersonalizedRoadmap/PersonalizedRoadmapForm.tsx b/src/components/PersonalizedRoadmap/PersonalizedRoadmapForm.tsx deleted file mode 100644 index b92a81289..000000000 --- a/src/components/PersonalizedRoadmap/PersonalizedRoadmapForm.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { PersonStandingIcon, XIcon } from 'lucide-react'; -import { useId, useState, type FormEvent } from 'react'; - -type PersonalizedRoadmapFormProps = { - info?: string; - onSubmit: (info: string) => void; - onClearProgress: () => void; -}; - -export function PersonalizedRoadmapForm(props: PersonalizedRoadmapFormProps) { - const { info: defaultInfo, onSubmit, onClearProgress } = props; - - const [info, setInfo] = useState(defaultInfo || ''); - const infoFieldId = useId(); - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - onSubmit(info); - }; - - return ( -
-

Personalize Roadmap

-
- -