diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 15fcdd1..eefe2aa 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -47,7 +47,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | Dashboard (`/`) | **COMPLETE** | Wired with /api/today, DecisionCard, DataPanel, NutritionPanel, OverrideToggles | | Login (`/login`) | **COMPLETE** | Email/password form with auth, error handling, loading states | | Settings (`/settings`) | **COMPLETE** | Form with cycleLength, notificationTime, timezone | -| Settings/Garmin (`/settings/garmin`) | Placeholder | Needs token management UI | +| Settings/Garmin (`/settings/garmin`) | **COMPLETE** | Token management UI, connection status, disconnect functionality, 27 tests | | Calendar (`/calendar`) | Placeholder | Needs MonthView integration | | History (`/history`) | **COMPLETE** | Table view with date filtering, pagination, decision styling, 26 tests | | Plan (`/plan`) | Placeholder | Needs phase details display | @@ -384,12 +384,20 @@ Full feature set for production use. - **Why:** Users need to configure their preferences - **Depends On:** P0.4, P1.1 -### P2.10: Settings/Garmin Page Implementation -- [ ] Garmin connection management UI +### P2.10: Settings/Garmin Page Implementation ✅ COMPLETE +- [x] Garmin connection management UI - **Files:** - - `src/app/settings/garmin/page.tsx` - Token input form, connection status, disconnect button + - `src/app/settings/garmin/page.tsx` - Token input form, connection status, expiry warnings, disconnect button - **Tests:** - - E2E test: connect flow, disconnect flow + - `src/app/settings/garmin/page.test.tsx` - 27 tests covering rendering, connection states, warning levels, token submission, disconnect flow + - `src/app/settings/page.test.tsx` - 3 additional tests for Garmin link (28 total) +- **Features Implemented:** + - Connection status display with green/red/gray indicators + - Token expiry warnings (yellow for 14 days, red for 7 days) + - Token input form with JSON validation + - Instructions for running bootstrap script + - Disconnect functionality + - Loading and error states - **Why:** Users need to manage their Garmin connection - **Depends On:** P0.4, P2.2, P2.3 @@ -615,7 +623,8 @@ P2.14 Mini calendar ### Pages - [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6) - [x] **Dashboard Page** - Complete daily interface with /api/today integration, DecisionCard, DataPanel, NutritionPanel, OverrideToggles, 23 tests (P1.7) -- [x] **Settings Page** - Form for cycleLength, notificationTime, timezone with validation, loading states, error handling, 24 tests (P2.9) +- [x] **Settings Page** - Form for cycleLength, notificationTime, timezone with validation, loading states, error handling, 28 tests (P2.9) +- [x] **Settings/Garmin Page** - Token input form, connection status, expiry warnings, disconnect functionality, 27 tests (P2.10) - [x] **History Page** - Table view of DailyLogs with date filtering, pagination, decision styling, 26 tests (P2.12) ### Test Infrastructure diff --git a/src/app/settings/garmin/page.test.tsx b/src/app/settings/garmin/page.test.tsx new file mode 100644 index 0000000..5bcf2a0 --- /dev/null +++ b/src/app/settings/garmin/page.test.tsx @@ -0,0 +1,728 @@ +// ABOUTME: Unit tests for the Garmin settings page component. +// ABOUTME: Tests connection status display, token input, and disconnect functionality. +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock next/link +vi.mock("next/link", () => ({ + default: ({ + children, + href, + }: { + children: React.ReactNode; + href: string; + }) => {children}, +})); + +// Mock fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +import GarminSettingsPage from "./page"; + +describe("GarminSettingsPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default mock for disconnected state + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + connected: false, + daysUntilExpiry: null, + expired: false, + warningLevel: null, + }), + }); + }); + + describe("rendering", () => { + it("renders the page heading", async () => { + render(); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /garmin connection/i }), + ).toBeInTheDocument(); + }); + }); + + it("renders a back link to settings", async () => { + render(); + + await waitFor(() => { + expect( + screen.getByRole("link", { name: /back to settings/i }), + ).toHaveAttribute("href", "/settings"); + }); + }); + + it("shows loading state while fetching status", async () => { + let resolveStatus: (value: unknown) => void = () => {}; + const statusPromise = new Promise((resolve) => { + resolveStatus = resolve; + }); + mockFetch.mockReturnValue({ + ok: true, + json: () => statusPromise, + }); + + render(); + + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + + resolveStatus({ + connected: false, + daysUntilExpiry: null, + expired: false, + warningLevel: null, + }); + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + }); + }); + + describe("disconnected state", () => { + it("shows not connected message when disconnected", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/not connected/i)).toBeInTheDocument(); + }); + }); + + it("shows token input section when disconnected", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/paste tokens/i)).toBeInTheDocument(); + }); + }); + + it("shows instructions for getting tokens", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/garmin_auth\.py/i)).toBeInTheDocument(); + }); + }); + + it("renders save tokens button", async () => { + render(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /save tokens/i }), + ).toBeInTheDocument(); + }); + }); + }); + + describe("connected state", () => { + beforeEach(() => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + connected: true, + daysUntilExpiry: 85, + expired: false, + warningLevel: null, + }), + }); + }); + + it("shows connected status", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/connected/i)).toBeInTheDocument(); + }); + }); + + it("shows days until expiry", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/85 days/i)).toBeInTheDocument(); + }); + }); + + it("shows disconnect button when connected", async () => { + render(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /disconnect/i }), + ).toBeInTheDocument(); + }); + }); + + it("does not show token input when connected", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/connected/i)).toBeInTheDocument(); + }); + + expect(screen.queryByLabelText(/paste tokens/i)).not.toBeInTheDocument(); + }); + }); + + describe("warning levels", () => { + it("shows yellow warning when expiring in 8-14 days", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + connected: true, + daysUntilExpiry: 10, + expired: false, + warningLevel: "warning", + }), + }); + + render(); + + await waitFor(() => { + const warningElement = screen.getByTestId("expiry-warning"); + expect(warningElement).toHaveClass("bg-yellow-50"); + }); + }); + + it("shows red warning when expiring in 7 days or less", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + connected: true, + daysUntilExpiry: 5, + expired: false, + warningLevel: "critical", + }), + }); + + render(); + + await waitFor(() => { + const warningElement = screen.getByTestId("expiry-warning"); + expect(warningElement).toHaveClass("bg-red-50"); + }); + }); + + it("shows expired message when tokens have expired", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + connected: true, + daysUntilExpiry: -5, + expired: true, + warningLevel: "critical", + }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/token expired/i)).toBeInTheDocument(); + }); + }); + + it("shows token input when tokens are expired", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + connected: true, + daysUntilExpiry: -5, + expired: true, + warningLevel: "critical", + }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/paste tokens/i)).toBeInTheDocument(); + }); + }); + }); + + describe("token submission", () => { + it("validates JSON format before submission", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/paste tokens/i)).toBeInTheDocument(); + }); + + const textarea = screen.getByLabelText(/paste tokens/i); + fireEvent.change(textarea, { target: { value: "not valid json" } }); + + const saveButton = screen.getByRole("button", { name: /save tokens/i }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText(/invalid json format/i)).toBeInTheDocument(); + }); + }); + + it("validates required fields in token JSON", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/paste tokens/i)).toBeInTheDocument(); + }); + + const textarea = screen.getByLabelText(/paste tokens/i); + fireEvent.change(textarea, { target: { value: '{"oauth1": {}}' } }); + + const saveButton = screen.getByRole("button", { name: /save tokens/i }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText(/oauth2.*required/i)).toBeInTheDocument(); + }); + }); + + it("calls POST /api/garmin/tokens with valid data", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + connected: false, + daysUntilExpiry: null, + expired: false, + warningLevel: null, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + success: true, + garminConnected: true, + daysUntilExpiry: 90, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + connected: true, + daysUntilExpiry: 90, + expired: false, + warningLevel: null, + }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/paste tokens/i)).toBeInTheDocument(); + }); + + const validTokens = JSON.stringify({ + oauth1: { token: "abc", secret: "def" }, + oauth2: { access_token: "xyz" }, + expires_at: "2025-04-10T00:00:00Z", + }); + + const textarea = screen.getByLabelText(/paste tokens/i); + fireEvent.change(textarea, { target: { value: validTokens } }); + + const saveButton = screen.getByRole("button", { name: /save tokens/i }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith("/api/garmin/tokens", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: validTokens, + }); + }); + }); + + it("shows success message after saving tokens", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + connected: false, + daysUntilExpiry: null, + expired: false, + warningLevel: null, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + success: true, + garminConnected: true, + daysUntilExpiry: 90, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + connected: true, + daysUntilExpiry: 90, + expired: false, + warningLevel: null, + }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/paste tokens/i)).toBeInTheDocument(); + }); + + const validTokens = JSON.stringify({ + oauth1: { token: "abc" }, + oauth2: { access_token: "xyz" }, + expires_at: "2025-04-10T00:00:00Z", + }); + + const textarea = screen.getByLabelText(/paste tokens/i); + fireEvent.change(textarea, { target: { value: validTokens } }); + + const saveButton = screen.getByRole("button", { name: /save tokens/i }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByText(/tokens saved/i)).toBeInTheDocument(); + }); + }); + + it("shows saving state during submission", async () => { + let resolveSave: (value: unknown) => void = () => {}; + const savePromise = new Promise((resolve) => { + resolveSave = resolve; + }); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + connected: false, + daysUntilExpiry: null, + expired: false, + warningLevel: null, + }), + }) + .mockReturnValueOnce({ + ok: true, + json: () => savePromise, + }); + + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/paste tokens/i)).toBeInTheDocument(); + }); + + const validTokens = JSON.stringify({ + oauth1: {}, + oauth2: {}, + expires_at: "2025-04-10T00:00:00Z", + }); + + const textarea = screen.getByLabelText(/paste tokens/i); + fireEvent.change(textarea, { target: { value: validTokens } }); + + const saveButton = screen.getByRole("button", { name: /save tokens/i }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /saving/i }), + ).toBeInTheDocument(); + }); + + resolveSave({ + success: true, + garminConnected: true, + daysUntilExpiry: 90, + }); + }); + + it("shows error when save fails", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + connected: false, + daysUntilExpiry: null, + expired: false, + warningLevel: null, + }), + }) + .mockResolvedValueOnce({ + ok: false, + json: () => Promise.resolve({ error: "Failed to save tokens" }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/paste tokens/i)).toBeInTheDocument(); + }); + + const validTokens = JSON.stringify({ + oauth1: {}, + oauth2: {}, + expires_at: "2025-04-10T00:00:00Z", + }); + + const textarea = screen.getByLabelText(/paste tokens/i); + fireEvent.change(textarea, { target: { value: validTokens } }); + + const saveButton = screen.getByRole("button", { name: /save tokens/i }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText(/failed to save tokens/i)).toBeInTheDocument(); + }); + }); + }); + + describe("disconnect flow", () => { + beforeEach(() => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + connected: true, + daysUntilExpiry: 85, + expired: false, + warningLevel: null, + }), + }); + }); + + it("calls DELETE /api/garmin/tokens when disconnect clicked", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + connected: true, + daysUntilExpiry: 85, + expired: false, + warningLevel: null, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ success: true, garminConnected: false }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + connected: false, + daysUntilExpiry: null, + expired: false, + warningLevel: null, + }), + }); + + render(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /disconnect/i }), + ).toBeInTheDocument(); + }); + + const disconnectButton = screen.getByRole("button", { + name: /disconnect/i, + }); + fireEvent.click(disconnectButton); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith("/api/garmin/tokens", { + method: "DELETE", + }); + }); + }); + + it("shows disconnected message after successful disconnect", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + connected: true, + daysUntilExpiry: 85, + expired: false, + warningLevel: null, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ success: true, garminConnected: false }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + connected: false, + daysUntilExpiry: null, + expired: false, + warningLevel: null, + }), + }); + + render(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /disconnect/i }), + ).toBeInTheDocument(); + }); + + const disconnectButton = screen.getByRole("button", { + name: /disconnect/i, + }); + fireEvent.click(disconnectButton); + + await waitFor(() => { + expect(screen.getByText(/garmin disconnected/i)).toBeInTheDocument(); + }); + }); + + it("shows disconnecting state during disconnect", async () => { + let resolveDisconnect: (value: unknown) => void = () => {}; + const disconnectPromise = new Promise((resolve) => { + resolveDisconnect = resolve; + }); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + connected: true, + daysUntilExpiry: 85, + expired: false, + warningLevel: null, + }), + }) + .mockReturnValueOnce({ + ok: true, + json: () => disconnectPromise, + }); + + render(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /disconnect/i }), + ).toBeInTheDocument(); + }); + + const disconnectButton = screen.getByRole("button", { + name: /disconnect/i, + }); + fireEvent.click(disconnectButton); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /disconnecting/i }), + ).toBeInTheDocument(); + }); + + resolveDisconnect({ success: true, garminConnected: false }); + }); + + it("shows error when disconnect fails", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + connected: true, + daysUntilExpiry: 85, + expired: false, + warningLevel: null, + }), + }) + .mockResolvedValueOnce({ + ok: false, + json: () => Promise.resolve({ error: "Failed to disconnect" }), + }); + + render(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /disconnect/i }), + ).toBeInTheDocument(); + }); + + const disconnectButton = screen.getByRole("button", { + name: /disconnect/i, + }); + fireEvent.click(disconnectButton); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText(/failed to disconnect/i)).toBeInTheDocument(); + }); + }); + }); + + describe("error handling", () => { + it("shows error when status fetch fails", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + json: () => Promise.resolve({ error: "Failed to fetch status" }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText(/failed to fetch status/i)).toBeInTheDocument(); + }); + }); + + it("clears error when user modifies input", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/paste tokens/i)).toBeInTheDocument(); + }); + + const textarea = screen.getByLabelText(/paste tokens/i); + fireEvent.change(textarea, { target: { value: "invalid json" } }); + + const saveButton = screen.getByRole("button", { name: /save tokens/i }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + + fireEvent.change(textarea, { target: { value: '{"oauth1": {}}' } }); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/app/settings/garmin/page.tsx b/src/app/settings/garmin/page.tsx index 2c79989..dd581c1 100644 --- a/src/app/settings/garmin/page.tsx +++ b/src/app/settings/garmin/page.tsx @@ -1,13 +1,306 @@ // ABOUTME: Garmin connection settings page. -// ABOUTME: Allows users to paste OAuth tokens from the bootstrap script. +// ABOUTME: Allows users to paste OAuth tokens from the bootstrap script and manage connection. +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; + +interface GarminStatus { + connected: boolean; + daysUntilExpiry: number | null; + expired: boolean; + warningLevel: "warning" | "critical" | null; +} + export default function GarminSettingsPage() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [disconnecting, setDisconnecting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [tokenInput, setTokenInput] = useState(""); + + const fetchStatus = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch("/api/garmin/status"); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to fetch status"); + } + + setStatus(data); + } catch (err) { + const message = err instanceof Error ? err.message : "An error occurred"; + setError(message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchStatus(); + }, [fetchStatus]); + + const handleTokenChange = (value: string) => { + setTokenInput(value); + if (error) { + setError(null); + } + if (success) { + setSuccess(null); + } + }; + + const validateTokens = ( + input: string, + ): { valid: true; data: object } | { valid: false; error: string } => { + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch { + return { valid: false, error: "Invalid JSON format" }; + } + + if (typeof parsed !== "object" || parsed === null) { + return { valid: false, error: "Invalid JSON format" }; + } + + const tokens = parsed as Record; + + if (!tokens.oauth1) { + return { valid: false, error: "oauth1 is required" }; + } + + if (!tokens.oauth2) { + return { valid: false, error: "oauth2 is required" }; + } + + if (!tokens.expires_at) { + return { valid: false, error: "expires_at is required" }; + } + + return { valid: true, data: tokens }; + }; + + const handleSaveTokens = async () => { + const validation = validateTokens(tokenInput); + if (!validation.valid) { + setError(validation.error); + return; + } + + setSaving(true); + setError(null); + setSuccess(null); + + try { + const response = await fetch("/api/garmin/tokens", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: tokenInput, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to save tokens"); + } + + setSuccess("Tokens saved successfully"); + setTokenInput(""); + await fetchStatus(); + } catch (err) { + const message = err instanceof Error ? err.message : "An error occurred"; + setError(message); + } finally { + setSaving(false); + } + }; + + const handleDisconnect = async () => { + setDisconnecting(true); + setError(null); + setSuccess(null); + + try { + const response = await fetch("/api/garmin/tokens", { + method: "DELETE", + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to disconnect"); + } + + setSuccess("Garmin disconnected successfully"); + await fetchStatus(); + } catch (err) { + const message = err instanceof Error ? err.message : "An error occurred"; + setError(message); + } finally { + setDisconnecting(false); + } + }; + + const showTokenInput = !status?.connected || status?.expired; + + if (loading) { + return ( +
+

