Add progress nudge on roadmap

pull/4669/head
Kamran Ahmed 1 year 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. 4
      tailwind.config.cjs

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

@ -1,6 +1,7 @@
--- ---
import Loader from '../Loader.astro'; import Loader from '../Loader.astro';
import './FrameRenderer.css'; import './FrameRenderer.css';
import { ProgressNudge } from "./ProgressNudge";
export interface Props { export interface Props {
resourceType: 'roadmap' | 'best-practice'; resourceType: 'roadmap' | 'best-practice';
@ -27,4 +28,6 @@ const { resourceId, resourceType, dimensions = null } = Astro.props;
</div> </div>
</div> </div>
<ProgressNudge resourceId={resourceId} resourceType={resourceType} client:only="react" />
<script src='./renderer.ts'></script> <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 isCurrentStatusDone = targetGroup.classList.contains('done');
const normalizedGroupId = groupId.replace(/^\d+-/, ''); const normalizedGroupId = groupId.replace(/^\d+-/, '');
if (normalizedGroupId.startsWith('ext_link:')) {
return;
}
this.updateTopicStatus( this.updateTopicStatus(
normalizedGroupId, normalizedGroupId,
!isCurrentStatusDone ? 'done' : 'pending', !isCurrentStatusDone ? 'done' : 'pending',

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

@ -1,8 +1,8 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: [ content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue,svg}', './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: { future: {
hoverOnlyWhenSupported: true, hoverOnlyWhenSupported: true,

Loading…
Cancel
Save