feat: implement calendar scheduling (#7574)

* wip

* feat: add calendar scheduling

* fix: update names

* UI Changes for calendar scheduling

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
pull/7704/head
Arik Chakma 2 weeks ago committed by GitHub
parent e4c863bbf4
commit 2a6c1bfce8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      .astro/settings.json
  2. 1
      package.json
  3. 18
      pnpm-lock.yaml
  4. 61
      src/components/FrameRenderer/ProgressNudge.tsx
  5. 37
      src/components/ReactIcons/AppleCalendarIcon.tsx
  6. 22
      src/components/ReactIcons/FileIcon.tsx
  7. 40
      src/components/ReactIcons/GoogleCalendarIcon.tsx
  8. 40
      src/components/ReactIcons/OutlookCalendarIcon.tsx
  9. 16
      src/components/RoadmapHeader.astro
  10. 40
      src/components/Schedule/ScheduleButton.tsx
  11. 325
      src/components/Schedule/ScheduleEventModal.tsx
  12. 2
      src/components/ShareRoadmapButton.tsx
  13. 2
      src/data/roadmaps/ai-data-scientist/ai-data-scientist.md
  14. 2
      src/data/roadmaps/ai-engineer/ai-engineer.md
  15. 2
      src/data/roadmaps/datastructures-and-algorithms/datastructures-and-algorithms.md

@ -3,6 +3,6 @@
"enabled": false "enabled": false
}, },
"_variables": { "_variables": {
"lastUpdateCheck": 1729612578122 "lastUpdateCheck": 1731065649795
} }
} }

@ -37,6 +37,7 @@
"@nanostores/react": "^0.8.0", "@nanostores/react": "^0.8.0",
"@napi-rs/image": "^1.9.2", "@napi-rs/image": "^1.9.2",
"@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js": "^2.6.2",
"@tanstack/react-query": "^5.59.16",
"@types/react": "^18.3.11", "@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"astro": "^4.16.1", "astro": "^4.16.1",

@ -32,6 +32,9 @@ importers:
'@resvg/resvg-js': '@resvg/resvg-js':
specifier: ^2.6.2 specifier: ^2.6.2
version: 2.6.2 version: 2.6.2
'@tanstack/react-query':
specifier: ^5.59.16
version: 5.59.16(react@18.3.1)
'@types/react': '@types/react':
specifier: ^18.3.11 specifier: ^18.3.11
version: 18.3.11 version: 18.3.11
@ -1198,6 +1201,14 @@ packages:
peerDependencies: peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20' 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': '@tybys/wasm-util@0.9.0':
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
@ -4278,6 +4289,13 @@ snapshots:
postcss-selector-parser: 6.0.10 postcss-selector-parser: 6.0.10
tailwindcss: 3.4.13 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': '@tybys/wasm-util@0.9.0':
dependencies: dependencies:
tslib: 2.7.0 tslib: 2.7.0

