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/RoadmapHeader.astro b/src/components/RoadmapHeader.astro
index 103f3dbda..2948b7ad4 100644
--- a/src/components/RoadmapHeader.astro
+++ b/src/components/RoadmapHeader.astro
@@ -12,6 +12,7 @@ import { MarkFavorite } from './FeaturedItems/MarkFavorite';
 import { type RoadmapFrontmatter } from '../lib/roadmap';
 import { ShareRoadmapButton } from './ShareRoadmapButton';
 import { DownloadRoadmapButton } from './DownloadRoadmapButton';
+import { ConnectCalendarButton } from './Schedule/ConnectCalendarButton';
 
 export interface Props {
   title: string;
@@ -127,6 +128,11 @@ const hasTnsBanner = !!tnsBannerLink;
           isActive={activeTab === 'projects'}
           badgeText={projectCount > 0 ? 'new' : 'soon'}
         />
+        <ConnectCalendarButton
+          resourceId={roadmapId}
+          resourceType='roadmap'
+          client:load
+        />
       </div>
 
       <TabLink
@@ -135,6 +141,7 @@ const hasTnsBanner = !!tnsBannerLink;
         text='Suggest Changes'
         isExternal={true}
         hideTextOnMobile={true}
+        isActive={false}
       />
     </div>
   </div>
diff --git a/src/components/Schedule/ConnectCalendarButton.tsx b/src/components/Schedule/ConnectCalendarButton.tsx
new file mode 100644
index 000000000..be20b41e4
--- /dev/null
+++ b/src/components/Schedule/ConnectCalendarButton.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 ConnectCalendarButtonProps = {
+  resourceId: string;
+  resourceType: ResourceType;
+};
+
+export function ConnectCalendarButton(props: ConnectCalendarButtonProps) {
+  const { resourceId, resourceType } = props;
+
+  const [isModalOpen, setIsModalOpen] = useState(false);
+
+  return (
+    <>
+      {isModalOpen && (
+        <ScheduleEventModal
+          onClose={() => {
+            setIsModalOpen(false);
+          }}
+        />
+      )}
+
+      <button
+        className={cn(
+          'group inline-flex items-center gap-1.5 border-b-2 border-b-transparent px-2 pb-2.5 text-sm font-normal text-gray-400 transition-colors hover:text-gray-700',
+        )}
+        onClick={() => {
+          setIsModalOpen(true);
+        }}
+      >
+        <Calendar className="h-4 w-4 flex-shrink-0" />
+        Schedule Learning
+      </button>
+    </>
+  );
+}
diff --git a/src/components/Schedule/ScheduleEventModal.tsx b/src/components/Schedule/ScheduleEventModal.tsx
new file mode 100644
index 000000000..fd33f1164
--- /dev/null
+++ b/src/components/Schedule/ScheduleEventModal.tsx
@@ -0,0 +1,67 @@
+import { useMutation } from '@tanstack/react-query';
+import { useListConnectedCalenders } from '../../hooks/use-schedule';
+import { Modal } from '../Modal';
+import { Calendar, Loader2, Plus, BookOpen, Trash2 } from 'lucide-react';
+import { httpPost } from '../../lib/query-http';
+import { queryClient } from '../../stores/query-client';
+import { useToast } from '../../hooks/use-toast';
+
+type ConnectGoogleCalendarResponse = {
+  redirectUrl: string;
+};
+
+type ScheduleEventModalProps = {
+  onClose: () => void;
+};
+
+export function ScheduleEventModal(props: ScheduleEventModalProps) {
+  const { onClose } = props;
+
+  const toast = useToast();
+  const { isLoading } = useListConnectedCalenders();
+
+  const connectGoogleCalendar = useMutation<ConnectGoogleCalendarResponse>(
+    {
+      mutationFn: async () => {
+        return httpPost('/v1-connect-google-calendar', {});
+      },
+      onSuccess(data) {
+        const { redirectUrl } = data;
+        if (!redirectUrl) {
+          return;
+        }
+
+        window.location.href = redirectUrl;
+      },
+      onError(error) {
+        toast.error(error?.message || 'Failed to connect Google Calendar');
+      },
+    },
+    queryClient,
+  );
+
+  return (
+    <Modal onClose={onClose}>
+      <div className="flex max-w-md flex-col items-center p-4 py-10 text-center">
+        <BookOpen className="text-primary h-14 w-14" />
+        <h2 className="mt-4 text-xl font-bold tracking-tight">
+          Learning Calendar
+        </h2>
+        <p className="mt-1 text-balance text-sm text-gray-600">
+          Link your Google Calendar to start scheduling your learning sessions.
+        </p>
+
+        <button
+          className="mt-4 flex items-center gap-2 rounded-full bg-black px-5 py-3 leading-none text-white disabled:opacity-60"
+          onClick={() => {
+            connectGoogleCalendar.mutate();
+          }}
+          disabled={connectGoogleCalendar.isPending}
+        >
+          <Calendar className="h-4 w-4 shrink-0 stroke-[2.5]" />
+          Connect Calendar
+        </button>
+      </div>
+    </Modal>
+  );
+}
diff --git a/src/hooks/use-schedule.ts b/src/hooks/use-schedule.ts
new file mode 100644
index 000000000..ea10e5ac6
--- /dev/null
+++ b/src/hooks/use-schedule.ts
@@ -0,0 +1,31 @@
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { queryClient } from '../stores/query-client';
+import { httpGet, httpPost } from '../lib/query-http';
+import { isLoggedIn } from '../lib/jwt';
+
+export interface CalendarDocument {
+  _id: string;
+
+  userId: string;
+  externalId: string;
+  credentials: {};
+
+  createdAt: Date;
+  updatedAt: Date;
+}
+
+type ListConnectedCalendarsResponse = Omit<CalendarDocument, 'credentials'>[];
+
+export function useListConnectedCalenders() {
+  return useQuery<ListConnectedCalendarsResponse>(
+    {
+      queryKey: ['connected-calendars'],
+      queryFn: async () => {
+        return httpGet('/v1-list-connected-calendars');
+      },
+      enabled: !!isLoggedIn(),
+      staleTime: 1000 * 60 * 5,
+    },
+    queryClient,
+  );
+}
diff --git a/src/lib/query-http.ts b/src/lib/query-http.ts
new file mode 100644
index 000000000..64ba4db7c
--- /dev/null
+++ b/src/lib/query-http.ts
@@ -0,0 +1,146 @@
+import Cookies from 'js-cookie';
+import fp from '@fingerprintjs/fingerprintjs';
+import { TOKEN_COOKIE_NAME, removeAuthToken } from './jwt.ts';
+
+type HttpOptionsType = RequestInit;
+
+type AppResponse = Record<string, any>;
+
+export interface FetchError extends Error {
+  status: number;
+  message: string;
+}
+
+type AppError = {
+  status: number;
+  message: string;
+  errors?: { message: string; location: string }[];
+};
+
+type ApiReturn<ResponseType> = ResponseType;
+
+/**
+ * Wrapper around fetch to make it easy to handle errors
+ *
+ * @param url
+ * @param options
+ */
+export async function httpCall<ResponseType = AppResponse>(
+  url: string,
+  options?: HttpOptionsType,
+): Promise<ApiReturn<ResponseType>> {
+  const fullUrl = url.startsWith('http')
+    ? url
+    : `${import.meta.env.PUBLIC_API_URL}${url}`;
+  try {
+    const fingerprintPromise = await fp.load();
+    const fingerprint = await fingerprintPromise.get();
+
+    const isMultiPartFormData = options?.body instanceof FormData;
+
+    const headers = new Headers({
+      Accept: 'application/json',
+      Authorization: `Bearer ${Cookies.get(TOKEN_COOKIE_NAME)}`,
+      fp: fingerprint.visitorId,
+      ...(options?.headers ?? {}),
+    });
+
+    if (!isMultiPartFormData) {
+      headers.set('Content-Type', 'application/json');
+    }
+
+    const response = await fetch(fullUrl, {
+      credentials: 'include',
+      ...options,
+      headers,
+    });
+
+    // @ts-ignore
+    const doesAcceptHtml = options?.headers?.['Accept'] === 'text/html';
+
+    const data = doesAcceptHtml ? await response.text() : await response.json();
+
+    // Logout user if token is invalid
+    if (data?.status === 401) {
+      removeAuthToken();
+      window.location.href = '/login';
+      return null as unknown as ApiReturn<ResponseType>;
+    }
+
+    if (!response.ok) {
+      if (data.errors) {
+        const error = new Error() as FetchError;
+        error.message = data.message;
+        error.status = response?.status;
+        throw error;
+      } else {
+        throw new Error('An unexpected error occurred');
+      }
+    }
+
+    return data as ResponseType;
+  } catch (error: any) {
+    throw error;
+  }
+}
+
+export async function httpPost<ResponseType = AppResponse>(
+  url: string,
+  body: Record<string, any>,
+  options?: HttpOptionsType,
+): Promise<ApiReturn<ResponseType>> {
+  return httpCall<ResponseType>(url, {
+    ...options,
+    method: 'POST',
+    body: body instanceof FormData ? body : JSON.stringify(body),
+  });
+}
+
+export async function httpGet<ResponseType = AppResponse>(
+  url: string,
+  queryParams?: Record<string, any>,
+  options?: HttpOptionsType,
+): Promise<ApiReturn<ResponseType>> {
+  const searchParams = new URLSearchParams(queryParams).toString();
+  const queryUrl = searchParams ? `${url}?${searchParams}` : url;
+
+  return httpCall<ResponseType>(queryUrl, {
+    credentials: 'include',
+    method: 'GET',
+    ...options,
+  });
+}
+
+export async function httpPatch<ResponseType = AppResponse>(
+  url: string,
+  body: Record<string, any>,
+  options?: HttpOptionsType,
+): Promise<ApiReturn<ResponseType>> {
+  return httpCall<ResponseType>(url, {
+    ...options,
+    method: 'PATCH',
+    body: JSON.stringify(body),
+  });
+}
+
+export async function httpPut<ResponseType = AppResponse>(
+  url: string,
+  body: Record<string, any>,
+  options?: HttpOptionsType,
+): Promise<ApiReturn<ResponseType>> {
+  return httpCall<ResponseType>(url, {
+    ...options,
+    method: 'PUT',
+    body: JSON.stringify(body),
+  });
+}
+
+export async function httpDelete<ResponseType = AppResponse>(
+  url: string,
+  options?: HttpOptionsType,
+): Promise<ApiReturn<ResponseType>> {
+  return httpCall<ResponseType>(url, {
+    ...options,
+    method: 'DELETE',
+  });
+}
diff --git a/src/stores/query-client.ts b/src/stores/query-client.ts
new file mode 100644
index 000000000..210d1d733
--- /dev/null
+++ b/src/stores/query-client.ts
@@ -0,0 +1,11 @@
+import { QueryCache, QueryClient } from '@tanstack/react-query';
+
+export const queryClient = new QueryClient({
+  queryCache: new QueryCache({}),
+  defaultOptions: {
+    queries: {
+      retry: false,
+      enabled: !import.meta.env.SSR,
+    },
+  },
+});