2026-06-16 09:43:48 +02:00
|
|
|
"use client";
|
|
|
|
|
|
2026-06-22 11:32:27 +02:00
|
|
|
import Link from "next/link";
|
|
|
|
|
import { useActionState, useMemo, useState } from "react";
|
|
|
|
|
import {
|
|
|
|
|
format,
|
|
|
|
|
startOfMonth,
|
|
|
|
|
endOfMonth,
|
|
|
|
|
startOfWeek,
|
|
|
|
|
endOfWeek,
|
|
|
|
|
addDays,
|
|
|
|
|
addMonths,
|
|
|
|
|
subMonths,
|
|
|
|
|
isSameMonth,
|
|
|
|
|
isSameDay,
|
|
|
|
|
isToday,
|
2026-06-22 14:28:05 +02:00
|
|
|
isAfter,
|
|
|
|
|
startOfDay,
|
2026-06-22 11:32:27 +02:00
|
|
|
} from "date-fns";
|
|
|
|
|
import { pl } from "date-fns/locale";
|
|
|
|
|
import { Activity, ChevronLeft, ChevronRight, Dumbbell, Sparkles } from "lucide-react";
|
2026-06-16 09:43:48 +02:00
|
|
|
import { generateDashboardAnalysisAction } from "@/app/ai/actions";
|
2026-06-22 11:32:27 +02:00
|
|
|
import { formatDate, formatDistance, formatDuration, formatPace } from "@/lib/format";
|
2026-06-16 11:51:10 +02:00
|
|
|
import type { SerializedAiAnalysis } from "@/lib/models/analysis";
|
2026-06-16 09:43:48 +02:00
|
|
|
|
2026-06-22 11:32:27 +02:00
|
|
|
export type DayRun = {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
startTime: string;
|
|
|
|
|
distanceM: number;
|
|
|
|
|
durationSec: number;
|
|
|
|
|
avgPaceSecPerKm: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type DayWorkout = {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
date: string;
|
|
|
|
|
exerciseCount: number;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-16 09:43:48 +02:00
|
|
|
type Props = {
|
2026-06-22 11:32:27 +02:00
|
|
|
analyses: SerializedAiAnalysis[];
|
|
|
|
|
runs: DayRun[];
|
|
|
|
|
workouts: DayWorkout[];
|
2026-06-16 09:43:48 +02:00
|
|
|
};
|
|
|
|
|
|
2026-06-22 11:32:27 +02:00
|
|
|
function dayKey(date: Date): string {
|
|
|
|
|
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function DashboardAnalysisCard({ analyses, runs, workouts }: Props) {
|
2026-06-16 09:43:48 +02:00
|
|
|
const [state, formAction, pending] = useActionState(generateDashboardAnalysisAction, null);
|
|
|
|
|
|
2026-06-22 11:32:27 +02:00
|
|
|
// 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());
|
|
|
|
|
|
2026-06-22 14:28:05 +02:00
|
|
|
const runDays = useMemo(
|
|
|
|
|
() => new Set(runs.map((r) => dayKey(new Date(r.startTime)))),
|
|
|
|
|
[runs]
|
|
|
|
|
);
|
|
|
|
|
const workoutDays = useMemo(
|
|
|
|
|
() => new Set(workouts.map((w) => dayKey(new Date(w.date)))),
|
|
|
|
|
[workouts]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const todayStart = startOfDay(new Date());
|
2026-06-22 11:32:27 +02:00
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
2026-06-16 09:43:48 +02:00
|
|
|
return (
|
2026-06-22 11:32:27 +02:00
|
|
|
<section className="flex flex-col gap-4 rounded-lg border border-muted/40 bg-surface p-4">
|
2026-06-16 09:43:48 +02:00
|
|
|
<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>
|
2026-06-22 11:32:27 +02:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-06-16 09:43:48 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{state && "error" in state ? <p className="text-sm text-accent">{state.error}</p> : null}
|
|
|
|
|
|
2026-06-22 11:32:27 +02:00
|
|
|
<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) => {
|
2026-06-22 14:28:05 +02:00
|
|
|
const key = dayKey(d);
|
2026-06-22 11:32:27 +02:00
|
|
|
const inMonth = isSameMonth(d, monthStart);
|
|
|
|
|
const today = isToday(d);
|
2026-06-22 14:28:05 +02:00
|
|
|
const future = isAfter(startOfDay(d), todayStart);
|
|
|
|
|
const selectable = !future;
|
2026-06-22 11:32:27 +02:00
|
|
|
const selected = isSameDay(d, selectedDate);
|
|
|
|
|
|
2026-06-22 14:28:05 +02:00
|
|
|
const hasRun = runDays.has(key);
|
|
|
|
|
const hasWorkout = workoutDays.has(key);
|
|
|
|
|
const hasAnalysis = byDay.has(key);
|
|
|
|
|
|
2026-06-22 11:32:27 +02:00
|
|
|
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",
|
2026-06-22 14:28:05 +02:00
|
|
|
!selectable && "cursor-not-allowed text-fg/30",
|
2026-06-22 11:32:27 +02:00
|
|
|
today && !selected && "ring-1 ring-accent/50",
|
|
|
|
|
]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join(" ")}
|
|
|
|
|
>
|
|
|
|
|
{d.getDate()}
|
2026-06-22 14:28:05 +02:00
|
|
|
{!selected && (hasRun || hasWorkout || hasAnalysis) && (
|
|
|
|
|
<span className="absolute bottom-0.5 left-1/2 flex -translate-x-1/2 gap-0.5">
|
|
|
|
|
{hasRun && <span className="h-1 w-1 rounded-full bg-[#5b9bd5]" />}
|
|
|
|
|
{hasWorkout && <span className="h-1 w-1 rounded-full bg-[#70ad47]" />}
|
|
|
|
|
{hasAnalysis && <span className="h-1 w-1 rounded-full bg-accent" />}
|
|
|
|
|
</span>
|
2026-06-22 11:32:27 +02:00
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-22 14:28:05 +02:00
|
|
|
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-fg/55">
|
|
|
|
|
<span className="flex items-center gap-1">
|
|
|
|
|
<span className="h-1.5 w-1.5 rounded-full bg-[#5b9bd5]" /> Bieg
|
|
|
|
|
</span>
|
|
|
|
|
<span className="flex items-center gap-1">
|
|
|
|
|
<span className="h-1.5 w-1.5 rounded-full bg-[#70ad47]" /> Siłownia
|
|
|
|
|
</span>
|
|
|
|
|
<span className="flex items-center gap-1">
|
|
|
|
|
<span className="h-1.5 w-1.5 rounded-full bg-accent" /> Analiza
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-22 11:32:27 +02:00
|
|
|
<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>
|
2026-06-16 09:43:48 +02:00
|
|
|
</div>
|
2026-06-22 11:32:27 +02:00
|
|
|
|
|
|
|
|
<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">
|
2026-06-22 14:28:05 +02:00
|
|
|
Brak analizy AI dla {format(selectedDate, "d MMMM yyyy", { locale: pl })}.
|
2026-06-22 11:32:27 +02:00
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-16 09:43:48 +02:00
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|