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

11
.claude/launch.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "next-dev",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev"],
"port": 3000
}
]
}

View File

@@ -1,93 +1,118 @@
import Link from "next/link";
import { startOfWeek } from "date-fns"; import { startOfWeek } from "date-fns";
import { Activity, Dumbbell } from "lucide-react";
import { DashboardAnalysisCard } from "@/components/dashboard-analysis-card"; import { DashboardAnalysisCard } from "@/components/dashboard-analysis-card";
import { EmptyState } from "@/components/empty-state"; import { RunningStatsChart } from "@/components/running-stats-chart";
import { StatCard } from "@/components/stat-card"; import { StatCard } from "@/components/stat-card";
import { formatDateShort, formatDistance, formatDuration, formatPace } from "@/lib/format"; import { VolumeTrendChart } from "@/components/volume-trend-chart";
import { getDashboardAnalysis, serializeAnalysis } from "@/lib/models/analysis"; import { formatDateShort } from "@/lib/format";
import { listAnalysesForCalendar, serializeAnalysis } from "@/lib/models/analysis";
import { listRunningActivities } from "@/lib/models/running"; import { listRunningActivities } from "@/lib/models/running";
import { listStrengthWorkouts } from "@/lib/models/strength"; import { listStrengthWorkouts } from "@/lib/models/strength";
import { workoutVolumeKg } from "@/lib/strength/stats";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const TREND_LIMIT = 20;
export default async function Home() { export default async function Home() {
const userId = await getCurrentUserId(); const userId = await getCurrentUserId();
const [runs, strengthWorkouts, dashboardAnalysis] = await Promise.all([ const [runs, strengthWorkouts, allAnalyses] = await Promise.all([
listRunningActivities(userId), listRunningActivities(userId),
listStrengthWorkouts(userId), listStrengthWorkouts(userId),
getDashboardAnalysis(userId), listAnalysesForCalendar(userId),
]); ]);
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
const weeklyKm = runs const weeklyKm =
.filter((run) => run.startTime >= weekStart) runs.filter((run) => run.startTime >= weekStart).reduce((sum, run) => sum + run.distanceM, 0) /
.reduce((sum, run) => sum + run.distanceM, 0) / 1000; 1000;
const weeklyStrengthSessions = strengthWorkouts.filter((workout) => workout.date >= weekStart).length; const weeklyStrengthSessions = strengthWorkouts.filter(
(workout) => workout.date >= weekStart
).length;
const latestRun = runs[0]; const totalRuns = runs.length;
const latestStrength = strengthWorkouts[0]; const totalDistanceKm = runs.reduce((sum, r) => sum + r.distanceM, 0) / 1000;
const totalStrengthWorkouts = strengthWorkouts.length;
const volumeTrendData = strengthWorkouts
.slice(0, TREND_LIMIT)
.map((w) => ({
label: formatDateShort(w.date),
date: w.date.toISOString().slice(0, 10),
name: w.name,
volumeKg: workoutVolumeKg(w),
}))
.reverse();
// Aggregate running distance per ISO week (Mon-based).
const weeklyDistanceM = new Map<number, number>();
for (const r of runs) {
const weekStartTs = startOfWeek(r.startTime, { weekStartsOn: 1 }).getTime();
weeklyDistanceM.set(weekStartTs, (weeklyDistanceM.get(weekStartTs) ?? 0) + r.distanceM);
}
const runTrendData = [...weeklyDistanceM.entries()]
.sort((a, b) => a[0] - b[0])
.slice(-TREND_LIMIT)
.map(([ts, meters]) => ({
label: formatDateShort(new Date(ts)),
distanceKm: Math.round((meters / 1000) * 100) / 100,
}));
const serializedAnalyses = allAnalyses.map(serializeAnalysis);
const dayRuns = runs.map((r) => ({
id: r._id.toString(),
name: r.name,
startTime: r.startTime.toISOString(),
distanceM: r.distanceM,
durationSec: r.durationSec,
avgPaceSecPerKm: r.avgPaceSecPerKm,
}));
const dayWorkouts = strengthWorkouts.map((w) => ({
id: w._id.toString(),
name: w.name,
date: w.date.toISOString(),
exerciseCount: w.exercises.length,
}));
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <StatCard label="Km w tym tygodniu" value={`${weeklyKm.toFixed(1)} km`} hint="Bieganie" />
<StatCard label="Kilometry w tym tygodniu" value={`${weeklyKm.toFixed(1)} km`} hint="Bieganie" /> <StatCard
<StatCard label="Treningi siłowe w tym tygodniu" value={weeklyStrengthSessions} hint="Siłownia" /> label="Siłownia w tym tygodniu"
value={weeklyStrengthSessions}
hint="Treningi"
/>
<StatCard
label="Łączny dystans"
value={`${totalDistanceKm.toFixed(0)} km`}
hint={`${totalRuns} biegów`}
/>
<StatCard
label="Treningi siłowe"
value={totalStrengthWorkouts}
hint="Łącznie"
/>
</section> </section>
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2"> {(volumeTrendData.length > 1 || runTrendData.length > 1) && (
<div className="flex flex-col gap-3"> <section className="flex flex-col gap-4">
<h2 className="text-lg font-semibold text-fg">Ostatni bieg</h2> <h2 className="text-lg font-semibold text-fg">Globalne statystyki</h2>
{latestRun ? ( <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Link {volumeTrendData.length > 1 && <VolumeTrendChart data={volumeTrendData} />}
href={`/running/${latestRun._id.toString()}`} {runTrendData.length > 1 && <RunningStatsChart data={runTrendData} />}
className="flex flex-col gap-2 rounded-lg border border-muted/40 bg-surface p-4 transition-colors hover:border-accent/60" </div>
> </section>
<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" }}
/>
)}
</div>
<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 ? serializeAnalysis(dashboardAnalysis) : null} /> <DashboardAnalysisCard
analyses={serializedAnalyses}
runs={dayRuns}
workouts={dayWorkouts}
/>
</div> </div>
); );
} }