@ -1,6 +1,10 @@
import { cn } from '../../lib/classname.ts'; import { cn } from '../../lib/classname.ts';
import { roadmapProgress, totalRoadmapNodes } from '../../stores/roadmap.ts'; import { roadmapProgress, totalRoadmapNodes } from '../../stores/roadmap.ts';
import { useStore } from '@nanostores/react'; 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 = { type ProgressNudgeProps = {
resourceType: 'roadmap' | 'best-practice'; resourceType: 'roadmap' | 'best-practice';
@ -8,6 +12,11 @@ type ProgressNudgeProps = {
}; };
export function ProgressNudge(props: 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 $totalRoadmapNodes = useStore(totalRoadmapNodes);
const $roadmapProgress = useStore(roadmapProgress); const $roadmapProgress = useStore(roadmapProgress);
@ -17,18 +26,32 @@ export function ProgressNudge(props: ProgressNudgeProps) {
const hasProgress = done > 0; const hasProgress = done > 0;
if (!$totalRoadmapNodes) { if (!$totalRoadmapNodes || isNudgeHidden) {
return null; return null;
} }
return ( return (
<>
{isScheduleModalOpen && (
<ScheduleEventModal
onClose={() => {
setIsScheduleModalOpen(false);
}}
roadmapId={resourceId}
/>
)}
<div
className={
'fixed bottom-5 left-1/2 z-30 hidden -translate-x-1/2 transform animate-fade-slide-up flex-row gap-1.5 transition-all duration-300 lg:flex'
}
>
<div <div
className={ className={
'fixed bottom-5 left-1/2 z-30 hidden -translate-x-1/2 transform animate-fade-slide-up overflow-hidden rounded-full bg-stone-900 px-4 py-2 text-center text-white shadow-2xl transition-all duration-300 sm:block' 'relative overflow-hidden rounded-full bg-stone-900 px-4 py-2 text-center text-white shadow-2xl'
} }
> >
<span <span
className={cn('block', { className={cn('flex items-center', {
hidden: hasProgress, hidden: hasProgress,
})} })}
> >
@ -36,13 +59,7 @@ export function ProgressNudge(props: ProgressNudgeProps) {
Tip Tip
</span> </span>
<span className="text-sm text-gray-200"> <span className="text-sm text-gray-200">
Right-click on a topic to mark it as done.{' '} Right-click a topic to mark it as done &nbsp;
<button
data-popup="progress-help"
className="cursor-pointer font-semibold text-yellow-500 underline"
>
Learn more.
</button>
</span> </span>
</span> </span>
<span <span
@ -53,8 +70,8 @@ export function ProgressNudge(props: ProgressNudgeProps) {
<span className="relative -top-[0.45px] mr-2 text-xs font-medium uppercase text-yellow-400"> <span className="relative -top-[0.45px] mr-2 text-xs font-medium uppercase text-yellow-400">
Progress Progress
</span> </span>
<span>{done > $totalRoadmapNodes ? $totalRoadmapNodes : done}</span> of{' '} <span>{done > $totalRoadmapNodes ? $totalRoadmapNodes : done}</span>{' '}
<span>{$totalRoadmapNodes}</span> Done of <span>{$totalRoadmapNodes}</span> Done
</span> </span>
<span <span
@ -64,5 +81,25 @@ export function ProgressNudge(props: ProgressNudgeProps) {
}} }}
></span> ></span>
</div> </div>
{resourceType === 'roadmap' && (
<button
onClick={() => {
setIsScheduleModalOpen(true);
}}
className="group relative flex items-center gap-2 rounded-full bg-stone-900 px-3 text-sm text-yellow-400"
>
<Calendar className="h-4 w-4 flex-shrink-0" strokeWidth={2.5} />
</button>
)}
<button
onClick={() => {
setIsNudgeHidden(true);
}}
className="group relative flex items-center gap-2 rounded-full bg-stone-900 px-3 text-sm text-yellow-400"
>
<X className="h-4 w-4 flex-shrink-0" strokeWidth={2.5} />
</button>
</div>
</>
); );
} }

