Add progress nudge on roadmap

pull/4669/head
Kamran Ahmed 11 months ago
parent 7e702ee385
commit 82dbca95fb
  1. 7
      src/components/CustomRoadmap/FlowRoadmapRenderer.tsx
  2. 3
      src/components/FrameRenderer/FrameRenderer.astro
  3. 83
      src/components/FrameRenderer/ProgressNudge.tsx
  4. 5
      src/components/FrameRenderer/renderer.ts
  5. 79
      src/lib/resource-progress.ts
  6. 9
      src/stores/roadmap.ts
  7. 2
      tailwind.config.cjs

@ -13,6 +13,7 @@ import type { Node } from 'reactflow';
import { useCallback, type MouseEvent, useMemo, useState, useRef } from 'react';
import { EmptyRoadmap } from './EmptyRoadmap';
import { cn } from '../../lib/classname';
import { totalRoadmapNodes } from '../../stores/roadmap.ts';
type FlowRoadmapRendererProps = {
roadmap: RoadmapDocument;
@ -138,6 +139,12 @@ export function FlowRoadmapRenderer(props: FlowRoadmapRendererProps) {
)}
onRendered={() => {
renderResourceProgress('roadmap', roadmapId).then(() => {
totalRoadmapNodes.set(
roadmap?.nodes?.filter((node) => {
return ['topic', 'subtopic'].includes(node.type);
}).length || 0,
);
if (roadmap?.nodes?.length === 0) {
setHideRenderer(true);
editorWrapperRef?.current?.classList.add('hidden');

@ -1,6 +1,7 @@
---
import Loader from '../Loader.astro';
import './FrameRenderer.css';
import { ProgressNudge } from "./ProgressNudge";
export interface Props {
resourceType: 'roadmap' | 'best-practice';
@ -27,4 +28,6 @@ const { resourceId, resourceType, dimensions = null } = Astro.props;
</div>
</div>
<ProgressNudge resourceId={resourceId} resourceType={resourceType} client:only="react" />
<script src='./renderer.ts'></script>

@ -0,0 +1,83 @@
import { Spinner } from '../ReactIcons/Spinner.tsx';
import { useEffect, useState } from 'react';
import { cn } from '../../lib/classname.ts';
import { getUser } from '../../lib/jwt.ts';
import { roadmapProgress, totalRoadmapNodes } from '../../stores/roadmap.ts';
import { useStore } from '@nanostores/react';
import {HelpCircle} from "lucide-react";
type ProgressNudgeProps = {
resourceType: 'roadmap' | 'best-practice';
resourceId: string;
};
export function ProgressNudge(props: ProgressNudgeProps) {
const { resourceId, resourceType } = props;
const $totalRoadmapNodes = useStore(totalRoadmapNodes);
const $roadmapProgress = useStore(roadmapProgress);
const done = $roadmapProgress?.done?.length || 0;
const [isLoading, setIsLoading] = useState(true);
const { id: userId } = getUser() || {};
const hasProgress = done > 0;
useEffect(() => {
setTimeout(() => {
setIsLoading(false);
}, 500);
}, []);
if (!$totalRoadmapNodes) {
return null;
}
return (
<div
className={cn(
'fixed hidden sm:block -bottom-full left-1/2 z-30 -translate-x-1/2 transform overflow-hidden rounded-full bg-stone-900 px-4 py-2 text-center text-white shadow-2xl transition-all ',
{
'bottom-5 opacity-100': !isLoading,
},
)}
>
<span
className={cn('block', {
hidden: hasProgress,
})}
>
<span className="mr-2 text-sm font-semibold uppercase text-yellow-400">
Tip
</span>
<span className="text-sm text-gray-200">
Right-click on a topic to mark it as done.{' '}
<button
data-popup="progress-help"
className="cursor-pointer font-semibold text-yellow-500 underline"
>
Learn more.
</button>
</span>
</span>
<span
className={cn('relative z-20 block text-sm', {
hidden: !hasProgress,
})}
>
<span className="relative -top-[0.45px] mr-2 text-xs font-medium uppercase text-yellow-400">
Progress
</span>
<span>{done}</span> of <span>{$totalRoadmapNodes}</span> Done
</span>
<span
className="absolute bottom-0 left-0 top-0 z-10 bg-stone-700"
style={{
width: `${(done / $totalRoadmapNodes) * 100}%`,
}}
></span>
</div>
);
}

@ -218,6 +218,11 @@ export class Renderer {
const isCurrentStatusDone = targetGroup.classList.contains('done');
const normalizedGroupId = groupId.replace(/^\d+-/, '');
if (normalizedGroupId.startsWith('ext_link:')) {
return;
}
this.updateTopicStatus(
normalizedGroupId,
!isCurrentStatusDone ? 'done' : 'pending',

@ -3,6 +3,7 @@ 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 =
@ -27,7 +28,7 @@ export async function isTopicDone(topic: TopicMeta): Promise<boolean> {
}
export async function getTopicStatus(
topic: TopicMeta
topic: TopicMeta,
): Promise<ResourceProgressType> {
const { topicId, resourceType, resourceId } = topic;
const progressResult = await getResourceProgress(resourceType, resourceId);
@ -49,7 +50,7 @@ export async function getTopicStatus(
export async function updateResourceProgress(
topic: TopicMeta,
progressType: ResourceProgressType
progressType: ResourceProgressType,
) {
const { topicId, resourceType, resourceId } = topic;
@ -74,7 +75,7 @@ export async function updateResourceProgress(
resourceId,
response.done,
response.learning,
response.skipped
response.skipped,
);
return response;
@ -82,7 +83,7 @@ export async function updateResourceProgress(
export async function getResourceProgress(
resourceType: 'roadmap' | 'best-practice',
resourceId: string
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)) {
@ -109,6 +110,14 @@ export async function getResourceProgress(
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
@ -119,7 +128,7 @@ export async function getResourceProgress(
resourceId,
isFavorite,
},
})
}),
);
return progress;
@ -127,7 +136,7 @@ export async function getResourceProgress(
async function loadFreshProgress(
resourceType: ResourceType,
resourceId: string
resourceId: string,
) {
const { response, error } = await httpGet<{
done: string[];
@ -153,7 +162,7 @@ async function loadFreshProgress(
resourceId,
response?.done || [],
response?.learning || [],
response?.skipped || []
response?.skipped || [],
);
// Dispatch event to update favorite status in the MarkFavorite component
@ -164,7 +173,7 @@ async function loadFreshProgress(
resourceId,
isFavorite: response.isFavorite,
},
})
}),
);
return response;
@ -175,8 +184,14 @@ export function setResourceProgress(
resourceId: string,
done: string[],
learning: string[],
skipped: string[]
skipped: string[],
): void {
roadmapProgress.set({
done,
learning,
skipped,
});
const userId = getUser()?.id;
localStorage.setItem(
`${resourceType}-${resourceId}-${userId}-progress`,
@ -185,13 +200,13 @@ export function setResourceProgress(
learning,
skipped,
timestamp: new Date().getTime(),
})
}),
);
}
export function topicSelectorAll(
topicId: string,
parentElement: Document | SVGElement | HTMLDivElement = document
parentElement: Document | SVGElement | HTMLDivElement = document,
): Element[] {
const matchingElements: Element[] = [];
@ -215,7 +230,7 @@ export function topicSelectorAll(
`[data-node-id="${topicId}"]`, // Matching custom roadmap nodes
`[data-id="${topicId}"]`, // Matching custom roadmap nodes
],
parentElement
parentElement,
).forEach((element) => {
matchingElements.push(element);
});
@ -225,7 +240,7 @@ export function topicSelectorAll(
export function renderTopicProgress(
topicId: string,
topicProgress: ResourceProgressType
topicProgress: ResourceProgressType,
) {
const isLearning = topicProgress === 'learning';
const isSkipped = topicProgress === 'skipped';
@ -268,7 +283,7 @@ export function clearResourceProgress() {
export async function renderResourceProgress(
resourceType: ResourceType,
resourceId: string
resourceId: string,
) {
const {
done = [],
@ -293,7 +308,7 @@ export async function renderResourceProgress(
function getMatchingElements(
quries: string[],
parentElement: Document | SVGElement | HTMLDivElement = document
parentElement: Document | SVGElement | HTMLDivElement = document,
): Element[] {
const matchingElements: Element[] = [];
quries.forEach((query) => {
@ -306,7 +321,7 @@ function getMatchingElements(
export function refreshProgressCounters() {
const progressNumsContainers = document.querySelectorAll(
'[data-progress-nums-container]'
'[data-progress-nums-container]',
);
const progressNums = document.querySelectorAll('[data-progress-nums]');
if (progressNumsContainers.length === 0 || progressNums.length === 0) {
@ -322,27 +337,27 @@ export function refreshProgressCounters() {
]).length;
const externalLinks = document.querySelectorAll(
'[data-group-id^="ext_link:"]'
'[data-group-id^="ext_link:"]',
).length;
const roadmapSwitchers = document.querySelectorAll(
'[data-group-id^="json:"]'
'[data-group-id^="json:"]',
).length;
const checkBoxes = document.querySelectorAll(
'[data-group-id^="check:"]'
'[data-group-id^="check:"]',
).length;
const totalCheckBoxesDone = document.querySelectorAll(
'[data-group-id^="check:"].done'
'[data-group-id^="check:"].done',
).length;
const totalCheckBoxesLearning = document.querySelectorAll(
'[data-group-id^="check:"].learning'
'[data-group-id^="check:"].learning',
).length;
const totalCheckBoxesSkipped = document.querySelectorAll(
'[data-group-id^="check:"].skipped'
'[data-group-id^="check:"].skipped',
).length;
const totalRemoved = document.querySelectorAll(
'.clickable-group.removed'
'.clickable-group.removed',
).length;
const totalItems =
totalClickable -
@ -351,6 +366,8 @@ export function refreshProgressCounters() {
checkBoxes -
totalRemoved;
totalRoadmapNodes.set(totalItems);
const totalDone =
getMatchingElements([
'.clickable-group.done:not([data-group-id^="ext_link:"])',
@ -373,47 +390,47 @@ export function refreshProgressCounters() {
const doneCountEls = document.querySelectorAll('[data-progress-done]');
if (doneCountEls.length > 0) {
doneCountEls.forEach(
(doneCountEl) => (doneCountEl.innerHTML = `${totalDone}`)
(doneCountEl) => (doneCountEl.innerHTML = `${totalDone}`),
);
}
const learningCountEls = document.querySelectorAll(
'[data-progress-learning]'
'[data-progress-learning]',
);
if (learningCountEls.length > 0) {
learningCountEls.forEach(
(learningCountEl) => (learningCountEl.innerHTML = `${totalLearning}`)
(learningCountEl) => (learningCountEl.innerHTML = `${totalLearning}`),
);
}
const skippedCountEls = document.querySelectorAll('[data-progress-skipped]');
if (skippedCountEls.length > 0) {
skippedCountEls.forEach(
(skippedCountEl) => (skippedCountEl.innerHTML = `${totalSkipped}`)
(skippedCountEl) => (skippedCountEl.innerHTML = `${totalSkipped}`),
);
}
const totalCountEls = document.querySelectorAll('[data-progress-total]');
if (totalCountEls.length > 0) {
totalCountEls.forEach(
(totalCountEl) => (totalCountEl.innerHTML = `${totalItems}`)
(totalCountEl) => (totalCountEl.innerHTML = `${totalItems}`),
);
}
const progressPercentage =
Math.round(((totalDone + totalSkipped) / totalItems) * 100) || 0;
const progressPercentageEls = document.querySelectorAll(
'[data-progress-percentage]'
'[data-progress-percentage]',
);
if (progressPercentageEls.length > 0) {
progressPercentageEls.forEach(
(progressPercentageEl) =>
(progressPercentageEl.innerHTML = `${progressPercentage}`)
(progressPercentageEl.innerHTML = `${progressPercentage}`),
);
}
progressNumsContainers.forEach((progressNumsContainer) =>
progressNumsContainer.classList.remove('striped-loader')
progressNumsContainer.classList.remove('striped-loader'),
);
progressNums.forEach((progressNum) => {
progressNum.classList.remove('opacity-0');

@ -4,9 +4,14 @@ import type { GetRoadmapResponse } from '../components/CustomRoadmap/CustomRoadm
export const currentRoadmap = atom<GetRoadmapResponse | undefined>(undefined);
export const isCurrentRoadmapPersonal = computed(
currentRoadmap,
(roadmap) => !roadmap?.teamId
(roadmap) => !roadmap?.teamId,
);
export const canManageCurrentRoadmap = computed(
currentRoadmap,
(roadmap) => roadmap?.canManage
(roadmap) => roadmap?.canManage,
);
export const roadmapProgress = atom<
{ done: string[]; learning: string[]; skipped: string[] } | undefined
>();
export const totalRoadmapNodes = atom<number | undefined>();

@ -2,7 +2,7 @@
module.exports = {
content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue,svg}',
'./editor/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue,svg}'
'./editor/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue,svg}',
],
future: {
hoverOnlyWhenSupported: true,

Loading…
Cancel
Save