init
This commit is contained in:
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