@ -0,0 +1,37 @@
import type { SVGProps } from 'react';
export function AppleCalendarIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="1736"
height="1693"
viewBox="0 0 1736 1693"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect width="1736" height="1693" fill="#ECEFF1" />
<rect x="1" width="1734" height="526" fill="#FF3D00" />
<path
d="M724.689 300.13L750.665 128H805.4L756.691 401.947H701.224L669.269 240.501L637.587 401.947H581.892L533 128H588.101L613.894 299.947L646.032 128H692.505L724.689 300.13Z"
fill="white"
/>
<path
d="M976.776 283.419H890.632V356.061H992.617V401.947H835.303V128H992.206V174.069H890.632V238.812H976.776V283.419Z"
fill="white"
/>
<path
d="M1024.39 401.947V128H1096.84C1128.79 128 1154.31 138.182 1173.3 158.454C1192.29 178.771 1201.97 206.623 1202.34 242.008V286.433C1202.34 322.411 1192.84 350.673 1173.8 371.219C1154.86 391.674 1128.66 401.947 1095.28 401.947H1024.39ZM1079.72 174.069V356.015H1096.29C1114.73 356.015 1127.7 351.175 1135.23 341.45C1142.76 331.725 1146.73 314.969 1147.1 291.135V243.514C1147.1 217.946 1143.49 200.094 1136.37 189.958C1129.2 179.867 1117.06 174.571 1099.85 174.069H1079.72Z"
fill="white"
/>
<path
d="M831.353 1451.15H380.138V1345.95L587.348 1082.46C613.643 1045.98 632.999 1013.97 645.462 986.442C657.925 958.91 664.133 932.52 664.133 907.271C664.133 873.256 658.29 846.592 646.512 827.324C634.78 808.056 617.843 798.423 595.748 798.423C571.553 798.423 552.379 809.654 538.182 832.072C523.984 854.536 516.863 886.086 516.863 926.767H367.492C367.492 879.785 377.216 836.821 396.663 797.875C416.111 758.929 443.456 728.703 478.698 707.153C513.941 685.556 553.84 674.781 598.35 674.781C666.735 674.781 719.736 693.638 757.444 731.351C795.152 769.065 814.006 822.621 814.006 892.067C814.006 935.168 803.552 978.954 782.735 1023.29C761.872 1067.67 724.073 1122.27 669.383 1187.11L571.051 1327.55H831.353V1451.15Z"
fill="#424242"
/>
<path
d="M1354.1 888.871C1354.1 926.036 1346.21 959.001 1330.41 987.766C1314.62 1016.53 1292.89 1039.5 1265.22 1056.66C1296.77 1074.56 1321.69 1099.17 1339.91 1130.58C1358.12 1161.95 1367.25 1198.89 1367.25 1241.3C1367.25 1309.33 1347.62 1363.07 1308.36 1402.52C1269.1 1441.97 1215.6 1461.69 1147.94 1461.69C1080.29 1461.69 1026.47 1441.97 986.475 1402.52C946.53 1363.07 926.535 1309.33 926.535 1241.3C926.535 1198.89 935.62 1161.9 953.88 1130.35C972.095 1098.81 997.203 1074.24 1029.11 1056.71C1001.04 1039.54 979.171 1016.58 963.376 987.811C947.58 959.047 939.683 926.128 939.683 888.916C939.683 821.936 958.445 769.521 995.971 731.625C1033.45 693.729 1083.8 674.781 1146.89 674.781C1210.71 674.781 1261.2 693.912 1298.36 732.127C1335.52 770.343 1354.1 822.576 1354.1 888.871ZM1147.94 1338.05C1170.36 1338.05 1187.66 1328.46 1199.76 1309.38C1211.85 1290.29 1217.88 1263.72 1217.88 1229.71C1217.88 1195.69 1211.58 1169.07 1198.94 1149.76C1186.29 1130.45 1168.94 1120.81 1146.89 1120.81C1124.8 1120.81 1107.36 1130.45 1094.58 1149.76C1081.79 1169.07 1075.36 1195.69 1075.36 1229.71C1075.36 1263.72 1081.75 1290.29 1094.58 1309.38C1107.36 1328.51 1125.16 1338.05 1147.94 1338.05ZM1205.78 896.724C1205.78 866.909 1200.94 843.076 1191.31 825.224C1181.68 807.326 1166.89 798.377 1146.89 798.377C1127.95 798.377 1113.57 807.052 1103.8 824.402C1093.98 841.752 1089.05 865.859 1089.05 896.724C1089.05 926.904 1093.98 951.148 1103.8 969.594C1113.61 987.994 1128.31 997.217 1147.94 997.217C1167.57 997.217 1182.14 987.994 1191.59 969.594C1201.04 951.194 1205.78 926.904 1205.78 896.724Z"
fill="#424242"
/>
</svg>
);
}

@ -0,0 +1,22 @@
import type { SVGProps } from 'react';
export function FileIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
width={100}
height={100}
viewBox="0 0 48 48"
{...props}
>
<path fill="#90CAF9" d="M40 45L8 45 8 3 30 3 40 13z" />
<path fill="#E1F5FE" d="M38.5 14L29 14 29 4.5z" />
<path
fill="#1976D2"
d="M16 21H33V23H16zM16 25H29V27H16zM16 29H33V31H16zM16 33H29V35H16z"
/>
</svg>
);
}

