From 2a6c1bfce8a9ff2dcb0cfc2e6a8e8c1e597c240f Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Fri, 8 Nov 2024 22:48:09 +0600 Subject: [PATCH] feat: implement calendar scheduling (#7574) * wip * feat: add calendar scheduling * fix: update names * UI Changes for calendar scheduling --------- Co-authored-by: Kamran Ahmed --- .astro/settings.json | 2 +- package.json | 1 + pnpm-lock.yaml | 18 + .../FrameRenderer/ProgressNudge.tsx | 115 ++++--- .../ReactIcons/AppleCalendarIcon.tsx | 37 ++ src/components/ReactIcons/FileIcon.tsx | 22 ++ .../ReactIcons/GoogleCalendarIcon.tsx | 40 +++ .../ReactIcons/OutlookCalendarIcon.tsx | 40 +++ src/components/RoadmapHeader.astro | 16 +- src/components/Schedule/ScheduleButton.tsx | 40 +++ .../Schedule/ScheduleEventModal.tsx | 325 ++++++++++++++++++ src/components/ShareRoadmapButton.tsx | 2 - .../ai-data-scientist/ai-data-scientist.md | 2 +- src/data/roadmaps/ai-engineer/ai-engineer.md | 2 +- .../datastructures-and-algorithms.md | 2 +- 15 files changed, 614 insertions(+), 50 deletions(-) create mode 100644 src/components/ReactIcons/AppleCalendarIcon.tsx create mode 100644 src/components/ReactIcons/FileIcon.tsx create mode 100644 src/components/ReactIcons/GoogleCalendarIcon.tsx create mode 100644 src/components/ReactIcons/OutlookCalendarIcon.tsx create mode 100644 src/components/Schedule/ScheduleButton.tsx create mode 100644 src/components/Schedule/ScheduleEventModal.tsx 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 ( - + ); } 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} + /> + )} + + + + ); +} 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 ( + + ); +} + +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) => ( +
+
+ {index + 1} +
+
+

{step}

+
+
+ ))} +
+ +
+ + +
+
+ ); +} 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