init
This commit is contained in:
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "next-dev",
|
||||||
|
"runtimeExecutable": "pnpm",
|
||||||
|
"runtimeArgs": ["dev"],
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
157
app/page.tsx
157
app/page.tsx
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
11
app/strength/actions.ts
Normal 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}`);
|
||||||
|
}
|
||||||
1
auth.ts
1
auth.ts
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
50
components/notes-editor.tsx
Normal file
50
components/notes-editor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
components/running-stats-chart.tsx
Normal file
71
components/running-stats-chart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
components/volume-trend-chart.tsx
Normal file
105
components/volume-trend-chart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user