View File

@@ -1,6 +1,7 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { AiAnalysisCard } from "@/components/ai-analysis-card"; import { AiAnalysisCard } from "@/components/ai-analysis-card";
import { ElevationChart } from "@/components/elevation-chart"; import { ElevationChart } from "@/components/elevation-chart";
import { NotesEditor } from "@/components/notes-editor";
import { RouteMapSection } from "@/components/route-map-section"; import { RouteMapSection } from "@/components/route-map-section";
import { GcbChart } from "@/components/gcb-chart"; import { GcbChart } from "@/components/gcb-chart";
import { RunMetricChart } from "@/components/run-metric-chart"; import { RunMetricChart } from "@/components/run-metric-chart";
@@ -12,6 +13,7 @@ import {
type RunningActivity, type RunningActivity,
} from "@/lib/models/running"; } from "@/lib/models/running";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { saveRunningNotes } from "@/app/running/actions";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -198,6 +200,11 @@ export default async function RunningActivityPage({
</div> </div>
)} )}
<NotesEditor
initialNotes={activity.notes ?? ""}
onSave={saveRunningNotes.bind(null, activity._id.toString())}
/>
<AiAnalysisCard <AiAnalysisCard
targetType="running" targetType="running"
targetId={activity._id.toString()} targetId={activity._id.toString()}

View File

@@ -17,6 +17,7 @@ import {
listRunningActivities, listRunningActivities,
setLastSyncAt, setLastSyncAt,
setRunningActivityMetrics, setRunningActivityMetrics,
setRunningActivityNotes,
setRunningActivityRoutePoints, setRunningActivityRoutePoints,
upsertRunningActivity, upsertRunningActivity,
} from "@/lib/models/running"; } from "@/lib/models/running";
@@ -151,3 +152,9 @@ export async function loadActivityRoute(activityMongoId: string): Promise<LoadRo
return { error: error instanceof Error ? error.message : "Nie udało się pobrać mapy trasy." }; return { error: error instanceof Error ? error.message : "Nie udało się pobrać mapy trasy." };
} }
} }
export async function saveRunningNotes(activityId: string, notes: string): Promise<void> {
const userId = await getCurrentUserId();
await setRunningActivityNotes(userId, activityId, notes);
revalidatePath(`/running/${activityId}`);
}