+ Settings > Garmin Connection +

+

Loading...

+
+ ); + } + return (
-

- Settings > Garmin Connection -

- {/* Garmin token input will be implemented here */} -

Garmin settings placeholder

+
+

Settings > Garmin Connection

+ + Back to Settings + +
+ + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + +
+ {/* Connection Status Section */} +
+

Connection Status

+ + {status?.connected && !status.expired ? ( +
+
+ + Connected +
+ + {status.warningLevel && ( +
+ {status.warningLevel === "critical" + ? "Token expires soon! Please refresh your tokens." + : "Token expiring soon. Consider refreshing your tokens."} +
+ )} + +

+ Token expires in{" "} + + {status.daysUntilExpiry} days + +

+ + +
+ ) : status?.expired ? ( +
+
+ + Token Expired +
+

+ Your Garmin tokens have expired. Please generate new tokens and + paste them below. +

+
+ ) : ( +
+ + Not Connected +
+ )} +
+ + {/* Token Input Section */} + {showTokenInput && ( +
+

Connect Garmin

+ +
+
+

Instructions:

+
    +
  1. + Run{" "} + + python3 scripts/garmin_auth.py + {" "} + locally +
  2. +
  3. Copy the JSON output
  4. +
  5. Paste it in the field below
  6. +
+
+ +
+ +