This commit is contained in:
Dominik Klarkowski
2026-06-16 09:43:48 +02:00
parent f0e87d8d11
commit 36407f534b
52 changed files with 3211 additions and 100 deletions

8
.env.local.example Normal file
View File

@@ -0,0 +1,8 @@
# Jeśli używasz docker-compose.yaml z tego repo (root/example), użyj:
# MONGODB_URI=mongodb://root:example@localhost:27017/?authSource=admin
MONGODB_URI=mongodb://localhost:27017
MONGODB_DB=knur
GARMIN_EMAIL=
GARMIN_PASSWORD=
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-sonnet-4-6

1
.gitignore vendored
View File

@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env*.example
# vercel # vercel
.vercel .vercel

12
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,12 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/
# Zeppelin ignored files
/ZeppelinRemoteNotebooks/

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

9
.idea/knur-app.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

9
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ComposerSettings">
<execution />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="corretto-25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/knur-app.iml" filepath="$PROJECT_DIR$/.idea/knur-app.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -1,36 +1,41 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # Knur
## Getting Started Aplikacja do analizy treningów biegowych (Garmin Connect) i siłowych (import ze Strong), z analizą AI (Claude).
First, run the development server: ## Wymagania
- Node 22 (`nvm use 22`)
- pnpm
- MongoDB (lokalnie lub Atlas)
## Konfiguracja
Skopiuj `.env.local.example` do `.env.local` i wypełnij wartości:
```bash ```bash
npm run dev cp .env.local.example .env.local
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - `MONGODB_URI`, `MONGODB_DB` — połączenie z MongoDB (domyślnie `mongodb://localhost:27017`, baza `knur`).
- `GARMIN_EMAIL`, `GARMIN_PASSWORD` — dane logowania do Garmin Connect, używane do synchronizacji biegów (nieoficjalne API).
- `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL` — klucz API Claude i model używany do generowania analiz potreningowych (domyślnie `claude-sonnet-4-6`).
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. Status konfiguracji widoczny jest na stronie `/settings`.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ## Uruchomienie
## Learn More ```bash
nvm use 22
pnpm install
pnpm dev
```
To learn more about Next.js, take a look at the following resources: Otwórz [http://localhost:3000](http://localhost:3000).
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ## Funkcje
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - **Panel** (`/`) — statystyki tygodniowe, ostatni bieg, ostatni trening siłowy, ostatnia analiza AI.
- **Bieganie** (`/running`) — lista biegów zsynchronizowanych z Garmin Connect (przycisk „Synchronizuj z Garmin”), szczegóły aktywności.
## Deploy on Vercel - **Siłownia** (`/strength`) — lista treningów, import (`/strength/import`) przez wklejenie tekstu z funkcji „Share workout” w aplikacji Strong, szczegóły treningu.
- **Analiza AI** — na stronach szczegółów biegu i treningu siłowego, przycisk „Generuj analizę” wywołuje Claude i zapisuje podsumowanie ze wskazówkami.
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - **Ustawienia** (`/settings`) — status konfiguracji (MongoDB, Garmin, Claude) i ostatnia synchronizacja Garmin.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

33
app/ai/actions.ts Normal file
View File

@@ -0,0 +1,33 @@
"use server";
import { revalidatePath } from "next/cache";
import { generateAnalysis, generateDashboardAnalysis } from "@/lib/ai/claude";
import type { AiAnalysisTargetType } from "@/lib/models/analysis";
export type GenerateAnalysisState = { error: string } | { success: true } | null;
export async function generateAnalysisAction(
targetType: AiAnalysisTargetType,
targetId: string
): Promise<GenerateAnalysisState> {
try {
await generateAnalysis(targetType, targetId);
} catch (error) {
return { error: error instanceof Error ? error.message : "Nie udało się wygenerować analizy." };
}
revalidatePath(`/${targetType}/${targetId}`);
revalidatePath("/");
return { success: true };
}
export async function generateDashboardAnalysisAction(): Promise<GenerateAnalysisState> {
try {
await generateDashboardAnalysis();
} catch (error) {
return { error: error instanceof Error ? error.message : "Nie udało się wygenerować analizy." };
}
revalidatePath("/");
return { success: true };
}

View File

@@ -1,26 +1,29 @@
@import "tailwindcss"; @import "tailwindcss";
:root { :root {
--background: #ffffff; --color-bg: #2b2d42;
--foreground: #171717; --color-surface: #363850;
--color-fg: #f7f3e9;
--color-accent: #fb4617;
--color-muted: #434247;
--color-secondary: #2e162e;
--color-sand: #d4cbbb;
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-bg: var(--color-bg);
--color-foreground: var(--foreground); --color-surface: var(--color-surface);
--color-fg: var(--color-fg);
--color-accent: var(--color-accent);
--color-muted: var(--color-muted);
--color-secondary: var(--color-secondary);
--color-sand: var(--color-sand);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
} }
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body { body {
background: var(--background); background: var(--color-bg);
color: var(--foreground); color: var(--color-fg);
font-family: Arial, Helvetica, sans-serif; font-family: var(--font-sans), Arial, Helvetica, sans-serif;
} }

1
app/icon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { Nav } from "@/components/nav";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "KNUR - Książka Notowań Udźwigów i Rezultatów",
description: "Generated by create next app", description: "Analiza treningów biegowych i siłowych",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -24,10 +25,15 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html <html
lang="en" lang="pl"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col bg-bg text-fg">
<Nav />
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6 sm:px-6">
{children}
</main>
</body>
</html> </html>
); );
} }

View File

@@ -1,65 +1,90 @@
import Image from "next/image"; import Link from "next/link";
import { startOfWeek } from "date-fns";
import { Activity, Dumbbell } from "lucide-react";
import { DashboardAnalysisCard } from "@/components/dashboard-analysis-card";
import { EmptyState } from "@/components/empty-state";
import { StatCard } from "@/components/stat-card";
import { formatDateShort, formatDistance, formatDuration, formatPace } from "@/lib/format";
import { getDashboardAnalysis } from "@/lib/models/analysis";
import { listRunningActivities } from "@/lib/models/running";
import { listStrengthWorkouts } from "@/lib/models/strength";
export const dynamic = "force-dynamic";
export default async function Home() {
const [runs, strengthWorkouts, dashboardAnalysis] = await Promise.all([
listRunningActivities(),
listStrengthWorkouts(),
getDashboardAnalysis(),
]);
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
const weeklyKm = runs
.filter((run) => run.startTime >= weekStart)
.reduce((sum, run) => sum + run.distanceM, 0) / 1000;
const weeklyStrengthSessions = strengthWorkouts.filter((workout) => workout.date >= weekStart).length;
const latestRun = runs[0];
const latestStrength = strengthWorkouts[0];
export default function Home() {
return ( return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div className="flex flex-col gap-8">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image <section className="grid grid-cols-1 gap-4 sm:grid-cols-2">
className="dark:invert" <StatCard label="Kilometry w tym tygodniu" value={`${weeklyKm.toFixed(1)} km`} hint="Bieganie" />
src="/next.svg" <StatCard label="Treningi siłowe w tym tygodniu" value={weeklyStrengthSessions} hint="Siłownia" />
alt="Next.js logo" </section>
width={100}
height={20} <section className="grid grid-cols-1 gap-4 sm:grid-cols-2">
priority <div className="flex flex-col gap-3">
/> <h2 className="text-lg font-semibold text-fg">Ostatni bieg</h2>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> {latestRun ? (
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50"> <Link
To get started, edit the page.tsx file. href={`/running/${latestRun._id.toString()}`}
</h1> className="flex flex-col gap-2 rounded-lg border border-muted/40 bg-surface p-4 transition-colors hover:border-accent/60"
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
> >
Templates <div className="font-semibold text-fg">{latestRun.name}</div>
</a>{" "} <div className="text-sm text-fg/60">{formatDateShort(latestRun.startTime)}</div>
or the{" "} <div className="text-sm text-fg/70">
<a {formatDistance(latestRun.distanceM)} · {formatDuration(latestRun.durationSec)} ·{" "}
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" {formatPace(latestRun.avgPaceSecPerKm)}
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div> </div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> </Link>
<a ) : (
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]" <EmptyState
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" icon={<Activity size={32} />}
target="_blank" title="Brak danych o bieganiu"
rel="noopener noreferrer" description="Zsynchronizuj aktywności z Garmin Connect, aby zobaczyć tutaj swoje biegi."
> action={{ href: "/running", label: "Przejdź do biegania" }}
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/> />
Deploy Now )}
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div> </div>
</main> <div className="flex flex-col gap-3">
<h2 className="text-lg font-semibold text-fg">Ostatni trening siłowy</h2>
{latestStrength ? (
<Link
href={`/strength/${latestStrength._id.toString()}`}
className="flex flex-col gap-2 rounded-lg border border-muted/40 bg-surface p-4 transition-colors hover:border-accent/60"
>
<div className="font-semibold text-fg">{latestStrength.name}</div>
<div className="text-sm text-fg/60">{formatDateShort(latestStrength.date)}</div>
<div className="text-sm text-fg/70">
{latestStrength.exercises.length}{" "}
{latestStrength.exercises.length === 1 ? "ćwiczenie" : "ćwiczeń"}
</div>
</Link>
) : (
<EmptyState
icon={<Dumbbell size={32} />}
title="Brak treningów siłowych"
description="Zaimportuj trening wklejając tekst wygenerowany przez aplikację Strong."
action={{ href: "/strength/import", label: "Zaimportuj trening" }}
/>
)}
</div>
</section>
<DashboardAnalysisCard analysis={dashboardAnalysis} />
</div> </div>
); );
} }

123
app/running/[id]/page.tsx Normal file
View File

@@ -0,0 +1,123 @@
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { AiAnalysisCard } from "@/components/ai-analysis-card";
import { RouteMapSection } from "@/components/route-map-section";
import { StatCard } from "@/components/stat-card";
import { formatDate, formatDistance, formatDuration, formatPace } from "@/lib/format";
import { fetchActivityRoutePoints, getAuthorizedClient } from "@/lib/garmin/client";
import { getLatestAnalysisForTarget } from "@/lib/models/analysis";
import { getRunningActivity, setRunningActivityRoutePoints, type RunningActivity } from "@/lib/models/running";
export const dynamic = "force-dynamic";
async function RouteMapFetcher({ activity }: { activity: RunningActivity }) {
let routePoints = activity.routePoints;
if (!routePoints && activity.hasRoute) {
try {
const client = await getAuthorizedClient();
const points = await fetchActivityRoutePoints(client, activity.garminActivityId);
if (points) {
await setRunningActivityRoutePoints(activity.garminActivityId, points);
routePoints = points;
}
} catch {
// GPS fetch failed silently
}
}
return (
<div className="col-span-2 row-span-3 min-h-[240px] overflow-hidden rounded-lg border border-muted/40">
{routePoints && routePoints.length > 0 ? (
<RouteMapSection points={routePoints} />
) : (
<div className="flex h-full min-h-[240px] items-center justify-center bg-surface">
<span className="text-sm text-fg/30">Brak danych GPS</span>
</div>
)}
</div>
);
}
function MapSkeleton() {
return (
<div className="col-span-2 row-span-3 min-h-[240px] animate-pulse overflow-hidden rounded-lg border border-muted/40 bg-surface" />
);
}
export default async function RunningActivityPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const activity = await getRunningActivity(id);
if (!activity) {
notFound();
}
const analysis = await getLatestAnalysisForTarget("running", activity._id);
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-fg">{activity.name}</h1>
<p className="mt-1 text-sm text-fg/60">{formatDate(activity.startTime)}</p>
</div>
<section className="grid grid-cols-3 gap-4">
{/* Map: cols 12, rows 13 — streamed in after page skeleton */}
<Suspense fallback={<MapSkeleton />}>
<RouteMapFetcher activity={activity} />
</Suspense>
{/* Col 3, rows 13: key pace stats */}
<StatCard highlight label="Dystans" value={formatDistance(activity.distanceM)} />
<StatCard highlight label="Czas" value={formatDuration(activity.durationSec)} />
<StatCard highlight label="Tempo" value={formatPace(activity.avgPaceSecPerKm)} />
{/* Row 4: HR, calories, cadence — always shown */}
<StatCard highlight label="Średnie HR" value={activity.avgHr ? `${Math.round(activity.avgHr)} bpm` : "—"} />
<StatCard highlight label="Kalorie" value={activity.calories ? `${Math.round(activity.calories)} kcal` : "—"} />
<StatCard highlight label="Kadencja" value={activity.avgCadence ? `${Math.round(activity.avgCadence)} kr/min` : "—"} />
{/* Row 5+: optional advanced stats, auto-flow */}
{activity.maxHr ? <StatCard label="Maks. HR" value={`${Math.round(activity.maxHr)} bpm`} /> : null}
{activity.elevationGainM ? <StatCard label="Podejście" value={`${Math.round(activity.elevationGainM)} m`} /> : null}
{activity.vo2Max ? <StatCard label="VO2max" value={`${Math.round(activity.vo2Max)}`} /> : null}
{activity.avgGroundContactTimeMs ? (
<StatCard label="Czas kontaktu z podłożem" value={`${Math.round(activity.avgGroundContactTimeMs)} ms`} />
) : null}
{activity.avgVerticalOscillationCm ? (
<StatCard label="Oscylacja wertykalna" value={`${activity.avgVerticalOscillationCm.toFixed(1)} cm`} />
) : null}
{activity.avgVerticalRatioPct ? (
<StatCard label="Wskaźnik wertykalny" value={`${activity.avgVerticalRatioPct.toFixed(1)}%`} />
) : null}
{activity.avgStrideLengthCm ? (
<StatCard label="Długość kroku" value={`${activity.avgStrideLengthCm.toFixed(0)} cm`} />
) : null}
{activity.avgGroundContactBalanceLeftPct ? (
<StatCard
label="Balans kontaktu (L/P)"
value={`${activity.avgGroundContactBalanceLeftPct.toFixed(1)}% / ${(100 - activity.avgGroundContactBalanceLeftPct).toFixed(1)}%`}
/>
) : null}
{activity.avgPowerW ? <StatCard label="Moc średnia" value={`${Math.round(activity.avgPowerW)} W`} /> : null}
{activity.maxPowerW ? <StatCard label="Moc maks." value={`${Math.round(activity.maxPowerW)} W`} /> : null}
{activity.avgRespirationRate ? (
<StatCard label="Częstość oddechów" value={`${activity.avgRespirationRate.toFixed(1)} /min`} />
) : null}
{activity.aerobicTrainingEffect ? (
<StatCard label="Efekt aerobowy" value={activity.aerobicTrainingEffect.toFixed(1)} />
) : null}
{activity.anaerobicTrainingEffect ? (
<StatCard label="Efekt anaerobowy" value={activity.anaerobicTrainingEffect.toFixed(1)} />
) : null}
</section>
<AiAnalysisCard targetType="running" targetId={activity._id.toString()} analysis={analysis} />
</div>
);
}

108
app/running/actions.ts Normal file
View File

@@ -0,0 +1,108 @@
"use server";
import { revalidatePath } from "next/cache";
import type { GarminConnect } from "garmin-connect";
import {
GarminLoginRequiredError,
beginGarminLogin,
completeGarminMfaLogin,
fetchActivityRoutePoints,
fetchRunningActivities,
getAuthorizedClient,
} from "@/lib/garmin/client";
import {
getLastSyncAt,
getRunningActivity,
setLastSyncAt,
setRunningActivityRoutePoints,
upsertRunningActivity,
} from "@/lib/models/running";
import {
clearPendingMfaState,
getPendingMfaState,
saveOauth1Token,
savePendingMfaState,
} from "@/lib/models/garmin-auth";
export type SyncGarminState = { error: string } | { success: string } | { mfaRequired: true } | null;
async function syncWithClient(client: GarminConnect): Promise<SyncGarminState> {
const since = await getLastSyncAt();
const activities = await fetchRunningActivities(client);
const newCount = activities.filter((activity) => !since || activity.startTime > since).length;
for (const activity of activities) {
await upsertRunningActivity(activity);
}
await setLastSyncAt(new Date());
revalidatePath("/running");
revalidatePath("/settings");
revalidatePath("/");
return { success: `Zsynchronizowano ${newCount} nowych aktywności (zaktualizowano ${activities.length}).` };
}
export async function syncGarminActivities(): Promise<SyncGarminState> {
try {
const client = await getAuthorizedClient();
return await syncWithClient(client);
} catch (error) {
if (!(error instanceof GarminLoginRequiredError)) {
return { error: error instanceof Error ? error.message : "Synchronizacja z Garmin nie powiodła się." };
}
}
try {
const result = await beginGarminLogin();
if ("mfaRequired" in result) {
await savePendingMfaState(result.pendingState);
return { mfaRequired: true };
}
await saveOauth1Token(result.oauth1Token);
return await syncWithClient(result.client);
} catch (error) {
return { error: error instanceof Error ? error.message : "Logowanie do Garmin nie powiodło się." };
}
}
export async function submitGarminMfaCode(code: string): Promise<SyncGarminState> {
const pending = await getPendingMfaState();
if (!pending) {
return { error: "Sesja logowania do Garmin wygasła. Kliknij \"Synchronizuj z Garmin\" ponownie." };
}
try {
const result = await completeGarminMfaLogin(pending, code);
await saveOauth1Token(result.oauth1Token);
await clearPendingMfaState();
return await syncWithClient(result.client);
} catch (error) {
return { error: error instanceof Error ? error.message : "Weryfikacja kodu MFA nie powiodła się." };
}
}
export type LoadRouteState = { error: string } | { success: true } | null;
export async function loadActivityRoute(activityMongoId: string): Promise<LoadRouteState> {
const activity = await getRunningActivity(activityMongoId);
if (!activity) return { error: "Nie znaleziono aktywności." };
let client: GarminConnect;
try {
client = await getAuthorizedClient();
} catch {
return { error: "Brak połączenia z Garmin Connect. Wykonaj synchronizację." };
}
try {
const points = await fetchActivityRoutePoints(client, activity.garminActivityId);
if (!points) return { error: "Brak danych GPS dla tej aktywności." };
await setRunningActivityRoutePoints(activity.garminActivityId, points);
revalidatePath(`/running/${activityMongoId}`);
return { success: true };
} catch (error) {
return { error: error instanceof Error ? error.message : "Nie udało się pobrać mapy trasy." };
}
}

54
app/running/page.tsx Normal file
View File

@@ -0,0 +1,54 @@
import Link from "next/link";
import { Activity } from "lucide-react";
import { EmptyState } from "@/components/empty-state";
import { SyncButton } from "@/components/sync-button";
import { formatDateShort, formatDistance, formatDuration, formatPace } from "@/lib/format";
import { listRunningActivities } from "@/lib/models/running";
export const dynamic = "force-dynamic";
export default async function RunningPage() {
const activities = await listRunningActivities();
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-fg">Bieganie</h1>
<p className="mt-1 text-sm text-fg/60">Aktywności zsynchronizowane z Garmin Connect.</p>
</div>
<SyncButton />
</div>
{activities.length === 0 ? (
<EmptyState
icon={<Activity size={32} />}
title="Brak biegów"
description="Zsynchronizuj aktywności z Garmin Connect, aby zobaczyć tutaj swoje biegi."
/>
) : (
<ul className="flex flex-col gap-3">
{activities.map((activity) => (
<li key={activity._id.toString()}>
<Link
href={`/running/${activity._id.toString()}`}
className="flex items-center justify-between rounded-lg border border-muted/40 bg-surface p-4 transition-colors hover:border-accent/60"
>
<div>
<div className="font-semibold text-fg">{activity.name}</div>
<div className="text-sm text-fg/60">{formatDateShort(activity.startTime)}</div>
</div>
<div className="flex flex-col items-end text-sm text-fg/60">
<span>{formatDistance(activity.distanceM)}</span>
<span>
{formatDuration(activity.durationSec)} · {formatPace(activity.avgPaceSecPerKm)}
</span>
</div>
</Link>
</li>
))}
</ul>
)}
</div>
);
}

59
app/settings/page.tsx Normal file
View File

@@ -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 (
<div className="flex items-center justify-between rounded-lg border border-muted/40 bg-surface p-4">
<span className="text-fg">{label}</span>
{configured ? (
<span className="flex items-center gap-1.5 text-sm text-fg/70">
<CheckCircle2 size={16} className="text-accent" />
Skonfigurowano
</span>
) : (
<span className="flex items-center gap-1.5 text-sm text-fg/50">
<XCircle size={16} />
Brak w .env.local
</span>
)}
</div>
);
}
export default async function SettingsPage() {
const lastSyncAt = await getLastSyncAt();
const mongoConfigured = Boolean(process.env.MONGODB_URI);
const garminConfigured = Boolean(process.env.GARMIN_EMAIL && process.env.GARMIN_PASSWORD);
const claudeConfigured = Boolean(process.env.ANTHROPIC_API_KEY);
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-fg">Ustawienia</h1>
<p className="mt-1 text-sm text-fg/60">Status konfiguracji i synchronizacja Garmin.</p>
</div>
<section className="flex flex-col gap-3">
<h2 className="text-lg font-semibold text-fg">Konfiguracja</h2>
<ConfigRow label="MongoDB" configured={mongoConfigured} />
<ConfigRow label="Garmin Connect" configured={garminConfigured} />
<ConfigRow label="Claude API" configured={claudeConfigured} />
</section>
<section className="flex flex-col gap-3">
<h2 className="text-lg font-semibold text-fg">Synchronizacja z Garmin</h2>
<div className="flex items-center justify-between rounded-lg border border-muted/40 bg-surface p-4">
<span className="text-sm text-fg/70">
{lastSyncAt ? `Ostatnia synchronizacja: ${formatDate(lastSyncAt)}` : "Jeszcze nie zsynchronizowano"}
</span>
<SyncButton />
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { notFound } from "next/navigation";
import { AiAnalysisCard } from "@/components/ai-analysis-card";
import { ExerciseProgressChart } from "@/components/exercise-progress-chart";
import { InfoTooltip } from "@/components/info-tooltip";
import { formatDate, formatDateShort } from "@/lib/format";
import { getLatestAnalysisForTarget } from "@/lib/models/analysis";
import { getStrengthWorkout, listStrengthWorkouts } from "@/lib/models/strength";
import { getExerciseHistory } from "@/lib/strength/stats";
export const dynamic = "force-dynamic";
const EXERCISE_HISTORY_LIMIT = 8;
export default async function StrengthWorkoutPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const workout = await getStrengthWorkout(id);
if (!workout) {
notFound();
}
const analysis = await getLatestAnalysisForTarget("strength", workout._id);
const allWorkouts = await listStrengthWorkouts();
const pastWorkouts = allWorkouts.filter((w) => w.date <= workout.date);
const exercisesWithHistory = workout.exercises.map((exercise) => ({
exercise,
history: getExerciseHistory(exercise.name, pastWorkouts, EXERCISE_HISTORY_LIMIT),
}));
return (
<div className="flex flex-col gap-5">
<div>
<h1 className="text-2xl font-bold text-fg">{workout.name}</h1>
<p className="mt-1 text-sm text-fg/60">{formatDate(workout.date)}</p>
{workout.notes ? <p className="mt-1.5 text-sm text-fg/70">{workout.notes}</p> : null}
</div>
<AiAnalysisCard targetType="strength" targetId={workout._id.toString()} analysis={analysis} />
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3">
{exercisesWithHistory.map(({ exercise }, index) => (
<div key={index} className="flex flex-col gap-2 rounded-lg border border-muted/40 bg-surface px-3 py-2.5">
<p className="text-xs font-semibold text-fg">{exercise.name}</p>
{exercise.notes ? <p className="text-xs text-fg/50 italic">{exercise.notes}</p> : null}
<div className="flex flex-wrap gap-1">
{exercise.sets.map((set) => (
<span key={set.order} className="rounded bg-bg px-1.5 py-0.5 text-xs text-fg/70">
{set.reps ?? "?"}×{set.weightKg !== undefined ? `${set.weightKg} kg` : "—"}
</span>
))}
</div>
</div>
))}
</div>
{exercisesWithHistory.some(({ history }) => history.length >= 2) ? (
<div className="flex flex-col gap-3">
<h2 className="flex items-center gap-1.5 text-sm font-semibold text-fg/70">
Postęp ćwiczeń
<InfoTooltip text="Wolumen (ciężar × powtórzenia) i maksymalny ciężar na tle poprzednich sesji z tym samym ćwiczeniem." />
</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{exercisesWithHistory
.filter(({ history }) => history.length >= 2)
.map(({ exercise, history }) => (
<ExerciseProgressChart
key={exercise.name}
name={exercise.name}
data={history.map((point) => ({
label: formatDateShort(point.date),
volumeKg: point.volumeKg,
topWeightKg: point.topWeightKg,
}))}
/>
))}
</div>
</div>
) : null}
{workout.sourceUrl ? (
<a
href={workout.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-fg/40 hover:text-accent"
>
{workout.sourceUrl}
</a>
) : null}
</div>
);
}

View File

@@ -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<ImportStrongWorkoutState> {
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");
}

View File

@@ -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 (
<form action={formAction} className="flex flex-col gap-4">
{state?.error ? (
<div className="rounded-md border border-accent/40 bg-accent/10 px-4 py-3 text-sm text-fg">
{state.error}
</div>
) : null}
<textarea
name="text"
rows={16}
required
placeholder={"Trening A\nWednesday, 10 June 2026 at 06:40\n\nDeadlift (Barbell)\nSet 1: 80 kg × 8\n..."}
className="w-full rounded-md border border-muted/40 bg-surface p-3 font-mono text-sm text-fg placeholder:text-fg/30 focus:border-accent focus:outline-none"
/>
<button
type="submit"
disabled={pending}
className="self-start rounded-md bg-accent px-4 py-2 text-sm font-semibold text-fg transition-opacity hover:opacity-90 disabled:opacity-50"
>
{pending ? "Importowanie..." : "Importuj"}
</button>
</form>
);
}

View File

@@ -0,0 +1,17 @@
import { ImportForm } from "./import-form";
export default function StrengthImportPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-fg">Importuj trening</h1>
<p className="mt-1 text-sm text-fg/60">
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.
</p>
</div>
<ImportForm />
</div>
);
}

70
app/strength/page.tsx Normal file
View File

@@ -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 (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-fg">Siłownia</h1>
<p className="mt-1 text-sm text-fg/60">
Treningi zaimportowane z aplikacji Strong.
</p>
</div>
<Link
href="/strength/import"
className="flex items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-fg transition-opacity hover:opacity-90"
>
<Plus size={16} />
Importuj
</Link>
</div>
{volumeData.length > 1 ? <VolumeChart data={volumeData} /> : null}
{workouts.length === 0 ? (
<EmptyState
title="Brak treningów siłowych"
description="Zaimportuj swój pierwszy trening, wklejając tekst wygenerowany przez funkcję 'Share workout' w aplikacji Strong."
action={{ href: "/strength/import", label: "Zaimportuj trening" }}
/>
) : (
<ul className="flex flex-col gap-3">
{workouts.map((workout) => (
<li key={workout._id.toString()}>
<Link
href={`/strength/${workout._id.toString()}`}
className="flex items-center justify-between rounded-lg border border-muted/40 bg-surface p-4 transition-colors hover:border-accent/60"
>
<div>
<div className="font-semibold text-fg">{workout.name}</div>
<div className="text-sm text-fg/60">{formatDateShort(workout.date)}</div>
</div>
<div className="text-sm text-fg/60">
{workout.exercises.length}{" "}
{workout.exercises.length === 1 ? "ćwiczenie" : "ćwiczeń"}
</div>
</Link>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -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 (
<section className="flex flex-col gap-3 rounded-lg border border-muted/40 bg-surface p-4">
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-1.5 text-lg font-semibold text-fg">
<Sparkles size={18} className="text-accent" />
Analiza AI
</h2>
<form action={formAction}>
<button
type="submit"
disabled={pending}
className="rounded-md bg-accent px-3 py-1.5 text-sm font-semibold text-fg transition-opacity hover:opacity-90 disabled:opacity-50"
>
{pending ? "Generowanie..." : analysis ? "Wygeneruj ponownie" : "Generuj analizę"}
</button>
</form>
</div>
{state && "error" in state ? <p className="text-sm text-accent">{state.error}</p> : null}
{analysis ? (
<div className="flex flex-col gap-2">
<p className="text-sm text-fg/90">{analysis.summary}</p>
{analysis.tips.length > 0 ? (
<ul className="list-disc pl-5 text-sm text-fg/80">
{analysis.tips.map((tip, index) => (
<li key={index}>{tip}</li>
))}
</ul>
) : null}
<p className="text-xs text-fg/40">
{formatDate(analysis.createdAt)} · {analysis.model}
</p>
</div>
) : (
<p className="text-sm text-fg/60">Brak analizy. Wygeneruj podsumowanie i wskazówki AI.</p>
)}
</section>
);
}

View File

@@ -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 (
<section className="flex flex-col gap-3 rounded-lg border border-muted/40 bg-surface p-4">
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-1.5 text-lg font-semibold text-fg">
<Sparkles size={18} className="text-accent" />
Kondycja treningowa
</h2>
<form action={formAction}>
<button
type="submit"
disabled={pending}
className="rounded-md bg-accent px-3 py-1.5 text-sm font-semibold text-fg transition-opacity hover:opacity-90 disabled:opacity-50"
>
{pending ? "Analizuję..." : analysis ? "Odśwież analizę" : "Generuj analizę"}
</button>
</form>
</div>
{state && "error" in state ? <p className="text-sm text-accent">{state.error}</p> : null}
{analysis ? (
<div className="flex flex-col gap-2">
<p className="text-sm text-fg/90">{analysis.summary}</p>
{analysis.tips.length > 0 ? (
<ul className="list-disc pl-5 text-sm text-fg/80">
{analysis.tips.map((tip, index) => (
<li key={index}>{tip}</li>
))}
</ul>
) : null}
<p className="text-xs text-fg/40">
{formatDate(analysis.createdAt)} · {analysis.model}
</p>
</div>
) : (
<p className="text-sm text-fg/60">
Wygeneruj kompleksową analizę kondycji łączącą dane biegowe, siłowe, HRV i sen.
</p>
)}
</section>
);
}

View File

@@ -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 (
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-muted/40 px-6 py-10 text-center">
{icon ? <div className="text-fg/40">{icon}</div> : null}
<div className="text-base font-semibold text-fg">{title}</div>
{description ? <p className="max-w-sm text-sm text-fg/60">{description}</p> : null}
{action ? (
<Link
href={action.href}
className="mt-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-fg transition-opacity hover:opacity-90"
>
{action.label}
</Link>
) : null}
</div>
);
}

View File

@@ -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 (
<div className="w-full rounded-lg border border-muted/40 bg-surface p-4">
<div className="mb-2 text-sm text-fg/60">{name}</div>
<ResponsiveContainer width="100%" height={150}>
<LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid stroke="var(--color-muted)" opacity={0.3} vertical={false} />
<XAxis dataKey="label" stroke="var(--color-fg)" opacity={0.5} fontSize={12} />
<YAxis yAxisId="volume" stroke="var(--color-accent)" opacity={0.7} fontSize={12} width={48} />
<YAxis
yAxisId="weight"
orientation="right"
stroke="var(--color-sand)"
opacity={0.7}
fontSize={12}
width={48}
/>
<Tooltip
contentStyle={{
background: "var(--color-bg)",
border: "1px solid var(--color-muted)",
borderRadius: 8,
fontSize: 12,
color: "var(--color-fg)",
}}
formatter={(value, key) => [
key === "topWeightKg" ? `${value} kg` : `${Math.round(Number(value)).toLocaleString("pl-PL")} kg`,
key === "topWeightKg" ? "Maks. ciężar" : "Wolumen",
]}
/>
<Line
yAxisId="volume"
type="monotone"
dataKey="volumeKg"
stroke="var(--color-accent)"
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
yAxisId="weight"
type="monotone"
dataKey="topWeightKg"
stroke="var(--color-sand)"
strokeWidth={2}
dot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,14 @@
"use client";
import { Info } from "lucide-react";
export function InfoTooltip({ text }: { text: string }) {
return (
<span className="group relative inline-flex items-center">
<Info size={13} className="cursor-default text-fg/40 transition-colors group-hover:text-fg/70" />
<span className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1.5 w-56 -translate-x-1/2 rounded-md border border-muted/40 bg-bg px-3 py-2 text-xs text-fg/70 opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
{text}
</span>
</span>
);
}

View File

@@ -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<LoadRouteState> => loadActivityRoute(activityId),
null
);
return (
<div className="flex flex-col gap-2">
<form action={formAction}>
<button
type="submit"
disabled={pending}
className="flex items-center gap-1.5 rounded-md border border-muted/40 bg-surface px-3 py-2 text-sm font-medium text-fg/80 transition-colors hover:border-accent/60 hover:text-accent disabled:opacity-50"
>
<Map size={15} className={pending ? "animate-pulse" : ""} />
{pending ? "Pobieranie mapy..." : "Załaduj mapę trasy"}
</button>
</form>
{state && "error" in state ? <p className="text-sm text-accent">{state.error}</p> : null}
</div>
);
}

46
components/nav.tsx Normal file
View File

@@ -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 (
<header className="border-b border-muted/40 bg-surface">
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3 sm:px-6">
<Link href="/" className="flex items-center gap-3 text-lg font-bold text-fg">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/logo.svg"
alt=""
width={72}
height={72}
className="-mt-1 -mb-6 h-[72px] w-[72px] rounded-2xl shadow-lg ring-2 ring-surface"
/>
<span className="hidden text-xs font-normal tracking-wide text-fg/50 sm:block">
<span className="font-semibold text-accent">K</span>siążka{" "}
<span className="font-semibold text-accent">N</span>otowań{" "}
<span className="font-semibold text-accent">U</span>dźwigów i{" "}
<span className="font-semibold text-accent">R</span>ezultatów
</span>
</Link>
<nav className="flex items-center gap-1 sm:gap-2">
{links.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium text-fg/80 transition-colors hover:bg-bg hover:text-accent sm:px-3"
>
<Icon size={16} />
<span className="hidden sm:inline">{label}</span>
</Link>
))}
</nav>
</div>
</header>
);
}

View File

@@ -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: () => <div className="h-full w-full animate-pulse rounded-lg bg-surface" />,
});
export function RouteMapSection({ points }: { points: RoutePoint[] }) {
return <RouteMap points={points} />;
}

53
components/route-map.tsx Normal file
View File

@@ -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 (
<MapContainer
center={start}
zoom={13}
style={{ height: "100%", width: "100%", borderRadius: "inherit", background: "#2b2d42" }}
zoomControl={false}
attributionControl={false}
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
subdomains="abcd"
maxZoom={19}
/>
<FitBounds points={points} />
<Polyline
positions={points}
pathOptions={{ color: "#fb4617", weight: 3, opacity: 0.9 }}
/>
<CircleMarker
center={start}
radius={6}
pathOptions={{ color: "#f7f3e9", fillColor: "#fb4617", fillOpacity: 1, weight: 2 }}
/>
<CircleMarker
center={end}
radius={6}
pathOptions={{ color: "#f7f3e9", fillColor: "#2b2d42", fillOpacity: 1, weight: 2 }}
/>
</MapContainer>
);
}

26
components/stat-card.tsx Normal file
View File

@@ -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 (
<div
className={
highlight
? "rounded-lg border border-l-2 border-muted/30 border-l-accent/70 bg-surface p-4"
: "rounded-lg border border-muted/40 bg-surface px-3 py-2.5"
}
>
<div className={highlight ? "text-xs font-medium uppercase tracking-widest text-fg/50" : "text-xs text-fg/55"}>
{label}
</div>
<div className={highlight ? "mt-1.5 text-2xl font-bold text-fg" : "mt-0.5 text-base font-semibold text-fg"}>{value}</div>
{hint ? <div className="mt-1 text-xs text-fg/50">{hint}</div> : null}
</div>
);
}

View File

@@ -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 (
<div className="flex flex-col items-end gap-2">
<form action={formAction}>
<button
type="submit"
disabled={pending}
className="flex items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-fg transition-opacity hover:opacity-90 disabled:opacity-50"
>
<RefreshCw size={16} className={pending ? "animate-spin" : ""} />
{pending ? "Synchronizowanie..." : "Synchronizuj z Garmin"}
</button>
</form>
{mfaRequired ? (
<form action={mfaAction} className="flex items-center gap-2">
<input
type="text"
name="code"
inputMode="numeric"
maxLength={6}
placeholder="Kod z e-maila"
required
className="w-32 rounded-md border border-muted/40 bg-bg px-2 py-1.5 text-sm text-fg"
/>
<button
type="submit"
disabled={mfaPending}
className="rounded-md bg-accent px-3 py-1.5 text-sm font-semibold text-fg transition-opacity hover:opacity-90 disabled:opacity-50"
>
{mfaPending ? "Weryfikacja..." : "Zatwierdź kod"}
</button>
</form>
) : null}
{mfaRequired ? (
<div className="text-sm text-fg/60">Garmin wysłał kod weryfikacyjny na e-mail. Wpisz go powyżej.</div>
) : null}
{activeState && "error" in activeState ? <div className="text-sm text-accent">{activeState.error}</div> : null}
{activeState && "success" in activeState ? <div className="text-sm text-fg/60">{activeState.success}</div> : null}
</div>
);
}

View File

@@ -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 (
<div className="w-full rounded-lg border border-muted/40 bg-surface p-4">
<div className="mb-2 text-sm text-fg/60">Wolumen treningowy (ciężar × powtórzenia)</div>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<XAxis dataKey="label" stroke="var(--color-fg)" opacity={0.5} fontSize={12} />
<YAxis stroke="var(--color-fg)" opacity={0.5} fontSize={12} width={48} />
<Tooltip
cursor={{ fill: "var(--color-bg)" }}
contentStyle={{
background: "var(--color-bg)",
border: "1px solid var(--color-muted)",
borderRadius: 8,
fontSize: 12,
color: "var(--color-fg)",
}}
formatter={(value) => [`${Math.round(Number(value)).toLocaleString("pl-PL")} kg`, "Wolumen"]}
/>
<Bar dataKey="volumeKg" fill="var(--color-accent)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}

20
docker-compose.yaml Normal file
View File

@@ -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

314
lib/ai/claude.ts Normal file
View File

@@ -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<AiAnalysis> {
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<string, string> = {
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<AiAnalysis> {
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);
}

21
lib/db.ts Normal file
View File

@@ -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<MongoClient> | undefined;
}
function getClientPromise(): Promise<MongoClient> {
if (!global._mongoClientPromise) {
const client = new MongoClient(uri);
global._mongoClientPromise = client.connect();
}
return global._mongoClientPromise;
}
export async function getDb(): Promise<Db> {
const client = await getClientPromise();
return client.db(dbName);
}

30
lib/format.ts Normal file
View File

@@ -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`;
}