@ -0,0 +1,40 @@
import type { SVGProps } from 'react';
export function GoogleCalendarIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
width="100"
height="100"
viewBox="0 0 48 48"
{...props}
>
<rect width="22" height="22" x="13" y="13" fill="#fff"></rect>
<polygon
fill="#1e88e5"
points="25.68,20.92 26.688,22.36 28.272,21.208 28.272,29.56 30,29.56 30,18.616 28.56,18.616"
></polygon>
<path
fill="#1e88e5"
d="M22.943,23.745c0.625-0.574,1.013-1.37,1.013-2.249c0-1.747-1.533-3.168-3.417-3.168 c-1.602,0-2.972,1.009-3.33,2.453l1.657,0.421c0.165-0.664,0.868-1.146,1.673-1.146c0.942,0,1.709,0.646,1.709,1.44 c0,0.794-0.767,1.44-1.709,1.44h-0.997v1.728h0.997c1.081,0,1.993,0.751,1.993,1.64c0,0.904-0.866,1.64-1.931,1.64 c-0.962,0-1.784-0.61-1.914-1.418L17,26.802c0.262,1.636,1.81,2.87,3.6,2.87c2.007,0,3.64-1.511,3.64-3.368 C24.24,25.281,23.736,24.363,22.943,23.745z"
></path>
<polygon
fill="#fbc02d"
points="34,42 14,42 13,38 14,34 34,34 35,38"
></polygon>
<polygon
fill="#4caf50"
points="38,35 42,34 42,14 38,13 34,14 34,34"
></polygon>
<path
fill="#1e88e5"
d="M34,14l1-4l-1-4H9C7.343,6,6,7.343,6,9v25l4,1l4-1V14H34z"
></path>
<polygon fill="#e53935" points="34,34 34,42 42,34"></polygon>
<path fill="#1565c0" d="M39,6h-5v8h8V9C42,7.343,40.657,6,39,6z"></path>
<path fill="#1565c0" d="M9,42h5v-8H6v5C6,40.657,7.343,42,9,42z"></path>
</svg>
);
}

@ -0,0 +1,40 @@
import type { SVGProps } from 'react';
export function OutlookCalendarIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
width={100}
height={100}
viewBox="0 0 48 48"
{...props}
>
<path
fill="#1976d2"
d="M28,13h14.533C43.343,13,44,13.657,44,14.467v19.066C44,34.343,43.343,35,42.533,35H28V13z"
/>
<rect width={14} height="15.542" x={28} y="17.958" fill="#fff" />
<polygon fill="#1976d2" points="27,44 4,39.5 4,8.5 27,4" />
<path
fill="#fff"
d="M15.25,16.5c-3.176,0-5.75,3.358-5.75,7.5s2.574,7.5,5.75,7.5S21,28.142,21,24 S18.426,16.5,15.25,16.5z M15,28.5c-1.657,0-3-2.015-3-4.5s1.343-4.5,3-4.5s3,2.015,3,4.5S16.657,28.5,15,28.5z"
/>
<rect width="2.7" height="2.9" x="28.047" y="29.737" fill="#1976d2" />
<rect width="2.7" height="2.9" x="31.448" y="29.737" fill="#1976d2" />
<rect width="2.7" height="2.9" x="34.849" y="29.737" fill="#1976d2" />
<rect width="2.7" height="2.9" x="28.047" y="26.159" fill="#1976d2" />
<rect width="2.7" height="2.9" x="31.448" y="26.159" fill="#1976d2" />
<rect width="2.7" height="2.9" x="34.849" y="26.159" fill="#1976d2" />
<rect width="2.7" height="2.9" x="38.25" y="26.159" fill="#1976d2" />
<rect width="2.7" height="2.9" x="28.047" y="22.706" fill="#1976d2" />
<rect width="2.7" height="2.9" x="31.448" y="22.706" fill="#1976d2" />
<rect width="2.7" height="2.9" x="34.849" y="22.706" fill="#1976d2" />
<rect width="2.7" height="2.9" x="38.25" y="22.706" fill="#1976d2" />
<rect width="2.7" height="2.9" x="31.448" y="19.112" fill="#1976d2" />
<rect width="2.7" height="2.9" x="34.849" y="19.112" fill="#1976d2" />
<rect width="2.7" height="2.9" x="38.25" y="19.112" fill="#1976d2" />
</svg>
);
}

