init
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user