Roadmap to becoming a developer in 2022
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

468 lines
12 KiB

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<boolean> {
const { topicId, resourceType, resourceId } = topic;
const { done = [] } =
(await getResourceProgress(resourceType, resourceId)) || {};
return done?.includes(topicId);
}
export async function getTopicStatus(
topic: TopicMeta,
): Promise<ResourceProgressType> {
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');
});
}