init
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { generateAnalysis, generateDashboardAnalysis } from "@/lib/ai/claude";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import type { AiAnalysisTargetType } from "@/lib/models/analysis";
|
||||
|
||||
export type GenerateAnalysisState = { error: string } | { success: true } | null;
|
||||
@@ -10,8 +11,9 @@ export async function generateAnalysisAction(
|
||||
targetType: AiAnalysisTargetType,
|
||||
targetId: string
|
||||
): Promise<GenerateAnalysisState> {
|
||||
const userId = await getCurrentUserId();
|
||||
try {
|
||||
await generateAnalysis(targetType, targetId);
|
||||
await generateAnalysis(userId, targetType, targetId);
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Nie udało się wygenerować analizy." };
|
||||
}
|
||||
@@ -22,8 +24,9 @@ export async function generateAnalysisAction(
|
||||
}
|
||||
|
||||
export async function generateDashboardAnalysisAction(): Promise<GenerateAnalysisState> {
|
||||
const userId = await getCurrentUserId();
|
||||
try {
|
||||
await generateDashboardAnalysis();
|
||||
await generateDashboardAnalysis(userId);
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Nie udało się wygenerować analizy." };
|
||||
}
|
||||
|
||||
3
app/api/auth/[...nextauth]/route.ts
Normal file
3
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Nav } from "@/components/nav";
|
||||
import { auth } from "@/auth";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -18,18 +19,20 @@ export const metadata: Metadata = {
|
||||
description: "Analiza treningów biegowych i siłowych",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="pl"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col bg-bg text-fg">
|
||||
<Nav />
|
||||
{session && <Nav />}
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6 sm:px-6">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
36
app/login/page.tsx
Normal file
36
app/login/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { signIn } from "@/auth";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-10">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="KNUR"
|
||||
width={160}
|
||||
height={160}
|
||||
className="rounded-3xl shadow-2xl ring-2 ring-surface"
|
||||
/>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-fg">KNUR</h1>
|
||||
<p className="text-sm text-fg/50">
|
||||
Książka Notowań Udźwigów i Rezultatów
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signIn("keycloak", { redirectTo: "/" });
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-accent px-8 py-3 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
||||
>
|
||||
Zaloguj
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,14 +8,17 @@ import { formatDateShort, formatDistance, formatDuration, formatPace } from "@/l
|
||||
import { getDashboardAnalysis, serializeAnalysis } from "@/lib/models/analysis";
|
||||
import { listRunningActivities } from "@/lib/models/running";
|
||||
import { listStrengthWorkouts } from "@/lib/models/strength";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Home() {
|
||||
const userId = await getCurrentUserId();
|
||||
|
||||
const [runs, strengthWorkouts, dashboardAnalysis] = await Promise.all([
|
||||
listRunningActivities(),
|
||||
listStrengthWorkouts(),
|
||||
getDashboardAnalysis(),
|
||||
listRunningActivities(userId),
|
||||
listStrengthWorkouts(userId),
|
||||
getDashboardAnalysis(userId),
|
||||
]);
|
||||
|
||||
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
|
||||
|
||||
@@ -20,24 +20,29 @@ import {
|
||||
type RunMetrics,
|
||||
type RunningActivity,
|
||||
} from "@/lib/models/running";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// hasRoute may be undefined for activities synced before the field was added,
|
||||
// even if routePoints are already in the DB.
|
||||
function mayHaveRoute(activity: RunningActivity): boolean {
|
||||
return Boolean(activity.hasRoute) || Boolean(activity.routePoints?.length);
|
||||
}
|
||||
|
||||
async function RouteMapFetcher({ activity }: { activity: RunningActivity }) {
|
||||
async function RouteMapFetcher({
|
||||
activity,
|
||||
userId,
|
||||
}: {
|
||||
activity: RunningActivity;
|
||||
userId: string;
|
||||
}) {
|
||||
let routePoints = activity.routePoints;
|
||||
|
||||
if ((!routePoints || !activity.elevationProfile) && mayHaveRoute(activity)) {
|
||||
try {
|
||||
const client = await getAuthorizedClient();
|
||||
const client = await getAuthorizedClient(userId);
|
||||
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
||||
if (result) {
|
||||
await setRunningActivityRoutePoints(activity.garminActivityId, result.points, result.elevationProfile);
|
||||
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
|
||||
routePoints = result.points;
|
||||
}
|
||||
} catch {
|
||||
@@ -62,15 +67,21 @@ function hasValidElevation(profile: number[] | undefined): boolean {
|
||||
return Array.isArray(profile) && profile.some((v) => v > 0);
|
||||
}
|
||||
|
||||
async function ElevationFetcher({ activity }: { activity: RunningActivity }) {
|
||||
async function ElevationFetcher({
|
||||
activity,
|
||||
userId,
|
||||
}: {
|
||||
activity: RunningActivity;
|
||||
userId: string;
|
||||
}) {
|
||||
let elevationProfile = activity.elevationProfile;
|
||||
|
||||
if (!hasValidElevation(elevationProfile) && mayHaveRoute(activity)) {
|
||||
try {
|
||||
const client = await getAuthorizedClient();
|
||||
const client = await getAuthorizedClient(userId);
|
||||
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
||||
if (result) {
|
||||
await setRunningActivityRoutePoints(activity.garminActivityId, result.points, result.elevationProfile);
|
||||
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
|
||||
elevationProfile = result.elevationProfile;
|
||||
}
|
||||
} catch {
|
||||
@@ -80,14 +91,23 @@ async function ElevationFetcher({ activity }: { activity: RunningActivity }) {
|
||||
|
||||
if (!elevationProfile || elevationProfile.length < 2) return null;
|
||||
|
||||
const data = elevationProfile
|
||||
const elevData = elevationProfile
|
||||
.map((altM, i) => ({
|
||||
distanceKm: Math.round((i / elevationProfile!.length) * activity.distanceM / 10) / 100,
|
||||
altM,
|
||||
}))
|
||||
.filter((p) => p.altM > 0);
|
||||
|
||||
if (data.length < 2) return null;
|
||||
if (elevData.length < 2) return null;
|
||||
|
||||
// Merge pace by fractional position (both arrays span the same run, different sample counts)
|
||||
const paceSrc = activity.runMetrics?.paceSec;
|
||||
const data = elevData.map((ep, i) => {
|
||||
if (!paceSrc || paceSrc.length === 0) return ep;
|
||||
const pi = Math.min(Math.round((i / elevData.length) * paceSrc.length), paceSrc.length - 1);
|
||||
const v = paceSrc[pi];
|
||||
return { ...ep, paceSec: v > 0 && v < 1800 ? v : undefined };
|
||||
});
|
||||
|
||||
return <ElevationChart data={data} />;
|
||||
}
|
||||
@@ -102,18 +122,27 @@ function toChartData(
|
||||
.filter((p) => p.value > 0);
|
||||
}
|
||||
|
||||
async function RunMetricsFetcher({ activity }: { activity: RunningActivity }) {
|
||||
async function RunMetricsFetcher({
|
||||
activity,
|
||||
userId,
|
||||
}: {
|
||||
activity: RunningActivity;
|
||||
userId: string;
|
||||
}) {
|
||||
let metrics: RunMetrics | undefined = activity.runMetrics;
|
||||
|
||||
const missingCadence = activity.avgCadence && !metrics?.cadenceSpm;
|
||||
const missingGcb = activity.avgGroundContactBalanceLeftPct && !metrics?.gcbLeftPct;
|
||||
// Re-fetch if paceSec missing or was computed from integer-rounded speeds (< 15 unique values = rounding artifact)
|
||||
const validPace = metrics?.paceSec?.filter((v) => v > 0) ?? [];
|
||||
const missingPace = validPace.length === 0 || new Set(validPace).size < 15;
|
||||
|
||||
if ((!metrics || missingCadence || missingGcb) && mayHaveRoute(activity)) {
|
||||
if ((!metrics || missingCadence || missingGcb || missingPace) && mayHaveRoute(activity)) {
|
||||
try {
|
||||
const client = await getAuthorizedClient();
|
||||
const client = await getAuthorizedClient(userId);
|
||||
const fetched = await fetchActivityRunMetrics(client, activity.garminActivityId);
|
||||
if (fetched) {
|
||||
await setRunningActivityMetrics(activity.garminActivityId, fetched);
|
||||
await setRunningActivityMetrics(userId, activity.garminActivityId, fetched);
|
||||
metrics = fetched;
|
||||
}
|
||||
} catch {
|
||||
@@ -125,7 +154,6 @@ async function RunMetricsFetcher({ activity }: { activity: RunningActivity }) {
|
||||
|
||||
const { hrBpm, gcbLeftPct } = metrics;
|
||||
|
||||
// Fall back to evenly-spaced distances if Garmin didn't provide them
|
||||
const maxDist = Math.max(...metrics.distanceKm);
|
||||
const distanceKm =
|
||||
maxDist > 0
|
||||
@@ -151,12 +179,7 @@ async function RunMetricsFetcher({ activity }: { activity: RunningActivity }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{hrData.length > 1 && (
|
||||
<RunMetricChart
|
||||
data={hrData}
|
||||
label="Tętno"
|
||||
unit="bpm"
|
||||
color="var(--color-accent)"
|
||||
/>
|
||||
<RunMetricChart data={hrData} label="Tętno" unit="bpm" color="var(--color-accent)" />
|
||||
)}
|
||||
{gcbData.length > 1 && <GcbChart data={gcbData} />}
|
||||
</div>
|
||||
@@ -175,13 +198,14 @@ export default async function RunningActivityPage({
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const activity = await getRunningActivity(id);
|
||||
const userId = await getCurrentUserId();
|
||||
const activity = await getRunningActivity(userId, id);
|
||||
|
||||
if (!activity) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const analysis = await getLatestAnalysisForTarget("running", activity._id);
|
||||
const analysis = await getLatestAnalysisForTarget(userId, "running", activity._id);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -192,7 +216,7 @@ export default async function RunningActivityPage({
|
||||
|
||||
<section className="grid grid-cols-3 gap-4">
|
||||
<Suspense fallback={<MapSkeleton />}>
|
||||
<RouteMapFetcher activity={activity} />
|
||||
<RouteMapFetcher activity={activity} userId={userId} />
|
||||
</Suspense>
|
||||
|
||||
<StatCard highlight label="Dystans" value={formatDistance(activity.distanceM)} />
|
||||
@@ -238,14 +262,18 @@ export default async function RunningActivityPage({
|
||||
</section>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<ElevationFetcher activity={activity} />
|
||||
<ElevationFetcher activity={activity} userId={userId} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<RunMetricsFetcher activity={activity} />
|
||||
<RunMetricsFetcher activity={activity} userId={userId} />
|
||||
</Suspense>
|
||||
|
||||
<AiAnalysisCard targetType="running" targetId={activity._id.toString()} analysis={analysis ? serializeAnalysis(analysis) : null} />
|
||||
<AiAnalysisCard
|
||||
targetType="running"
|
||||
targetId={activity._id.toString()}
|
||||
analysis={analysis ? serializeAnalysis(analysis) : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
||||
import type { GarminConnect } from "garmin-connect";
|
||||
import {
|
||||
GarminLoginRequiredError,
|
||||
GarminCredentialsMissingError,
|
||||
beginGarminLogin,
|
||||
completeGarminMfaLogin,
|
||||
fetchActivityRoutePoints,
|
||||
@@ -23,19 +24,20 @@ import {
|
||||
saveOauth1Token,
|
||||
savePendingMfaState,
|
||||
} from "@/lib/models/garmin-auth";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export type SyncGarminState = { error: string } | { success: string } | { mfaRequired: true } | null;
|
||||
|
||||
async function syncWithClient(client: GarminConnect): Promise<SyncGarminState> {
|
||||
const since = await getLastSyncAt();
|
||||
async function syncWithClient(userId: string, client: GarminConnect): Promise<SyncGarminState> {
|
||||
const since = await getLastSyncAt(userId);
|
||||
const activities = await fetchRunningActivities(client);
|
||||
const newCount = activities.filter((activity) => !since || activity.startTime > since).length;
|
||||
|
||||
for (const activity of activities) {
|
||||
await upsertRunningActivity(activity);
|
||||
await upsertRunningActivity(userId, activity);
|
||||
}
|
||||
|
||||
await setLastSyncAt(new Date());
|
||||
await setLastSyncAt(userId, new Date());
|
||||
|
||||
revalidatePath("/running");
|
||||
revalidatePath("/settings");
|
||||
@@ -45,39 +47,45 @@ async function syncWithClient(client: GarminConnect): Promise<SyncGarminState> {
|
||||
}
|
||||
|
||||
export async function syncGarminActivities(): Promise<SyncGarminState> {
|
||||
const userId = await getCurrentUserId();
|
||||
|
||||
try {
|
||||
const client = await getAuthorizedClient();
|
||||
return await syncWithClient(client);
|
||||
const client = await getAuthorizedClient(userId);
|
||||
return await syncWithClient(userId, client);
|
||||
} catch (error) {
|
||||
if (error instanceof GarminCredentialsMissingError) {
|
||||
return { error: error.message };
|
||||
}
|
||||
if (!(error instanceof GarminLoginRequiredError)) {
|
||||
return { error: error instanceof Error ? error.message : "Synchronizacja z Garmin nie powiodła się." };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await beginGarminLogin();
|
||||
const result = await beginGarminLogin(userId);
|
||||
if ("mfaRequired" in result) {
|
||||
await savePendingMfaState(result.pendingState);
|
||||
await savePendingMfaState(userId, result.pendingState);
|
||||
return { mfaRequired: true };
|
||||
}
|
||||
await saveOauth1Token(result.oauth1Token);
|
||||
return await syncWithClient(result.client);
|
||||
await saveOauth1Token(userId, result.oauth1Token);
|
||||
return await syncWithClient(userId, result.client);
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Logowanie do Garmin nie powiodło się." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitGarminMfaCode(code: string): Promise<SyncGarminState> {
|
||||
const pending = await getPendingMfaState();
|
||||
const userId = await getCurrentUserId();
|
||||
const pending = await getPendingMfaState(userId);
|
||||
if (!pending) {
|
||||
return { error: "Sesja logowania do Garmin wygasła. Kliknij \"Synchronizuj z Garmin\" ponownie." };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await completeGarminMfaLogin(pending, code);
|
||||
await saveOauth1Token(result.oauth1Token);
|
||||
await clearPendingMfaState();
|
||||
return await syncWithClient(result.client);
|
||||
await saveOauth1Token(userId, result.oauth1Token);
|
||||
await clearPendingMfaState(userId);
|
||||
return await syncWithClient(userId, result.client);
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Weryfikacja kodu MFA nie powiodła się." };
|
||||
}
|
||||
@@ -86,12 +94,13 @@ export async function submitGarminMfaCode(code: string): Promise<SyncGarminState
|
||||
export type LoadRouteState = { error: string } | { success: true } | null;
|
||||
|
||||
export async function loadActivityRoute(activityMongoId: string): Promise<LoadRouteState> {
|
||||
const activity = await getRunningActivity(activityMongoId);
|
||||
const userId = await getCurrentUserId();
|
||||
const activity = await getRunningActivity(userId, activityMongoId);
|
||||
if (!activity) return { error: "Nie znaleziono aktywności." };
|
||||
|
||||
let client: GarminConnect;
|
||||
try {
|
||||
client = await getAuthorizedClient();
|
||||
client = await getAuthorizedClient(userId);
|
||||
} catch {
|
||||
return { error: "Brak połączenia z Garmin Connect. Wykonaj synchronizację." };
|
||||
}
|
||||
@@ -99,7 +108,7 @@ export async function loadActivityRoute(activityMongoId: string): Promise<LoadRo
|
||||
try {
|
||||
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
||||
if (!result) return { error: "Brak danych GPS dla tej aktywności." };
|
||||
await setRunningActivityRoutePoints(activity.garminActivityId, result.points, result.elevationProfile);
|
||||
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
|
||||
revalidatePath(`/running/${activityMongoId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,11 +4,13 @@ import { EmptyState } from "@/components/empty-state";
|
||||
import { SyncButton } from "@/components/sync-button";
|
||||
import { formatDateShort, formatDistance, formatDuration, formatPace } from "@/lib/format";
|
||||
import { listRunningActivities } from "@/lib/models/running";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RunningPage() {
|
||||
const activities = await listRunningActivities();
|
||||
const userId = await getCurrentUserId();
|
||||
const activities = await listRunningActivities(userId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
28
app/settings/actions.ts
Normal file
28
app/settings/actions.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { saveGarminCredentials } from "@/lib/models/garmin-auth";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export type SaveGarminCredentialsState = { error: string } | { success: true } | null;
|
||||
|
||||
export async function saveGarminCredentialsAction(
|
||||
_prevState: SaveGarminCredentialsState,
|
||||
formData: FormData
|
||||
): Promise<SaveGarminCredentialsState> {
|
||||
const email = formData.get("email");
|
||||
const password = formData.get("password");
|
||||
|
||||
if (typeof email !== "string" || !email.includes("@")) {
|
||||
return { error: "Podaj prawidłowy adres e-mail." };
|
||||
}
|
||||
if (typeof password !== "string" || password.length < 1) {
|
||||
return { error: "Podaj hasło." };
|
||||
}
|
||||
|
||||
const userId = await getCurrentUserId();
|
||||
await saveGarminCredentials(userId, email.trim(), password);
|
||||
|
||||
revalidatePath("/settings");
|
||||
return { success: true };
|
||||
}
|
||||
58
app/settings/garmin-credentials-form.tsx
Normal file
58
app/settings/garmin-credentials-form.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { saveGarminCredentialsAction, type SaveGarminCredentialsState } from "./actions";
|
||||
|
||||
export function GarminCredentialsForm({ savedEmail }: { savedEmail: string | null }) {
|
||||
const [state, action, pending] = useActionState<SaveGarminCredentialsState, FormData>(
|
||||
saveGarminCredentialsAction,
|
||||
null
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={action} className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="garmin-email" className="text-sm text-fg/70">
|
||||
E-mail Garmin Connect
|
||||
</label>
|
||||
<input
|
||||
id="garmin-email"
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={savedEmail ?? ""}
|
||||
placeholder="twoj@email.com"
|
||||
required
|
||||
className="rounded-md border border-muted/40 bg-bg px-3 py-2 text-sm text-fg placeholder:text-fg/30 focus:border-accent/60 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="garmin-password" className="text-sm text-fg/70">
|
||||
Hasło Garmin Connect
|
||||
</label>
|
||||
<input
|
||||
id="garmin-password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="rounded-md border border-muted/40 bg-bg px-3 py-2 text-sm text-fg placeholder:text-fg/30 focus:border-accent/60 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state && "error" in state && (
|
||||
<p className="text-sm text-red-400">{state.error}</p>
|
||||
)}
|
||||
{state && "success" in state && (
|
||||
<p className="text-sm text-accent">Zapisano dane logowania.</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="self-start rounded-md bg-accent px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Zapisywanie…" : "Zapisz"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -2,22 +2,30 @@ import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import { SyncButton } from "@/components/sync-button";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { getLastSyncAt } from "@/lib/models/running";
|
||||
import { getGarminCredentials, getSavedOauth1Token } from "@/lib/models/garmin-auth";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { GarminCredentialsForm } from "./garmin-credentials-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function ConfigRow({ label, configured }: { label: string; configured: boolean }) {
|
||||
function StatusRow({ label, ok, okLabel = "Skonfigurowano", failLabel = "Brak konfiguracji" }: {
|
||||
label: string;
|
||||
ok: boolean;
|
||||
okLabel?: string;
|
||||
failLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border border-muted/40 bg-surface p-4">
|
||||
<span className="text-fg">{label}</span>
|
||||
{configured ? (
|
||||
{ok ? (
|
||||
<span className="flex items-center gap-1.5 text-sm text-fg/70">
|
||||
<CheckCircle2 size={16} className="text-accent" />
|
||||
Skonfigurowano
|
||||
{okLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-sm text-fg/50">
|
||||
<XCircle size={16} />
|
||||
Brak w .env.local
|
||||
{failLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -25,31 +33,52 @@ function ConfigRow({ label, configured }: { label: string; configured: boolean }
|
||||
}
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const lastSyncAt = await getLastSyncAt();
|
||||
|
||||
const mongoConfigured = Boolean(process.env.MONGODB_URI);
|
||||
const garminConfigured = Boolean(process.env.GARMIN_EMAIL && process.env.GARMIN_PASSWORD);
|
||||
const claudeConfigured = Boolean(process.env.ANTHROPIC_API_KEY);
|
||||
const userId = await getCurrentUserId();
|
||||
const [lastSyncAt, garminCreds, garminToken] = await Promise.all([
|
||||
getLastSyncAt(userId),
|
||||
getGarminCredentials(userId),
|
||||
getSavedOauth1Token(userId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-fg">Ustawienia</h1>
|
||||
<p className="mt-1 text-sm text-fg/60">Status konfiguracji i synchronizacja Garmin.</p>
|
||||
<p className="mt-1 text-sm text-fg/60">Konfiguracja konta i synchronizacja Garmin.</p>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold text-fg">Konfiguracja</h2>
|
||||
<ConfigRow label="MongoDB" configured={mongoConfigured} />
|
||||
<ConfigRow label="Garmin Connect" configured={garminConfigured} />
|
||||
<ConfigRow label="Claude API" configured={claudeConfigured} />
|
||||
<h2 className="text-lg font-semibold text-fg">Status</h2>
|
||||
<StatusRow label="MongoDB" ok={Boolean(process.env.MONGODB_URI)} />
|
||||
<StatusRow
|
||||
label="Garmin Connect — token sesji"
|
||||
ok={Boolean(garminToken)}
|
||||
okLabel="Aktywny (synchronizacja działa)"
|
||||
failLabel="Brak tokenu — wymagane logowanie"
|
||||
/>
|
||||
<StatusRow
|
||||
label="Garmin Connect — dane logowania"
|
||||
ok={Boolean(garminCreds)}
|
||||
okLabel={`Zapisano (${garminCreds?.email})`}
|
||||
failLabel="Brak — potrzebne gdy token wygaśnie"
|
||||
/>
|
||||
<StatusRow label="Claude API" ok={Boolean(process.env.ANTHROPIC_API_KEY)} />
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold text-fg">Konto Garmin Connect</h2>
|
||||
<div className="rounded-lg border border-muted/40 bg-surface p-4">
|
||||
<GarminCredentialsForm savedEmail={garminCreds?.email ?? null} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold text-fg">Synchronizacja z Garmin</h2>
|
||||
<div className="flex items-center justify-between rounded-lg border border-muted/40 bg-surface p-4">
|
||||
<span className="text-sm text-fg/70">
|
||||
{lastSyncAt ? `Ostatnia synchronizacja: ${formatDate(lastSyncAt)}` : "Jeszcze nie zsynchronizowano"}
|
||||
{lastSyncAt
|
||||
? `Ostatnia synchronizacja: ${formatDate(lastSyncAt)}`
|
||||
: "Jeszcze nie zsynchronizowano"}
|
||||
</span>
|
||||
<SyncButton />
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -17,14 +18,15 @@ export default async function StrengthWorkoutPage({
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const workout = await getStrengthWorkout(id);
|
||||
const userId = await getCurrentUserId();
|
||||
const workout = await getStrengthWorkout(userId, id);
|
||||
|
||||
if (!workout) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const analysis = await getLatestAnalysisForTarget("strength", workout._id);
|
||||
const allWorkouts = await listStrengthWorkouts();
|
||||
const analysis = await getLatestAnalysisForTarget(userId, "strength", workout._id);
|
||||
const allWorkouts = await listStrengthWorkouts(userId);
|
||||
const pastWorkouts = allWorkouts.filter((w) => w.date <= workout.date);
|
||||
|
||||
const exercisesWithHistory = workout.exercises.map((exercise) => ({
|
||||
@@ -73,9 +75,9 @@ export default async function StrengthWorkoutPage({
|
||||
{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>
|
||||
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)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { parseStrongShareText } from "@/lib/strong/parser";
|
||||
import { upsertStrengthWorkout } from "@/lib/models/strength";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export type ImportStrongWorkoutState = { error: string } | null;
|
||||
|
||||
@@ -27,8 +28,9 @@ export async function importStrongWorkout(
|
||||
return { error: "Nie znaleziono żadnego treningu w podanym tekście." };
|
||||
}
|
||||
|
||||
const userId = await getCurrentUserId();
|
||||
for (const workout of workouts) {
|
||||
await upsertStrengthWorkout(workout);
|
||||
await upsertStrengthWorkout(userId, workout);
|
||||
}
|
||||
|
||||
revalidatePath("/strength");
|
||||
|
||||
@@ -5,13 +5,15 @@ import { VolumeChart } from "@/components/volume-chart";
|
||||
import { formatDateShort } from "@/lib/format";
|
||||
import { listStrengthWorkouts } from "@/lib/models/strength";
|
||||
import { workoutVolumeKg } from "@/lib/strength/stats";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const VOLUME_CHART_LIMIT = 12;
|
||||
|
||||
export default async function StrengthPage() {
|
||||
const workouts = await listStrengthWorkouts();
|
||||
const userId = await getCurrentUserId();
|
||||
const workouts = await listStrengthWorkouts(userId);
|
||||
|
||||
const volumeData = workouts
|
||||
.slice(0, VOLUME_CHART_LIMIT)
|
||||
|
||||
Reference in New Issue
Block a user