This commit is contained in:
Dominik Klarkowski
2026-06-22 11:32:27 +02:00
parent bf8624c954
commit d908965c95
16 changed files with 642 additions and 103 deletions

View File

@@ -1,57 +1,260 @@
"use client";
import { useActionState } from "react";
import { Sparkles } from "lucide-react";
import Link from "next/link";
import { useActionState, useMemo, useState } from "react";
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
addDays,
addMonths,
subMonths,
isSameMonth,
isSameDay,
isToday,
} from "date-fns";
import { pl } from "date-fns/locale";
import { Activity, ChevronLeft, ChevronRight, Dumbbell, Sparkles } from "lucide-react";
import { generateDashboardAnalysisAction } from "@/app/ai/actions";
import { formatDate } from "@/lib/format";
import { formatDate, formatDistance, formatDuration, formatPace } from "@/lib/format";
import type { SerializedAiAnalysis } from "@/lib/models/analysis";
type Props = {
analysis: SerializedAiAnalysis | null;
export type DayRun = {
id: string;
name: string;
startTime: string;
distanceM: number;
durationSec: number;
avgPaceSecPerKm: number;
};
export function DashboardAnalysisCard({ analysis }: Props) {
export type DayWorkout = {
id: string;
name: string;
date: string;
exerciseCount: number;
};
type Props = {
analyses: SerializedAiAnalysis[];
runs: DayRun[];
workouts: DayWorkout[];
};
function dayKey(date: Date): string {
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
}
export function DashboardAnalysisCard({ analyses, runs, workouts }: Props) {
const [state, formAction, pending] = useActionState(generateDashboardAnalysisAction, null);
// Map each calendar day to its (latest) analysis. Selecting a day is a pure
// local lookup, no server round-trip.
const byDay = useMemo(() => {
const map = new Map<string, SerializedAiAnalysis>();
for (const a of analyses) {
const key = dayKey(new Date(a.createdAt));
if (!map.has(key)) map.set(key, a); // analyses are newest-first
}
return map;
}, [analyses]);
const [currentMonth, setCurrentMonth] = useState(() => new Date());
const [selectedDate, setSelectedDate] = useState<Date>(() => new Date());
function hasAnalysis(date: Date) {
return byDay.has(dayKey(date));
}
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(monthStart);
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
const days: Date[] = [];
let cursor = calendarStart;
while (cursor <= calendarEnd) {
days.push(cursor);
cursor = addDays(cursor, 1);
}
const weekDays = ["Pn", "Wt", "Śr", "Cz", "Pt", "Sb", "Nd"];
const selectedAnalysis = byDay.get(dayKey(selectedDate)) ?? null;
const viewingToday = isToday(selectedDate);
const dayRuns = runs.filter((r) => isSameDay(new Date(r.startTime), selectedDate));
const dayWorkouts = workouts.filter((w) => isSameDay(new Date(w.date), selectedDate));
return (
<section className="flex flex-col gap-3 rounded-lg border border-muted/40 bg-surface p-4">
<section className="flex flex-col gap-4 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>
{viewingToday && (
<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ę..." : selectedAnalysis ? "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 className="flex flex-col gap-4 sm:flex-row">
<div className="min-w-[280px]">
<div className="mb-2 flex items-center justify-between">
<button
type="button"
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
className="rounded p-1 text-fg/60 transition-colors hover:text-fg"
>
<ChevronLeft size={16} />
</button>
<span className="text-sm font-medium capitalize text-fg">
{format(currentMonth, "LLLL yyyy", { locale: pl })}
</span>
<button
type="button"
onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}
className="rounded p-1 text-fg/60 transition-colors hover:text-fg"
>
<ChevronRight size={16} />
</button>
</div>
<div className="grid grid-cols-7 gap-1 text-center text-xs text-fg/50">
{weekDays.map((d) => (
<div key={d} className="py-1">
{d}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{days.map((d, i) => {
const inMonth = isSameMonth(d, monthStart);
const has = hasAnalysis(d);
const today = isToday(d);
const selectable = has || today;
const selected = isSameDay(d, selectedDate);
return (
<button
type="button"
key={i}
onClick={() => selectable && setSelectedDate(d)}
disabled={!selectable}
className={[
"relative flex h-8 w-full items-center justify-center rounded text-xs transition-colors",
!inMonth && "opacity-30",
selectable && !selected && "cursor-pointer text-fg hover:bg-accent/20",
selected && "bg-accent font-semibold text-fg",
!selectable && "cursor-default text-fg/40",
today && !selected && "ring-1 ring-accent/50",
]
.filter(Boolean)
.join(" ")}
>
{d.getDate()}
{has && !selected && (
<span className="absolute bottom-0.5 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full bg-accent" />
)}
</button>
);
})}
</div>
<div className="mt-4 flex flex-col gap-1.5">
<p className="text-xs font-medium text-fg/50">
Treningi · {format(selectedDate, "d MMM yyyy", { locale: pl })}
</p>
{dayRuns.length === 0 && dayWorkouts.length === 0 ? (
<p className="text-xs text-fg/40">Brak treningów tego dnia.</p>
) : (
<ul className="flex flex-col gap-1.5">
{dayRuns.map((run) => (
<li key={run.id}>
<Link
href={`/running/${run.id}`}
className="flex items-center gap-2 rounded-md border border-muted/40 bg-bg/40 px-2.5 py-1.5 transition-colors hover:border-accent/60"
>
<Activity size={14} className="shrink-0 text-accent" />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-fg">
{run.name}
</span>
<span className="block text-[11px] text-fg/55">
{formatDistance(run.distanceM)} · {formatDuration(run.durationSec)} ·{" "}
{formatPace(run.avgPaceSecPerKm)}
</span>
</span>
</Link>
</li>
))}
{dayWorkouts.map((workout) => (
<li key={workout.id}>
<Link
href={`/strength/${workout.id}`}
className="flex items-center gap-2 rounded-md border border-muted/40 bg-bg/40 px-2.5 py-1.5 transition-colors hover:border-accent/60"
>
<Dumbbell size={14} className="shrink-0 text-accent" />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-fg">
{workout.name}
</span>
<span className="block text-[11px] text-fg/55">
{workout.exerciseCount}{" "}
{workout.exerciseCount === 1 ? "ćwiczenie" : "ćwiczeń"}
</span>
</span>
</Link>
</li>
))}
</ul>
)}
</div>
</div>
) : (
<p className="text-sm text-fg/60">
Wygeneruj kompleksową analizę kondycji łączącą dane biegowe, siłowe, HRV i sen.
</p>
)}
<div className="flex-1">
{selectedAnalysis ? (
<div className="flex flex-col gap-2">
<p className="text-xs font-medium text-fg/50">
{format(selectedDate, "d MMMM yyyy", { locale: pl })}
</p>
<p className="text-sm text-fg/90">{selectedAnalysis.summary}</p>
{selectedAnalysis.tips.length > 0 && (
<ul className="list-disc pl-5 text-sm text-fg/80">
{selectedAnalysis.tips.map((tip, index) => (
<li key={index}>{tip}</li>
))}
</ul>
)}
<p className="text-xs text-fg/40">
{formatDate(selectedAnalysis.createdAt)} · {selectedAnalysis.model}
</p>
</div>
) : viewingToday ? (
<p className="text-sm text-fg/60">
Brak analizy na dziś. Wygeneruj kompleksową analizę kondycji łączącą dane biegowe,
siłowe, HRV i sen.
</p>
) : (
<p className="text-sm text-fg/60">
Wybierz dzień oznaczony kropką, aby zobaczyć analizę z tego dnia.
</p>
)}
</div>
</div>
</section>
);
}

View File

@@ -54,7 +54,7 @@ export function ElevationChart({ data, syncId }: Props) {
};
return (
<div className="w-full rounded-lg border border-muted/40 bg-surface p-4">
<div className="rounded-lg border border-muted/40 bg-surface p-4" style={{ minWidth: 0 }}>
<div className="mb-2 flex items-center gap-4 text-sm text-fg/60">
<span>Profil wysokości</span>
{hasPace && (
@@ -64,8 +64,9 @@ export function ElevationChart({ data, syncId }: Props) {
</span>
)}
</div>
<div style={{ width: "100%", position: "relative", overflow: "hidden" }}>
<ResponsiveContainer width="100%" height={110}>
<ComposedChart syncId={syncId} data={data} margin={{ top: 4, right: hasPace ? 52 : 8, left: 0, bottom: 0 }}>
<ComposedChart syncId={syncId} data={data} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={`elev-${uid}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-accent)" stopOpacity={0.25} />
@@ -98,7 +99,7 @@ export function ElevationChart({ data, syncId }: Props) {
stroke="var(--color-sand)"
opacity={0.5}
fontSize={11}
width={50}
width={62}
tickFormatter={fmtPace}
domain={[minPace - pacePad, maxPace + pacePad]}
/>
@@ -136,6 +137,7 @@ export function ElevationChart({ data, syncId }: Props) {
)}
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { useRef, useState, useTransition } from "react";
type Props = {
initialNotes: string;
onSave: (notes: string) => Promise<void>;
};
export function NotesEditor({ initialNotes, onSave }: Props) {
const [value, setValue] = useState(initialNotes);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const savedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const dirty = value !== initialNotes;
function handleSave() {
startTransition(async () => {
await onSave(value);
if (savedTimer.current) clearTimeout(savedTimer.current);
setSaved(true);
savedTimer.current = setTimeout(() => setSaved(false), 2000);
});
}
return (
<div className="flex flex-col gap-2 rounded-lg border border-muted/40 bg-surface p-4">
<label className="text-sm font-medium text-fg/60">Notatki</label>
<textarea
value={value}
onChange={(e) => { setValue(e.target.value); setSaved(false); }}
placeholder="Dodaj notatki do tego treningu…"
rows={3}
className="w-full resize-none rounded border border-muted/40 bg-bg px-3 py-2 text-sm text-fg placeholder:text-fg/30 focus:border-accent focus:outline-none"
/>
<div className="flex items-center justify-end gap-3">
{saved && <span className="text-xs text-fg/40">Zapisano</span>}
<button
onClick={handleSave}
disabled={!dirty || isPending}
className="rounded px-3 py-1.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40"
style={{ background: "var(--color-accent)", color: "#fff" }}
>
{isPending ? "Zapisuję…" : "Zapisz"}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import { useEffect, useState } from "react";
import {
Area,
AreaChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
type RunDataPoint = {
label: string;
distanceKm: number;
};
type Props = {
data: RunDataPoint[];
};
export function RunningStatsChart({ data }: Props) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return (
<div className="w-full rounded-lg border border-muted/40 bg-surface p-4">
<div className="mb-2 h-4 w-56 animate-pulse rounded bg-muted/30" />
<div className="h-[220px] animate-pulse rounded bg-muted/20" />
</div>
);
}
return (
<div className="w-full rounded-lg border border-muted/40 bg-surface p-4">
<div className="mb-2 text-sm text-fg/60">Tygodniowy dystans biegowy (od poniedziałku)</div>
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="distGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-accent)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--color-accent)" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="label" stroke="var(--color-fg)" opacity={0.5} fontSize={11} />
<YAxis stroke="var(--color-fg)" opacity={0.5} fontSize={11} width={40} />
<Tooltip
cursor={{ stroke: "var(--color-accent)", strokeWidth: 1 }}
contentStyle={{
background: "var(--color-bg)",
border: "1px solid var(--color-muted)",
borderRadius: 8,
fontSize: 12,
color: "var(--color-fg)",
}}
formatter={(value) => [`${Number(value).toFixed(2)} km`, "Dystans"]}
/>
<Area
type="monotone"
dataKey="distanceKm"
stroke="var(--color-accent)"
fill="url(#distGradient)"
strokeWidth={2}
name="distanceKm"
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { useEffect, useState } from "react";
import {
Bar,
BarChart,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
type WorkoutDataPoint = {
label: string;
date: string;
name: string;
volumeKg: number;
};
type Props = {
data: WorkoutDataPoint[];
};
const COLORS: Record<string, string> = {};
const PALETTE = ["var(--color-accent)", "#5b9bd5", "#70ad47", "#ffc000", "#ed7d31"];
function getColor(name: string): string {
if (!COLORS[name]) {
COLORS[name] = PALETTE[Object.keys(COLORS).length % PALETTE.length];
}
return COLORS[name];
}
export function VolumeTrendChart({ data }: Props) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return (
<div className="w-full rounded-lg border border-muted/40 bg-surface p-4">
<div className="mb-2 h-4 w-56 animate-pulse rounded bg-muted/30" />
<div className="h-[220px] animate-pulse rounded bg-muted/20" />
</div>
);
}
const workoutNames = [...new Set(data.map((d) => d.name))];
const chartData: Record<string, string | number>[] = [];
const grouped = new Map<string, Record<string, string | number>>();
for (const point of data) {
const key = point.date;
if (!grouped.has(key)) {
grouped.set(key, { label: point.label });
}
const entry = grouped.get(key)!;
entry[point.name] = ((entry[point.name] as number) || 0) + point.volumeKg;
}
for (const entry of grouped.values()) {
chartData.push(entry);
}
return (
<div className="w-full rounded-lg border border-muted/40 bg-surface p-4">
<div className="mb-2 text-sm text-fg/60">
Trend wolumenu treningowego wg typu
</div>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<XAxis dataKey="label" stroke="var(--color-fg)" opacity={0.5} fontSize={11} />
<YAxis stroke="var(--color-fg)" opacity={0.5} fontSize={11} 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",
]}
/>
<Legend
wrapperStyle={{ fontSize: 12, color: "var(--color-fg)" }}
/>
{workoutNames.map((name) => (
<Bar
key={name}
dataKey={name}
fill={getColor(name)}
radius={[4, 4, 0, 0]}
stackId="volume"
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
);
}