@ -7,6 +7,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { TabLink } from './TabLink'; import { TabLink } from './TabLink';
import LoginPopup from './AuthenticationFlow/LoginPopup.astro'; import LoginPopup from './AuthenticationFlow/LoginPopup.astro';
import { ScheduleButton } from './Schedule/ScheduleButton';
import ProgressHelpPopup from './ProgressHelpPopup.astro'; import ProgressHelpPopup from './ProgressHelpPopup.astro';
import { MarkFavorite } from './FeaturedItems/MarkFavorite'; import { MarkFavorite } from './FeaturedItems/MarkFavorite';
import { type RoadmapFrontmatter } from '../lib/roadmap'; import { type RoadmapFrontmatter } from '../lib/roadmap';
@ -45,8 +46,6 @@ const roadmapTitle =
roadmapId === 'devops' roadmapId === 'devops'
? 'DevOps' ? 'DevOps'
: `${roadmapId.charAt(0).toUpperCase()}${roadmapId.slice(1)}`; : `${roadmapId.charAt(0).toUpperCase()}${roadmapId.slice(1)}`;
const hasTnsBanner = !!tnsBannerLink;
--- ---
<LoginPopup /> <LoginPopup />
@ -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' 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' client:only='react'
/> />
<ScheduleButton
resourceId={roadmapId}
resourceType='roadmap'
resourceTitle={title}
client:load
/>
<DownloadRoadmapButton roadmapId={roadmapId} client:idle /> <DownloadRoadmapButton roadmapId={roadmapId} client:idle />
<ShareRoadmapButton <ShareRoadmapButton
description={description} description={description}
@ -103,11 +108,11 @@ const hasTnsBanner = !!tnsBannerLink;
/> />
</div> </div>
</div> </div>
<div class='mb-5 mt-5 sm:mb-8 sm:mt-5'> <div class='mb-5 mt-5 sm:mb-12 sm:mt-12'>
<h1 class='mb-0.5 text-2xl font-bold sm:mb-2 sm:text-3xl'> <h1 class='mb-0.5 text-2xl font-bold sm:mb-3.5 sm:text-5xl'>
{title} {title}
</h1> </h1>
<p class='text-balance text-sm text-gray-500 sm:text-base'> <p class='text-balance text-sm text-gray-500 sm:text-lg'>
{description} {description}
</p> </p>
</div> </div>
@ -135,6 +140,7 @@ const hasTnsBanner = !!tnsBannerLink;
text='Suggest Changes' text='Suggest Changes'
isExternal={true} isExternal={true}
hideTextOnMobile={true} hideTextOnMobile={true}
isActive={false}
/> />
</div> </div>
</div> </div>

@ -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 && (
<ScheduleEventModal
onClose={() => {
setIsModalOpen(false);
}}
roadmapId={resourceId}
/>
)}
<button
className="inline-flex items-center justify-center gap-2 rounded-md bg-gray-200 px-3 py-1.5 text-xs font-medium hover:bg-gray-300 sm:text-sm"
onClick={() => {
setIsModalOpen(true);
}}
>
<Calendar className="h-4 w-4 flex-shrink-0" />
<span className="hidden sm:inline">Schedule Learning Time</span>
</button>
</>
);
}