View File

@@ -2,11 +2,13 @@ import { notFound } from "next/navigation";
import { AiAnalysisCard } from "@/components/ai-analysis-card"; import { AiAnalysisCard } from "@/components/ai-analysis-card";
import { ExerciseProgressChart } from "@/components/exercise-progress-chart"; import { ExerciseProgressChart } from "@/components/exercise-progress-chart";
import { InfoTooltip } from "@/components/info-tooltip"; import { InfoTooltip } from "@/components/info-tooltip";
import { NotesEditor } from "@/components/notes-editor";
import { formatDate, formatDateShort } from "@/lib/format"; import { formatDate, formatDateShort } from "@/lib/format";
import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis"; import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis";
import { getStrengthWorkout, listStrengthWorkouts } from "@/lib/models/strength"; import { getStrengthWorkout, listStrengthWorkouts } from "@/lib/models/strength";
import { exerciseE1rm, getExerciseHistory } from "@/lib/strength/stats"; import { exerciseE1rm, getExerciseHistory } from "@/lib/strength/stats";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { saveStrengthNotes } from "@/app/strength/actions";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -42,6 +44,11 @@ export default async function StrengthWorkoutPage({
{workout.notes ? <p className="mt-1.5 text-sm text-fg/70">{workout.notes}</p> : null} {workout.notes ? <p className="mt-1.5 text-sm text-fg/70">{workout.notes}</p> : null}
</div> </div>
<NotesEditor
initialNotes={workout.notes ?? ""}
onSave={saveStrengthNotes.bind(null, workout._id.toString())}
/>
<AiAnalysisCard targetType="strength" targetId={workout._id.toString()} analysis={analysis ? serializeAnalysis(analysis) : null} /> <AiAnalysisCard targetType="strength" targetId={workout._id.toString()} analysis={analysis ? serializeAnalysis(analysis) : null} />
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3">

11
app/strength/actions.ts Normal file
View File

@@ -0,0 +1,11 @@
"use server";
import { revalidatePath } from "next/cache";
import { setStrengthWorkoutNotes } from "@/lib/models/strength";
import { getCurrentUserId } from "@/lib/session";
export async function saveStrengthNotes(workoutId: string, notes: string): Promise<void> {
const userId = await getCurrentUserId();
await setStrengthWorkoutNotes(userId, workoutId, notes);
revalidatePath(`/strength/${workoutId}`);
}

View File

@@ -7,6 +7,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
clientId: process.env.KEYCLOAK_CLIENT_ID!, clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!, clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER!, issuer: process.env.KEYCLOAK_ISSUER!,
authorization: { params: { prompt: "login" } },
}), }),
], ],
callbacks: { callbacks: {

View File

@@ -1,57 +1,260 @@
"use client"; "use client";
import { useActionState } from "react"; import Link from "next/link";
import { Sparkles } from "lucide-react"; 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 { 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"; import type { SerializedAiAnalysis } from "@/lib/models/analysis";
type Props = { export type DayRun = {
analysis: SerializedAiAnalysis | null; 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); 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 ( 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"> <div className="flex items-center justify-between">
<h2 className="flex items-center gap-1.5 text-lg font-semibold text-fg"> <h2 className="flex items-center gap-1.5 text-lg font-semibold text-fg">
<Sparkles size={18} className="text-accent" /> <Sparkles size={18} className="text-accent" />
Kondycja treningowa Kondycja treningowa
</h2> </h2>
<form action={formAction}> {viewingToday && (
<button <form action={formAction}>
type="submit" <button
disabled={pending} type="submit"
className="rounded-md bg-accent px-3 py-1.5 text-sm font-semibold text-fg transition-opacity hover:opacity-90 disabled:opacity-50" 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> {pending ? "Analizuję..." : selectedAnalysis ? "Odśwież analizę" : "Generuj analizę"}
</form> </button>
</form>
)}
</div> </div>
{state && "error" in state ? <p className="text-sm text-accent">{state.error}</p> : null} {state && "error" in state ? <p className="text-sm text-accent">{state.error}</p> : null}
{analysis ? ( <div className="flex flex-col gap-4 sm:flex-row">
<div className="flex flex-col gap-2"> <div className="min-w-[280px]">
<p className="text-sm text-fg/90">{analysis.summary}</p> <div className="mb-2 flex items-center justify-between">
{analysis.tips.length > 0 ? ( <button
<ul className="list-disc pl-5 text-sm text-fg/80"> type="button"
{analysis.tips.map((tip, index) => ( onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
<li key={index}>{tip}</li> className="rounded p-1 text-fg/60 transition-colors hover:text-fg"
))} >
</ul> <ChevronLeft size={16} />
) : null} </button>
<p className="text-xs text-fg/40"> <span className="text-sm font-medium capitalize text-fg">
{formatDate(analysis.createdAt)} · {analysis.model} {format(currentMonth, "LLLL yyyy", { locale: pl })}
</p> </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> </div>
) : (
<p className="text-sm text-fg/60"> <div className="flex-1">
Wygeneruj kompleksową analizę kondycji łączącą dane biegowe, siłowe, HRV i sen. {selectedAnalysis ? (
</p> <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> </section>
); );
} }

View File

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

View File

@@ -120,6 +120,10 @@ function buildRunningPrompt(activity: RunningActivity, previousRuns: PreviousRun
} }
} }
if (activity.notes) {
lines.push(``, `Notatki zawodnika: ${activity.notes}`);
}
if (previousRuns.length > 0) { if (previousRuns.length > 0) {
lines.push(``, `Poprzednie biegi (od najnowszego):`); lines.push(``, `Poprzednie biegi (od najnowszego):`);
for (const { run, analysis } of previousRuns) { for (const { run, analysis } of previousRuns) {

View File

@@ -72,6 +72,16 @@ export async function getDashboardAnalysis(userId: string): Promise<AiAnalysis |
); );
} }
export async function listAnalysesForCalendar(
userId: string
): Promise<AiAnalysis[]> {
const collection = await getCollection();
return collection
.find({ userId, targetType: "dashboard", targetId: DASHBOARD_TARGET_ID })
.sort({ createdAt: -1 })
.toArray();
}
export async function saveDashboardAnalysis( export async function saveDashboardAnalysis(
userId: string, userId: string,
summary: string, summary: string,

View File

@@ -47,6 +47,7 @@ export type RunningActivity = RunningActivityInput & {
_id: ObjectId; _id: ObjectId;
userId: string; userId: string;
createdAt: Date; createdAt: Date;
notes?: string;
routePoints?: RoutePoint[]; routePoints?: RoutePoint[];
elevationProfile?: number[]; elevationProfile?: number[];
runMetrics?: RunMetrics; runMetrics?: RunMetrics;
@@ -90,6 +91,18 @@ export async function getRunningActivity(
return collection.findOne({ _id: new ObjectId(id), userId }); return collection.findOne({ _id: new ObjectId(id), userId });
} }
export async function setRunningActivityNotes(
userId: string,
id: string,
notes: string
): Promise<void> {
const collection = await getCollection();
await collection.updateOne(
{ _id: new ObjectId(id), userId },
{ $set: { notes: notes.trim() || undefined } }
);
}
export async function setRunningActivityMetrics( export async function setRunningActivityMetrics(
userId: string, userId: string,
garminActivityId: number, garminActivityId: number,

View File

@@ -69,3 +69,15 @@ export async function getStrengthWorkout(
const collection = await getCollection(); const collection = await getCollection();
return collection.findOne({ _id: new ObjectId(id), userId }); return collection.findOne({ _id: new ObjectId(id), userId });
} }
export async function setStrengthWorkoutNotes(
userId: string,
id: string,
notes: string
): Promise<void> {
const collection = await getCollection();
await collection.updateOne(
{ _id: new ObjectId(id), userId },
{ $set: { notes: notes.trim() || undefined } }
);
}