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

View File

@@ -0,0 +1,60 @@
"use client";
import { useActionState } from "react";
import { Sparkles } from "lucide-react";
import { generateAnalysisAction } from "@/app/ai/actions";
import { formatDate } from "@/lib/format";
import type { AiAnalysis, AiAnalysisTargetType } from "@/lib/models/analysis";
type AiAnalysisCardProps = {
targetType: AiAnalysisTargetType;
targetId: string;
analysis: AiAnalysis | null;
};
export function AiAnalysisCard({ targetType, targetId, analysis }: AiAnalysisCardProps) {
const [state, formAction, pending] = useActionState(
() => generateAnalysisAction(targetType, targetId),
null
);
return (
<section className="flex flex-col gap-3 rounded-lg border border-muted/40 bg-surface p-4">
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-1.5 text-lg font-semibold text-fg">
<Sparkles size={18} className="text-accent" />
Analiza AI
</h2>
<form action={formAction}>
<button
type="submit"
disabled={pending}
className="rounded-md bg-accent px-3 py-1.5 text-sm font-semibold text-fg transition-opacity hover:opacity-90 disabled:opacity-50"
>
{pending ? "Generowanie..." : analysis ? "Wygeneruj ponownie" : "Generuj analizę"}
</button>
</form>
</div>
{state && "error" in state ? <p className="text-sm text-accent">{state.error}</p> : null}
{analysis ? (
<div className="flex flex-col gap-2">
<p className="text-sm text-fg/90">{analysis.summary}</p>
{analysis.tips.length > 0 ? (
<ul className="list-disc pl-5 text-sm text-fg/80">
{analysis.tips.map((tip, index) => (
<li key={index}>{tip}</li>
))}
</ul>
) : null}
<p className="text-xs text-fg/40">
{formatDate(analysis.createdAt)} · {analysis.model}
</p>
</div>
) : (
<p className="text-sm text-fg/60">Brak analizy. Wygeneruj podsumowanie i wskazówki AI.</p>
)}
</section>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import { useActionState } from "react";
import { Sparkles } from "lucide-react";
import { generateDashboardAnalysisAction } from "@/app/ai/actions";
import { formatDate } from "@/lib/format";
import type { AiAnalysis } from "@/lib/models/analysis";
type Props = {
analysis: AiAnalysis | null;
};
export function DashboardAnalysisCard({ analysis }: Props) {
const [state, formAction, pending] = useActionState(generateDashboardAnalysisAction, null);
return (
<section className="flex flex-col gap-3 rounded-lg border border-muted/40 bg-surface p-4">
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-1.5 text-lg font-semibold text-fg">
<Sparkles size={18} className="text-accent" />
Kondycja treningowa
</h2>
<form action={formAction}>
<button
type="submit"
disabled={pending}
className="rounded-md bg-accent px-3 py-1.5 text-sm font-semibold text-fg transition-opacity hover:opacity-90 disabled:opacity-50"
>
{pending ? "Analizuję..." : analysis ? "Odśwież analizę" : "Generuj analizę"}
</button>
</form>
</div>
{state && "error" in state ? <p className="text-sm text-accent">{state.error}</p> : null}
{analysis ? (
<div className="flex flex-col gap-2">
<p className="text-sm text-fg/90">{analysis.summary}</p>
{analysis.tips.length > 0 ? (
<ul className="list-disc pl-5 text-sm text-fg/80">
{analysis.tips.map((tip, index) => (
<li key={index}>{tip}</li>
))}
</ul>
) : null}
<p className="text-xs text-fg/40">
{formatDate(analysis.createdAt)} · {analysis.model}
</p>
</div>
) : (
<p className="text-sm text-fg/60">
Wygeneruj kompleksową analizę kondycji łączącą dane biegowe, siłowe, HRV i sen.
</p>
)}
</section>
);
}

View File

@@ -0,0 +1,27 @@
import Link from "next/link";
import type { ReactNode } from "react";
type EmptyStateProps = {
title: string;
description?: string;
action?: { href: string; label: string };
icon?: ReactNode;
};
export function EmptyState({ title, description, action, icon }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-muted/40 px-6 py-10 text-center">
{icon ? <div className="text-fg/40">{icon}</div> : null}
<div className="text-base font-semibold text-fg">{title}</div>
{description ? <p className="max-w-sm text-sm text-fg/60">{description}</p> : null}
{action ? (
<Link
href={action.href}
className="mt-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-fg transition-opacity hover:opacity-90"
>
{action.label}
</Link>
) : null}
</div>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
"use client";
import { useActionState } from "react";
import { Map } from "lucide-react";
import { loadActivityRoute, type LoadRouteState } from "@/app/running/actions";
export function LoadRouteButton({ activityId }: { activityId: string }) {
const [state, formAction, pending] = useActionState(
async (): Promise<LoadRouteState> => loadActivityRoute(activityId),
null
);
return (
<div className="flex flex-col gap-2">
<form action={formAction}>
<button
type="submit"
disabled={pending}
className="flex items-center gap-1.5 rounded-md border border-muted/40 bg-surface px-3 py-2 text-sm font-medium text-fg/80 transition-colors hover:border-accent/60 hover:text-accent disabled:opacity-50"
>
<Map size={15} className={pending ? "animate-pulse" : ""} />
{pending ? "Pobieranie mapy..." : "Załaduj mapę trasy"}
</button>
</form>
{state && "error" in state ? <p className="text-sm text-accent">{state.error}</p> : null}
</div>
);
}

46
components/nav.tsx Normal file
View File

@@ -0,0 +1,46 @@
import Link from "next/link";
import { Activity, Dumbbell, LayoutDashboard, Settings } from "lucide-react";
const links = [
{ href: "/", label: "Panel", icon: LayoutDashboard },
{ href: "/running", label: "Bieganie", icon: Activity },
{ href: "/strength", label: "Siłownia", icon: Dumbbell },
{ href: "/settings", label: "Ustawienia", icon: Settings },
];
export function Nav() {
return (
<header className="border-b border-muted/40 bg-surface">
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3 sm:px-6">
<Link href="/" className="flex items-center gap-3 text-lg font-bold text-fg">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/logo.svg"
alt=""
width={72}
height={72}
className="-mt-1 -mb-6 h-[72px] w-[72px] rounded-2xl shadow-lg ring-2 ring-surface"
/>
<span className="hidden text-xs font-normal tracking-wide text-fg/50 sm:block">
<span className="font-semibold text-accent">K</span>siążka{" "}
<span className="font-semibold text-accent">N</span>otowań{" "}
<span className="font-semibold text-accent">U</span>dźwigów i{" "}
<span className="font-semibold text-accent">R</span>ezultatów
</span>
</Link>
<nav className="flex items-center gap-1 sm:gap-2">
{links.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium text-fg/80 transition-colors hover:bg-bg hover:text-accent sm:px-3"
>
<Icon size={16} />
<span className="hidden sm:inline">{label}</span>
</Link>
))}
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,13 @@
"use client";
import nextDynamic from "next/dynamic";
import type { RoutePoint } from "@/lib/models/running";
const RouteMap = nextDynamic(() => import("@/components/route-map").then((m) => m.RouteMap), {
ssr: false,
loading: () => <div className="h-full w-full animate-pulse rounded-lg bg-surface" />,
});
export function RouteMapSection({ points }: { points: RoutePoint[] }) {
return <RouteMap points={points} />;
}

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

@@ -0,0 +1,53 @@
"use client";
import "leaflet/dist/leaflet.css";
import { MapContainer, Polyline, TileLayer, CircleMarker, useMap } from "react-leaflet";
import type { RoutePoint } from "@/lib/models/running";
type FitBoundsProps = { points: RoutePoint[] };
function FitBounds({ points }: FitBoundsProps) {
const map = useMap();
map.fitBounds(points, { padding: [24, 24] });
return null;
}
type RouteMapProps = { points: RoutePoint[] };
export function RouteMap({ points }: RouteMapProps) {
if (points.length === 0) return null;
const start = points[0];
const end = points[points.length - 1];
return (
<MapContainer
center={start}
zoom={13}
style={{ height: "100%", width: "100%", borderRadius: "inherit", background: "#2b2d42" }}
zoomControl={false}
attributionControl={false}
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
subdomains="abcd"
maxZoom={19}
/>
<FitBounds points={points} />
<Polyline
positions={points}
pathOptions={{ color: "#fb4617", weight: 3, opacity: 0.9 }}
/>
<CircleMarker
center={start}
radius={6}
pathOptions={{ color: "#f7f3e9", fillColor: "#fb4617", fillOpacity: 1, weight: 2 }}
/>
<CircleMarker
center={end}
radius={6}
pathOptions={{ color: "#f7f3e9", fillColor: "#2b2d42", fillOpacity: 1, weight: 2 }}
/>
</MapContainer>
);
}

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

@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
type StatCardProps = {
label: string;
value: ReactNode;
hint?: string;
highlight?: boolean;
};
export function StatCard({ label, value, hint, highlight }: StatCardProps) {
return (
<div
className={
highlight
? "rounded-lg border border-l-2 border-muted/30 border-l-accent/70 bg-surface p-4"
: "rounded-lg border border-muted/40 bg-surface px-3 py-2.5"
}
>
<div className={highlight ? "text-xs font-medium uppercase tracking-widest text-fg/50" : "text-xs text-fg/55"}>
{label}
</div>
<div className={highlight ? "mt-1.5 text-2xl font-bold text-fg" : "mt-0.5 text-base font-semibold text-fg"}>{value}</div>
{hint ? <div className="mt-1 text-xs text-fg/50">{hint}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import { useActionState } from "react";
import { RefreshCw } from "lucide-react";
import { submitGarminMfaCode, syncGarminActivities, type SyncGarminState } from "@/app/running/actions";
export function SyncButton() {
const [state, formAction, pending] = useActionState(async () => syncGarminActivities(), null);
const [mfaState, mfaAction, mfaPending] = useActionState(
async (_prev: SyncGarminState, formData: FormData) => submitGarminMfaCode(String(formData.get("code") ?? "")),
null
);
const mfaRequired = (state && "mfaRequired" in state) || (mfaState && "mfaRequired" in mfaState);
const activeState = mfaState ?? state;
return (
<div className="flex flex-col items-end gap-2">
<form action={formAction}>
<button
type="submit"
disabled={pending}
className="flex items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-fg transition-opacity hover:opacity-90 disabled:opacity-50"
>
<RefreshCw size={16} className={pending ? "animate-spin" : ""} />
{pending ? "Synchronizowanie..." : "Synchronizuj z Garmin"}
</button>
</form>
{mfaRequired ? (
<form action={mfaAction} className="flex items-center gap-2">
<input
type="text"
name="code"
inputMode="numeric"
maxLength={6}
placeholder="Kod z e-maila"
required
className="w-32 rounded-md border border-muted/40 bg-bg px-2 py-1.5 text-sm text-fg"
/>
<button
type="submit"
disabled={mfaPending}
className="rounded-md bg-accent px-3 py-1.5 text-sm font-semibold text-fg transition-opacity hover:opacity-90 disabled:opacity-50"
>
{mfaPending ? "Weryfikacja..." : "Zatwierdź kod"}
</button>
</form>
) : null}
{mfaRequired ? (
<div className="text-sm text-fg/60">Garmin wysłał kod weryfikacyjny na e-mail. Wpisz go powyżej.</div>
) : null}
{activeState && "error" in activeState ? <div className="text-sm text-accent">{activeState.error}</div> : null}
{activeState && "success" in activeState ? <div className="text-sm text-fg/60">{activeState.success}</div> : null}
</div>
);
}

View File

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