@ -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 <strong>File &gt; Import</strong>, and choose the
downloaded file.
</>,
],
},
outlook: {
title: 'Add to Outlook Calendar',
steps: [
'Download the iCS File',
<>
Open Outlook and go to{' '}
<strong>File &gt; Open & Export &gt; Import/Export</strong>.
</>,
<>
In the Import and Export Wizard select{' '}
<strong>Import an iCalendar (.ics) or vCalendar file (.vcs)</strong>.
You can then choose to keep it a separate calendar or make it a new
calendar.
</>,
],
},
};
return (
<Modal
onClose={onClose}
bodyClassName="bg-transparent shadow-none"
wrapperClassName="h-auto max-w-lg"
overlayClassName="items-start md:items-center"
>
<div className="rounded-xl bg-white px-3">
<button
className="absolute right-4 top-4 text-gray-400 hover:text-black"
onClick={onClose}
>
<X className="h-4 w-4 stroke-[2.5]" />
</button>
<div className="flex flex-col items-center p-4 py-6 text-center">
{selectedCalendar && (
<CalendarSteps
title={stepDetails[selectedCalendar].title}
steps={stepDetails[selectedCalendar].steps}
onDownloadICS={handleDownloadICS}
onCancel={() => {
setSelectedCalendar(null);
}}
isLoading={isLoading}
/>
)}
{!selectedCalendar && (
<>
<h2 className="text-3xl font-semibold">Schedule Learning Time</h2>
<p className="mt-1.5 text-balance text-base text-gray-600">
Block some time on your calendar to stay consistent
</p>
<div className="mt-6 flex w-full flex-col gap-1">
<CalendarButton
icon={GoogleCalendarIcon}
label="Google Calendar"
onClick={handleGoogleCalendar}
isLoading={isLoading}
/>
<CalendarButton
icon={AppleCalendarIcon}
label="Apple Calendar"
onClick={() => {
setSelectedCalendar('apple');
}}
/>
<CalendarButton
icon={OutlookCalendarIcon}
label="Outlook Calendar"
onClick={() => {
setSelectedCalendar('outlook');
}}
/>
<div className="mx-auto my-4 text-base text-gray-600">
or download the iCS file and import it to your calendar app
</div>
<CalendarButton
icon={FileIcon}
label="Download File (.ics)"
onClick={handleDownloadICS}
/>
</div>
</>
)}
</div>
</div>
</Modal>
);
}
type SVGIcon = (props: SVGProps<SVGSVGElement>) => ReactNode;
type CalendarButtonProps = {
icon: LucideIcon | SVGIcon;
label: string;
isLoading?: boolean;
onClick: () => void;
};
function CalendarButton(props: CalendarButtonProps) {
const { icon: Icon, label, isLoading, onClick } = props;
return (
<button
className="flex w-full items-center justify-between gap-2 rounded-lg border px-3 py-3 leading-none hover:bg-gray-100 disabled:opacity-60 data-[loading='true']:cursor-progress"
data-loading={isLoading}
disabled={isLoading}
onClick={onClick}
>
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 shrink-0 stroke-[2.5]" />
{label}
</div>
<ChevronRight className="h-4 w-4 stroke-[2.5]" />
</button>
);
}
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 (
<div className="flex flex-col">
<h2 className="text-3xl font-semibold">{title}</h2>
<div className="mt-6 flex flex-col gap-2">
{steps.map((step, index) => (
<div key={index} className="flex items-baseline gap-3">
<div className="flex h-6 w-6 relative top-px text-sm shrink-0 items-center justify-center rounded-full bg-gray-200 text-gray-600">
{index + 1}
</div>
<div className="flex flex-col gap-1">
<p className="text-left text-base text-gray-800">{step}</p>
</div>
</div>
))}
</div>
<div className="mt-6 flex gap-2">
<button
className="flex-1 rounded-md border hover:bg-gray-100 border-gray-300 py-2 text-sm text-gray-600 disabled:opacity-60 data-[loading='true']:cursor-progress"
onClick={onCancel}
disabled={isLoading}
>
Go back
</button>
<button
className="flex-1 rounded-md bg-black py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-60 data-[loading='true']:cursor-progress"
onClick={onDownloadICS}
disabled={isLoading}
data-loading={isLoading}
>
Download
</button>
</div>
</div>
);
}

@ -33,8 +33,6 @@ export function ShareRoadmapButton(props: ShareRoadmapButtonProps) {
setIsDropdownOpen(false); setIsDropdownOpen(false);
}); });
const embedHtml = `<iframe src="https://roadmap.sh/r/embed?id=${roadmapId}" width="100%" height="500px" frameBorder="0"\n></iframe>`;
return ( return (
<div className="relative" ref={containerRef}> <div className="relative" ref={containerRef}>
{isEmbedModalOpen && ( {isEmbedModalOpen && (

@ -5,7 +5,7 @@ order: 5
renderer: 'editor' renderer: 'editor'
briefTitle: 'AI and Data Scientist' briefTitle: 'AI and Data Scientist'
briefDescription: 'Step by step guide to becoming an AI and Data Scientist in 2024' 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' description: 'Step by step guide to becoming an AI and Data Scientist in 2024'
hasTopics: true hasTopics: true
isNew: false isNew: false

@ -5,7 +5,7 @@ order: 4
renderer: 'editor' renderer: 'editor'
briefTitle: 'AI Engineer' briefTitle: 'AI Engineer'
briefDescription: 'Step by step guide to becoming an AI Engineer in 2024' 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' description: 'Step by step guide to becoming an AI Engineer in 2024'
hasTopics: true hasTopics: true
isNew: true isNew: true

@ -4,7 +4,7 @@ pdfUrl: '/pdfs/roadmaps/datastructures-and-algorithms.pdf'
order: 18 order: 18
briefTitle: 'Data Structures & Algorithms' briefTitle: 'Data Structures & Algorithms'
briefDescription: 'Step by step guide to learn Data Structures and Algorithms in 2024' 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' description: 'Step by step guide to learn Data Structures and Algorithms in 2024'
hasTopics: true hasTopics: true
isNew: false isNew: false

Loading…
Cancel
Save