diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..c248f49 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "next-dev", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["dev"], + "port": 3000 + } + ] +} diff --git a/app/page.tsx b/app/page.tsx index 93fd473..862ebe3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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(); + 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 (
- -
- - +
+ + + +
-
-
-

Ostatni bieg

- {latestRun ? ( - -
{latestRun.name}
-
{formatDateShort(latestRun.startTime)}
-
- {formatDistance(latestRun.distanceM)} · {formatDuration(latestRun.durationSec)} ·{" "} - {formatPace(latestRun.avgPaceSecPerKm)} -
- - ) : ( - } - title="Brak danych o bieganiu" - description="Zsynchronizuj aktywności z Garmin Connect, aby zobaczyć tutaj swoje biegi." - action={{ href: "/running", label: "Przejdź do biegania" }} - /> - )} -
-
-

Ostatni trening siłowy

- {latestStrength ? ( - -
{latestStrength.name}
-
{formatDateShort(latestStrength.date)}
-
- {latestStrength.exercises.length}{" "} - {latestStrength.exercises.length === 1 ? "ćwiczenie" : "ćwiczeń"} -
- - ) : ( - } - title="Brak treningów siłowych" - description="Zaimportuj trening wklejając tekst wygenerowany przez aplikację Strong." - action={{ href: "/strength/import", label: "Zaimportuj trening" }} - /> - )} -
-
+ {(volumeTrendData.length > 1 || runTrendData.length > 1) && ( +
+

Globalne statystyki

+
+ {volumeTrendData.length > 1 && } + {runTrendData.length > 1 && } +
+
+ )} - +
); } diff --git a/app/running/[id]/page.tsx b/app/running/[id]/page.tsx index f1420f9..e73192d 100644 --- a/app/running/[id]/page.tsx +++ b/app/running/[id]/page.tsx @@ -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({ )} + + { + const userId = await getCurrentUserId(); + await setRunningActivityNotes(userId, activityId, notes); + revalidatePath(`/running/${activityId}`); +} diff --git a/app/strength/[id]/page.tsx b/app/strength/[id]/page.tsx index 6a07266..a3d0ee6 100644 --- a/app/strength/[id]/page.tsx +++ b/app/strength/[id]/page.tsx @@ -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 ?

{workout.notes}

: null} + +
diff --git a/app/strength/actions.ts b/app/strength/actions.ts new file mode 100644 index 0000000..1a6e837 --- /dev/null +++ b/app/strength/actions.ts @@ -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 { + const userId = await getCurrentUserId(); + await setStrengthWorkoutNotes(userId, workoutId, notes); + revalidatePath(`/strength/${workoutId}`); +} diff --git a/auth.ts b/auth.ts index 06da78f..0a8cfc6 100644 --- a/auth.ts +++ b/auth.ts @@ -7,6 +7,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ clientId: process.env.KEYCLOAK_CLIENT_ID!, clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!, issuer: process.env.KEYCLOAK_ISSUER!, + authorization: { params: { prompt: "login" } }, }), ], callbacks: { diff --git a/components/dashboard-analysis-card.tsx b/components/dashboard-analysis-card.tsx index f1e1037..c0d6925 100644 --- a/components/dashboard-analysis-card.tsx +++ b/components/dashboard-analysis-card.tsx @@ -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(); + 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(() => 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 ( -
+

Kondycja treningowa

-
- -
+ {viewingToday && ( +
+ +
+ )}
{state && "error" in state ?

{state.error}

: null} - {analysis ? ( -
-

{analysis.summary}

- {analysis.tips.length > 0 ? ( -
    - {analysis.tips.map((tip, index) => ( -
  • {tip}
  • - ))} -
- ) : null} -

- {formatDate(analysis.createdAt)} · {analysis.model} -

+
+
+
+ + + {format(currentMonth, "LLLL yyyy", { locale: pl })} + + +
+ +
+ {weekDays.map((d) => ( +
+ {d} +
+ ))} +
+ +
+ {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 ( + + ); + })} +
+ +
+

+ Treningi · {format(selectedDate, "d MMM yyyy", { locale: pl })} +

+ {dayRuns.length === 0 && dayWorkouts.length === 0 ? ( +

Brak treningów tego dnia.

+ ) : ( +
    + {dayRuns.map((run) => ( +
  • + + + + + {run.name} + + + {formatDistance(run.distanceM)} · {formatDuration(run.durationSec)} ·{" "} + {formatPace(run.avgPaceSecPerKm)} + + + +
  • + ))} + {dayWorkouts.map((workout) => ( +
  • + + + + + {workout.name} + + + {workout.exerciseCount}{" "} + {workout.exerciseCount === 1 ? "ćwiczenie" : "ćwiczeń"} + + + +
  • + ))} +
+ )} +
- ) : ( -

- Wygeneruj kompleksową analizę kondycji łączącą dane biegowe, siłowe, HRV i sen. -

- )} + +
+ {selectedAnalysis ? ( +
+

+ {format(selectedDate, "d MMMM yyyy", { locale: pl })} +

+

{selectedAnalysis.summary}

+ {selectedAnalysis.tips.length > 0 && ( +
    + {selectedAnalysis.tips.map((tip, index) => ( +
  • {tip}
  • + ))} +
+ )} +

+ {formatDate(selectedAnalysis.createdAt)} · {selectedAnalysis.model} +

+
+ ) : viewingToday ? ( +

+ Brak analizy na dziś. Wygeneruj kompleksową analizę kondycji łączącą dane biegowe, + siłowe, HRV i sen. +

+ ) : ( +

+ Wybierz dzień oznaczony kropką, aby zobaczyć analizę z tego dnia. +

+ )} +
+
); } diff --git a/components/elevation-chart.tsx b/components/elevation-chart.tsx index 55f2df6..ca62b8b 100644 --- a/components/elevation-chart.tsx +++ b/components/elevation-chart.tsx @@ -54,7 +54,7 @@ export function ElevationChart({ data, syncId }: Props) { }; return ( -
+
Profil wysokości {hasPace && ( @@ -64,8 +64,9 @@ export function ElevationChart({ data, syncId }: Props) { )}
+
- + @@ -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) { )} +
); } diff --git a/components/notes-editor.tsx b/components/notes-editor.tsx new file mode 100644 index 0000000..7d179d2 --- /dev/null +++ b/components/notes-editor.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useRef, useState, useTransition } from "react"; + +type Props = { + initialNotes: string; + onSave: (notes: string) => Promise; +}; + +export function NotesEditor({ initialNotes, onSave }: Props) { + const [value, setValue] = useState(initialNotes); + const [saved, setSaved] = useState(false); + const [isPending, startTransition] = useTransition(); + const savedTimer = useRef | 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 ( +
+ +