init
This commit is contained in:
8
.env.local.example
Normal file
8
.env.local.example
Normal 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
1
.gitignore
vendored
@@ -32,6 +32,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env*.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
12
.idea/.gitignore
generated
vendored
Normal file
12
.idea/.gitignore
generated
vendored
Normal 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/
|
||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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
9
.idea/knur-app.iml
generated
Normal 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
9
.idea/misc.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
53
README.md
53
README.md
@@ -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
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
cp .env.local.example .env.local
|
||||
```
|
||||
|
||||
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.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
## Funkcje
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
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.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Ustawienia** (`/settings`) — status konfiguracji (MongoDB, Garmin, Claude) i ostatnia synchronizacja Garmin.
|
||||
|
||||
33
app/ai/actions.ts
Normal file
33
app/ai/actions.ts
Normal 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 };
|
||||
}
|
||||
@@ -1,26 +1,29 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--color-bg: #2b2d42;
|
||||
--color-surface: #363850;
|
||||
--color-fg: #f7f3e9;
|
||||
--color-accent: #fb4617;
|
||||
--color-muted: #434247;
|
||||
--color-secondary: #2e162e;
|
||||
--color-sand: #d4cbbb;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-bg: var(--color-bg);
|
||||
--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-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-fg);
|
||||
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
1
app/icon.svg
Normal file
1
app/icon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.6 KiB |
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Nav } from "@/components/nav";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "KNUR - Książka Notowań Udźwigów i Rezultatów",
|
||||
description: "Analiza treningów biegowych i siłowych",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,10 +25,15 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
lang="pl"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
139
app/page.tsx
139
app/page.tsx
@@ -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 (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<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
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<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"
|
||||
<div className="flex flex-col gap-8">
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<StatCard label="Kilometry w tym tygodniu" value={`${weeklyKm.toFixed(1)} km`} hint="Bieganie" />
|
||||
<StatCard label="Treningi siłowe w tym tygodniu" value={weeklyStrengthSessions} hint="Siłownia" />
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold text-fg">Ostatni bieg</h2>
|
||||
{latestRun ? (
|
||||
<Link
|
||||
href={`/running/${latestRun._id.toString()}`}
|
||||
className="flex flex-col gap-2 rounded-lg border border-muted/40 bg-surface p-4 transition-colors hover:border-accent/60"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?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"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<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]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
<div className="font-semibold text-fg">{latestRun.name}</div>
|
||||
<div className="text-sm text-fg/60">{formatDateShort(latestRun.startTime)}</div>
|
||||
<div className="text-sm text-fg/70">
|
||||
{formatDistance(latestRun.distanceM)} · {formatDuration(latestRun.durationSec)} ·{" "}
|
||||
{formatPace(latestRun.avgPaceSecPerKm)}
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Activity size={32} />}
|
||||
title="Brak danych o bieganiu"
|
||||
description="Zsynchronizuj aktywności z Garmin Connect, aby zobaczyć tutaj swoje biegi."
|
||||
action={{ href: "/running", label: "Przejdź do biegania" }}
|
||||
/>
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
123
app/running/[id]/page.tsx
Normal file
123
app/running/[id]/page.tsx
Normal 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 1–2, rows 1–3 — streamed in after page skeleton */}
|
||||
<Suspense fallback={<MapSkeleton />}>
|
||||
<RouteMapFetcher activity={activity} />
|
||||
</Suspense>
|
||||
|
||||
{/* Col 3, rows 1–3: 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
108
app/running/actions.ts
Normal 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
54
app/running/page.tsx
Normal 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
59
app/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
app/strength/[id]/page.tsx
Normal file
97
app/strength/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
app/strength/import/actions.ts
Normal file
37
app/strength/import/actions.ts
Normal 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");
|
||||
}
|
||||
32
app/strength/import/import-form.tsx
Normal file
32
app/strength/import/import-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
app/strength/import/page.tsx
Normal file
17
app/strength/import/page.tsx
Normal 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
70
app/strength/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
components/ai-analysis-card.tsx
Normal file
60
components/ai-analysis-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
components/dashboard-analysis-card.tsx
Normal file
57
components/dashboard-analysis-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
components/empty-state.tsx
Normal file
27
components/empty-state.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
components/exercise-progress-chart.tsx
Normal file
64
components/exercise-progress-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
components/info-tooltip.tsx
Normal file
14
components/info-tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
components/load-route-button.tsx
Normal file
28
components/load-route-button.tsx
Normal 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
46
components/nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
components/route-map-section.tsx
Normal file
13
components/route-map-section.tsx
Normal 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
53
components/route-map.tsx
Normal 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
26
components/stat-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
components/sync-button.tsx
Normal file
58
components/sync-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
components/volume-chart.tsx
Normal file
33
components/volume-chart.tsx
Normal 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
20
docker-compose.yaml
Normal 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
314
lib/ai/claude.ts
Normal 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
21
lib/db.ts
Normal 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
30
lib/format.ts
Normal 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
164
lib/garmin/client.ts
Normal 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
176
lib/garmin/sso.ts
Normal 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
52
lib/garmin/wellness.ts
Normal 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
62
lib/models/analysis.ts
Normal 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
40
lib/models/garmin-auth.ts
Normal 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
96
lib/models/running.ts
Normal 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
66
lib/models/strength.ts
Normal 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
46
lib/strength/stats.ts
Normal 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
97
lib/strong/parser.ts
Normal 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
1
logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.6 KiB |
13
package.json
13
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",
|
||||
|
||||
706
pnpm-lock.yaml
generated
706
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
1
public/logo.svg
Normal file
1
public/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.6 KiB |
Reference in New Issue
Block a user