164
lib/garmin/client.ts Normal file
View File

@@ -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<RoutePoint[] | null> {
const url = `${GC_API}/activity-service/activity/${garminActivityId}/details?maxPolylineSize=${MAX_POLYLINE_POINTS}`;
const data = await client.get<GarminActivityDetailsResponse>(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<void> {
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<GarminConnect> {
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<RunningActivityInput[]> {
const activities = await client.getActivities(0, FETCH_LIMIT);
return activities.filter(isRunningActivity).map(mapActivity);
}

176
lib/garmin/sso.ts Normal file
View File

@@ -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<string, string | boolean> = {
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, string | boolean>): string {
return Object.entries(params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
.join("&");
}
class CookieJar {
private cookies = new Map<string, string>();
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<GarminLoginResult> {
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<string> {
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];
}

52
lib/garmin/wellness.ts Normal file
View File

@@ -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<DayWellness[]> {
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));
}

62
lib/models/analysis.ts Normal file
View File

@@ -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<AiAnalysis>(COLLECTION);
}
export async function saveAiAnalysis(input: AiAnalysisInput): Promise<AiAnalysis> {
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<AiAnalysis | null> {
const collection = await getCollection();
return collection.findOne({ targetType, targetId }, { sort: { createdAt: -1 } });
}
export async function getLatestAnalysis(): Promise<AiAnalysis | null> {
const collection = await getCollection();
return collection.findOne({}, { sort: { createdAt: -1 } });
}
const DASHBOARD_TARGET_ID = new ObjectId("000000000000000000000001");
export async function getDashboardAnalysis(): Promise<AiAnalysis | null> {
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<AiAnalysis> {
return saveAiAnalysis({ targetType: "dashboard", targetId: DASHBOARD_TARGET_ID, summary, tips, model });
}

40
lib/models/garmin-auth.ts Normal file
View File

@@ -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<IOauth1Token | null> {
const db = await getDb();
const doc = await db.collection<GarminAuthDoc>(AUTH_COLLECTION).findOne({ _id: "tokens" });
return doc?.oauth1Token ?? null;
}
export async function saveOauth1Token(oauth1Token: IOauth1Token): Promise<void> {
const db = await getDb();
await db
.collection<GarminAuthDoc>(AUTH_COLLECTION)
.updateOne({ _id: "tokens" }, { $set: { oauth1Token, updatedAt: new Date() } }, { upsert: true });
}
export async function savePendingMfaState(state: GarminPendingMfa): Promise<void> {
const db = await getDb();
await db
.collection<GarminPendingDoc>(PENDING_COLLECTION)
.updateOne({ _id: "pending" }, { $set: { state, createdAt: new Date() } }, { upsert: true });
}
export async function getPendingMfaState(): Promise<GarminPendingMfa | null> {
const db = await getDb();
const doc = await db.collection<GarminPendingDoc>(PENDING_COLLECTION).findOne({ _id: "pending" });
return doc?.state ?? null;
}
export async function clearPendingMfaState(): Promise<void> {
const db = await getDb();
await db.collection<GarminPendingDoc>(PENDING_COLLECTION).deleteOne({ _id: "pending" });
}

96
lib/models/running.ts Normal file
View File

@@ -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<typeof runningActivitySchema>;
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<RunningActivity>(COLLECTION);
await collection.createIndex({ garminActivityId: 1 }, { unique: true });
return collection;
}
export async function upsertRunningActivity(activity: RunningActivityInput): Promise<void> {
const collection = await getCollection();
await collection.updateOne(
{ garminActivityId: activity.garminActivityId },
{
$set: activity,
$setOnInsert: { createdAt: new Date() },
},
{ upsert: true }
);
}
export async function listRunningActivities(): Promise<RunningActivity[]> {
const collection = await getCollection();
return collection.find().sort({ startTime: -1 }).toArray();
}
export async function getRunningActivity(id: string): Promise<RunningActivity | null> {
const collection = await getCollection();
return collection.findOne({ _id: new ObjectId(id) });
}
export async function setRunningActivityRoutePoints(
garminActivityId: number,
points: RoutePoint[]
): Promise<void> {
const collection = await getCollection();
await collection.updateOne({ garminActivityId }, { $set: { routePoints: points } });
}
type SyncState = { _id: "garmin"; lastSyncAt: Date };
export async function getLastSyncAt(): Promise<Date | null> {
const db = await getDb();
const state = await db.collection<SyncState>(SYNC_STATE_COLLECTION).findOne({ _id: "garmin" });
return state?.lastSyncAt ?? null;
}
export async function setLastSyncAt(date: Date): Promise<void> {
const db = await getDb();
await db
.collection<SyncState>(SYNC_STATE_COLLECTION)
.updateOne({ _id: "garmin" }, { $set: { lastSyncAt: date } }, { upsert: true });
}

66
lib/models/strength.ts Normal file
View File

@@ -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<typeof strengthSetSchema>;
export type StrengthExercise = z.infer<typeof strengthExerciseSchema>;
export type StrengthWorkoutInput = z.infer<typeof strengthWorkoutSchema>;
export type StrengthWorkout = StrengthWorkoutInput & {
_id: ObjectId;
createdAt: Date;
};
const COLLECTION = "strength_workouts";
async function getCollection() {
const db = await getDb();
const collection = db.collection<StrengthWorkout>(COLLECTION);
await collection.createIndex({ sourceKey: 1 }, { unique: true });
return collection;
}
export async function upsertStrengthWorkout(
workout: StrengthWorkoutInput
): Promise<void> {
const collection = await getCollection();
await collection.updateOne(
{ sourceKey: workout.sourceKey },
{
$set: workout,
$setOnInsert: { createdAt: new Date() },
},
{ upsert: true }
);
}
export async function listStrengthWorkouts(): Promise<StrengthWorkout[]> {
const collection = await getCollection();
return collection.find().sort({ date: -1 }).toArray();
}
export async function getStrengthWorkout(id: string): Promise<StrengthWorkout | null> {
const collection = await getCollection();
return collection.findOne({ _id: new ObjectId(id) });
}

46
lib/strength/stats.ts Normal file
View File

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

97
lib/strong/parser.ts Normal file
View File

@@ -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<StrengthWorkoutInput, "sourceKey">): 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<StrengthWorkoutInput, "sourceKey">[] = [];
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<typeof set> => set !== null);
current.exercises.push({ name: exerciseName, sets });
}
return workouts.map((workout) => ({
...workout,
sourceKey: makeSourceKey(workout),
}));
}

1
logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -9,9 +9,20 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "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", "next": "16.2.9",
"react": "19.2.4", "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": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

706
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

1
public/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.6 KiB