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,93 +1,118 @@
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 { RunningStatsChart } from "@/components/running-stats-chart";
import { StatCard } from "@/components/stat-card";
import { formatDateShort, formatDistance, formatDuration, formatPace } from "@/lib/format";
import { getDashboardAnalysis, serializeAnalysis } from "@/lib/models/analysis";
import { VolumeTrendChart } from "@/components/volume-trend-chart";
import { formatDateShort } from "@/lib/format";
import { listAnalysesForCalendar, serializeAnalysis } from "@/lib/models/analysis";
import { listRunningActivities } from "@/lib/models/running";
import { listStrengthWorkouts } from "@/lib/models/strength";
import { workoutVolumeKg } from "@/lib/strength/stats";
import { getCurrentUserId } from "@/lib/session";
export const dynamic = "force-dynamic";
const TREND_LIMIT = 20;
export default async function Home() {
const userId = await getCurrentUserId();
const [runs, strengthWorkouts, dashboardAnalysis] = await Promise.all([
const [runs, strengthWorkouts, allAnalyses] = await Promise.all([
listRunningActivities(userId),
listStrengthWorkouts(userId),
getDashboardAnalysis(userId),
listAnalysesForCalendar(userId),
]);
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 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];
const totalRuns = runs.length;
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 (
<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 className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Km w tym tygodniu" value={`${weeklyKm.toFixed(1)} km`} hint="Bieganie" />
<StatCard
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 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"
>
<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>
{(volumeTrendData.length > 1 || runTrendData.length > 1) && (
<section className="flex flex-col gap-4">
<h2 className="text-lg font-semibold text-fg">Globalne statystyki</h2>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{volumeTrendData.length > 1 && <VolumeTrendChart data={volumeTrendData} />}
{runTrendData.length > 1 && <RunningStatsChart data={runTrendData} />}
</div>
</section>
)}
<DashboardAnalysisCard analysis={dashboardAnalysis ? serializeAnalysis(dashboardAnalysis) : null} />
<DashboardAnalysisCard
analyses={serializedAnalyses}
runs={dayRuns}
workouts={dayWorkouts}
/>
</div>
);
}

View File

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

View File

@@ -17,6 +17,7 @@ import {
listRunningActivities,
setLastSyncAt,
setRunningActivityMetrics,
setRunningActivityNotes,
setRunningActivityRoutePoints,
upsertRunningActivity,
} 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." };
}
}
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 { ExerciseProgressChart } from "@/components/exercise-progress-chart";
import { InfoTooltip } from "@/components/info-tooltip";
import { NotesEditor } from "@/components/notes-editor";
import { formatDate, formatDateShort } from "@/lib/format";
import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis";
import { getStrengthWorkout, listStrengthWorkouts } from "@/lib/models/strength";
import { exerciseE1rm, getExerciseHistory } from "@/lib/strength/stats";
import { getCurrentUserId } from "@/lib/session";
import { saveStrengthNotes } from "@/app/strength/actions";
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}
</div>
<NotesEditor
initialNotes={workout.notes ?? ""}
onSave={saveStrengthNotes.bind(null, workout._id.toString())}
/>
<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">

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}`);
}