import Cookies from 'js-cookie'; import { httpGet, httpPost } from './http'; import { TOKEN_COOKIE_NAME, getUser } from './jwt'; // @ts-ignore import Element = astroHTML.JSX.Element; import { roadmapProgress, totalRoadmapNodes } from '../stores/roadmap.ts'; export type ResourceType = 'roadmap' | 'best-practice'; export type ResourceProgressType = | 'done' | 'learning' | 'pending' | 'skipped' | 'removed'; type TopicMeta = { topicId: string; resourceType: ResourceType; resourceId: string; }; export async function isTopicDone(topic: TopicMeta): Promise { const { topicId, resourceType, resourceId } = topic; const { done = [] } = (await getResourceProgress(resourceType, resourceId)) || {}; return done?.includes(topicId); } export async function getTopicStatus( topic: TopicMeta, ): Promise { const { topicId, resourceType, resourceId } = topic; const progressResult = await getResourceProgress(resourceType, resourceId); if (progressResult?.done?.includes(topicId)) { return 'done'; } if (progressResult?.learning?.includes(topicId)) { return 'learning'; } if (progressResult?.skipped?.includes(topicId)) { return 'skipped'; } return 'pending'; } export async function updateResourceProgress( topic: TopicMeta, progressType: ResourceProgressType, ) { const { topicId, resourceType, resourceId } = topic; const { response, error } = await httpPost<{ done: string[]; learning: string[]; skipped: string[]; isFavorite: boolean; }>(`${import.meta.env.PUBLIC_API_URL}/v1-update-resource-progress`, { topicId, resourceType, resourceId, progress: progressType, }); if (error || !response?.done || !response?.learning) { throw new Error(error?.message || 'Something went wrong'); } setResourceProgress( resourceType, resourceId, response.done, response.learning, response.skipped, ); return response; } export function clearMigratedRoadmapProgress( resourceType: string, resourceId: string, ) { const migratedRoadmaps = ['frontend', 'backend']; if (!migratedRoadmaps.includes(resourceId)) { return; } const userId = getUser()?.id; if (!userId) { return; } const roadmapKey = `${resourceType}-${resourceId}-${userId}-progress`; const clearedKey = `${resourceType}-${resourceId}-${userId}-cleared`; const clearedCount = parseInt(localStorage.getItem(clearedKey) || '0', 10); if (clearedCount >= 10) { return; } localStorage.removeItem(roadmapKey); localStorage.setItem(clearedKey, `${clearedCount + 1}`); } export async function getResourceProgress( resourceType: 'roadmap' | 'best-practice', resourceId: string, ): Promise<{ done: string[]; learning: string[]; skipped: string[] }> { // No need to load progress if user is not logged in if (!Cookies.get(TOKEN_COOKIE_NAME)) { return { done: [], learning: [], skipped: [], }; } const userId = getUser()?.id; const progressKey = `${resourceType}-${resourceId}-${userId}-progress`; const isFavoriteKey = `${resourceType}-${resourceId}-favorite`; const rawIsFavorite = localStorage.getItem(isFavoriteKey); const isFavorite = JSON.parse(rawIsFavorite || '0') === 1; const rawProgress = localStorage.getItem(progressKey); const progress = JSON.parse(rawProgress || 'null'); const progressTimestamp = progress?.timestamp; const diff = new Date().getTime() - parseInt(progressTimestamp || '0', 10); const isProgressExpired = diff > 15 * 60 * 1000; // 15 minutes if (!progress || isProgressExpired) { return loadFreshProgress(resourceType, resourceId); } else { setResourceProgress( resourceType, resourceId, progress?.done || [], progress?.learning || [], progress?.skipped || [], ); } // Dispatch event to update favorite status in the MarkFavorite component window.dispatchEvent( new CustomEvent('mark-favorite', { detail: { resourceType, resourceId, isFavorite, }, }), ); return progress; } async function loadFreshProgress( resourceType: ResourceType, resourceId: string, ) { const { response, error } = await httpGet<{ done: string[]; learning: string[]; skipped: string[]; isFavorite: boolean; }>(`${import.meta.env.PUBLIC_API_URL}/v1-get-user-resource-progress`, { resourceType, resourceId, }); if (error || !response) { console.error(error); return { done: [], learning: [], skipped: [], }; } setResourceProgress( resourceType, resourceId, response?.done || [], response?.learning || [], response?.skipped || [], ); // Dispatch event to update favorite status in the MarkFavorite component window.dispatchEvent( new CustomEvent('mark-favorite', { detail: { resourceType, resourceId, isFavorite: response.isFavorite, }, }), ); return response; } export function setResourceProgress( resourceType: 'roadmap' | 'best-practice', resourceId: string, done: string[], learning: string[], skipped: string[], ): void { roadmapProgress.set({ done, learning, skipped, }); const userId = getUser()?.id; localStorage.setItem( `${resourceType}-${resourceId}-${userId}-progress`, JSON.stringify({ done, learning, skipped, timestamp: new Date().getTime(), }), ); } export function topicSelectorAll( topicId: string, parentElement: Document | SVGElement | HTMLDivElement = document, ): Element[] { const matchingElements: Element[] = []; // Elements having sort order in the beginning of the group id parentElement .querySelectorAll(`[data-group-id$="-${topicId}"]`) .forEach((element: unknown) => { const foundGroupId = (element as HTMLOrSVGElement)?.dataset?.groupId || ''; const validGroupRegex = new RegExp(`^\\d+-${topicId}$`); if (validGroupRegex.test(foundGroupId)) { matchingElements.push(element); } }); getMatchingElements( [ `[data-group-id="${topicId}"]`, // Elements with exact match of the topic id `[data-group-id="check:${topicId}"]`, // Matching "check:XXXX" box of the topic `[data-node-id="${topicId}"]`, // Matching custom roadmap nodes `[data-id="${topicId}"]`, // Matching custom roadmap nodes `[data-checklist-checkbox][data-checklist-id="${topicId}"]`, // Matching checklist checkboxes `[data-checklist-label][data-checklist-id="${topicId}"]`, // Matching checklist labels ], parentElement, ).forEach((element) => { matchingElements.push(element); }); return matchingElements; } export function renderTopicProgress( topicId: string, topicProgress: ResourceProgressType, ) { const isLearning = topicProgress === 'learning'; const isSkipped = topicProgress === 'skipped'; const isDone = topicProgress === 'done'; const isRemoved = topicProgress === 'removed'; const matchingElements: Element[] = topicSelectorAll(topicId); matchingElements.forEach((element) => { if (isDone) { element.classList.add('done'); element.classList.remove('learning', 'skipped'); } else if (isLearning) { element.classList.add('learning'); element.classList.remove('done', 'skipped'); } else if (isSkipped) { element.classList.add('skipped'); element.classList.remove('done', 'learning'); } else if (isRemoved) { element.classList.add('removed'); element.classList.remove('done', 'learning', 'skipped'); } else { element.classList.remove('done', 'skipped', 'learning', 'removed'); } }); } export function clearResourceProgress() { const matchingElements = getMatchingElements([ '.clickable-group', '[data-type="topic"]', '[data-type="subtopic"]', '.react-flow__node-topic', '.react-flow__node-subtopic', ]); for (const clickableElement of matchingElements) { clickableElement.classList.remove('done', 'skipped', 'learning', 'removed'); } } export async function renderResourceProgress( resourceType: ResourceType, resourceId: string, ) { const { done = [], learning = [], skipped = [], } = (await getResourceProgress(resourceType, resourceId)) || {}; done.forEach((topicId) => { renderTopicProgress(topicId, 'done'); }); learning.forEach((topicId) => { renderTopicProgress(topicId, 'learning'); }); skipped.forEach((topicId) => { renderTopicProgress(topicId, 'skipped'); }); refreshProgressCounters(); } function getMatchingElements( queries: string[], parentElement: Document | SVGElement | HTMLDivElement = document, ): Element[] { const matchingElements: Element[] = []; queries.forEach((query) => { parentElement.querySelectorAll(query).forEach((element) => { matchingElements.push(element); }); }); return matchingElements; } export function refreshProgressCounters() { const progressNumsContainers = document.querySelectorAll( '[data-progress-nums-container]', ); const progressNums = document.querySelectorAll('[data-progress-nums]'); if (progressNumsContainers.length === 0 || progressNums.length === 0) { return; } const totalClickable = getMatchingElements([ '.clickable-group', '[data-type="topic"]', '[data-type="subtopic"]', '.react-flow__node-topic', '.react-flow__node-subtopic', ]).length; const externalLinks = document.querySelectorAll( '[data-group-id^="ext_link:"]', ).length; const roadmapSwitchers = document.querySelectorAll( '[data-group-id^="json:"]', ).length; const checkBoxes = document.querySelectorAll( '[data-group-id^="check:"]', ).length; const totalCheckBoxesDone = document.querySelectorAll( '[data-group-id^="check:"].done', ).length; const totalCheckBoxesLearning = document.querySelectorAll( '[data-group-id^="check:"].learning', ).length; const totalCheckBoxesSkipped = document.querySelectorAll( '[data-group-id^="check:"].skipped', ).length; const totalRemoved = document.querySelectorAll( '.clickable-group.removed', ).length; const totalItems = totalClickable - externalLinks - roadmapSwitchers - checkBoxes - totalRemoved; totalRoadmapNodes.set(totalItems); const totalDone = getMatchingElements([ '.clickable-group.done:not([data-group-id^="ext_link:"])', '[data-node-id].done', // All data-node-id=*.done elements are custom roadmap nodes '[data-id].done', // All data-id=*.done elements are custom roadmap nodes ]).length - totalCheckBoxesDone; const totalLearning = getMatchingElements([ '.clickable-group.learning', '[data-node-id].learning', '[data-id].learning', ]).length - totalCheckBoxesLearning; const totalSkipped = getMatchingElements([ '.clickable-group.skipped', '[data-node-id].skipped', '[data-id].skipped', ]).length - totalCheckBoxesSkipped; const doneCountEls = document.querySelectorAll('[data-progress-done]'); if (doneCountEls.length > 0) { doneCountEls.forEach( (doneCountEl) => (doneCountEl.innerHTML = `${totalDone}`), ); } const learningCountEls = document.querySelectorAll( '[data-progress-learning]', ); if (learningCountEls.length > 0) { learningCountEls.forEach( (learningCountEl) => (learningCountEl.innerHTML = `${totalLearning}`), ); } const skippedCountEls = document.querySelectorAll('[data-progress-skipped]'); if (skippedCountEls.length > 0) { skippedCountEls.forEach( (skippedCountEl) => (skippedCountEl.innerHTML = `${totalSkipped}`), ); } const totalCountEls = document.querySelectorAll('[data-progress-total]'); if (totalCountEls.length > 0) { totalCountEls.forEach( (totalCountEl) => (totalCountEl.innerHTML = `${totalItems}`), ); } const progressPercentage = Math.round(((totalDone + totalSkipped) / totalItems) * 100) || 0; const progressPercentageEls = document.querySelectorAll( '[data-progress-percentage]', ); if (progressPercentageEls.length > 0) { progressPercentageEls.forEach( (progressPercentageEl) => (progressPercentageEl.innerHTML = `${progressPercentage}`), ); } progressNumsContainers.forEach((progressNumsContainer) => progressNumsContainer.classList.remove('striped-loader'), ); progressNums.forEach((progressNum) => { progressNum.classList.remove('opacity-0'); }); }