diff --git a/.astro/settings.json b/.astro/settings.json
index 82558a7e0..ebb445cdf 100644
--- a/.astro/settings.json
+++ b/.astro/settings.json
@@ -3,6 +3,6 @@
"enabled": false
},
"_variables": {
- "lastUpdateCheck": 1729612578122
+ "lastUpdateCheck": 1731065649795
}
}
\ No newline at end of file
diff --git a/package.json b/package.json
index dc5ef9fb1..28b522487 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"@nanostores/react": "^0.8.0",
"@napi-rs/image": "^1.9.2",
"@resvg/resvg-js": "^2.6.2",
+ "@tanstack/react-query": "^5.59.16",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"astro": "^4.16.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ac1af87ab..c65650762 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,6 +32,9 @@ importers:
'@resvg/resvg-js':
specifier: ^2.6.2
version: 2.6.2
+ '@tanstack/react-query':
+ specifier: ^5.59.16
+ version: 5.59.16(react@18.3.1)
'@types/react':
specifier: ^18.3.11
version: 18.3.11
@@ -1198,6 +1201,14 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20'
+ '@tanstack/query-core@5.59.16':
+ resolution: {integrity: sha512-crHn+G3ltqb5JG0oUv6q+PMz1m1YkjpASrXTU+sYWW9pLk0t2GybUHNRqYPZWhxgjPaVGC4yp92gSFEJgYEsPw==}
+
+ '@tanstack/react-query@5.59.16':
+ resolution: {integrity: sha512-MuyWheG47h6ERd4PKQ6V8gDyBu3ThNG22e1fRVwvq6ap3EqsFhyuxCAwhNP/03m/mLg+DAb0upgbPaX6VB+CkQ==}
+ peerDependencies:
+ react: ^18 || ^19
+
'@tybys/wasm-util@0.9.0':
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
@@ -4278,6 +4289,13 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.13
+ '@tanstack/query-core@5.59.16': {}
+
+ '@tanstack/react-query@5.59.16(react@18.3.1)':
+ dependencies:
+ '@tanstack/query-core': 5.59.16
+ react: 18.3.1
+
'@tybys/wasm-util@0.9.0':
dependencies:
tslib: 2.7.0
diff --git a/src/components/FrameRenderer/ProgressNudge.tsx b/src/components/FrameRenderer/ProgressNudge.tsx
index bbe893050..04f2b01c9 100644
--- a/src/components/FrameRenderer/ProgressNudge.tsx
+++ b/src/components/FrameRenderer/ProgressNudge.tsx
@@ -1,6 +1,10 @@
import { cn } from '../../lib/classname.ts';
import { roadmapProgress, totalRoadmapNodes } from '../../stores/roadmap.ts';
import { useStore } from '@nanostores/react';
+import { Calendar, Info, X } from 'lucide-react';
+import { Tooltip } from '../Tooltip.tsx';
+import { useState } from 'react';
+import { ScheduleEventModal } from '../Schedule/ScheduleEventModal.tsx';
type ProgressNudgeProps = {
resourceType: 'roadmap' | 'best-practice';
@@ -8,6 +12,11 @@ type ProgressNudgeProps = {
};
export function ProgressNudge(props: ProgressNudgeProps) {
+ const { resourceId, resourceType } = props;
+
+ const [isNudgeHidden, setIsNudgeHidden] = useState(false);
+ const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
+
const $totalRoadmapNodes = useStore(totalRoadmapNodes);
const $roadmapProgress = useStore(roadmapProgress);
@@ -17,52 +26,80 @@ export function ProgressNudge(props: ProgressNudgeProps) {
const hasProgress = done > 0;
- if (!$totalRoadmapNodes) {
+ if (!$totalRoadmapNodes || isNudgeHidden) {
return null;
}
return (
-
-
+ {isScheduleModalOpen && (
+ {
+ setIsScheduleModalOpen(false);
+ }}
+ roadmapId={resourceId}
+ />
+ )}
+
-
- Tip
-
-
- Right-click on a topic to mark it as done.{' '}
-
+
- Learn more.
-
-
-
-
-
- Progress
-
- {done > $totalRoadmapNodes ? $totalRoadmapNodes : done} of{' '}
- {$totalRoadmapNodes} Done
-
+
+ Tip
+
+
+ Right-click a topic to mark it as done
+
+
+
+
+ Progress
+
+ {done > $totalRoadmapNodes ? $totalRoadmapNodes : done} {' '}
+ of {$totalRoadmapNodes} Done
+
-
-
+
+
+ {resourceType === 'roadmap' && (
+ {
+ setIsScheduleModalOpen(true);
+ }}
+ className="group relative flex items-center gap-2 rounded-full bg-stone-900 px-3 text-sm text-yellow-400"
+ >
+
+
+ )}
+ {
+ setIsNudgeHidden(true);
+ }}
+ className="group relative flex items-center gap-2 rounded-full bg-stone-900 px-3 text-sm text-yellow-400"
+ >
+
+
+
+ >
);
}
diff --git a/src/components/ReactIcons/AppleCalendarIcon.tsx b/src/components/ReactIcons/AppleCalendarIcon.tsx
new file mode 100644
index 000000000..e69b3a78d
--- /dev/null
+++ b/src/components/ReactIcons/AppleCalendarIcon.tsx
@@ -0,0 +1,37 @@
+import type { SVGProps } from 'react';
+
+export function AppleCalendarIcon(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ReactIcons/FileIcon.tsx b/src/components/ReactIcons/FileIcon.tsx
new file mode 100644
index 000000000..72634e66d
--- /dev/null
+++ b/src/components/ReactIcons/FileIcon.tsx
@@ -0,0 +1,22 @@
+import type { SVGProps } from 'react';
+
+export function FileIcon(props: SVGProps) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/ReactIcons/GoogleCalendarIcon.tsx b/src/components/ReactIcons/GoogleCalendarIcon.tsx
new file mode 100644
index 000000000..2540d23da
--- /dev/null
+++ b/src/components/ReactIcons/GoogleCalendarIcon.tsx
@@ -0,0 +1,40 @@
+import type { SVGProps } from 'react';
+
+export function GoogleCalendarIcon(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ReactIcons/OutlookCalendarIcon.tsx b/src/components/ReactIcons/OutlookCalendarIcon.tsx
new file mode 100644
index 000000000..6a7e4a058
--- /dev/null
+++ b/src/components/ReactIcons/OutlookCalendarIcon.tsx
@@ -0,0 +1,40 @@
+import type { SVGProps } from 'react';
+
+export function OutlookCalendarIcon(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/RoadmapHeader.astro b/src/components/RoadmapHeader.astro
index 103f3dbda..6a0e5904a 100644
--- a/src/components/RoadmapHeader.astro
+++ b/src/components/RoadmapHeader.astro
@@ -7,6 +7,7 @@ import {
} from 'lucide-react';
import { TabLink } from './TabLink';
import LoginPopup from './AuthenticationFlow/LoginPopup.astro';
+import { ScheduleButton } from './Schedule/ScheduleButton';
import ProgressHelpPopup from './ProgressHelpPopup.astro';
import { MarkFavorite } from './FeaturedItems/MarkFavorite';
import { type RoadmapFrontmatter } from '../lib/roadmap';
@@ -45,8 +46,6 @@ const roadmapTitle =
roadmapId === 'devops'
? 'DevOps'
: `${roadmapId.charAt(0).toUpperCase()}${roadmapId.slice(1)}`;
-
-const hasTnsBanner = !!tnsBannerLink;
---
@@ -95,6 +94,12 @@ const hasTnsBanner = !!tnsBannerLink;
className='relative top-px mr-2 text-gray-500 !opacity-100 hover:text-gray-600 focus:outline-0 [&>svg]:h-4 [&>svg]:w-4 [&>svg]:stroke-gray-400 [&>svg]:stroke-[0.4] hover:[&>svg]:stroke-gray-600 sm:[&>svg]:h-4 sm:[&>svg]:w-4'
client:only='react'
/>
+
-
-
+
+
{title}
-
+
{description}
@@ -135,6 +140,7 @@ const hasTnsBanner = !!tnsBannerLink;
text='Suggest Changes'
isExternal={true}
hideTextOnMobile={true}
+ isActive={false}
/>
diff --git a/src/components/Schedule/ScheduleButton.tsx b/src/components/Schedule/ScheduleButton.tsx
new file mode 100644
index 000000000..caf24215f
--- /dev/null
+++ b/src/components/Schedule/ScheduleButton.tsx
@@ -0,0 +1,40 @@
+import { Calendar } from 'lucide-react';
+import { cn } from '../../lib/classname';
+import type { ResourceType } from '../../lib/resource-progress';
+import { ScheduleEventModal } from './ScheduleEventModal';
+import { useState } from 'react';
+
+type ScheduleButtonProps = {
+ resourceId: string;
+ resourceType: ResourceType;
+ resourceTitle: string;
+};
+
+export function ScheduleButton(props: ScheduleButtonProps) {
+ const { resourceId, resourceType, resourceTitle } = props;
+
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ return (
+ <>
+ {isModalOpen && (
+ {
+ setIsModalOpen(false);
+ }}
+ roadmapId={resourceId}
+ />
+ )}
+
+ {
+ setIsModalOpen(true);
+ }}
+ >
+
+ Schedule Learning Time
+
+ >
+ );
+}
diff --git a/src/components/Schedule/ScheduleEventModal.tsx b/src/components/Schedule/ScheduleEventModal.tsx
new file mode 100644
index 000000000..221805fe8
--- /dev/null
+++ b/src/components/Schedule/ScheduleEventModal.tsx
@@ -0,0 +1,325 @@
+import { DateTime } from 'luxon';
+import { Modal } from '../Modal';
+import { ChevronRight, type LucideIcon, X } from 'lucide-react';
+import { useState, type ReactNode, type SVGProps } from 'react';
+import { GoogleCalendarIcon } from '../ReactIcons/GoogleCalendarIcon';
+import { OutlookCalendarIcon } from '../ReactIcons/OutlookCalendarIcon';
+import { AppleCalendarIcon } from '../ReactIcons/AppleCalendarIcon';
+import { FileIcon } from '../ReactIcons/FileIcon';
+
+function generateRoadmapIcsFile(
+ title: string,
+ details: string,
+ location: string,
+ startDate: Date,
+ endDate: Date,
+) {
+ const ics = `
+BEGIN:VCALENDAR
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:${title}
+DESCRIPTION:${details}
+LOCATION:${location}
+DTSTART:${startDate.toISOString().replace(/-|:|\.\d+/g, '')}
+DTEND:${endDate.toISOString().replace(/-|:|\.\d+/g, '')}
+RRULE:FREQ=DAILY
+
+BEGIN:VALARM
+TRIGGER:-PT30M
+ACTION:DISPLAY
+DESCRIPTION:Reminder: ${title} starts in 30 minutes
+END:VALARM
+
+BEGIN:VALARM
+TRIGGER:-PT15M
+ACTION:DISPLAY
+DESCRIPTION:Reminder: ${title} starts in 15 minutes
+END:VALARM
+
+END:VEVENT
+END:VCALENDAR
+ `.trim();
+
+ return new Blob([ics], { type: 'text/calendar' });
+}
+
+type ScheduleEventModalProps = {
+ roadmapId: string;
+ onClose: () => void;
+};
+
+export function ScheduleEventModal(props: ScheduleEventModalProps) {
+ const { onClose, roadmapId } = props;
+
+ let roadmapTitle = '';
+
+ if (roadmapId === 'devops') {
+ roadmapTitle = 'DevOps';
+ } else if (roadmapId === 'ios') {
+ roadmapTitle = 'iOS';
+ } else if (roadmapId === 'postgresql-dba') {
+ roadmapTitle = 'PostgreSQL';
+ } else if (roadmapId === 'devrel') {
+ roadmapTitle = 'DevRel';
+ } else if (roadmapId === 'qa') {
+ roadmapTitle = 'QA';
+ } else if (roadmapId === 'api-design') {
+ roadmapTitle = 'API Design';
+ } else if (roadmapId === 'ai-data-scientist') {
+ roadmapTitle = 'AI/Data Scientist';
+ } else if (roadmapId === 'technical-writer') {
+ } else if (roadmapId === 'software-architect') {
+ roadmapTitle = 'Software Architecture';
+ } else if (roadmapId === 'ai-engineer') {
+ roadmapTitle = 'AI Engineer';
+ } else {
+ roadmapTitle = roadmapId
+ .split('-')
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' ');
+ }
+
+ const [selectedCalendar, setSelectedCalendar] = useState<
+ 'apple' | 'outlook' | null
+ >(null);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const location = `https://roadmap.sh/${roadmapId}`;
+ const title = `Learn from ${roadmapTitle} Roadmap - roadmap.sh`;
+ const details = `
+Learn from the ${roadmapTitle} roadmap on roadmap.sh
+
+Visit the roadmap at https://roadmap.sh/${roadmapId}
+ `.trim();
+
+ const handleDownloadICS = () => {
+ setIsLoading(true);
+
+ const startDate = DateTime.now().minus({
+ minutes: DateTime.now().minute % 30,
+ });
+ const endDate = startDate.plus({ hours: 1 });
+ const blob = generateRoadmapIcsFile(
+ title,
+ details,
+ location,
+ startDate.toJSDate(),
+ endDate.toJSDate(),
+ );
+
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${roadmapTitle}.ics`;
+ a.click();
+
+ setIsLoading(false);
+ URL.revokeObjectURL(url);
+ };
+
+ const handleGoogleCalendar = () => {
+ setIsLoading(true);
+ const baseURL =
+ 'https://calendar.google.com/calendar/render?action=TEMPLATE';
+
+ const startDate = DateTime.now().minus({
+ minutes: DateTime.now().minute % 30,
+ });
+ const endDate = startDate.plus({ hours: 1 });
+
+ const eventDetails = new URLSearchParams({
+ text: title,
+ dates: `${startDate.toISO().replace(/-|:|\.\d+/g, '')}/${endDate.toISO().replace(/-|:|\.\d+/g, '')}`,
+ details,
+ location,
+ recur: 'RRULE:FREQ=DAILY',
+ }).toString();
+
+ setIsLoading(false);
+ window.open(`${baseURL}&${eventDetails}`, '_blank');
+ };
+
+ const stepDetails = {
+ apple: {
+ title: 'Add to Apple Calendar',
+ steps: [
+ 'Download the iCS File',
+ 'Open the downloaded file, and it will automatically open your default calendar app.',
+ <>
+ If Apple Calendar is not your default calendar app, open Apple
+ Calendar, go to File > Import , and choose the
+ downloaded file.
+ >,
+ ],
+ },
+ outlook: {
+ title: 'Add to Outlook Calendar',
+ steps: [
+ 'Download the iCS File',
+ <>
+ Open Outlook and go to{' '}
+ File > Open & Export > Import/Export .
+ >,
+ <>
+ In the Import and Export Wizard select{' '}
+ Import an iCalendar (.ics) or vCalendar file (.vcs) .
+ You can then choose to keep it a separate calendar or make it a new
+ calendar.
+ >,
+ ],
+ },
+ };
+
+ return (
+
+
+
+
+
+
+
+ {selectedCalendar && (
+
{
+ setSelectedCalendar(null);
+ }}
+ isLoading={isLoading}
+ />
+ )}
+
+ {!selectedCalendar && (
+ <>
+ Schedule Learning Time
+
+ Block some time on your calendar to stay consistent
+
+
+
+
+
{
+ setSelectedCalendar('apple');
+ }}
+ />
+ {
+ setSelectedCalendar('outlook');
+ }}
+ />
+
+
+ or download the iCS file and import it to your calendar app
+
+
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+type SVGIcon = (props: SVGProps) => ReactNode;
+
+type CalendarButtonProps = {
+ icon: LucideIcon | SVGIcon;
+ label: string;
+ isLoading?: boolean;
+ onClick: () => void;
+};
+
+function CalendarButton(props: CalendarButtonProps) {
+ const { icon: Icon, label, isLoading, onClick } = props;
+
+ return (
+
+
+
+ {label}
+
+
+
+ );
+}
+
+type CalendarStepsProps = {
+ title: string;
+ steps: (string | ReactNode)[];
+ onDownloadICS: () => void;
+ isLoading?: boolean;
+ onCancel: () => void;
+};
+
+export function CalendarSteps(props: CalendarStepsProps) {
+ const { steps, onDownloadICS, onCancel, title, isLoading } = props;
+
+ return (
+
+
{title}
+
+
+ {steps.map((step, index) => (
+
+ ))}
+
+
+
+
+ Go back
+
+
+ Download
+
+
+
+ );
+}
diff --git a/src/components/ShareRoadmapButton.tsx b/src/components/ShareRoadmapButton.tsx
index f40a74ed3..58eb06c7f 100644
--- a/src/components/ShareRoadmapButton.tsx
+++ b/src/components/ShareRoadmapButton.tsx
@@ -33,8 +33,6 @@ export function ShareRoadmapButton(props: ShareRoadmapButtonProps) {
setIsDropdownOpen(false);
});
- const embedHtml = ``;
-
return (
{isEmbedModalOpen && (
diff --git a/src/data/roadmaps/ai-data-scientist/ai-data-scientist.md b/src/data/roadmaps/ai-data-scientist/ai-data-scientist.md
index d6b91ec80..bba8aba07 100644
--- a/src/data/roadmaps/ai-data-scientist/ai-data-scientist.md
+++ b/src/data/roadmaps/ai-data-scientist/ai-data-scientist.md
@@ -5,7 +5,7 @@ order: 5
renderer: 'editor'
briefTitle: 'AI and Data Scientist'
briefDescription: 'Step by step guide to becoming an AI and Data Scientist in 2024'
-title: 'AI and Data Scientist Roadmap'
+title: 'AI and Data Scientist'
description: 'Step by step guide to becoming an AI and Data Scientist in 2024'
hasTopics: true
isNew: false
diff --git a/src/data/roadmaps/ai-engineer/ai-engineer.md b/src/data/roadmaps/ai-engineer/ai-engineer.md
index 9bbbafb69..a6731e9d2 100644
--- a/src/data/roadmaps/ai-engineer/ai-engineer.md
+++ b/src/data/roadmaps/ai-engineer/ai-engineer.md
@@ -5,7 +5,7 @@ order: 4
renderer: 'editor'
briefTitle: 'AI Engineer'
briefDescription: 'Step by step guide to becoming an AI Engineer in 2024'
-title: 'AI Engineer Roadmap'
+title: 'AI Engineer'
description: 'Step by step guide to becoming an AI Engineer in 2024'
hasTopics: true
isNew: true
diff --git a/src/data/roadmaps/datastructures-and-algorithms/datastructures-and-algorithms.md b/src/data/roadmaps/datastructures-and-algorithms/datastructures-and-algorithms.md
index d7d43d70a..f8b062af1 100644
--- a/src/data/roadmaps/datastructures-and-algorithms/datastructures-and-algorithms.md
+++ b/src/data/roadmaps/datastructures-and-algorithms/datastructures-and-algorithms.md
@@ -4,7 +4,7 @@ pdfUrl: '/pdfs/roadmaps/datastructures-and-algorithms.pdf'
order: 18
briefTitle: 'Data Structures & Algorithms'
briefDescription: 'Step by step guide to learn Data Structures and Algorithms in 2024'
-title: 'Data Structures & Algorithms Roadmap'
+title: 'Data Structures & Algorithms'
description: 'Step by step guide to learn Data Structures and Algorithms in 2024'
hasTopics: true
isNew: false