+
+
+
Bieganie
+
Aktywności zsynchronizowane z Garmin Connect.
+
+
+
+
+ {activities.length === 0 ? (
+
}
+ title="Brak biegów"
+ description="Zsynchronizuj aktywności z Garmin Connect, aby zobaczyć tutaj swoje biegi."
+ />
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/app/settings/page.tsx b/app/settings/page.tsx
new file mode 100644
index 0000000..0a8d40c
--- /dev/null
+++ b/app/settings/page.tsx
@@ -0,0 +1,59 @@
+import { CheckCircle2, XCircle } from "lucide-react";
+import { SyncButton } from "@/components/sync-button";
+import { formatDate } from "@/lib/format";
+import { getLastSyncAt } from "@/lib/models/running";
+
+export const dynamic = "force-dynamic";
+
+function ConfigRow({ label, configured }: { label: string; configured: boolean }) {
+ return (
+
+
+
{workout.name}
+
{formatDate(workout.date)}
+ {workout.notes ?
{workout.notes}
: null}
+
+
+
+
+
+ {exercisesWithHistory.map(({ exercise }, index) => (
+
+
{exercise.name}
+ {exercise.notes ?
{exercise.notes}
: null}
+
+ {exercise.sets.map((set) => (
+
+ {set.reps ?? "?"}×{set.weightKg !== undefined ? `${set.weightKg} kg` : "—"}
+
+ ))}
+
+
+ ))}
+
+
+ {exercisesWithHistory.some(({ history }) => history.length >= 2) ? (
+
+
+ Postęp ćwiczeń
+
+
+
+ {exercisesWithHistory
+ .filter(({ history }) => history.length >= 2)
+ .map(({ exercise, history }) => (
+ ({
+ label: formatDateShort(point.date),
+ volumeKg: point.volumeKg,
+ topWeightKg: point.topWeightKg,
+ }))}
+ />
+ ))}
+
+
+ ) : null}
+
+ {workout.sourceUrl ? (
+
+ {workout.sourceUrl}
+
+ ) : null}
+
+ );
+}
diff --git a/app/strength/import/actions.ts b/app/strength/import/actions.ts
new file mode 100644
index 0000000..080e2ea
--- /dev/null
+++ b/app/strength/import/actions.ts
@@ -0,0 +1,37 @@
+"use server";
+
+import { redirect } from "next/navigation";
+import { revalidatePath } from "next/cache";
+import { parseStrongShareText } from "@/lib/strong/parser";
+import { upsertStrengthWorkout } from "@/lib/models/strength";
+
+export type ImportStrongWorkoutState = { error: string } | null;
+
+export async function importStrongWorkout(
+ _prevState: ImportStrongWorkoutState,
+ formData: FormData
+): Promise {
+ const text = formData.get("text");
+ if (typeof text !== "string" || text.trim().length === 0) {
+ return { error: "Wklej tekst wygenerowany przez funkcję 'Share workout' w Strong." };
+ }
+
+ let workouts;
+ try {
+ workouts = parseStrongShareText(text);
+ } catch (error) {
+ return { error: error instanceof Error ? error.message : "Nie udało się przetworzyć tekstu." };
+ }
+
+ if (workouts.length === 0) {
+ return { error: "Nie znaleziono żadnego treningu w podanym tekście." };
+ }
+
+ for (const workout of workouts) {
+ await upsertStrengthWorkout(workout);
+ }
+
+ revalidatePath("/strength");
+ revalidatePath("/");
+ redirect("/strength");
+}
diff --git a/app/strength/import/import-form.tsx b/app/strength/import/import-form.tsx
new file mode 100644
index 0000000..c0867f8
--- /dev/null
+++ b/app/strength/import/import-form.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { useActionState } from "react";
+import { importStrongWorkout } from "./actions";
+
+export function ImportForm() {
+ const [state, formAction, pending] = useActionState(importStrongWorkout, null);
+
+ return (
+
+ );
+}
diff --git a/app/strength/import/page.tsx b/app/strength/import/page.tsx
new file mode 100644
index 0000000..4dcdf90
--- /dev/null
+++ b/app/strength/import/page.tsx
@@ -0,0 +1,17 @@
+import { ImportForm } from "./import-form";
+
+export default function StrengthImportPage() {
+ return (
+
+
+
Importuj trening
+
+ W aplikacji Strong otwórz zakończony trening, wybierz „Share workout”
+ i wklej poniżej skopiowany tekst. Można wkleić kilka treningów na raz.
+
+
+
+
+
+ );
+}
diff --git a/app/strength/page.tsx b/app/strength/page.tsx
new file mode 100644
index 0000000..3fbd8ec
--- /dev/null
+++ b/app/strength/page.tsx
@@ -0,0 +1,70 @@
+import Link from "next/link";
+import { Plus } from "lucide-react";
+import { EmptyState } from "@/components/empty-state";
+import { VolumeChart } from "@/components/volume-chart";
+import { formatDateShort } from "@/lib/format";
+import { listStrengthWorkouts } from "@/lib/models/strength";
+import { workoutVolumeKg } from "@/lib/strength/stats";
+
+export const dynamic = "force-dynamic";
+
+const VOLUME_CHART_LIMIT = 12;
+
+export default async function StrengthPage() {
+ const workouts = await listStrengthWorkouts();
+
+ const volumeData = workouts
+ .slice(0, VOLUME_CHART_LIMIT)
+ .map((workout) => ({ label: formatDateShort(workout.date), volumeKg: workoutVolumeKg(workout) }))
+ .reverse();
+
+ return (
+
+
+
+
Siłownia
+
+ Treningi zaimportowane z aplikacji Strong.
+
+
+
+
+ Importuj
+
+
+
+ {volumeData.length > 1 ?
: null}
+
+ {workouts.length === 0 ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/components/ai-analysis-card.tsx b/components/ai-analysis-card.tsx
new file mode 100644
index 0000000..6aca758
--- /dev/null
+++ b/components/ai-analysis-card.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { useActionState } from "react";
+import { Sparkles } from "lucide-react";
+import { generateAnalysisAction } from "@/app/ai/actions";
+import { formatDate } from "@/lib/format";
+import type { AiAnalysis, AiAnalysisTargetType } from "@/lib/models/analysis";
+
+type AiAnalysisCardProps = {
+ targetType: AiAnalysisTargetType;
+ targetId: string;
+ analysis: AiAnalysis | null;
+};
+
+export function AiAnalysisCard({ targetType, targetId, analysis }: AiAnalysisCardProps) {
+ const [state, formAction, pending] = useActionState(
+ () => generateAnalysisAction(targetType, targetId),
+ null
+ );
+
+ return (
+
+
+
+
+ Analiza AI
+
+
+
+
+ {state && "error" in state ? {state.error}
: null}
+
+ {analysis ? (
+
+
{analysis.summary}
+ {analysis.tips.length > 0 ? (
+
+ {analysis.tips.map((tip, index) => (
+ - {tip}
+ ))}
+
+ ) : null}
+
+ {formatDate(analysis.createdAt)} · {analysis.model}
+
+
+ ) : (
+ Brak analizy. Wygeneruj podsumowanie i wskazówki AI.
+ )}
+
+ );
+}
diff --git a/components/dashboard-analysis-card.tsx b/components/dashboard-analysis-card.tsx
new file mode 100644
index 0000000..7ae9f1a
--- /dev/null
+++ b/components/dashboard-analysis-card.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { useActionState } from "react";
+import { Sparkles } from "lucide-react";
+import { generateDashboardAnalysisAction } from "@/app/ai/actions";
+import { formatDate } from "@/lib/format";
+import type { AiAnalysis } from "@/lib/models/analysis";
+
+type Props = {
+ analysis: AiAnalysis | null;
+};
+
+export function DashboardAnalysisCard({ analysis }: Props) {
+ const [state, formAction, pending] = useActionState(generateDashboardAnalysisAction, null);
+
+ return (
+
+
+
+
+ Kondycja treningowa
+
+
+
+
+ {state && "error" in state ? {state.error}
: null}
+
+ {analysis ? (
+
+
{analysis.summary}
+ {analysis.tips.length > 0 ? (
+
+ {analysis.tips.map((tip, index) => (
+ - {tip}
+ ))}
+
+ ) : null}
+
+ {formatDate(analysis.createdAt)} · {analysis.model}
+
+
+ ) : (
+
+ Wygeneruj kompleksową analizę kondycji łączącą dane biegowe, siłowe, HRV i sen.
+
+ )}
+
+ );
+}
diff --git a/components/empty-state.tsx b/components/empty-state.tsx
new file mode 100644
index 0000000..e127ce0
--- /dev/null
+++ b/components/empty-state.tsx
@@ -0,0 +1,27 @@
+import Link from "next/link";
+import type { ReactNode } from "react";
+
+type EmptyStateProps = {
+ title: string;
+ description?: string;
+ action?: { href: string; label: string };
+ icon?: ReactNode;
+};
+
+export function EmptyState({ title, description, action, icon }: EmptyStateProps) {
+ return (
+
+ {icon ?
{icon}
: null}
+
{title}
+ {description ?
{description}
: null}
+ {action ? (
+
+ {action.label}
+
+ ) : null}
+
+ );
+}
diff --git a/components/exercise-progress-chart.tsx b/components/exercise-progress-chart.tsx
new file mode 100644
index 0000000..186f9e0
--- /dev/null
+++ b/components/exercise-progress-chart.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
+
+type ExerciseProgressChartProps = {
+ name: string;
+ data: { label: string; volumeKg: number; topWeightKg?: number }[];
+};
+
+export function ExerciseProgressChart({ name, data }: ExerciseProgressChartProps) {
+ if (data.length < 2) {
+ return null;
+ }
+
+ return (
+
+
{name}
+
+
+
+
+
+
+ [
+ key === "topWeightKg" ? `${value} kg` : `${Math.round(Number(value)).toLocaleString("pl-PL")} kg`,
+ key === "topWeightKg" ? "Maks. ciężar" : "Wolumen",
+ ]}
+ />
+
+
+
+
+
+ );
+}
diff --git a/components/info-tooltip.tsx b/components/info-tooltip.tsx
new file mode 100644
index 0000000..6d96ad7
--- /dev/null
+++ b/components/info-tooltip.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import { Info } from "lucide-react";
+
+export function InfoTooltip({ text }: { text: string }) {
+ return (
+
+
+
+ {text}
+
+
+ );
+}
diff --git a/components/load-route-button.tsx b/components/load-route-button.tsx
new file mode 100644
index 0000000..f1a4630
--- /dev/null
+++ b/components/load-route-button.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { useActionState } from "react";
+import { Map } from "lucide-react";
+import { loadActivityRoute, type LoadRouteState } from "@/app/running/actions";
+
+export function LoadRouteButton({ activityId }: { activityId: string }) {
+ const [state, formAction, pending] = useActionState(
+ async (): Promise => loadActivityRoute(activityId),
+ null
+ );
+
+ return (
+
+
+ {state && "error" in state ?
{state.error}
: null}
+
+ );
+}
diff --git a/components/nav.tsx b/components/nav.tsx
new file mode 100644
index 0000000..a447f96
--- /dev/null
+++ b/components/nav.tsx
@@ -0,0 +1,46 @@
+import Link from "next/link";
+import { Activity, Dumbbell, LayoutDashboard, Settings } from "lucide-react";
+
+const links = [
+ { href: "/", label: "Panel", icon: LayoutDashboard },
+ { href: "/running", label: "Bieganie", icon: Activity },
+ { href: "/strength", label: "Siłownia", icon: Dumbbell },
+ { href: "/settings", label: "Ustawienia", icon: Settings },
+];
+
+export function Nav() {
+ return (
+
+ );
+}
diff --git a/components/route-map-section.tsx b/components/route-map-section.tsx
new file mode 100644
index 0000000..8c3e429
--- /dev/null
+++ b/components/route-map-section.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import nextDynamic from "next/dynamic";
+import type { RoutePoint } from "@/lib/models/running";
+
+const RouteMap = nextDynamic(() => import("@/components/route-map").then((m) => m.RouteMap), {
+ ssr: false,
+ loading: () => ,
+});
+
+export function RouteMapSection({ points }: { points: RoutePoint[] }) {
+ return ;
+}
diff --git a/components/route-map.tsx b/components/route-map.tsx
new file mode 100644
index 0000000..33671ce
--- /dev/null
+++ b/components/route-map.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import "leaflet/dist/leaflet.css";
+import { MapContainer, Polyline, TileLayer, CircleMarker, useMap } from "react-leaflet";
+import type { RoutePoint } from "@/lib/models/running";
+
+type FitBoundsProps = { points: RoutePoint[] };
+
+function FitBounds({ points }: FitBoundsProps) {
+ const map = useMap();
+ map.fitBounds(points, { padding: [24, 24] });
+ return null;
+}
+
+type RouteMapProps = { points: RoutePoint[] };
+
+export function RouteMap({ points }: RouteMapProps) {
+ if (points.length === 0) return null;
+
+ const start = points[0];
+ const end = points[points.length - 1];
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/stat-card.tsx b/components/stat-card.tsx
new file mode 100644
index 0000000..7979cf5
--- /dev/null
+++ b/components/stat-card.tsx
@@ -0,0 +1,26 @@
+import type { ReactNode } from "react";
+
+type StatCardProps = {
+ label: string;
+ value: ReactNode;
+ hint?: string;
+ highlight?: boolean;
+};
+
+export function StatCard({ label, value, hint, highlight }: StatCardProps) {
+ return (
+
+
+ {label}
+
+
{value}
+ {hint ?
{hint}
: null}
+
+ );
+}
diff --git a/components/sync-button.tsx b/components/sync-button.tsx
new file mode 100644
index 0000000..96c39f6
--- /dev/null
+++ b/components/sync-button.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { useActionState } from "react";
+import { RefreshCw } from "lucide-react";
+import { submitGarminMfaCode, syncGarminActivities, type SyncGarminState } from "@/app/running/actions";
+
+export function SyncButton() {
+ const [state, formAction, pending] = useActionState(async () => syncGarminActivities(), null);
+ const [mfaState, mfaAction, mfaPending] = useActionState(
+ async (_prev: SyncGarminState, formData: FormData) => submitGarminMfaCode(String(formData.get("code") ?? "")),
+ null
+ );
+
+ const mfaRequired = (state && "mfaRequired" in state) || (mfaState && "mfaRequired" in mfaState);
+ const activeState = mfaState ?? state;
+
+ return (
+
+
+
+ {mfaRequired ? (
+
+ ) : null}
+
+ {mfaRequired ? (
+
Garmin wysłał kod weryfikacyjny na e-mail. Wpisz go powyżej.
+ ) : null}
+ {activeState && "error" in activeState ?
{activeState.error}
: null}
+ {activeState && "success" in activeState ?
{activeState.success}
: null}
+
+ );
+}
diff --git a/components/volume-chart.tsx b/components/volume-chart.tsx
new file mode 100644
index 0000000..b8631fa
--- /dev/null
+++ b/components/volume-chart.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
+
+type VolumeChartProps = {
+ data: { label: string; volumeKg: number }[];
+};
+
+export function VolumeChart({ data }: VolumeChartProps) {
+ return (
+
+
Wolumen treningowy (ciężar × powtórzenia)
+
+
+
+
+ [`${Math.round(Number(value)).toLocaleString("pl-PL")} kg`, "Wolumen"]}
+ />
+
+
+
+
+ );
+}
diff --git a/docker-compose.yaml b/docker-compose.yaml
new file mode 100644
index 0000000..7b15098
--- /dev/null
+++ b/docker-compose.yaml
@@ -0,0 +1,20 @@
+services:
+ mongo:
+ image: mongo
+ restart: always
+ ports:
+ - 27017:27017
+ environment:
+ MONGO_INITDB_ROOT_USERNAME: root
+ MONGO_INITDB_ROOT_PASSWORD: example
+
+ mongo-express:
+ image: mongo-express
+ restart: always
+ ports:
+ - 8081:8081
+ environment:
+ ME_CONFIG_MONGODB_URL: mongodb://root:example@mongo:27017/
+ ME_CONFIG_BASICAUTH_ENABLED: true
+ ME_CONFIG_BASICAUTH_USERNAME: mongoexpressuser
+ ME_CONFIG_BASICAUTH_PASSWORD: mongoexpresspass
diff --git a/lib/ai/claude.ts b/lib/ai/claude.ts
new file mode 100644
index 0000000..e6dc32f
--- /dev/null
+++ b/lib/ai/claude.ts
@@ -0,0 +1,314 @@
+import Anthropic from "@anthropic-ai/sdk";
+import { ObjectId } from "mongodb";
+import { formatDate, formatDateShort, formatDistance, formatDuration, formatPace } from "@/lib/format";
+import { fetchRecentWellness, type DayWellness } from "@/lib/garmin/wellness";
+import { getAuthorizedClient } from "@/lib/garmin/client";
+import { getRunningActivity, listRunningActivities, type RunningActivity } from "@/lib/models/running";
+import { getStrengthWorkout, listStrengthWorkouts, type StrengthWorkout } from "@/lib/models/strength";
+import {
+ getLatestAnalysisForTarget,
+ saveAiAnalysis,
+ saveDashboardAnalysis,
+ type AiAnalysis,
+ type AiAnalysisTargetType,
+} from "@/lib/models/analysis";
+
+const DEFAULT_MODEL = "claude-sonnet-4-6";
+const PREVIOUS_RUNS_LIMIT = 5;
+const PREVIOUS_WORKOUTS_LIMIT = 2;
+const DASHBOARD_RUNS_LIMIT = 6;
+const DASHBOARD_WORKOUTS_LIMIT = 4;
+const DASHBOARD_WELLNESS_DAYS = 7;
+
+const PROMPT_INSTRUCTIONS = `Odpowiedz wyłącznie w formacie JSON (bez dodatkowego tekstu, bez markdown):
+{"summary": "krótkie podsumowanie treningu po polsku (2-3 zdania)", "tips": ["wskazówka 1", "wskazówka 2", "wskazówka 3"]}
+Podaj od 2 do 4 konkretnych, praktycznych wskazówek na kolejne treningi.
+Jeśli podano dane z poprzednich treningów, odnieś się do progresu (np. zmiana dystansu, tempa, ciężarów czy powtórzeń względem poprzednich sesji).`;
+
+type PreviousRun = { run: RunningActivity; analysis: AiAnalysis | null };
+type PreviousWorkout = { workout: StrengthWorkout; analysis: AiAnalysis | null };
+
+function buildRunningPrompt(activity: RunningActivity, previousRuns: PreviousRun[]): string {
+ const lines = [
+ `Przeanalizuj poniższy bieg i podaj krótkie podsumowanie oraz wskazówki potreningowe.`,
+ ``,
+ `Nazwa: ${activity.name}`,
+ `Data: ${formatDate(activity.startTime)}`,
+ `Dystans: ${formatDistance(activity.distanceM)}`,
+ `Czas: ${formatDuration(activity.durationSec)}`,
+ `Tempo: ${formatPace(activity.avgPaceSecPerKm)}`,
+ ];
+ if (activity.avgHr) lines.push(`Średnie tętno: ${Math.round(activity.avgHr)} bpm`);
+ if (activity.maxHr) lines.push(`Maksymalne tętno: ${Math.round(activity.maxHr)} bpm`);
+ if (activity.avgCadence) lines.push(`Kadencja: ${Math.round(activity.avgCadence)} kroków/min`);
+ if (activity.elevationGainM) lines.push(`Suma podejść: ${Math.round(activity.elevationGainM)} m`);
+ if (activity.calories) lines.push(`Spalone kalorie: ${Math.round(activity.calories)} kcal`);
+ if (activity.vo2Max) lines.push(`VO2max: ${Math.round(activity.vo2Max)}`);
+ if (activity.avgGroundContactTimeMs) lines.push(`Czas kontaktu z podłożem: ${Math.round(activity.avgGroundContactTimeMs)} ms`);
+ if (activity.avgVerticalOscillationCm) lines.push(`Oscylacja wertykalna: ${activity.avgVerticalOscillationCm.toFixed(1)} cm`);
+ if (activity.avgVerticalRatioPct) lines.push(`Wskaźnik wertykalny: ${activity.avgVerticalRatioPct.toFixed(1)}%`);
+ if (activity.avgStrideLengthCm) lines.push(`Długość kroku: ${activity.avgStrideLengthCm.toFixed(0)} cm`);
+ if (activity.avgGroundContactBalanceLeftPct) {
+ lines.push(
+ `Balans kontaktu z podłożem (L/P): ${activity.avgGroundContactBalanceLeftPct.toFixed(1)}% / ${(100 - activity.avgGroundContactBalanceLeftPct).toFixed(1)}%`
+ );
+ }
+ if (activity.avgPowerW) lines.push(`Moc średnia: ${Math.round(activity.avgPowerW)} W`);
+ if (activity.avgRespirationRate) lines.push(`Częstość oddechów: ${activity.avgRespirationRate.toFixed(1)}/min`);
+ if (activity.aerobicTrainingEffect) lines.push(`Efekt treningowy aerobowy: ${activity.aerobicTrainingEffect.toFixed(1)}`);
+ if (activity.anaerobicTrainingEffect) lines.push(`Efekt treningowy anaerobowy: ${activity.anaerobicTrainingEffect.toFixed(1)}`);
+
+ if (previousRuns.length > 0) {
+ lines.push(``, `Poprzednie biegi (od najnowszego):`);
+ for (const { run, analysis } of previousRuns) {
+ lines.push(
+ `- ${formatDateShort(run.startTime)}: ${formatDistance(run.distanceM)}, ${formatDuration(run.durationSec)}, tempo ${formatPace(run.avgPaceSecPerKm)}`
+ );
+ if (analysis) {
+ lines.push(` Poprzednia analiza AI: ${analysis.summary}`);
+ if (analysis.tips.length > 0) {
+ lines.push(` Wskazówki z poprzedniej analizy: ${analysis.tips.join(" | ")}`);
+ }
+ }
+ }
+ }
+
+ lines.push(``, PROMPT_INSTRUCTIONS);
+ return lines.join("\n");
+}
+
+function formatExerciseSets(exercise: StrengthWorkout["exercises"][number]): string {
+ return exercise.sets
+ .map((set) => {
+ const weight = set.weightKg !== undefined ? `${set.weightKg} kg` : "bez obciążenia";
+ return `${weight} × ${set.reps ?? "?"}`;
+ })
+ .join(", ");
+}
+
+function buildStrengthPrompt(workout: StrengthWorkout, previousWorkouts: PreviousWorkout[]): string {
+ const lines = [
+ `Przeanalizuj poniższy trening siłowy i podaj krótkie podsumowanie oraz wskazówki potreningowe.`,
+ ``,
+ `Nazwa: ${workout.name}`,
+ `Data: ${formatDate(workout.date)}`,
+ ];
+ if (workout.notes) lines.push(`Notatki: ${workout.notes}`);
+ lines.push(``, `Ćwiczenia:`);
+ for (const exercise of workout.exercises) {
+ lines.push(`- ${exercise.name}: ${formatExerciseSets(exercise)}`);
+ if (exercise.notes) lines.push(` Notatka: ${exercise.notes}`);
+ }
+
+ if (previousWorkouts.length > 0) {
+ lines.push(``, `Poprzednie treningi (od najnowszego):`);
+ for (const { workout: previous, analysis } of previousWorkouts) {
+ lines.push(`${formatDateShort(previous.date)} - ${previous.name}:`);
+ for (const exercise of previous.exercises) {
+ lines.push(` - ${exercise.name}: ${formatExerciseSets(exercise)}`);
+ }
+ if (analysis) {
+ lines.push(` Poprzednia analiza AI: ${analysis.summary}`);
+ if (analysis.tips.length > 0) {
+ lines.push(` Wskazówki z poprzedniej analizy: ${analysis.tips.join(" | ")}`);
+ }
+ }
+ }
+ }
+
+ lines.push(``, PROMPT_INSTRUCTIONS);
+ return lines.join("\n");
+}
+
+function parseAnalysisResponse(text: string): { summary: string; tips: string[] } {
+ try {
+ const match = text.match(/\{[\s\S]*\}/);
+ const parsed = JSON.parse(match ? match[0] : text);
+ const summary = typeof parsed.summary === "string" ? parsed.summary : text;
+ const tips = Array.isArray(parsed.tips) ? parsed.tips.filter((tip: unknown) => typeof tip === "string") : [];
+ return { summary, tips };
+ } catch {
+ return { summary: text, tips: [] };
+ }
+}
+
+export async function generateAnalysis(
+ targetType: AiAnalysisTargetType,
+ targetId: string
+): Promise {
+ const apiKey = process.env.ANTHROPIC_API_KEY;
+ if (!apiKey) {
+ throw new Error("Brak klucza ANTHROPIC_API_KEY w konfiguracji.");
+ }
+
+ let prompt: string;
+ if (targetType === "running") {
+ const activity = await getRunningActivity(targetId);
+ if (!activity) throw new Error("Nie znaleziono biegu.");
+ const previousRuns = (await listRunningActivities())
+ .filter((run) => run.startTime < activity.startTime)
+ .slice(0, PREVIOUS_RUNS_LIMIT);
+ const previousRunsWithAnalysis: PreviousRun[] = await Promise.all(
+ previousRuns.map(async (run) => ({
+ run,
+ analysis: await getLatestAnalysisForTarget("running", run._id),
+ }))
+ );
+ prompt = buildRunningPrompt(activity, previousRunsWithAnalysis);
+ } else {
+ const workout = await getStrengthWorkout(targetId);
+ if (!workout) throw new Error("Nie znaleziono treningu.");
+ const previousWorkouts = (await listStrengthWorkouts())
+ .filter((previous) => previous.date < workout.date)
+ .slice(0, PREVIOUS_WORKOUTS_LIMIT);
+ const previousWorkoutsWithAnalysis: PreviousWorkout[] = await Promise.all(
+ previousWorkouts.map(async (previous) => ({
+ workout: previous,
+ analysis: await getLatestAnalysisForTarget("strength", previous._id),
+ }))
+ );
+ prompt = buildStrengthPrompt(workout, previousWorkoutsWithAnalysis);
+ }
+
+ const model = process.env.ANTHROPIC_MODEL ?? DEFAULT_MODEL;
+ const client = new Anthropic({ apiKey });
+ const message = await client.messages.create({
+ model,
+ max_tokens: 1024,
+ messages: [{ role: "user", content: prompt }],
+ });
+
+ const textBlock = message.content.find((block) => block.type === "text");
+ const text = textBlock && textBlock.type === "text" ? textBlock.text : "";
+ const { summary, tips } = parseAnalysisResponse(text);
+
+ return saveAiAnalysis({
+ targetType,
+ targetId: new ObjectId(targetId),
+ summary,
+ tips,
+ model,
+ });
+}
+
+function formatHrvStatus(status: string): string {
+ const map: Record = {
+ BALANCED: "zrównoważone",
+ UNBALANCED: "niezrównoważone",
+ LOW: "niskie",
+ POOR: "złe",
+ };
+ return map[status] ?? status;
+}
+
+function buildDashboardPrompt(
+ runs: RunningActivity[],
+ workouts: StrengthWorkout[],
+ wellness: DayWellness[]
+): string {
+ const lines = [
+ `Jesteś asystentem sportowym analizującym pełny obraz kondycji i stanu treningowego zawodnika.`,
+ `Na podstawie poniższych danych oceń: poziom zmęczenia i regeneracji, balans między treningiem siłowym a biegowym, trendy wydolnościowe oraz gotowość do kolejnych treningów.`,
+ ``,
+ ];
+
+ if (runs.length > 0) {
+ lines.push(`BIEGI (${runs.length} ostatnich sesji, od najnowszej):`);
+ for (const run of runs) {
+ const parts = [
+ formatDateShort(run.startTime),
+ formatDistance(run.distanceM),
+ `tempo ${formatPace(run.avgPaceSecPerKm)}`,
+ ];
+ if (run.avgHr) parts.push(`HR śr. ${Math.round(run.avgHr)} bpm`);
+ if (run.vo2Max) parts.push(`VO2max ${Math.round(run.vo2Max)}`);
+ if (run.aerobicTrainingEffect) parts.push(`TE aerobowy ${run.aerobicTrainingEffect.toFixed(1)}`);
+ lines.push(`- ${parts.join(", ")}`);
+ }
+ lines.push(``);
+ }
+
+ if (workouts.length > 0) {
+ lines.push(`TRENINGI SIŁOWE (${workouts.length} ostatnich sesji, od najnowszej):`);
+ for (const workout of workouts) {
+ lines.push(`- ${formatDateShort(workout.date)} — ${workout.name}:`);
+ for (const exercise of workout.exercises) {
+ const topSet = exercise.sets.reduce(
+ (best, set) => (set.weightKg ?? 0) > (best.weightKg ?? 0) ? set : best,
+ exercise.sets[0]
+ );
+ const summary = topSet
+ ? `maks. ${topSet.weightKg ?? "—"} kg × ${topSet.reps ?? "?"} (${exercise.sets.length} serie)`
+ : `${exercise.sets.length} serie`;
+ lines.push(` · ${exercise.name}: ${summary}`);
+ }
+ }
+ lines.push(``);
+ }
+
+ const wellnessWithData = wellness.filter(
+ (d) => d.sleepScore || d.avgOvernightHrv || d.sleepDurationMin
+ );
+ if (wellnessWithData.length > 0) {
+ lines.push(`SEN I HRV (ostatnie ${wellness.length} dni):`);
+ for (const day of wellness) {
+ const parts: string[] = [day.date];
+ if (day.sleepDurationMin) {
+ const h = Math.floor(day.sleepDurationMin / 60);
+ const m = day.sleepDurationMin % 60;
+ parts.push(`sen ${h}h ${m}min`);
+ }
+ if (day.sleepScore) parts.push(`wynik snu ${day.sleepScore}/100`);
+ if (day.avgOvernightHrv) {
+ parts.push(`HRV ${Math.round(day.avgOvernightHrv)} ms${day.hrvStatus ? ` (${formatHrvStatus(day.hrvStatus)})` : ""}`);
+ }
+ if (day.restingHr) parts.push(`HR spoczynkowe ${day.restingHr} bpm`);
+ if (typeof day.bodyBatteryChange === "number") {
+ parts.push(`Body Battery ${day.bodyBatteryChange > 0 ? "+" : ""}${day.bodyBatteryChange}`);
+ }
+ if (parts.length > 1) lines.push(`- ${parts.join(", ")}`);
+ }
+ lines.push(``);
+ }
+
+ lines.push(
+ `Odpowiedz wyłącznie w formacie JSON (bez dodatkowego tekstu, bez markdown):`,
+ `{"summary": "ocena ogólnego stanu kondycji i regeneracji po polsku (3-4 zdania)", "tips": ["wskazówka 1", "wskazówka 2", "wskazówka 3"]}`,
+ `Podaj 3-5 konkretnych, praktycznych wskazówek dotyczących planowania kolejnych treningów, regeneracji i zdrowia.`,
+ `Uwzględnij trendy HRV i jakości snu przy ocenie gotowości do wysiłku.`
+ );
+
+ return lines.join("\n");
+}
+
+export async function generateDashboardAnalysis(): Promise {
+ const apiKey = process.env.ANTHROPIC_API_KEY;
+ if (!apiKey) throw new Error("Brak klucza ANTHROPIC_API_KEY w konfiguracji.");
+
+ const [runs, workouts] = await Promise.all([
+ listRunningActivities().then((r) => r.slice(0, DASHBOARD_RUNS_LIMIT)),
+ listStrengthWorkouts().then((w) => w.slice(0, DASHBOARD_WORKOUTS_LIMIT)),
+ ]);
+
+ let wellness: DayWellness[] = [];
+ try {
+ const garminClient = await getAuthorizedClient();
+ wellness = await fetchRecentWellness(garminClient, DASHBOARD_WELLNESS_DAYS);
+ } catch {
+ // Wellness data not available, proceed without it
+ }
+
+ const prompt = buildDashboardPrompt(runs, workouts, wellness);
+ const model = process.env.ANTHROPIC_MODEL ?? DEFAULT_MODEL;
+ const anthropic = new Anthropic({ apiKey });
+ const message = await anthropic.messages.create({
+ model,
+ max_tokens: 1024,
+ messages: [{ role: "user", content: prompt }],
+ });
+
+ const textBlock = message.content.find((b) => b.type === "text");
+ const text = textBlock && textBlock.type === "text" ? textBlock.text : "";
+ const { summary, tips } = parseAnalysisResponse(text);
+ return saveDashboardAnalysis(summary, tips, model);
+}
diff --git a/lib/db.ts b/lib/db.ts
new file mode 100644
index 0000000..8ca7ff3
--- /dev/null
+++ b/lib/db.ts
@@ -0,0 +1,21 @@
+import { MongoClient, type Db } from "mongodb";
+
+const uri = process.env.MONGODB_URI ?? "mongodb://localhost:27017";
+const dbName = process.env.MONGODB_DB ?? "knur";
+
+declare global {
+ var _mongoClientPromise: Promise | undefined;
+}
+
+function getClientPromise(): Promise {
+ if (!global._mongoClientPromise) {
+ const client = new MongoClient(uri);
+ global._mongoClientPromise = client.connect();
+ }
+ return global._mongoClientPromise;
+}
+
+export async function getDb(): Promise {
+ const client = await getClientPromise();
+ return client.db(dbName);
+}
diff --git a/lib/format.ts b/lib/format.ts
new file mode 100644
index 0000000..02636ba
--- /dev/null
+++ b/lib/format.ts
@@ -0,0 +1,30 @@
+import { format } from "date-fns";
+import { pl } from "date-fns/locale";
+
+export function formatDate(date: Date): string {
+ return format(date, "d MMMM yyyy, HH:mm", { locale: pl });
+}
+
+export function formatDateShort(date: Date): string {
+ return format(date, "d MMM yyyy", { locale: pl });
+}
+
+export function formatDuration(seconds: number): string {
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ const s = Math.floor(seconds % 60);
+ if (h > 0) {
+ return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
+ }
+ return `${m}:${String(s).padStart(2, "0")}`;
+}
+
+export function formatDistance(meters: number): string {
+ return `${(meters / 1000).toFixed(2)} km`;
+}
+
+export function formatPace(secPerKm: number): string {
+ const m = Math.floor(secPerKm / 60);
+ const s = Math.round(secPerKm % 60);
+ return `${m}:${String(s).padStart(2, "0")} /km`;
+}
diff --git a/lib/garmin/client.ts b/lib/garmin/client.ts
new file mode 100644
index 0000000..e75fb30
--- /dev/null
+++ b/lib/garmin/client.ts
@@ -0,0 +1,164 @@
+import { GarminConnect } from "garmin-connect";
+import type { IActivity } from "garmin-connect/dist/garmin/types/activity";
+import type { IOauth1Token } from "garmin-connect/dist/garmin/types";
+import type { RoutePoint, RunningActivityInput } from "@/lib/models/running";
+import { getSavedOauth1Token } from "@/lib/models/garmin-auth";
+import { completeMfaAndGetTicket, loginAndGetTicket, type GarminPendingMfa } from "@/lib/garmin/sso";
+
+const FETCH_LIMIT = 50;
+
+export class GarminLoginRequiredError extends Error {
+ constructor() {
+ super("Wymagane logowanie do Garmin Connect.");
+ }
+}
+
+function parseGarminDate(value: string): Date {
+ return new Date(`${value.replace(" ", "T")}Z`);
+}
+
+function isRunningActivity(activity: IActivity): boolean {
+ return activity.activityType?.typeKey?.toLowerCase().includes("running") ?? false;
+}
+
+function toNumber(value: unknown): number | undefined {
+ return typeof value === "number" && value > 0 ? value : undefined;
+}
+
+function toText(value: unknown): string | undefined {
+ return typeof value === "string" && value.length > 0 ? value : undefined;
+}
+
+function mapActivity(activity: IActivity): RunningActivityInput {
+ return {
+ garminActivityId: activity.activityId,
+ name: activity.activityName,
+ startTime: parseGarminDate(activity.startTimeGMT),
+ durationSec: activity.duration,
+ distanceM: activity.distance,
+ avgPaceSecPerKm: activity.averageSpeed > 0 ? 1000 / activity.averageSpeed : 0,
+ avgHr: activity.averageHR || undefined,
+ maxHr: activity.maxHR || undefined,
+ calories: activity.calories || undefined,
+ elevationGainM: activity.elevationGain || undefined,
+ avgCadence: activity.averageRunningCadenceInStepsPerMinute || undefined,
+ avgVerticalOscillationCm: toNumber(activity.avgVerticalOscillation),
+ avgGroundContactTimeMs: toNumber(activity.avgGroundContactTime),
+ avgStrideLengthCm: activity.avgStrideLength || undefined,
+ avgGroundContactBalanceLeftPct: toNumber(activity.avgGroundContactBalance),
+ avgVerticalRatioPct: toNumber(activity.avgVerticalRatio),
+ vo2Max: activity.vO2MaxValue || undefined,
+ aerobicTrainingEffect: toNumber(activity.aerobicTrainingEffect),
+ anaerobicTrainingEffect: toNumber(activity.anaerobicTrainingEffect),
+ trainingEffectLabel: toText(activity.trainingEffectLabel),
+ avgPowerW: toNumber(activity.avgPower),
+ maxPowerW: toNumber(activity.maxPower),
+ normPowerW: toNumber(activity.normPower),
+ avgRespirationRate: toNumber(activity.avgRespirationRate),
+ hasRoute: activity.hasPolyline || undefined,
+ };
+}
+
+const GC_API = "https://connectapi.garmin.com";
+const MAX_POLYLINE_POINTS = 500;
+
+type GarminPolylinePoint = { lat: number; lon: number; altitude?: number };
+type GarminActivityDetailsResponse = {
+ geoPolylineDTO?: { polyline?: GarminPolylinePoint[] };
+};
+
+export async function fetchActivityRoutePoints(
+ client: GarminConnect,
+ garminActivityId: number
+): Promise {
+ const url = `${GC_API}/activity-service/activity/${garminActivityId}/details?maxPolylineSize=${MAX_POLYLINE_POINTS}`;
+ const data = await client.get(url);
+ const polyline = data?.geoPolylineDTO?.polyline;
+ if (!Array.isArray(polyline) || polyline.length === 0) return null;
+ return polyline.map((p) => [p.lat, p.lon] as RoutePoint);
+}
+
+function getCredentials(): { username: string; password: string } {
+ const username = process.env.GARMIN_EMAIL;
+ const password = process.env.GARMIN_PASSWORD;
+ if (!username || !password) {
+ throw new Error("Brak danych logowania do Garmin Connect (GARMIN_EMAIL / GARMIN_PASSWORD).");
+ }
+ return { username, password };
+}
+
+async function exchangeOauth1Token(client: GarminConnect, oauth1Token: IOauth1Token): Promise {
+ const http = client.client;
+ if (!http.OAUTH_CONSUMER) {
+ await http.fetchOauthConsumer();
+ }
+ const consumer = http.OAUTH_CONSUMER;
+ if (!consumer) {
+ throw new Error("Nie udało się pobrać konfiguracji OAuth Garmin.");
+ }
+
+ const oauth = http.getOauthClient(consumer);
+ http.oauth1Token = oauth1Token;
+ await http.exchange({ oauth, token: oauth1Token });
+}
+
+/**
+ * Returns a client authenticated using a previously saved OAuth1 token
+ * (long-lived, survives across syncs) - no MFA needed if it's still valid.
+ */
+export async function getAuthorizedClient(): Promise {
+ const saved = await getSavedOauth1Token();
+ if (!saved) {
+ throw new GarminLoginRequiredError();
+ }
+
+ const client = new GarminConnect({ username: "", password: "" });
+ try {
+ await exchangeOauth1Token(client, saved);
+ } catch {
+ throw new GarminLoginRequiredError();
+ }
+ return client;
+}
+
+async function establishClientFromTicket(ticket: string): Promise<{ client: GarminConnect; oauth1Token: IOauth1Token }> {
+ const client = new GarminConnect({ username: "", password: "" });
+ await client.client.fetchOauthConsumer();
+ const oauth1 = await client.client.getOauth1Token(ticket);
+ await client.client.exchange(oauth1);
+ return { client, oauth1Token: oauth1.token };
+}
+
+/**
+ * Starts a fresh SSO login using env credentials. If the account requires
+ * MFA, returns the pending state needed to complete it via
+ * `completeGarminMfaLogin` once the user supplies the emailed code.
+ */
+export async function beginGarminLogin(): Promise<
+ { client: GarminConnect; oauth1Token: IOauth1Token } | { mfaRequired: true; pendingState: GarminPendingMfa }
+> {
+ const { username, password } = getCredentials();
+ const result = await loginAndGetTicket(username, password);
+ if ("mfaRequired" in result) return result;
+ return establishClientFromTicket(result.ticket);
+}
+
+export async function completeGarminMfaLogin(
+ pendingState: GarminPendingMfa,
+ code: string
+): Promise<{ client: GarminConnect; oauth1Token: IOauth1Token }> {
+ const ticket = await completeMfaAndGetTicket(pendingState, code);
+ return establishClientFromTicket(ticket);
+}
+
+/**
+ * Returns all recent running activities (mapped), regardless of `since` -
+ * callers should upsert all of them so previously-synced activities get
+ * backfilled with newly added metric fields, but can use `since` to decide
+ * which ones are "new" for reporting purposes.
+ */
+export async function fetchRunningActivities(client: GarminConnect): Promise {
+ const activities = await client.getActivities(0, FETCH_LIMIT);
+
+ return activities.filter(isRunningActivity).map(mapActivity);
+}
diff --git a/lib/garmin/sso.ts b/lib/garmin/sso.ts
new file mode 100644
index 0000000..42d6d7e
--- /dev/null
+++ b/lib/garmin/sso.ts
@@ -0,0 +1,176 @@
+const GARMIN_SSO_ORIGIN = "https://sso.garmin.com";
+const GARMIN_SSO = `${GARMIN_SSO_ORIGIN}/sso`;
+const GARMIN_SSO_EMBED = `${GARMIN_SSO}/embed`;
+const GC_MODERN = "https://connect.garmin.com/modern";
+const SIGNIN_URL = `${GARMIN_SSO}/signin`;
+const USER_AGENT_BROWSER =
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36";
+
+const TICKET_RE = /ticket=([^"]+)"/;
+const CSRF_RE = /name="_csrf"\s+value="(.+?)"/;
+
+const SIGNIN_PARAMS: Record = {
+ id: "gauth-widget",
+ embedWidget: true,
+ clientId: "GarminConnect",
+ locale: "en",
+ gauthHost: GARMIN_SSO_EMBED,
+ service: GARMIN_SSO_EMBED,
+ source: GARMIN_SSO_EMBED,
+ redirectAfterAccountLoginUrl: GARMIN_SSO_EMBED,
+ redirectAfterAccountCreationUrl: GARMIN_SSO_EMBED,
+};
+
+export type GarminPendingMfa = {
+ cookies: [string, string][];
+ mfaUrl: string;
+ csrf: string;
+};
+
+export type GarminLoginResult = { ticket: string } | { mfaRequired: true; pendingState: GarminPendingMfa };
+
+function toQueryString(params: Record): string {
+ return Object.entries(params)
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
+ .join("&");
+}
+
+class CookieJar {
+ private cookies = new Map();
+
+ constructor(initial?: [string, string][]) {
+ if (initial) {
+ for (const [key, value] of initial) this.cookies.set(key, value);
+ }
+ }
+
+ apply(response: Response): void {
+ const setCookies = response.headers.getSetCookie?.() ?? [];
+ for (const cookie of setCookies) {
+ const [pair] = cookie.split(";");
+ const idx = pair.indexOf("=");
+ this.cookies.set(pair.slice(0, idx), pair.slice(idx + 1));
+ }
+ }
+
+ header(): string {
+ return Array.from(this.cookies.entries())
+ .map(([key, value]) => `${key}=${value}`)
+ .join("; ");
+ }
+
+ entries(): [string, string][] {
+ return Array.from(this.cookies.entries());
+ }
+}
+
+async function request(jar: CookieJar, url: string, init: RequestInit = {}): Promise<{ response: Response; body: string }> {
+ const response = await fetch(url, {
+ ...init,
+ redirect: "manual",
+ headers: {
+ "User-Agent": USER_AGENT_BROWSER,
+ Cookie: jar.header(),
+ ...(init.headers ?? {}),
+ },
+ });
+ jar.apply(response);
+ const body = await response.text();
+ return { response, body };
+}
+
+/**
+ * Replays the Garmin SSO web login flow (garmin-connect's HttpClient has no
+ * cookie jar and a no-op handleMFA, so it cannot complete login when the
+ * account has email-based MFA enabled).
+ */
+export async function loginAndGetTicket(username: string, password: string): Promise {
+ const jar = new CookieJar();
+
+ const embedUrl = `${GARMIN_SSO_EMBED}?${toQueryString({ clientId: "GarminConnect", locale: "en", service: GC_MODERN })}`;
+ await request(jar, embedUrl);
+
+ const signinPageUrl = `${SIGNIN_URL}?${toQueryString({
+ id: "gauth-widget",
+ embedWidget: true,
+ locale: "en",
+ gauthHost: GARMIN_SSO_EMBED,
+ })}`;
+ const signinPage = await request(jar, signinPageUrl);
+ const csrfMatch = signinPage.body.match(CSRF_RE);
+ if (!csrfMatch) {
+ throw new Error("Logowanie do Garmin nie powiodło się (brak tokenu CSRF na stronie logowania).");
+ }
+
+ const signinUrl = `${SIGNIN_URL}?${toQueryString(SIGNIN_PARAMS)}`;
+ const credentialsForm = new URLSearchParams({ username, password, embed: "true", _csrf: csrfMatch[1] });
+ const credentialsResult = await request(jar, signinUrl, {
+ method: "POST",
+ body: credentialsForm.toString(),
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Dnt: "1",
+ Origin: GARMIN_SSO_ORIGIN,
+ Referer: SIGNIN_URL,
+ },
+ });
+
+ const redirectLocation = credentialsResult.response.headers.get("location");
+ if (redirectLocation?.includes("verifyMFA")) {
+ const mfaPage = await request(jar, redirectLocation, { headers: { Referer: signinUrl } });
+ const mfaCsrfMatch = mfaPage.body.match(CSRF_RE);
+ if (!mfaCsrfMatch) {
+ throw new Error("Logowanie do Garmin nie powiodło się (nie znaleziono formularza kodu MFA).");
+ }
+ return {
+ mfaRequired: true,
+ pendingState: { cookies: jar.entries(), mfaUrl: redirectLocation, csrf: mfaCsrfMatch[1] },
+ };
+ }
+
+ const ticketMatch = credentialsResult.body.match(TICKET_RE);
+ if (!ticketMatch) {
+ throw new Error("Logowanie do Garmin nie powiodło się (Ticket not found or MFA), sprawdź login i hasło.");
+ }
+ return { ticket: ticketMatch[1] };
+}
+
+export async function completeMfaAndGetTicket(pendingState: GarminPendingMfa, code: string): Promise {
+ const jar = new CookieJar(pendingState.cookies);
+
+ const mfaForm = new URLSearchParams({
+ "mfa-code": code.trim(),
+ embed: "true",
+ _csrf: pendingState.csrf,
+ fromPage: "setupEnterMfaCode",
+ });
+
+ const verifyResult = await request(jar, pendingState.mfaUrl, {
+ method: "POST",
+ body: mfaForm.toString(),
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Dnt: "1",
+ Origin: GARMIN_SSO_ORIGIN,
+ Referer: pendingState.mfaUrl,
+ },
+ });
+
+ let body = verifyResult.body;
+ let location = verifyResult.response.headers.get("location");
+ let previousUrl = pendingState.mfaUrl;
+ let hops = 0;
+ while (location && hops < 5) {
+ const next = await request(jar, location, { headers: { Referer: previousUrl } });
+ body = next.body;
+ previousUrl = location;
+ location = next.response.headers.get("location");
+ hops += 1;
+ }
+
+ const ticketMatch = body.match(TICKET_RE);
+ if (!ticketMatch) {
+ throw new Error("Weryfikacja kodu MFA nie powiodła się - sprawdź kod i spróbuj ponownie.");
+ }
+ return ticketMatch[1];
+}
diff --git a/lib/garmin/wellness.ts b/lib/garmin/wellness.ts
new file mode 100644
index 0000000..8c6c2bb
--- /dev/null
+++ b/lib/garmin/wellness.ts
@@ -0,0 +1,52 @@
+import type { GarminConnect } from "garmin-connect";
+
+export type DayWellness = {
+ date: string;
+ sleepDurationMin?: number;
+ sleepScore?: number;
+ deepSleepMin?: number;
+ remSleepMin?: number;
+ avgOvernightHrv?: number;
+ hrvStatus?: string;
+ restingHr?: number;
+ bodyBatteryChange?: number;
+};
+
+export async function fetchRecentWellness(
+ client: GarminConnect,
+ days: number
+): Promise {
+ const today = new Date();
+
+ const dates = Array.from({ length: days }, (_, i) => {
+ const d = new Date(today);
+ d.setDate(d.getDate() - i);
+ return d;
+ });
+
+ const results = await Promise.allSettled(
+ dates.map((date) => client.getSleepData(date))
+ );
+
+ return results
+ .map((result, i) => {
+ const dateStr = dates[i].toISOString().slice(0, 10);
+ if (result.status === "rejected" || !result.value?.dailySleepDTO) {
+ return { date: dateStr };
+ }
+ const { value: data } = result;
+ const dto = data.dailySleepDTO;
+ return {
+ date: dateStr,
+ sleepDurationMin: dto.sleepTimeSeconds ? Math.round(dto.sleepTimeSeconds / 60) : undefined,
+ sleepScore: dto.sleepScores?.overall?.value ?? undefined,
+ deepSleepMin: dto.deepSleepSeconds ? Math.round(dto.deepSleepSeconds / 60) : undefined,
+ remSleepMin: dto.remSleepSeconds ? Math.round(dto.remSleepSeconds / 60) : undefined,
+ avgOvernightHrv: data.avgOvernightHrv || undefined,
+ hrvStatus: data.hrvStatus || undefined,
+ restingHr: data.restingHeartRate || undefined,
+ bodyBatteryChange: typeof data.bodyBatteryChange === "number" ? data.bodyBatteryChange : undefined,
+ };
+ })
+ .sort((a, b) => a.date.localeCompare(b.date));
+}
diff --git a/lib/models/analysis.ts b/lib/models/analysis.ts
new file mode 100644
index 0000000..d12e23f
--- /dev/null
+++ b/lib/models/analysis.ts
@@ -0,0 +1,62 @@
+import { ObjectId } from "mongodb";
+import { getDb } from "@/lib/db";
+
+export type AiAnalysisTargetType = "running" | "strength" | "dashboard";
+
+export type AiAnalysisInput = {
+ targetType: AiAnalysisTargetType;
+ targetId: ObjectId;
+ summary: string;
+ tips: string[];
+ model: string;
+};
+
+export type AiAnalysis = AiAnalysisInput & {
+ _id: ObjectId;
+ createdAt: Date;
+};
+
+const COLLECTION = "ai_analyses";
+
+async function getCollection() {
+ const db = await getDb();
+ return db.collection(COLLECTION);
+}
+
+export async function saveAiAnalysis(input: AiAnalysisInput): Promise {
+ const collection = await getCollection();
+ const doc = { ...input, _id: new ObjectId(), createdAt: new Date() };
+ await collection.insertOne(doc);
+ return doc;
+}
+
+export async function getLatestAnalysisForTarget(
+ targetType: AiAnalysisTargetType,
+ targetId: ObjectId
+): Promise {
+ const collection = await getCollection();
+ return collection.findOne({ targetType, targetId }, { sort: { createdAt: -1 } });
+}
+
+export async function getLatestAnalysis(): Promise {
+ const collection = await getCollection();
+ return collection.findOne({}, { sort: { createdAt: -1 } });
+}
+
+const DASHBOARD_TARGET_ID = new ObjectId("000000000000000000000001");
+
+export async function getDashboardAnalysis(): Promise {
+ const collection = await getCollection();
+ return collection.findOne(
+ { targetType: "dashboard", targetId: DASHBOARD_TARGET_ID },
+ { sort: { createdAt: -1 } }
+ );
+}
+
+export async function saveDashboardAnalysis(
+ summary: string,
+ tips: string[],
+ model: string
+): Promise {
+ return saveAiAnalysis({ targetType: "dashboard", targetId: DASHBOARD_TARGET_ID, summary, tips, model });
+}
diff --git a/lib/models/garmin-auth.ts b/lib/models/garmin-auth.ts
new file mode 100644
index 0000000..67f3954
--- /dev/null
+++ b/lib/models/garmin-auth.ts
@@ -0,0 +1,40 @@
+import type { IOauth1Token } from "garmin-connect/dist/garmin/types";
+import { getDb } from "@/lib/db";
+import type { GarminPendingMfa } from "@/lib/garmin/sso";
+
+const AUTH_COLLECTION = "garmin_auth";
+const PENDING_COLLECTION = "garmin_login_pending";
+
+type GarminAuthDoc = { _id: "tokens"; oauth1Token: IOauth1Token; updatedAt: Date };
+type GarminPendingDoc = { _id: "pending"; state: GarminPendingMfa; createdAt: Date };
+
+export async function getSavedOauth1Token(): Promise {
+ const db = await getDb();
+ const doc = await db.collection(AUTH_COLLECTION).findOne({ _id: "tokens" });
+ return doc?.oauth1Token ?? null;
+}
+
+export async function saveOauth1Token(oauth1Token: IOauth1Token): Promise {
+ const db = await getDb();
+ await db
+ .collection(AUTH_COLLECTION)
+ .updateOne({ _id: "tokens" }, { $set: { oauth1Token, updatedAt: new Date() } }, { upsert: true });
+}
+
+export async function savePendingMfaState(state: GarminPendingMfa): Promise {
+ const db = await getDb();
+ await db
+ .collection(PENDING_COLLECTION)
+ .updateOne({ _id: "pending" }, { $set: { state, createdAt: new Date() } }, { upsert: true });
+}
+
+export async function getPendingMfaState(): Promise {
+ const db = await getDb();
+ const doc = await db.collection(PENDING_COLLECTION).findOne({ _id: "pending" });
+ return doc?.state ?? null;
+}
+
+export async function clearPendingMfaState(): Promise {
+ const db = await getDb();
+ await db.collection(PENDING_COLLECTION).deleteOne({ _id: "pending" });
+}
diff --git a/lib/models/running.ts b/lib/models/running.ts
new file mode 100644
index 0000000..eb6f71d
--- /dev/null
+++ b/lib/models/running.ts
@@ -0,0 +1,96 @@
+import { ObjectId } from "mongodb";
+import { z } from "zod";
+import { getDb } from "@/lib/db";
+
+export const runningActivitySchema = z.object({
+ garminActivityId: z.number().int(),
+ name: z.string().min(1),
+ startTime: z.date(),
+ durationSec: z.number().positive(),
+ distanceM: z.number().nonnegative(),
+ avgPaceSecPerKm: z.number().nonnegative(),
+ avgHr: z.number().positive().optional(),
+ maxHr: z.number().positive().optional(),
+ calories: z.number().nonnegative().optional(),
+ elevationGainM: z.number().nonnegative().optional(),
+ avgCadence: z.number().nonnegative().optional(),
+ avgVerticalOscillationCm: z.number().nonnegative().optional(),
+ avgGroundContactTimeMs: z.number().nonnegative().optional(),
+ avgStrideLengthCm: z.number().nonnegative().optional(),
+ avgGroundContactBalanceLeftPct: z.number().nonnegative().optional(),
+ avgVerticalRatioPct: z.number().nonnegative().optional(),
+ vo2Max: z.number().nonnegative().optional(),
+ aerobicTrainingEffect: z.number().nonnegative().optional(),
+ anaerobicTrainingEffect: z.number().nonnegative().optional(),
+ trainingEffectLabel: z.string().optional(),
+ avgPowerW: z.number().nonnegative().optional(),
+ maxPowerW: z.number().nonnegative().optional(),
+ normPowerW: z.number().nonnegative().optional(),
+ avgRespirationRate: z.number().nonnegative().optional(),
+ hasRoute: z.boolean().optional(),
+});
+
+export type RunningActivityInput = z.infer;
+
+export type RoutePoint = [number, number];
+
+export type RunningActivity = RunningActivityInput & {
+ _id: ObjectId;
+ createdAt: Date;
+ routePoints?: RoutePoint[];
+};
+
+const COLLECTION = "running_activities";
+const SYNC_STATE_COLLECTION = "sync_state";
+
+async function getCollection() {
+ const db = await getDb();
+ const collection = db.collection(COLLECTION);
+ await collection.createIndex({ garminActivityId: 1 }, { unique: true });
+ return collection;
+}
+
+export async function upsertRunningActivity(activity: RunningActivityInput): Promise {
+ const collection = await getCollection();
+ await collection.updateOne(
+ { garminActivityId: activity.garminActivityId },
+ {
+ $set: activity,
+ $setOnInsert: { createdAt: new Date() },
+ },
+ { upsert: true }
+ );
+}
+
+export async function listRunningActivities(): Promise {
+ const collection = await getCollection();
+ return collection.find().sort({ startTime: -1 }).toArray();
+}
+
+export async function getRunningActivity(id: string): Promise {
+ const collection = await getCollection();
+ return collection.findOne({ _id: new ObjectId(id) });
+}
+
+export async function setRunningActivityRoutePoints(
+ garminActivityId: number,
+ points: RoutePoint[]
+): Promise {
+ const collection = await getCollection();
+ await collection.updateOne({ garminActivityId }, { $set: { routePoints: points } });
+}
+
+type SyncState = { _id: "garmin"; lastSyncAt: Date };
+
+export async function getLastSyncAt(): Promise {
+ const db = await getDb();
+ const state = await db.collection(SYNC_STATE_COLLECTION).findOne({ _id: "garmin" });
+ return state?.lastSyncAt ?? null;
+}
+
+export async function setLastSyncAt(date: Date): Promise {
+ const db = await getDb();
+ await db
+ .collection(SYNC_STATE_COLLECTION)
+ .updateOne({ _id: "garmin" }, { $set: { lastSyncAt: date } }, { upsert: true });
+}
diff --git a/lib/models/strength.ts b/lib/models/strength.ts
new file mode 100644
index 0000000..409744f
--- /dev/null
+++ b/lib/models/strength.ts
@@ -0,0 +1,66 @@
+import { ObjectId } from "mongodb";
+import { z } from "zod";
+import { getDb } from "@/lib/db";
+
+export const strengthSetSchema = z.object({
+ order: z.number().int().positive(),
+ weightKg: z.number().positive().optional(),
+ reps: z.number().int().positive().optional(),
+});
+
+export const strengthExerciseSchema = z.object({
+ name: z.string().min(1),
+ notes: z.string().optional(),
+ sets: z.array(strengthSetSchema),
+});
+
+export const strengthWorkoutSchema = z.object({
+ date: z.date(),
+ name: z.string().min(1),
+ notes: z.string().optional(),
+ exercises: z.array(strengthExerciseSchema),
+ sourceUrl: z.string().optional(),
+ sourceKey: z.string().min(1),
+});
+
+export type StrengthSet = z.infer;
+export type StrengthExercise = z.infer;
+export type StrengthWorkoutInput = z.infer;
+
+export type StrengthWorkout = StrengthWorkoutInput & {
+ _id: ObjectId;
+ createdAt: Date;
+};
+
+const COLLECTION = "strength_workouts";
+
+async function getCollection() {
+ const db = await getDb();
+ const collection = db.collection(COLLECTION);
+ await collection.createIndex({ sourceKey: 1 }, { unique: true });
+ return collection;
+}
+
+export async function upsertStrengthWorkout(
+ workout: StrengthWorkoutInput
+): Promise {
+ const collection = await getCollection();
+ await collection.updateOne(
+ { sourceKey: workout.sourceKey },
+ {
+ $set: workout,
+ $setOnInsert: { createdAt: new Date() },
+ },
+ { upsert: true }
+ );
+}
+
+export async function listStrengthWorkouts(): Promise {
+ const collection = await getCollection();
+ return collection.find().sort({ date: -1 }).toArray();
+}
+
+export async function getStrengthWorkout(id: string): Promise {
+ const collection = await getCollection();
+ return collection.findOne({ _id: new ObjectId(id) });
+}
diff --git a/lib/strength/stats.ts b/lib/strength/stats.ts
new file mode 100644
index 0000000..720c05a
--- /dev/null
+++ b/lib/strength/stats.ts
@@ -0,0 +1,46 @@
+import type { StrengthExercise, StrengthWorkout } from "@/lib/models/strength";
+
+export function exerciseVolumeKg(exercise: StrengthExercise): number {
+ return exercise.sets.reduce((sum, set) => sum + (set.weightKg ?? 0) * (set.reps ?? 0), 0);
+}
+
+export function exerciseTopWeightKg(exercise: StrengthExercise): number | undefined {
+ const weights = exercise.sets
+ .map((set) => set.weightKg)
+ .filter((weight): weight is number => weight !== undefined);
+ return weights.length > 0 ? Math.max(...weights) : undefined;
+}
+
+export function workoutVolumeKg(workout: StrengthWorkout): number {
+ return workout.exercises.reduce((sum, exercise) => sum + exerciseVolumeKg(exercise), 0);
+}
+
+export type ExerciseHistoryPoint = {
+ date: Date;
+ volumeKg: number;
+ topWeightKg?: number;
+};
+
+/**
+ * History of a single exercise across past workouts (oldest first, including
+ * the workout it was found in), used to chart progression.
+ */
+export function getExerciseHistory(
+ exerciseName: string,
+ workouts: StrengthWorkout[],
+ limit: number
+): ExerciseHistoryPoint[] {
+ const points: ExerciseHistoryPoint[] = [];
+ for (const workout of workouts) {
+ const exercise = workout.exercises.find((e) => e.name === exerciseName);
+ if (!exercise) continue;
+ points.push({
+ date: workout.date,
+ volumeKg: exerciseVolumeKg(exercise),
+ topWeightKg: exerciseTopWeightKg(exercise),
+ });
+ }
+
+ points.sort((a, b) => a.date.getTime() - b.date.getTime());
+ return points.slice(-limit);
+}
diff --git a/lib/strong/parser.ts b/lib/strong/parser.ts
new file mode 100644
index 0000000..464fc9c
--- /dev/null
+++ b/lib/strong/parser.ts
@@ -0,0 +1,97 @@
+import { createHash } from "crypto";
+import { parse } from "date-fns";
+import { enUS } from "date-fns/locale";
+import type { StrengthWorkoutInput } from "@/lib/models/strength";
+
+const DATE_FORMAT = "EEEE, d MMMM yyyy 'at' HH:mm";
+const HEADER_DATE_RE = /^[A-Za-z]+,\s+\d{1,2}\s+[A-Za-z]+\s+\d{4}\s+at\s+\d{1,2}:\d{2}$/;
+const SET_RE = /^Set\s+\d+:\s*(?:([\d.,]+)\s*kg\s*[×x]\s*)?(\d+)$/i;
+const SOURCE_URL_RE = /^https:\/\/link\.strong\.app\/\S+$/;
+
+type ParsedBlock = string[];
+
+function splitBlocks(text: string): ParsedBlock[] {
+ return text
+ .replace(/\r\n/g, "\n")
+ .split(/\n\s*\n+/)
+ .map((block) =>
+ block
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
+ )
+ .filter((block) => block.length > 0);
+}
+
+function parseWeight(raw: string | undefined): number | undefined {
+ if (!raw) return undefined;
+ const value = Number.parseFloat(raw.replace(",", "."));
+ return Number.isFinite(value) ? value : undefined;
+}
+
+function makeSourceKey(workout: Omit): string {
+ if (workout.sourceUrl) return workout.sourceUrl;
+ return createHash("sha256")
+ .update(`${workout.date.toISOString()}|${workout.name}`)
+ .digest("hex");
+}
+
+export function parseStrongShareText(text: string): StrengthWorkoutInput[] {
+ const blocks = splitBlocks(text);
+ const workouts: Omit[] = [];
+
+ for (const block of blocks) {
+ const isHeader = block.length === 2 && HEADER_DATE_RE.test(block[1]);
+
+ if (isHeader) {
+ const date = parse(block[1], DATE_FORMAT, new Date(), { locale: enUS });
+ workouts.push({ date, name: block[0], exercises: [] });
+ continue;
+ }
+
+ const current = workouts[workouts.length - 1];
+ if (!current) {
+ throw new Error(`Nieoczekiwany blok przed nagłówkiem treningu: "${block[0]}"`);
+ }
+
+ const lines = [...block];
+ const lastLine = lines[lines.length - 1];
+ if (SOURCE_URL_RE.test(lastLine)) {
+ current.sourceUrl = lastLine;
+ lines.pop();
+ }
+
+ if (lines.length === 0) continue;
+
+ if (/^Notes:/i.test(lines[0])) {
+ const note = lines.join(" ").replace(/^Notes:\s*/i, "");
+ const lastExercise = current.exercises[current.exercises.length - 1];
+ if (lastExercise) {
+ lastExercise.notes = note;
+ } else {
+ current.notes = note;
+ }
+ continue;
+ }
+
+ const [exerciseName, ...setLines] = lines;
+ const sets = setLines
+ .map((line, index) => {
+ const match = SET_RE.exec(line);
+ if (!match) return null;
+ return {
+ order: index + 1,
+ weightKg: parseWeight(match[1]),
+ reps: Number.parseInt(match[2], 10),
+ };
+ })
+ .filter((set): set is NonNullable => set !== null);
+
+ current.exercises.push({ name: exerciseName, sets });
+ }
+
+ return workouts.map((workout) => ({
+ ...workout,
+ sourceKey: makeSourceKey(workout),
+ }));
+}
diff --git a/logo.svg b/logo.svg
new file mode 100644
index 0000000..f013553
--- /dev/null
+++ b/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/package.json b/package.json
index 8370399..8e563b9 100644
--- a/package.json
+++ b/package.json
@@ -9,9 +9,20 @@
"lint": "eslint"
},
"dependencies": {
+ "@anthropic-ai/sdk": "^0.104.1",
+ "@types/leaflet": "^1.9.21",
+ "clsx": "^2.1.1",
+ "date-fns": "^4.4.0",
+ "garmin-connect": "^1.6.2",
+ "leaflet": "^1.9.4",
+ "lucide-react": "^1.18.0",
+ "mongodb": "^7.3.0",
"next": "16.2.9",
"react": "19.2.4",
- "react-dom": "19.2.4"
+ "react-dom": "19.2.4",
+ "react-leaflet": "^5.0.0",
+ "recharts": "^3.8.1",
+ "zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cd7c30d..5b8f32a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,30 @@ importers:
.:
dependencies:
+ '@anthropic-ai/sdk':
+ specifier: ^0.104.1
+ version: 0.104.1(zod@4.4.3)
+ '@types/leaflet':
+ specifier: ^1.9.21
+ version: 1.9.21
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
+ date-fns:
+ specifier: ^4.4.0
+ version: 4.4.0
+ garmin-connect:
+ specifier: ^1.6.2
+ version: 1.6.2
+ leaflet:
+ specifier: ^1.9.4
+ version: 1.9.4
+ lucide-react:
+ specifier: ^1.18.0
+ version: 1.18.0(react@19.2.4)
+ mongodb:
+ specifier: ^7.3.0
+ version: 7.3.0
next:
specifier: 16.2.9
version: 16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -17,6 +41,15 @@ importers:
react-dom:
specifier: 19.2.4
version: 19.2.4(react@19.2.4)
+ react-leaflet:
+ specifier: ^5.0.0
+ version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ recharts:
+ specifier: ^3.8.1
+ version: 3.8.1(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1)
+ zod:
+ specifier: ^4.4.3
+ version: 4.4.3
devDependencies:
'@tailwindcss/postcss':
specifier: ^4
@@ -49,6 +82,15 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
+ '@anthropic-ai/sdk@0.104.1':
+ resolution: {integrity: sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q==}
+ hasBin: true
+ peerDependencies:
+ zod: ^3.25.0 || ^4.0.0
+ peerDependenciesMeta:
+ zod:
+ optional: true
+
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -104,6 +146,10 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'}
@@ -339,6 +385,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@mongodb-js/saslprep@1.4.11':
+ resolution: {integrity: sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==}
+
'@napi-rs/wasm-runtime@1.1.5':
resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==}
peerDependencies:
@@ -415,9 +464,36 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
+ '@react-leaflet/core@3.0.0':
+ resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
+ peerDependencies:
+ leaflet: ^1.9.0
+ react: ^19.0.0
+ react-dom: ^19.0.0
+
+ '@reduxjs/toolkit@2.12.0':
+ resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
+ peerDependencies:
+ react: ^16.9.0 || ^17.0.0 || ^18 || ^19
+ react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-redux:
+ optional: true
+
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
+ '@stablelib/base64@1.0.1':
+ resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
+
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
+ '@standard-schema/utils@0.3.0':
+ resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
+
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -512,15 +588,48 @@ packages:
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
+ '@types/d3-array@3.2.2':
+ resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
+
+ '@types/d3-color@3.1.3':
+ resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
+
+ '@types/d3-ease@3.0.2':
+ resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
+
+ '@types/d3-interpolate@3.0.4':
+ resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
+
+ '@types/d3-path@3.1.1':
+ resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
+
+ '@types/d3-scale@4.0.9':
+ resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
+
+ '@types/d3-shape@3.1.8':
+ resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
+
+ '@types/d3-time@3.0.4':
+ resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
+
+ '@types/d3-timer@3.0.2':
+ resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+ '@types/geojson@7946.0.16':
+ resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
+
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+ '@types/leaflet@1.9.21':
+ resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
+
'@types/node@20.19.43':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
@@ -532,6 +641,15 @@ packages:
'@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
+ '@types/use-sync-external-store@0.0.6':
+ resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
+
+ '@types/webidl-conversions@7.0.3':
+ resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==}
+
+ '@types/whatwg-url@13.0.0':
+ resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==}
+
'@typescript-eslint/eslint-plugin@8.61.0':
resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -711,6 +829,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ agent-base@6.0.2:
+ resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
+ engines: {node: '>= 6.0.0'}
+
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
@@ -718,6 +840,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
+ app-root-path@3.1.0:
+ resolution: {integrity: sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==}
+ engines: {node: '>= 6.0.0'}
+
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -764,6 +890,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
+ asynckit@0.4.0:
+ resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
@@ -772,6 +901,9 @@ packages:
resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==}
engines: {node: '>=4'}
+ axios@1.18.0:
+ resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==}
+
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@@ -804,6 +936,10 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
+ bson@7.2.0:
+ resolution: {integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==}
+ engines: {node: '>=20.19.0'}
+
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -830,6 +966,10 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -837,6 +977,10 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+ combined-stream@1.0.8:
+ resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+ engines: {node: '>= 0.8'}
+
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -847,9 +991,57 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ crypto@1.0.1:
+ resolution: {integrity: sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==}
+ deprecated: This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.
+
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ d3-array@3.2.4:
+ resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
+ engines: {node: '>=12'}
+
+ d3-color@3.1.0:
+ resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
+ engines: {node: '>=12'}
+
+ d3-ease@3.0.1:
+ resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+ engines: {node: '>=12'}
+
+ d3-format@3.1.2:
+ resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
+ engines: {node: '>=12'}
+
+ d3-interpolate@3.0.1:
+ resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
+ engines: {node: '>=12'}
+
+ d3-path@3.1.0:
+ resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
+ engines: {node: '>=12'}
+
+ d3-scale@4.0.2:
+ resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
+ engines: {node: '>=12'}
+
+ d3-shape@3.2.0:
+ resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
+ engines: {node: '>=12'}
+
+ d3-time-format@4.1.0:
+ resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
+ engines: {node: '>=12'}
+
+ d3-time@3.1.0:
+ resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
+ engines: {node: '>=12'}
+
+ d3-timer@3.0.1:
+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+ engines: {node: '>=12'}
+
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -865,6 +1057,9 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
+ date-fns@4.4.0:
+ resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
+
debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
@@ -882,6 +1077,9 @@ packages:
supports-color:
optional: true
+ decimal.js-light@2.5.1:
+ resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
+
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -893,6 +1091,10 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
+ delayed-stream@1.0.0:
+ resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+ engines: {node: '>=0.4.0'}
+
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -947,6 +1149,9 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
+ es-toolkit@1.47.1:
+ resolution: {integrity: sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==}
+
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -1075,6 +1280,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
+ eventemitter3@5.0.4:
+ resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1088,6 +1296,9 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+ fast-sha256@1.3.0:
+ resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
+
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -1119,10 +1330,23 @@ packages:
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
+ follow-redirects@1.16.0:
+ resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
+
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
+ form-data@4.0.6:
+ resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
+ engines: {node: '>= 6'}
+
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -1133,6 +1357,9 @@ packages:
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
+ garmin-connect@1.6.2:
+ resolution: {integrity: sha512-8QGeB3ZQTfaG1UZe1hd3CEOsaAozC1quG+CjX8yV0P6x+AyyWz4jqgIJ3MEK2NhDsxlXsF4jBOdpjUQubzmdFQ==}
+
generator-function@2.0.1:
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
engines: {node: '>= 0.4'}
@@ -1216,6 +1443,10 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+ https-proxy-agent@5.0.1:
+ resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
+ engines: {node: '>= 6'}
+
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -1224,6 +1455,12 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
+ immer@10.2.0:
+ resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
+
+ immer@11.1.8:
+ resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==}
+
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -1236,6 +1473,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
+ internmap@2.0.3:
+ resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
+ engines: {node: '>=12'}
+
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -1372,6 +1613,10 @@ packages:
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+ json-schema-to-ts@3.1.1:
+ resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
+ engines: {node: '>=16'}
+
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -1401,6 +1646,9 @@ packages:
resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
engines: {node: '>=0.10'}
+ leaflet@1.9.4:
+ resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
+
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -1482,6 +1730,9 @@ packages:
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+ lodash@4.18.1:
+ resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
+
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@@ -1489,6 +1740,15 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ lucide-react@1.18.0:
+ resolution: {integrity: sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==}
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ luxon@3.7.2:
+ resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
+ engines: {node: '>=12'}
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -1496,6 +1756,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ memory-pager@1.5.0:
+ resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
+
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -1504,6 +1767,14 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
+ mime-db@1.52.0:
+ resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+ engines: {node: '>= 0.6'}
+
+ mime-types@2.1.35:
+ resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+ engines: {node: '>= 0.6'}
+
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
@@ -1514,6 +1785,37 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+ mongodb-connection-string-url@7.0.1:
+ resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==}
+ engines: {node: '>=20.19.0'}
+
+ mongodb@7.3.0:
+ resolution: {integrity: sha512-WpCqSx7JAU9vcyjm/SU7ydnHls2YrfU3Y3sx4Ml9D7sPe4mXPlaapndiurDXrQ7/VvJkB4/i7b7WovHb8bd8sg==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@aws-sdk/credential-providers': ^3.806.0
+ '@mongodb-js/zstd': ^7.0.0
+ gcp-metadata: ^7.0.1
+ kerberos: ^7.0.0
+ mongodb-client-encryption: '>=7.0.0 <7.1.0'
+ snappy: ^7.3.2
+ socks: ^2.8.6
+ peerDependenciesMeta:
+ '@aws-sdk/credential-providers':
+ optional: true
+ '@mongodb-js/zstd':
+ optional: true
+ gcp-metadata:
+ optional: true
+ kerberos:
+ optional: true
+ mongodb-client-encryption:
+ optional: true
+ snappy:
+ optional: true
+ socks:
+ optional: true
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1559,6 +1861,9 @@ packages:
resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
engines: {node: '>=18'}
+ oauth-1.0a@2.2.6:
+ resolution: {integrity: sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==}
+
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -1652,10 +1957,18 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+ proxy-from-env@2.1.0:
+ resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
+ engines: {node: '>=10'}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ qs@6.15.2:
+ resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
+ engines: {node: '>=0.6'}
+
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -1667,10 +1980,45 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+ react-leaflet@5.0.0:
+ resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==}
+ peerDependencies:
+ leaflet: ^1.9.0
+ react: ^19.0.0
+ react-dom: ^19.0.0
+
+ react-redux@9.3.0:
+ resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
+ peerDependencies:
+ '@types/react': ^18.2.25 || ^19
+ react: ^18.0 || ^19
+ redux: ^5.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ redux:
+ optional: true
+
react@19.2.4:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
+ recharts@3.8.1:
+ resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ redux-thunk@3.1.0:
+ resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
+ peerDependencies:
+ redux: ^5.0.0
+
+ redux@5.0.1:
+ resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
+
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -1679,6 +2027,9 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
+ reselect@5.1.1:
+ resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
+
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -1766,9 +2117,15 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ sparse-bitfield@3.0.3:
+ resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==}
+
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
+ standardwebhooks@1.0.0:
+ resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
+
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
@@ -1832,6 +2189,9 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
+ tiny-invariant@1.3.3:
+ resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
@@ -1840,6 +2200,13 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
+ tr46@5.1.1:
+ resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
+ engines: {node: '>=18'}
+
+ ts-algebra@2.0.0:
+ resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
+
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@@ -1903,6 +2270,22 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ use-sync-external-store@1.6.0:
+ resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ victory-vendor@37.3.6:
+ resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
+
+ webidl-conversions@7.0.0:
+ resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
+ engines: {node: '>=12'}
+
+ whatwg-url@14.2.0:
+ resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
+ engines: {node: '>=18'}
+
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -1948,6 +2331,13 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
+ '@anthropic-ai/sdk@0.104.1(zod@4.4.3)':
+ dependencies:
+ json-schema-to-ts: 3.1.1
+ standardwebhooks: 1.0.0
+ optionalDependencies:
+ zod: 4.4.3
+
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -2025,6 +2415,8 @@ snapshots:
dependencies:
'@babel/types': 7.29.7
+ '@babel/runtime@7.29.7': {}
+
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
@@ -2247,6 +2639,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@mongodb-js/saslprep@1.4.11':
+ dependencies:
+ sparse-bitfield: 3.0.3
+
'@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
@@ -2298,8 +2694,32 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
+ '@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ leaflet: 1.9.4
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
+ '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.4)(redux@5.0.1))(react@19.2.4)':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@standard-schema/utils': 0.3.0
+ immer: 11.1.8
+ redux: 5.0.1
+ redux-thunk: 3.1.0(redux@5.0.1)
+ reselect: 5.1.1
+ optionalDependencies:
+ react: 19.2.4
+ react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.4)(redux@5.0.1)
+
'@rtsao/scc@1.1.0': {}
+ '@stablelib/base64@1.0.1': {}
+
+ '@standard-schema/spec@1.1.0': {}
+
+ '@standard-schema/utils@0.3.0': {}
+
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -2378,12 +2798,42 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@types/d3-array@3.2.2': {}
+
+ '@types/d3-color@3.1.3': {}
+
+ '@types/d3-ease@3.0.2': {}
+
+ '@types/d3-interpolate@3.0.4':
+ dependencies:
+ '@types/d3-color': 3.1.3
+
+ '@types/d3-path@3.1.1': {}
+
+ '@types/d3-scale@4.0.9':
+ dependencies:
+ '@types/d3-time': 3.0.4
+
+ '@types/d3-shape@3.1.8':
+ dependencies:
+ '@types/d3-path': 3.1.1
+
+ '@types/d3-time@3.0.4': {}
+
+ '@types/d3-timer@3.0.2': {}
+
'@types/estree@1.0.9': {}
+ '@types/geojson@7946.0.16': {}
+
'@types/json-schema@7.0.15': {}
'@types/json5@0.0.29': {}
+ '@types/leaflet@1.9.21':
+ dependencies:
+ '@types/geojson': 7946.0.16
+
'@types/node@20.19.43':
dependencies:
undici-types: 6.21.0
@@ -2396,6 +2846,14 @@ snapshots:
dependencies:
csstype: 3.2.3
+ '@types/use-sync-external-store@0.0.6': {}
+
+ '@types/webidl-conversions@7.0.3': {}
+
+ '@types/whatwg-url@13.0.0':
+ dependencies:
+ '@types/webidl-conversions': 7.0.3
+
'@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -2563,6 +3021,12 @@ snapshots:
acorn@8.17.0: {}
+ agent-base@6.0.2:
+ dependencies:
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
ajv@6.15.0:
dependencies:
fast-deep-equal: 3.1.3
@@ -2574,6 +3038,8 @@ snapshots:
dependencies:
color-convert: 2.0.1
+ app-root-path@3.1.0: {}
+
argparse@2.0.1: {}
aria-query@5.3.2: {}
@@ -2649,12 +3115,24 @@ snapshots:
async-function@1.0.0: {}
+ asynckit@0.4.0: {}
+
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
axe-core@4.12.1: {}
+ axios@1.18.0:
+ dependencies:
+ follow-redirects: 1.16.0
+ form-data: 4.0.6
+ https-proxy-agent: 5.0.1
+ proxy-from-env: 2.1.0
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
axobject-query@4.1.0: {}
balanced-match@1.0.2: {}
@@ -2684,6 +3162,8 @@ snapshots:
node-releases: 2.0.47
update-browserslist-db: 1.2.3(browserslist@4.28.2)
+ bson@7.2.0: {}
+
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -2712,12 +3192,18 @@ snapshots:
client-only@0.0.1: {}
+ clsx@2.1.1: {}
+
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
+ combined-stream@1.0.8:
+ dependencies:
+ delayed-stream: 1.0.0
+
concat-map@0.0.1: {}
convert-source-map@2.0.0: {}
@@ -2728,8 +3214,48 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ crypto@1.0.1: {}
+
csstype@3.2.3: {}
+ d3-array@3.2.4:
+ dependencies:
+ internmap: 2.0.3
+
+ d3-color@3.1.0: {}
+
+ d3-ease@3.0.1: {}
+
+ d3-format@3.1.2: {}
+
+ d3-interpolate@3.0.1:
+ dependencies:
+ d3-color: 3.1.0
+
+ d3-path@3.1.0: {}
+
+ d3-scale@4.0.2:
+ dependencies:
+ d3-array: 3.2.4
+ d3-format: 3.1.2
+ d3-interpolate: 3.0.1
+ d3-time: 3.1.0
+ d3-time-format: 4.1.0
+
+ d3-shape@3.2.0:
+ dependencies:
+ d3-path: 3.1.0
+
+ d3-time-format@4.1.0:
+ dependencies:
+ d3-time: 3.1.0
+
+ d3-time@3.1.0:
+ dependencies:
+ d3-array: 3.2.4
+
+ d3-timer@3.0.1: {}
+
damerau-levenshtein@1.0.8: {}
data-view-buffer@1.0.2:
@@ -2750,6 +3276,8 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
+ date-fns@4.4.0: {}
+
debug@3.2.7:
dependencies:
ms: 2.1.3
@@ -2758,6 +3286,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decimal.js-light@2.5.1: {}
+
deep-is@0.1.4: {}
define-data-property@1.1.4:
@@ -2772,6 +3302,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
+ delayed-stream@1.0.0: {}
+
detect-libc@2.1.2: {}
doctrine@2.1.0:
@@ -2894,6 +3426,8 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
+ es-toolkit@1.47.1: {}
+
escalade@3.2.0: {}
escape-string-regexp@4.0.0: {}
@@ -3103,6 +3637,8 @@ snapshots:
esutils@2.0.3: {}
+ eventemitter3@5.0.4: {}
+
fast-deep-equal@3.1.3: {}
fast-glob@3.3.1:
@@ -3117,6 +3653,8 @@ snapshots:
fast-levenshtein@2.0.6: {}
+ fast-sha256@1.3.0: {}
+
fastq@1.20.1:
dependencies:
reusify: 1.1.0
@@ -3145,10 +3683,20 @@ snapshots:
flatted@3.4.2: {}
+ follow-redirects@1.16.0: {}
+
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
+ form-data@4.0.6:
+ dependencies:
+ asynckit: 0.4.0
+ combined-stream: 1.0.8
+ es-set-tostringtag: 2.1.0
+ hasown: 2.0.4
+ mime-types: 2.1.35
+
function-bind@1.1.2: {}
function.prototype.name@1.2.0:
@@ -3165,6 +3713,20 @@ snapshots:
functions-have-names@1.2.3: {}
+ garmin-connect@1.6.2:
+ dependencies:
+ app-root-path: 3.1.0
+ axios: 1.18.0
+ crypto: 1.0.1
+ form-data: 4.0.6
+ lodash: 4.18.1
+ luxon: 3.7.2
+ oauth-1.0a: 2.2.6
+ qs: 6.15.2
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
generator-function@2.0.1: {}
gensync@1.0.0-beta.2: {}
@@ -3246,10 +3808,21 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
+ https-proxy-agent@5.0.1:
+ dependencies:
+ agent-base: 6.0.2
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
ignore@5.3.2: {}
ignore@7.0.5: {}
+ immer@10.2.0: {}
+
+ immer@11.1.8: {}
+
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -3263,6 +3836,8 @@ snapshots:
hasown: 2.0.4
side-channel: 1.1.1
+ internmap@2.0.3: {}
+
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.9
@@ -3404,6 +3979,11 @@ snapshots:
json-buffer@3.0.1: {}
+ json-schema-to-ts@3.1.1:
+ dependencies:
+ '@babel/runtime': 7.29.7
+ ts-algebra: 2.0.0
+
json-schema-traverse@0.4.1: {}
json-stable-stringify-without-jsonify@1.0.1: {}
@@ -3431,6 +4011,8 @@ snapshots:
dependencies:
language-subtag-registry: 0.3.23
+ leaflet@1.9.4: {}
+
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -3491,6 +4073,8 @@ snapshots:
lodash.merge@4.6.2: {}
+ lodash@4.18.1: {}
+
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@@ -3499,12 +4083,20 @@ snapshots:
dependencies:
yallist: 3.1.1
+ lucide-react@1.18.0(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+
+ luxon@3.7.2: {}
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
math-intrinsics@1.1.0: {}
+ memory-pager@1.5.0: {}
+
merge2@1.4.1: {}
micromatch@4.0.8:
@@ -3512,6 +4104,12 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
+ mime-db@1.52.0: {}
+
+ mime-types@2.1.35:
+ dependencies:
+ mime-db: 1.52.0
+
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.6
@@ -3522,6 +4120,17 @@ snapshots:
minimist@1.2.8: {}
+ mongodb-connection-string-url@7.0.1:
+ dependencies:
+ '@types/whatwg-url': 13.0.0
+ whatwg-url: 14.2.0
+
+ mongodb@7.3.0:
+ dependencies:
+ '@mongodb-js/saslprep': 1.4.11
+ bson: 7.2.0
+ mongodb-connection-string-url: 7.0.1
+
ms@2.1.3: {}
nanoid@3.3.12: {}
@@ -3563,6 +4172,8 @@ snapshots:
node-releases@2.0.47: {}
+ oauth-1.0a@2.2.6: {}
+
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -3666,8 +4277,14 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
+ proxy-from-env@2.1.0: {}
+
punycode@2.3.1: {}
+ qs@6.15.2:
+ dependencies:
+ side-channel: 1.1.1
+
queue-microtask@1.2.3: {}
react-dom@19.2.4(react@19.2.4):
@@ -3677,8 +4294,50 @@ snapshots:
react-is@16.13.1: {}
+ react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ dependencies:
+ '@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ leaflet: 1.9.4
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
+ react-redux@9.3.0(@types/react@19.2.17)(react@19.2.4)(redux@5.0.1):
+ dependencies:
+ '@types/use-sync-external-store': 0.0.6
+ react: 19.2.4
+ use-sync-external-store: 1.6.0(react@19.2.4)
+ optionalDependencies:
+ '@types/react': 19.2.17
+ redux: 5.0.1
+
react@19.2.4: {}
+ recharts@3.8.1(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1):
+ dependencies:
+ '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.4)(redux@5.0.1))(react@19.2.4)
+ clsx: 2.1.1
+ decimal.js-light: 2.5.1
+ es-toolkit: 1.47.1
+ eventemitter3: 5.0.4
+ immer: 10.2.0
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ react-is: 16.13.1
+ react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.4)(redux@5.0.1)
+ reselect: 5.1.1
+ tiny-invariant: 1.3.3
+ use-sync-external-store: 1.6.0(react@19.2.4)
+ victory-vendor: 37.3.6
+ transitivePeerDependencies:
+ - '@types/react'
+ - redux
+
+ redux-thunk@3.1.0(redux@5.0.1):
+ dependencies:
+ redux: 5.0.1
+
+ redux@5.0.1: {}
+
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.9
@@ -3699,6 +4358,8 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
+ reselect@5.1.1: {}
+
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -3833,8 +4494,17 @@ snapshots:
source-map-js@1.2.1: {}
+ sparse-bitfield@3.0.3:
+ dependencies:
+ memory-pager: 1.5.0
+
stable-hash@0.0.5: {}
+ standardwebhooks@1.0.0:
+ dependencies:
+ '@stablelib/base64': 1.0.1
+ fast-sha256: 1.3.0
+
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -3912,6 +4582,8 @@ snapshots:
tapable@2.3.3: {}
+ tiny-invariant@1.3.3: {}
+
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
@@ -3921,6 +4593,12 @@ snapshots:
dependencies:
is-number: 7.0.0
+ tr46@5.1.1:
+ dependencies:
+ punycode: 2.3.1
+
+ ts-algebra@2.0.0: {}
+
ts-api-utils@2.5.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -4030,6 +4708,34 @@ snapshots:
dependencies:
punycode: 2.3.1
+ use-sync-external-store@1.6.0(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+
+ victory-vendor@37.3.6:
+ dependencies:
+ '@types/d3-array': 3.2.2
+ '@types/d3-ease': 3.0.2
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-scale': 4.0.9
+ '@types/d3-shape': 3.1.8
+ '@types/d3-time': 3.0.4
+ '@types/d3-timer': 3.0.2
+ d3-array: 3.2.4
+ d3-ease: 3.0.1
+ d3-interpolate: 3.0.1
+ d3-scale: 4.0.2
+ d3-shape: 3.2.0
+ d3-time: 3.1.0
+ d3-timer: 3.0.1
+
+ webidl-conversions@7.0.0: {}
+
+ whatwg-url@14.2.0:
+ dependencies:
+ tr46: 5.1.1
+ webidl-conversions: 7.0.0
+
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
diff --git a/public/logo.svg b/public/logo.svg
new file mode 100644
index 0000000..f013553
--- /dev/null
+++ b/public/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file