Compare commits
2 Commits
ee178feff0
...
047e580da0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
047e580da0 | ||
|
|
d00a5a42ac |
17
.idea/dataSources.xml
generated
Normal file
17
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="@localhost" uuid="42f0cf9c-c76e-4f63-bced-ee4de72b5936">
|
||||||
|
<driver-ref>mongo.4</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>com.dbschema.MongoJdbcDriver</jdbc-driver>
|
||||||
|
<jdbc-url>mongodb://localhost:27017/?authSource=admin</jdbc-url>
|
||||||
|
<jdbc-additional-properties>
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||||
|
</jdbc-additional-properties>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/db-forest-config.xml
generated
Normal file
6
.idea/db-forest-config.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="db-tree-configuration">
|
||||||
|
<option name="data" value="---------------------------------------- 1:0:cad974ec-67a1-45af-aa5e-0f2afd65b89b 2:0:25e55397-437c-4c68-99f5-2d21fbba46d2 3:0:d401f651-e4ab-4ff5-9655-e2b6c0b396dc 4:0:ec87ba30-ade3-4e14-8f35-5c06f6cf8662 5:0:e9fca1ad-1d37-4a8c-a7b4-95d6a736484c 6:0:c4b8376d-a287-4fa4-bc44-2d2fac1cd37b 7:0:66be42c8-917f-4bb8-ae8d-4016f762e870 8:0:0b74125a-9a07-4cfa-afd4-c5fccf1496b4 9:0:07d79dcf-bacf-49b0-936e-5cb4c251b3e1 10:0:986e8611-b865-4ec3-8cb6-92fc42c283a7 11:0:70ccb5fc-6aef-4cd5-9f4b-ccbea8e185c6 12:0:d2358406-bd5f-4030-a822-5a1c2f653b55 13:0:52d85314-8e9f-4e42-bd9d-f146df941871 14:0:07d72045-e35c-4264-b612-a64c58c635d7 15:0:c05a40be-4e30-4606-bc06-511d9b109dbb 16:0:810ba5f7-c9cc-4010-a3c3-195abacabb8e 17:0:5da9a988-52c5-4bcf-b758-1677ab67bf26 18:0:7d0a6ab5-b6df-4898-afec-cad19b908728 19:0:bd763ebe-280e-49f8-a248-fcd7c4c8d712 20:0:233739c2-121e-45b9-be8b-613eadecd69f 21:0:e8230f4d-40da-406b-a7ca-bf59ada3230a 22:0:02b1616a-5718-42d3-a31e-22f9f32e5333 23:0:1e611642-8fd8-44d0-876b-9c8c2b4ba00c 24:0:42f0cf9c-c76e-4f63-bced-ee4de72b5936 " />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { generateAnalysis, generateDashboardAnalysis } from "@/lib/ai/claude";
|
import { generateAnalysis, generateDashboardAnalysis } from "@/lib/ai/claude";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
import type { AiAnalysisTargetType } from "@/lib/models/analysis";
|
import type { AiAnalysisTargetType } from "@/lib/models/analysis";
|
||||||
|
|
||||||
export type GenerateAnalysisState = { error: string } | { success: true } | null;
|
export type GenerateAnalysisState = { error: string } | { success: true } | null;
|
||||||
@@ -10,8 +11,9 @@ export async function generateAnalysisAction(
|
|||||||
targetType: AiAnalysisTargetType,
|
targetType: AiAnalysisTargetType,
|
||||||
targetId: string
|
targetId: string
|
||||||
): Promise<GenerateAnalysisState> {
|
): Promise<GenerateAnalysisState> {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
try {
|
try {
|
||||||
await generateAnalysis(targetType, targetId);
|
await generateAnalysis(userId, targetType, targetId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { error: error instanceof Error ? error.message : "Nie udało się wygenerować analizy." };
|
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> {
|
export async function generateDashboardAnalysisAction(): Promise<GenerateAnalysisState> {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
try {
|
try {
|
||||||
await generateDashboardAnalysis();
|
await generateDashboardAnalysis(userId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { error: error instanceof Error ? error.message : "Nie udało się wygenerować analizy." };
|
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 type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { Nav } from "@/components/nav";
|
import { Nav } from "@/components/nav";
|
||||||
|
import { auth } from "@/auth";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -18,18 +19,20 @@ export const metadata: Metadata = {
|
|||||||
description: "Analiza treningów biegowych i siłowych",
|
description: "Analiza treningów biegowych i siłowych",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="pl"
|
lang="pl"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col bg-bg text-fg">
|
<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">
|
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6 sm:px-6">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</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 { getDashboardAnalysis, 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 { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
|
||||||
const [runs, strengthWorkouts, dashboardAnalysis] = await Promise.all([
|
const [runs, strengthWorkouts, dashboardAnalysis] = await Promise.all([
|
||||||
listRunningActivities(),
|
listRunningActivities(userId),
|
||||||
listStrengthWorkouts(),
|
listStrengthWorkouts(userId),
|
||||||
getDashboardAnalysis(),
|
getDashboardAnalysis(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
|
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
|
||||||
|
|||||||
@@ -1,25 +1,49 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
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 { RouteMapSection } from "@/components/route-map-section";
|
import { RouteMapSection } from "@/components/route-map-section";
|
||||||
|
import { GcbChart } from "@/components/gcb-chart";
|
||||||
|
import { RunMetricChart } from "@/components/run-metric-chart";
|
||||||
import { StatCard } from "@/components/stat-card";
|
import { StatCard } from "@/components/stat-card";
|
||||||
import { formatDate, formatDistance, formatDuration, formatPace } from "@/lib/format";
|
import { formatDate, formatDistance, formatDuration, formatPace } from "@/lib/format";
|
||||||
import { fetchActivityRoutePoints, getAuthorizedClient } from "@/lib/garmin/client";
|
import {
|
||||||
|
fetchActivityRoutePoints,
|
||||||
|
fetchActivityRunMetrics,
|
||||||
|
getAuthorizedClient,
|
||||||
|
} from "@/lib/garmin/client";
|
||||||
import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis";
|
import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis";
|
||||||
import { getRunningActivity, setRunningActivityRoutePoints, type RunningActivity } from "@/lib/models/running";
|
import {
|
||||||
|
getRunningActivity,
|
||||||
|
setRunningActivityMetrics,
|
||||||
|
setRunningActivityRoutePoints,
|
||||||
|
type RunMetrics,
|
||||||
|
type RunningActivity,
|
||||||
|
} from "@/lib/models/running";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
async function RouteMapFetcher({ activity }: { activity: RunningActivity }) {
|
function mayHaveRoute(activity: RunningActivity): boolean {
|
||||||
|
return Boolean(activity.hasRoute) || Boolean(activity.routePoints?.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function RouteMapFetcher({
|
||||||
|
activity,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
activity: RunningActivity;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
let routePoints = activity.routePoints;
|
let routePoints = activity.routePoints;
|
||||||
|
|
||||||
if (!routePoints && activity.hasRoute) {
|
if ((!routePoints || !activity.elevationProfile) && mayHaveRoute(activity)) {
|
||||||
try {
|
try {
|
||||||
const client = await getAuthorizedClient();
|
const client = await getAuthorizedClient(userId);
|
||||||
const points = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
||||||
if (points) {
|
if (result) {
|
||||||
await setRunningActivityRoutePoints(activity.garminActivityId, points);
|
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
|
||||||
routePoints = points;
|
routePoints = result.points;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// GPS fetch failed silently
|
// GPS fetch failed silently
|
||||||
@@ -39,6 +63,129 @@ async function RouteMapFetcher({ activity }: { activity: RunningActivity }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasValidElevation(profile: number[] | undefined): boolean {
|
||||||
|
return Array.isArray(profile) && profile.some((v) => v > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ElevationFetcher({
|
||||||
|
activity,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
activity: RunningActivity;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
let elevationProfile = activity.elevationProfile;
|
||||||
|
|
||||||
|
if (!hasValidElevation(elevationProfile) && mayHaveRoute(activity)) {
|
||||||
|
try {
|
||||||
|
const client = await getAuthorizedClient(userId);
|
||||||
|
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
||||||
|
if (result) {
|
||||||
|
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
|
||||||
|
elevationProfile = result.elevationProfile;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!elevationProfile || elevationProfile.length < 2) return null;
|
||||||
|
|
||||||
|
const elevData = elevationProfile
|
||||||
|
.map((altM, i) => ({
|
||||||
|
distanceKm: Math.round((i / elevationProfile!.length) * activity.distanceM / 10) / 100,
|
||||||
|
altM,
|
||||||
|
}))
|
||||||
|
.filter((p) => p.altM > 0);
|
||||||
|
|
||||||
|
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} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toChartData(
|
||||||
|
values: number[] | undefined,
|
||||||
|
distances: number[]
|
||||||
|
): { distanceKm: number; value: number }[] {
|
||||||
|
if (!values) return [];
|
||||||
|
return distances
|
||||||
|
.map((distanceKm, i) => ({ distanceKm, value: values[i] ?? 0 }))
|
||||||
|
.filter((p) => p.value > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || missingPace) && mayHaveRoute(activity)) {
|
||||||
|
try {
|
||||||
|
const client = await getAuthorizedClient(userId);
|
||||||
|
const fetched = await fetchActivityRunMetrics(client, activity.garminActivityId);
|
||||||
|
if (fetched) {
|
||||||
|
await setRunningActivityMetrics(userId, activity.garminActivityId, fetched);
|
||||||
|
metrics = fetched;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metrics || metrics.distanceKm.length === 0) return null;
|
||||||
|
|
||||||
|
const { hrBpm, gcbLeftPct } = metrics;
|
||||||
|
|
||||||
|
const maxDist = Math.max(...metrics.distanceKm);
|
||||||
|
const distanceKm =
|
||||||
|
maxDist > 0
|
||||||
|
? metrics.distanceKm
|
||||||
|
: Array.from({ length: metrics.distanceKm.length }, (_, i) =>
|
||||||
|
Math.round(((i / (metrics.distanceKm.length - 1)) * activity.distanceM) / 10) / 100
|
||||||
|
);
|
||||||
|
|
||||||
|
const hrData = toChartData(hrBpm, distanceKm);
|
||||||
|
const gcbData = gcbLeftPct
|
||||||
|
? distanceKm
|
||||||
|
.map((d, i) => {
|
||||||
|
const left = gcbLeftPct[i] ?? 0;
|
||||||
|
return left > 0
|
||||||
|
? { distanceKm: d, left, right: Math.round((100 - left) * 10) / 10 }
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
.filter((p): p is NonNullable<typeof p> => p !== null)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!hrData.length && !gcbData.length) return null;
|
||||||
|
|
||||||
|
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)" />
|
||||||
|
)}
|
||||||
|
{gcbData.length > 1 && <GcbChart data={gcbData} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MapSkeleton() {
|
function MapSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="col-span-2 row-span-3 min-h-[240px] animate-pulse overflow-hidden rounded-lg border border-muted/40 bg-surface" />
|
<div className="col-span-2 row-span-3 min-h-[240px] animate-pulse overflow-hidden rounded-lg border border-muted/40 bg-surface" />
|
||||||
@@ -51,13 +198,14 @@ export default async function RunningActivityPage({
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const activity = await getRunningActivity(id);
|
const userId = await getCurrentUserId();
|
||||||
|
const activity = await getRunningActivity(userId, id);
|
||||||
|
|
||||||
if (!activity) {
|
if (!activity) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const analysis = await getLatestAnalysisForTarget("running", activity._id);
|
const analysis = await getLatestAnalysisForTarget(userId, "running", activity._id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -67,22 +215,18 @@ export default async function RunningActivityPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="grid grid-cols-3 gap-4">
|
<section className="grid grid-cols-3 gap-4">
|
||||||
{/* Map: cols 1–2, rows 1–3 — streamed in after page skeleton */}
|
|
||||||
<Suspense fallback={<MapSkeleton />}>
|
<Suspense fallback={<MapSkeleton />}>
|
||||||
<RouteMapFetcher activity={activity} />
|
<RouteMapFetcher activity={activity} userId={userId} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
{/* Col 3, rows 1–3: key pace stats */}
|
|
||||||
<StatCard highlight label="Dystans" value={formatDistance(activity.distanceM)} />
|
<StatCard highlight label="Dystans" value={formatDistance(activity.distanceM)} />
|
||||||
<StatCard highlight label="Czas" value={formatDuration(activity.durationSec)} />
|
<StatCard highlight label="Czas" value={formatDuration(activity.durationSec)} />
|
||||||
<StatCard highlight label="Tempo" value={formatPace(activity.avgPaceSecPerKm)} />
|
<StatCard highlight label="Tempo" value={formatPace(activity.avgPaceSecPerKm)} />
|
||||||
|
|
||||||
{/* Row 4: HR, calories, cadence — always shown */}
|
|
||||||
<StatCard highlight label="Średnie HR" value={activity.avgHr ? `${Math.round(activity.avgHr)} bpm` : "—"} />
|
<StatCard highlight label="Średnie HR" value={activity.avgHr ? `${Math.round(activity.avgHr)} bpm` : "—"} />
|
||||||
<StatCard highlight label="Kalorie" value={activity.calories ? `${Math.round(activity.calories)} kcal` : "—"} />
|
<StatCard highlight label="Kalorie" value={activity.calories ? `${Math.round(activity.calories)} kcal` : "—"} />
|
||||||
<StatCard highlight label="Kadencja" value={activity.avgCadence ? `${Math.round(activity.avgCadence)} kr/min` : "—"} />
|
<StatCard highlight label="Kadencja" value={activity.avgCadence ? `${Math.round(activity.avgCadence)} kr/min` : "—"} />
|
||||||
|
|
||||||
{/* Row 5+: optional advanced stats, auto-flow */}
|
|
||||||
{activity.maxHr ? <StatCard label="Maks. HR" value={`${Math.round(activity.maxHr)} bpm`} /> : null}
|
{activity.maxHr ? <StatCard label="Maks. HR" value={`${Math.round(activity.maxHr)} bpm`} /> : null}
|
||||||
{activity.elevationGainM ? <StatCard label="Podejście" value={`${Math.round(activity.elevationGainM)} m`} /> : null}
|
{activity.elevationGainM ? <StatCard label="Podejście" value={`${Math.round(activity.elevationGainM)} m`} /> : null}
|
||||||
{activity.vo2Max ? <StatCard label="VO2max" value={`${Math.round(activity.vo2Max)}`} /> : null}
|
{activity.vo2Max ? <StatCard label="VO2max" value={`${Math.round(activity.vo2Max)}`} /> : null}
|
||||||
@@ -117,7 +261,19 @@ export default async function RunningActivityPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<AiAnalysisCard targetType="running" targetId={activity._id.toString()} analysis={analysis ? serializeAnalysis(analysis) : null} />
|
<Suspense fallback={null}>
|
||||||
|
<ElevationFetcher activity={activity} userId={userId} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<RunMetricsFetcher activity={activity} userId={userId} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<AiAnalysisCard
|
||||||
|
targetType="running"
|
||||||
|
targetId={activity._id.toString()}
|
||||||
|
analysis={analysis ? serializeAnalysis(analysis) : null}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import type { GarminConnect } from "garmin-connect";
|
import type { GarminConnect } from "garmin-connect";
|
||||||
import {
|
import {
|
||||||
GarminLoginRequiredError,
|
GarminLoginRequiredError,
|
||||||
|
GarminCredentialsMissingError,
|
||||||
beginGarminLogin,
|
beginGarminLogin,
|
||||||
completeGarminMfaLogin,
|
completeGarminMfaLogin,
|
||||||
fetchActivityRoutePoints,
|
fetchActivityRoutePoints,
|
||||||
@@ -23,19 +24,20 @@ import {
|
|||||||
saveOauth1Token,
|
saveOauth1Token,
|
||||||
savePendingMfaState,
|
savePendingMfaState,
|
||||||
} from "@/lib/models/garmin-auth";
|
} from "@/lib/models/garmin-auth";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
export type SyncGarminState = { error: string } | { success: string } | { mfaRequired: true } | null;
|
export type SyncGarminState = { error: string } | { success: string } | { mfaRequired: true } | null;
|
||||||
|
|
||||||
async function syncWithClient(client: GarminConnect): Promise<SyncGarminState> {
|
async function syncWithClient(userId: string, client: GarminConnect): Promise<SyncGarminState> {
|
||||||
const since = await getLastSyncAt();
|
const since = await getLastSyncAt(userId);
|
||||||
const activities = await fetchRunningActivities(client);
|
const activities = await fetchRunningActivities(client);
|
||||||
const newCount = activities.filter((activity) => !since || activity.startTime > since).length;
|
const newCount = activities.filter((activity) => !since || activity.startTime > since).length;
|
||||||
|
|
||||||
for (const activity of activities) {
|
for (const activity of activities) {
|
||||||
await upsertRunningActivity(activity);
|
await upsertRunningActivity(userId, activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
await setLastSyncAt(new Date());
|
await setLastSyncAt(userId, new Date());
|
||||||
|
|
||||||
revalidatePath("/running");
|
revalidatePath("/running");
|
||||||
revalidatePath("/settings");
|
revalidatePath("/settings");
|
||||||
@@ -45,39 +47,45 @@ async function syncWithClient(client: GarminConnect): Promise<SyncGarminState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function syncGarminActivities(): Promise<SyncGarminState> {
|
export async function syncGarminActivities(): Promise<SyncGarminState> {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = await getAuthorizedClient();
|
const client = await getAuthorizedClient(userId);
|
||||||
return await syncWithClient(client);
|
return await syncWithClient(userId, client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof GarminCredentialsMissingError) {
|
||||||
|
return { error: error.message };
|
||||||
|
}
|
||||||
if (!(error instanceof GarminLoginRequiredError)) {
|
if (!(error instanceof GarminLoginRequiredError)) {
|
||||||
return { error: error instanceof Error ? error.message : "Synchronizacja z Garmin nie powiodła się." };
|
return { error: error instanceof Error ? error.message : "Synchronizacja z Garmin nie powiodła się." };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await beginGarminLogin();
|
const result = await beginGarminLogin(userId);
|
||||||
if ("mfaRequired" in result) {
|
if ("mfaRequired" in result) {
|
||||||
await savePendingMfaState(result.pendingState);
|
await savePendingMfaState(userId, result.pendingState);
|
||||||
return { mfaRequired: true };
|
return { mfaRequired: true };
|
||||||
}
|
}
|
||||||
await saveOauth1Token(result.oauth1Token);
|
await saveOauth1Token(userId, result.oauth1Token);
|
||||||
return await syncWithClient(result.client);
|
return await syncWithClient(userId, result.client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { error: error instanceof Error ? error.message : "Logowanie do Garmin nie powiodło się." };
|
return { error: error instanceof Error ? error.message : "Logowanie do Garmin nie powiodło się." };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function submitGarminMfaCode(code: string): Promise<SyncGarminState> {
|
export async function submitGarminMfaCode(code: string): Promise<SyncGarminState> {
|
||||||
const pending = await getPendingMfaState();
|
const userId = await getCurrentUserId();
|
||||||
|
const pending = await getPendingMfaState(userId);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
return { error: "Sesja logowania do Garmin wygasła. Kliknij \"Synchronizuj z Garmin\" ponownie." };
|
return { error: "Sesja logowania do Garmin wygasła. Kliknij \"Synchronizuj z Garmin\" ponownie." };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await completeGarminMfaLogin(pending, code);
|
const result = await completeGarminMfaLogin(pending, code);
|
||||||
await saveOauth1Token(result.oauth1Token);
|
await saveOauth1Token(userId, result.oauth1Token);
|
||||||
await clearPendingMfaState();
|
await clearPendingMfaState(userId);
|
||||||
return await syncWithClient(result.client);
|
return await syncWithClient(userId, result.client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { error: error instanceof Error ? error.message : "Weryfikacja kodu MFA nie powiodła się." };
|
return { error: error instanceof Error ? error.message : "Weryfikacja kodu MFA nie powiodła się." };
|
||||||
}
|
}
|
||||||
@@ -86,20 +94,21 @@ export async function submitGarminMfaCode(code: string): Promise<SyncGarminState
|
|||||||
export type LoadRouteState = { error: string } | { success: true } | null;
|
export type LoadRouteState = { error: string } | { success: true } | null;
|
||||||
|
|
||||||
export async function loadActivityRoute(activityMongoId: string): Promise<LoadRouteState> {
|
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." };
|
if (!activity) return { error: "Nie znaleziono aktywności." };
|
||||||
|
|
||||||
let client: GarminConnect;
|
let client: GarminConnect;
|
||||||
try {
|
try {
|
||||||
client = await getAuthorizedClient();
|
client = await getAuthorizedClient(userId);
|
||||||
} catch {
|
} catch {
|
||||||
return { error: "Brak połączenia z Garmin Connect. Wykonaj synchronizację." };
|
return { error: "Brak połączenia z Garmin Connect. Wykonaj synchronizację." };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const points = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
||||||
if (!points) return { error: "Brak danych GPS dla tej aktywności." };
|
if (!result) return { error: "Brak danych GPS dla tej aktywności." };
|
||||||
await setRunningActivityRoutePoints(activity.garminActivityId, points);
|
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
|
||||||
revalidatePath(`/running/${activityMongoId}`);
|
revalidatePath(`/running/${activityMongoId}`);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { EmptyState } from "@/components/empty-state";
|
|||||||
import { SyncButton } from "@/components/sync-button";
|
import { SyncButton } from "@/components/sync-button";
|
||||||
import { formatDateShort, formatDistance, formatDuration, formatPace } from "@/lib/format";
|
import { formatDateShort, formatDistance, formatDuration, formatPace } from "@/lib/format";
|
||||||
import { listRunningActivities } from "@/lib/models/running";
|
import { listRunningActivities } from "@/lib/models/running";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function RunningPage() {
|
export default async function RunningPage() {
|
||||||
const activities = await listRunningActivities();
|
const userId = await getCurrentUserId();
|
||||||
|
const activities = await listRunningActivities(userId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<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 { SyncButton } from "@/components/sync-button";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import { getLastSyncAt } from "@/lib/models/running";
|
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";
|
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 (
|
return (
|
||||||
<div className="flex items-center justify-between rounded-lg border border-muted/40 bg-surface p-4">
|
<div className="flex items-center justify-between rounded-lg border border-muted/40 bg-surface p-4">
|
||||||
<span className="text-fg">{label}</span>
|
<span className="text-fg">{label}</span>
|
||||||
{configured ? (
|
{ok ? (
|
||||||
<span className="flex items-center gap-1.5 text-sm text-fg/70">
|
<span className="flex items-center gap-1.5 text-sm text-fg/70">
|
||||||
<CheckCircle2 size={16} className="text-accent" />
|
<CheckCircle2 size={16} className="text-accent" />
|
||||||
Skonfigurowano
|
{okLabel}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex items-center gap-1.5 text-sm text-fg/50">
|
<span className="flex items-center gap-1.5 text-sm text-fg/50">
|
||||||
<XCircle size={16} />
|
<XCircle size={16} />
|
||||||
Brak w .env.local
|
{failLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -25,31 +33,52 @@ function ConfigRow({ label, configured }: { label: string; configured: boolean }
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const lastSyncAt = await getLastSyncAt();
|
const userId = await getCurrentUserId();
|
||||||
|
const [lastSyncAt, garminCreds, garminToken] = await Promise.all([
|
||||||
const mongoConfigured = Boolean(process.env.MONGODB_URI);
|
getLastSyncAt(userId),
|
||||||
const garminConfigured = Boolean(process.env.GARMIN_EMAIL && process.env.GARMIN_PASSWORD);
|
getGarminCredentials(userId),
|
||||||
const claudeConfigured = Boolean(process.env.ANTHROPIC_API_KEY);
|
getSavedOauth1Token(userId),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-fg">Ustawienia</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
<h2 className="text-lg font-semibold text-fg">Konfiguracja</h2>
|
<h2 className="text-lg font-semibold text-fg">Status</h2>
|
||||||
<ConfigRow label="MongoDB" configured={mongoConfigured} />
|
<StatusRow label="MongoDB" ok={Boolean(process.env.MONGODB_URI)} />
|
||||||
<ConfigRow label="Garmin Connect" configured={garminConfigured} />
|
<StatusRow
|
||||||
<ConfigRow label="Claude API" configured={claudeConfigured} />
|
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>
|
||||||
|
|
||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
<h2 className="text-lg font-semibold text-fg">Synchronizacja z Garmin</h2>
|
<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">
|
<div className="flex items-center justify-between rounded-lg border border-muted/40 bg-surface p-4">
|
||||||
<span className="text-sm text-fg/70">
|
<span className="text-sm text-fg/70">
|
||||||
{lastSyncAt ? `Ostatnia synchronizacja: ${formatDate(lastSyncAt)}` : "Jeszcze nie zsynchronizowano"}
|
{lastSyncAt
|
||||||
|
? `Ostatnia synchronizacja: ${formatDate(lastSyncAt)}`
|
||||||
|
: "Jeszcze nie zsynchronizowano"}
|
||||||
</span>
|
</span>
|
||||||
<SyncButton />
|
<SyncButton />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -17,14 +18,15 @@ export default async function StrengthWorkoutPage({
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const workout = await getStrengthWorkout(id);
|
const userId = await getCurrentUserId();
|
||||||
|
const workout = await getStrengthWorkout(userId, id);
|
||||||
|
|
||||||
if (!workout) {
|
if (!workout) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const analysis = await getLatestAnalysisForTarget("strength", workout._id);
|
const analysis = await getLatestAnalysisForTarget(userId, "strength", workout._id);
|
||||||
const allWorkouts = await listStrengthWorkouts();
|
const allWorkouts = await listStrengthWorkouts(userId);
|
||||||
const pastWorkouts = allWorkouts.filter((w) => w.date <= workout.date);
|
const pastWorkouts = allWorkouts.filter((w) => w.date <= workout.date);
|
||||||
|
|
||||||
const exercisesWithHistory = workout.exercises.map((exercise) => ({
|
const exercisesWithHistory = workout.exercises.map((exercise) => ({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { parseStrongShareText } from "@/lib/strong/parser";
|
import { parseStrongShareText } from "@/lib/strong/parser";
|
||||||
import { upsertStrengthWorkout } from "@/lib/models/strength";
|
import { upsertStrengthWorkout } from "@/lib/models/strength";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
export type ImportStrongWorkoutState = { error: string } | null;
|
export type ImportStrongWorkoutState = { error: string } | null;
|
||||||
|
|
||||||
@@ -27,8 +28,9 @@ export async function importStrongWorkout(
|
|||||||
return { error: "Nie znaleziono żadnego treningu w podanym tekście." };
|
return { error: "Nie znaleziono żadnego treningu w podanym tekście." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
for (const workout of workouts) {
|
for (const workout of workouts) {
|
||||||
await upsertStrengthWorkout(workout);
|
await upsertStrengthWorkout(userId, workout);
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/strength");
|
revalidatePath("/strength");
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import { VolumeChart } from "@/components/volume-chart";
|
|||||||
import { formatDateShort } from "@/lib/format";
|
import { formatDateShort } from "@/lib/format";
|
||||||
import { listStrengthWorkouts } from "@/lib/models/strength";
|
import { listStrengthWorkouts } from "@/lib/models/strength";
|
||||||
import { workoutVolumeKg } from "@/lib/strength/stats";
|
import { workoutVolumeKg } from "@/lib/strength/stats";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const VOLUME_CHART_LIMIT = 12;
|
const VOLUME_CHART_LIMIT = 12;
|
||||||
|
|
||||||
export default async function StrengthPage() {
|
export default async function StrengthPage() {
|
||||||
const workouts = await listStrengthWorkouts();
|
const userId = await getCurrentUserId();
|
||||||
|
const workouts = await listStrengthWorkouts(userId);
|
||||||
|
|
||||||
const volumeData = workouts
|
const volumeData = workouts
|
||||||
.slice(0, VOLUME_CHART_LIMIT)
|
.slice(0, VOLUME_CHART_LIMIT)
|
||||||
|
|||||||
34
auth.ts
Normal file
34
auth.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import Keycloak from "next-auth/providers/keycloak";
|
||||||
|
|
||||||
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
|
providers: [
|
||||||
|
Keycloak({
|
||||||
|
clientId: process.env.KEYCLOAK_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||||
|
issuer: process.env.KEYCLOAK_ISSUER!,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
authorized({ auth }) {
|
||||||
|
return !!auth;
|
||||||
|
},
|
||||||
|
jwt({ token, account }) {
|
||||||
|
if (account) {
|
||||||
|
// providerAccountId = Keycloak sub UUID, guaranteed on every login
|
||||||
|
token.keycloakId = account.providerAccountId;
|
||||||
|
token.accessToken = account.access_token;
|
||||||
|
token.idToken = account.id_token;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
session({ session, token }) {
|
||||||
|
session.user.id = (token.keycloakId ?? token.sub) as string;
|
||||||
|
session.idToken = token.idToken as string | undefined;
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
},
|
||||||
|
});
|
||||||
140
components/elevation-chart.tsx
Normal file
140
components/elevation-chart.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useState } from "react";
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
CartesianGrid,
|
||||||
|
ComposedChart,
|
||||||
|
Line,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
type Point = { distanceKm: number; altM: number; paceSec?: number };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: Point[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtPace(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.round(sec % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")} /km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ElevationChart({ data }: Props) {
|
||||||
|
const uid = useId();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return <div className="h-[140px] animate-pulse rounded-lg border border-muted/40 bg-surface" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const altitudes = data.map((p) => p.altM);
|
||||||
|
const minAlt = Math.min(...altitudes);
|
||||||
|
const maxAlt = Math.max(...altitudes);
|
||||||
|
const altPad = Math.max(5, Math.round((maxAlt - minAlt) * 0.15));
|
||||||
|
|
||||||
|
const pacePoints = data.map((p) => p.paceSec).filter((v): v is number => v != null && v > 0);
|
||||||
|
const hasPace = pacePoints.length > 5;
|
||||||
|
const minPace = hasPace ? Math.min(...pacePoints) : 0;
|
||||||
|
const maxPace = hasPace ? Math.max(...pacePoints) : 0;
|
||||||
|
const pacePad = Math.max(5, Math.round((maxPace - minPace) * 0.15));
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
background: "var(--color-bg)",
|
||||||
|
border: "1px solid var(--color-muted)",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--color-fg)",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-muted/40 bg-surface p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-4 text-sm text-fg/60">
|
||||||
|
<span>Profil wysokości</span>
|
||||||
|
{hasPace && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block h-0.5 w-4" style={{ background: "var(--color-sand)" }} />
|
||||||
|
Tempo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={110}>
|
||||||
|
<ComposedChart data={data} margin={{ top: 4, right: hasPace ? 52 : 8, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`elev-${uid}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-accent)" stopOpacity={0.25} />
|
||||||
|
<stop offset="95%" stopColor="var(--color-accent)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid stroke="var(--color-muted)" opacity={0.3} vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="distanceKm"
|
||||||
|
stroke="var(--color-fg)"
|
||||||
|
opacity={0.5}
|
||||||
|
fontSize={11}
|
||||||
|
tickFormatter={(v) => `${Number(v).toFixed(1)} km`}
|
||||||
|
interval={Math.max(0, Math.floor(data.length / 5) - 1)}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="elev"
|
||||||
|
stroke="var(--color-fg)"
|
||||||
|
opacity={0.5}
|
||||||
|
fontSize={11}
|
||||||
|
width={44}
|
||||||
|
tickFormatter={(v) => `${Math.round(v)} m`}
|
||||||
|
domain={[minAlt - altPad, maxAlt + altPad]}
|
||||||
|
/>
|
||||||
|
{hasPace && (
|
||||||
|
<YAxis
|
||||||
|
yAxisId="pace"
|
||||||
|
orientation="right"
|
||||||
|
reversed
|
||||||
|
stroke="var(--color-sand)"
|
||||||
|
opacity={0.5}
|
||||||
|
fontSize={11}
|
||||||
|
width={50}
|
||||||
|
tickFormatter={fmtPace}
|
||||||
|
domain={[minPace - pacePad, maxPace + pacePad]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
formatter={(value, name) => {
|
||||||
|
if (name === "altM") return [`${Math.round(Number(value))} m n.p.m.`, "Wysokość"];
|
||||||
|
if (name === "paceSec") return [fmtPace(Number(value)), "Tempo"];
|
||||||
|
return [value, name];
|
||||||
|
}}
|
||||||
|
labelFormatter={(l) => `${Number(l).toFixed(2)} km`}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
yAxisId="elev"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="altM"
|
||||||
|
name="altM"
|
||||||
|
stroke="var(--color-accent)"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill={`url(#elev-${uid})`}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
{hasPace && (
|
||||||
|
<Line
|
||||||
|
yAxisId="pace"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="paceSec"
|
||||||
|
name="paceSec"
|
||||||
|
stroke="var(--color-sand)"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
connectNulls={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
components/gcb-chart.tsx
Normal file
110
components/gcb-chart.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useState } from "react";
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
type GcbPoint = { distanceKm: number; left: number; right: number };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: GcbPoint[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GcbChart({ data }: Props) {
|
||||||
|
const uid = useId();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return <div className="h-[160px] animate-pulse rounded-lg border border-muted/40 bg-surface" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-muted/40 bg-surface p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-4 text-sm text-fg/60">
|
||||||
|
<span>Balans kontaktu</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block h-2 w-4 rounded" style={{ background: "var(--color-accent)" }} />
|
||||||
|
Lewa
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block h-2 w-4 rounded" style={{ background: "var(--color-sand)" }} />
|
||||||
|
Prawa
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
|
<AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`gcb-l-${uid}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-accent)" stopOpacity={0.2} />
|
||||||
|
<stop offset="95%" stopColor="var(--color-accent)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={`gcb-r-${uid}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-sand)" stopOpacity={0.2} />
|
||||||
|
<stop offset="95%" stopColor="var(--color-sand)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid stroke="var(--color-muted)" opacity={0.3} vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="distanceKm"
|
||||||
|
stroke="var(--color-fg)"
|
||||||
|
opacity={0.5}
|
||||||
|
fontSize={11}
|
||||||
|
tickFormatter={(v) => `${Number(v).toFixed(1)} km`}
|
||||||
|
interval={Math.max(0, Math.floor(data.length / 5) - 1)}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="var(--color-fg)"
|
||||||
|
opacity={0.5}
|
||||||
|
fontSize={11}
|
||||||
|
width={44}
|
||||||
|
tickFormatter={(v) => `${Number(v).toFixed(0)}%`}
|
||||||
|
domain={[40, 60]}
|
||||||
|
ticks={[40, 45, 50, 55, 60]}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: "var(--color-bg)",
|
||||||
|
border: "1px solid var(--color-muted)",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--color-fg)",
|
||||||
|
}}
|
||||||
|
formatter={(value, name) => [
|
||||||
|
`${Number(value).toFixed(1)}%`,
|
||||||
|
name === "left" ? "Lewa" : "Prawa",
|
||||||
|
]}
|
||||||
|
labelFormatter={(l) => `${Number(l).toFixed(2)} km`}
|
||||||
|
/>
|
||||||
|
<ReferenceLine y={50} stroke="var(--color-fg)" strokeOpacity={0.3} strokeDasharray="4 4" />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="left"
|
||||||
|
name="left"
|
||||||
|
stroke="var(--color-accent)"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill={`url(#gcb-l-${uid})`}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="right"
|
||||||
|
name="right"
|
||||||
|
stroke="var(--color-sand)"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill={`url(#gcb-r-${uid})`}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Activity, Dumbbell, LayoutDashboard, Settings } from "lucide-react";
|
import { Activity, Dumbbell, LayoutDashboard, Settings } from "lucide-react";
|
||||||
|
import { auth, signOut } from "@/auth";
|
||||||
|
import { SignOutButton } from "./sign-out-button";
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ href: "/", label: "Panel", icon: LayoutDashboard },
|
{ href: "/", label: "Panel", icon: LayoutDashboard },
|
||||||
@@ -8,7 +10,14 @@ const links = [
|
|||||||
{ href: "/settings", label: "Ustawienia", icon: Settings },
|
{ href: "/settings", label: "Ustawienia", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Nav() {
|
export async function Nav() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
const signOutAction = async () => {
|
||||||
|
"use server";
|
||||||
|
await signOut({ redirectTo: "/login" });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="border-b border-muted/40 bg-surface">
|
<header className="border-b border-muted/40 bg-surface">
|
||||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3 sm:px-6">
|
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3 sm:px-6">
|
||||||
@@ -39,6 +48,12 @@ export function Nav() {
|
|||||||
<span className="hidden sm:inline">{label}</span>
|
<span className="hidden sm:inline">{label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{session?.user?.name && (
|
||||||
|
<span className="hidden px-2 text-xs text-fg/40 sm:inline">
|
||||||
|
{session.user.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<SignOutButton action={signOutAction} />
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
120
components/run-metric-chart.tsx
Normal file
120
components/run-metric-chart.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { useId } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: { distanceKm: number; value: number }[];
|
||||||
|
label: string;
|
||||||
|
unit: string;
|
||||||
|
color?: string;
|
||||||
|
referenceLine?: number;
|
||||||
|
decimals?: number;
|
||||||
|
format?: "pace";
|
||||||
|
reversed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RunMetricChart({
|
||||||
|
data,
|
||||||
|
label,
|
||||||
|
unit,
|
||||||
|
color = "var(--color-accent)",
|
||||||
|
referenceLine,
|
||||||
|
decimals = 0,
|
||||||
|
format,
|
||||||
|
reversed = false,
|
||||||
|
}: Props) {
|
||||||
|
const uid = useId();
|
||||||
|
const gradId = `grad-${uid.replace(/:/g, "")}`;
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return <div className="h-[160px] animate-pulse rounded-lg border border-muted/40 bg-surface" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = data.map((p) => p.value);
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const pad = Math.max(1, Math.round((max - min) * 0.15));
|
||||||
|
const fmt = (v: number) => {
|
||||||
|
if (format === "pace") {
|
||||||
|
const m = Math.floor(v / 60);
|
||||||
|
const s = Math.round(v % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")} /km`;
|
||||||
|
}
|
||||||
|
return decimals > 0 ? `${Number(v).toFixed(decimals)} ${unit}` : `${Math.round(v)} ${unit}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-muted/40 bg-surface p-4">
|
||||||
|
<div className="mb-2 text-sm text-fg/60">{label}</div>
|
||||||
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
|
<AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={color} stopOpacity={0.25} />
|
||||||
|
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid stroke="var(--color-muted)" opacity={0.3} vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="distanceKm"
|
||||||
|
stroke="var(--color-fg)"
|
||||||
|
opacity={0.5}
|
||||||
|
fontSize={11}
|
||||||
|
tickFormatter={(v) => `${Number(v).toFixed(1)} km`}
|
||||||
|
interval={Math.max(0, Math.floor(data.length / 5) - 1)}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="var(--color-fg)"
|
||||||
|
opacity={0.5}
|
||||||
|
fontSize={11}
|
||||||
|
width={50}
|
||||||
|
tickFormatter={fmt}
|
||||||
|
domain={reversed ? [max + pad, min - pad] : [min - pad, max + pad]}
|
||||||
|
reversed={reversed}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: "var(--color-bg)",
|
||||||
|
border: "1px solid var(--color-muted)",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--color-fg)",
|
||||||
|
}}
|
||||||
|
formatter={(value) => fmt(Number(value))}
|
||||||
|
labelFormatter={(l) => `${Number(l).toFixed(2)} km`}
|
||||||
|
/>
|
||||||
|
{referenceLine !== undefined && (
|
||||||
|
<ReferenceLine
|
||||||
|
y={referenceLine}
|
||||||
|
stroke="var(--color-fg)"
|
||||||
|
strokeOpacity={0.4}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
name={label}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
fill={`url(#${gradId})`}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
components/sign-out-button.tsx
Normal file
18
components/sign-out-button.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LogOut } from "lucide-react";
|
||||||
|
|
||||||
|
export function SignOutButton({ action }: { action: () => Promise<void> }) {
|
||||||
|
return (
|
||||||
|
<form action={action}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium text-fg/80 transition-colors hover:bg-bg hover:text-accent sm:px-3"
|
||||||
|
title="Wyloguj się"
|
||||||
|
>
|
||||||
|
<LogOut size={16} />
|
||||||
|
<span className="hidden sm:inline">Wyloguj</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { ObjectId } from "mongodb";
|
|||||||
import { formatDate, formatDateShort, formatDistance, formatDuration, formatPace } from "@/lib/format";
|
import { formatDate, formatDateShort, formatDistance, formatDuration, formatPace } from "@/lib/format";
|
||||||
import { fetchRecentWellness, type DayWellness } from "@/lib/garmin/wellness";
|
import { fetchRecentWellness, type DayWellness } from "@/lib/garmin/wellness";
|
||||||
import { getAuthorizedClient } from "@/lib/garmin/client";
|
import { getAuthorizedClient } from "@/lib/garmin/client";
|
||||||
import { getRunningActivity, listRunningActivities, type RunningActivity } from "@/lib/models/running";
|
import { getRunningActivity, listRunningActivities, type RunMetrics, type RunningActivity } from "@/lib/models/running";
|
||||||
import { getStrengthWorkout, listStrengthWorkouts, type StrengthWorkout } from "@/lib/models/strength";
|
import { getStrengthWorkout, listStrengthWorkouts, type StrengthWorkout } from "@/lib/models/strength";
|
||||||
import {
|
import {
|
||||||
getLatestAnalysisForTarget,
|
getLatestAnalysisForTarget,
|
||||||
@@ -28,6 +28,61 @@ Jeśli podano dane z poprzednich treningów, odnieś się do progresu (np. zmian
|
|||||||
type PreviousRun = { run: RunningActivity; analysis: AiAnalysis | null };
|
type PreviousRun = { run: RunningActivity; analysis: AiAnalysis | null };
|
||||||
type PreviousWorkout = { workout: StrengthWorkout; analysis: AiAnalysis | null };
|
type PreviousWorkout = { workout: StrengthWorkout; analysis: AiAnalysis | null };
|
||||||
|
|
||||||
|
function secToMinKm(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.round(sec % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")} min/km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRunMetricsSummary(metrics: RunMetrics, totalDistanceM: number): string[] {
|
||||||
|
const { distanceKm, hrBpm, gcbLeftPct, paceSec } = metrics;
|
||||||
|
if (!hrBpm && !gcbLeftPct && !paceSec) return [];
|
||||||
|
const n = distanceKm.length;
|
||||||
|
if (n < 8) return [];
|
||||||
|
|
||||||
|
const maxDist = Math.max(...distanceKm);
|
||||||
|
const totalKm = totalDistanceM / 1000;
|
||||||
|
const useIndex = maxDist === 0;
|
||||||
|
|
||||||
|
const position = (i: number) => (useIndex ? i / n : distanceKm[i] / maxDist);
|
||||||
|
const kmLabel = (i: number) =>
|
||||||
|
useIndex ? ((i / n) * totalKm).toFixed(1) : distanceKm[i].toFixed(1);
|
||||||
|
const avg = (vals: number[]) =>
|
||||||
|
vals.length ? Math.round(vals.reduce((s, v) => s + v, 0) / vals.length) : null;
|
||||||
|
|
||||||
|
const lines = [`Dane w trakcie biegu (4 kwartyle):`];
|
||||||
|
for (let q = 0; q < 4; q++) {
|
||||||
|
const from = q / 4;
|
||||||
|
const to = (q + 1) / 4;
|
||||||
|
const idx = Array.from({ length: n }, (_, i) => i).filter(
|
||||||
|
(i) => position(i) >= from && position(i) < to
|
||||||
|
);
|
||||||
|
if (idx.length === 0) continue;
|
||||||
|
|
||||||
|
const parts = [`${kmLabel(idx[0])}–${kmLabel(idx[idx.length - 1])} km`];
|
||||||
|
|
||||||
|
if (hrBpm) {
|
||||||
|
const vals = idx.map((i) => hrBpm[i]).filter((v) => v > 0);
|
||||||
|
const a = avg(vals);
|
||||||
|
if (a !== null) parts.push(`HR śr. ${a} bpm`);
|
||||||
|
}
|
||||||
|
if (paceSec) {
|
||||||
|
const vals = idx.map((i) => paceSec[i]).filter((v) => v > 0 && v < 1800);
|
||||||
|
const a = avg(vals);
|
||||||
|
if (a !== null) parts.push(`tempo śr. ${secToMinKm(a)}`);
|
||||||
|
}
|
||||||
|
if (gcbLeftPct) {
|
||||||
|
const vals = idx.map((i) => gcbLeftPct[i]).filter((v) => v > 0);
|
||||||
|
if (vals.length > 0) {
|
||||||
|
const mean = vals.reduce((s, v) => s + v, 0) / vals.length;
|
||||||
|
parts.push(`balans L/P ${mean.toFixed(1)}%/${(100 - mean).toFixed(1)}%`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(`- ${parts.join(", ")}`);
|
||||||
|
}
|
||||||
|
return lines.length > 1 ? lines : [];
|
||||||
|
}
|
||||||
|
|
||||||
function buildRunningPrompt(activity: RunningActivity, previousRuns: PreviousRun[]): string {
|
function buildRunningPrompt(activity: RunningActivity, previousRuns: PreviousRun[]): string {
|
||||||
const lines = [
|
const lines = [
|
||||||
`Przeanalizuj poniższy bieg i podaj krótkie podsumowanie oraz wskazówki potreningowe.`,
|
`Przeanalizuj poniższy bieg i podaj krótkie podsumowanie oraz wskazówki potreningowe.`,
|
||||||
@@ -58,6 +113,13 @@ function buildRunningPrompt(activity: RunningActivity, previousRuns: PreviousRun
|
|||||||
if (activity.aerobicTrainingEffect) lines.push(`Efekt treningowy aerobowy: ${activity.aerobicTrainingEffect.toFixed(1)}`);
|
if (activity.aerobicTrainingEffect) lines.push(`Efekt treningowy aerobowy: ${activity.aerobicTrainingEffect.toFixed(1)}`);
|
||||||
if (activity.anaerobicTrainingEffect) lines.push(`Efekt treningowy anaerobowy: ${activity.anaerobicTrainingEffect.toFixed(1)}`);
|
if (activity.anaerobicTrainingEffect) lines.push(`Efekt treningowy anaerobowy: ${activity.anaerobicTrainingEffect.toFixed(1)}`);
|
||||||
|
|
||||||
|
if (activity.runMetrics) {
|
||||||
|
const metricLines = buildRunMetricsSummary(activity.runMetrics, activity.distanceM);
|
||||||
|
if (metricLines.length > 0) {
|
||||||
|
lines.push(``, ...metricLines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
@@ -133,6 +195,7 @@ function parseAnalysisResponse(text: string): { summary: string; tips: string[]
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateAnalysis(
|
export async function generateAnalysis(
|
||||||
|
userId: string,
|
||||||
targetType: AiAnalysisTargetType,
|
targetType: AiAnalysisTargetType,
|
||||||
targetId: string
|
targetId: string
|
||||||
): Promise<AiAnalysis> {
|
): Promise<AiAnalysis> {
|
||||||
@@ -143,28 +206,28 @@ export async function generateAnalysis(
|
|||||||
|
|
||||||
let prompt: string;
|
let prompt: string;
|
||||||
if (targetType === "running") {
|
if (targetType === "running") {
|
||||||
const activity = await getRunningActivity(targetId);
|
const activity = await getRunningActivity(userId, targetId);
|
||||||
if (!activity) throw new Error("Nie znaleziono biegu.");
|
if (!activity) throw new Error("Nie znaleziono biegu.");
|
||||||
const previousRuns = (await listRunningActivities())
|
const previousRuns = (await listRunningActivities(userId))
|
||||||
.filter((run) => run.startTime < activity.startTime)
|
.filter((run) => run.startTime < activity.startTime)
|
||||||
.slice(0, PREVIOUS_RUNS_LIMIT);
|
.slice(0, PREVIOUS_RUNS_LIMIT);
|
||||||
const previousRunsWithAnalysis: PreviousRun[] = await Promise.all(
|
const previousRunsWithAnalysis: PreviousRun[] = await Promise.all(
|
||||||
previousRuns.map(async (run) => ({
|
previousRuns.map(async (run) => ({
|
||||||
run,
|
run,
|
||||||
analysis: await getLatestAnalysisForTarget("running", run._id),
|
analysis: await getLatestAnalysisForTarget(userId, "running", run._id),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
prompt = buildRunningPrompt(activity, previousRunsWithAnalysis);
|
prompt = buildRunningPrompt(activity, previousRunsWithAnalysis);
|
||||||
} else {
|
} else {
|
||||||
const workout = await getStrengthWorkout(targetId);
|
const workout = await getStrengthWorkout(userId, targetId);
|
||||||
if (!workout) throw new Error("Nie znaleziono treningu.");
|
if (!workout) throw new Error("Nie znaleziono treningu.");
|
||||||
const previousWorkouts = (await listStrengthWorkouts())
|
const previousWorkouts = (await listStrengthWorkouts(userId))
|
||||||
.filter((previous) => previous.date < workout.date)
|
.filter((previous) => previous.date < workout.date)
|
||||||
.slice(0, PREVIOUS_WORKOUTS_LIMIT);
|
.slice(0, PREVIOUS_WORKOUTS_LIMIT);
|
||||||
const previousWorkoutsWithAnalysis: PreviousWorkout[] = await Promise.all(
|
const previousWorkoutsWithAnalysis: PreviousWorkout[] = await Promise.all(
|
||||||
previousWorkouts.map(async (previous) => ({
|
previousWorkouts.map(async (previous) => ({
|
||||||
workout: previous,
|
workout: previous,
|
||||||
analysis: await getLatestAnalysisForTarget("strength", previous._id),
|
analysis: await getLatestAnalysisForTarget(userId, "strength", previous._id),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
prompt = buildStrengthPrompt(workout, previousWorkoutsWithAnalysis);
|
prompt = buildStrengthPrompt(workout, previousWorkoutsWithAnalysis);
|
||||||
@@ -183,6 +246,7 @@ export async function generateAnalysis(
|
|||||||
const { summary, tips } = parseAnalysisResponse(text);
|
const { summary, tips } = parseAnalysisResponse(text);
|
||||||
|
|
||||||
return saveAiAnalysis({
|
return saveAiAnalysis({
|
||||||
|
userId,
|
||||||
targetType,
|
targetType,
|
||||||
targetId: new ObjectId(targetId),
|
targetId: new ObjectId(targetId),
|
||||||
summary,
|
summary,
|
||||||
@@ -281,18 +345,18 @@ function buildDashboardPrompt(
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateDashboardAnalysis(): Promise<AiAnalysis> {
|
export async function generateDashboardAnalysis(userId: string): Promise<AiAnalysis> {
|
||||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||||
if (!apiKey) throw new Error("Brak klucza ANTHROPIC_API_KEY w konfiguracji.");
|
if (!apiKey) throw new Error("Brak klucza ANTHROPIC_API_KEY w konfiguracji.");
|
||||||
|
|
||||||
const [runs, workouts] = await Promise.all([
|
const [runs, workouts] = await Promise.all([
|
||||||
listRunningActivities().then((r) => r.slice(0, DASHBOARD_RUNS_LIMIT)),
|
listRunningActivities(userId).then((r) => r.slice(0, DASHBOARD_RUNS_LIMIT)),
|
||||||
listStrengthWorkouts().then((w) => w.slice(0, DASHBOARD_WORKOUTS_LIMIT)),
|
listStrengthWorkouts(userId).then((w) => w.slice(0, DASHBOARD_WORKOUTS_LIMIT)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let wellness: DayWellness[] = [];
|
let wellness: DayWellness[] = [];
|
||||||
try {
|
try {
|
||||||
const garminClient = await getAuthorizedClient();
|
const garminClient = await getAuthorizedClient(userId);
|
||||||
wellness = await fetchRecentWellness(garminClient, DASHBOARD_WELLNESS_DAYS);
|
wellness = await fetchRecentWellness(garminClient, DASHBOARD_WELLNESS_DAYS);
|
||||||
} catch {
|
} catch {
|
||||||
// Wellness data not available, proceed without it
|
// Wellness data not available, proceed without it
|
||||||
@@ -310,5 +374,5 @@ export async function generateDashboardAnalysis(): Promise<AiAnalysis> {
|
|||||||
const textBlock = message.content.find((b) => b.type === "text");
|
const textBlock = message.content.find((b) => b.type === "text");
|
||||||
const text = textBlock && textBlock.type === "text" ? textBlock.text : "";
|
const text = textBlock && textBlock.type === "text" ? textBlock.text : "";
|
||||||
const { summary, tips } = parseAnalysisResponse(text);
|
const { summary, tips } = parseAnalysisResponse(text);
|
||||||
return saveDashboardAnalysis(summary, tips, model);
|
return saveDashboardAnalysis(userId, summary, tips, model);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { GarminConnect } from "garmin-connect";
|
import { GarminConnect } from "garmin-connect";
|
||||||
import type { IActivity } from "garmin-connect/dist/garmin/types/activity";
|
import type { IActivity } from "garmin-connect/dist/garmin/types/activity";
|
||||||
import type { IOauth1Token } from "garmin-connect/dist/garmin/types";
|
import type { IOauth1Token } from "garmin-connect/dist/garmin/types";
|
||||||
import type { RoutePoint, RunningActivityInput } from "@/lib/models/running";
|
import type { RoutePoint, RunMetrics, RunningActivityInput } from "@/lib/models/running";
|
||||||
import { getSavedOauth1Token } from "@/lib/models/garmin-auth";
|
import { getGarminCredentials, getSavedOauth1Token } from "@/lib/models/garmin-auth";
|
||||||
import { completeMfaAndGetTicket, loginAndGetTicket, type GarminPendingMfa } from "@/lib/garmin/sso";
|
import { completeMfaAndGetTicket, loginAndGetTicket, type GarminPendingMfa } from "@/lib/garmin/sso";
|
||||||
|
|
||||||
const FETCH_LIMIT = 50;
|
const FETCH_LIMIT = 50;
|
||||||
@@ -13,6 +13,12 @@ export class GarminLoginRequiredError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class GarminCredentialsMissingError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Brak danych logowania do Garmin Connect. Skonfiguruj je w Ustawieniach.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseGarminDate(value: string): Date {
|
function parseGarminDate(value: string): Date {
|
||||||
return new Date(`${value.replace(" ", "T")}Z`);
|
return new Date(`${value.replace(" ", "T")}Z`);
|
||||||
}
|
}
|
||||||
@@ -61,13 +67,14 @@ function mapActivity(activity: IActivity): RunningActivityInput {
|
|||||||
|
|
||||||
const GC_API = "https://connectapi.garmin.com";
|
const GC_API = "https://connectapi.garmin.com";
|
||||||
const MAX_POLYLINE_POINTS = 500;
|
const MAX_POLYLINE_POINTS = 500;
|
||||||
|
const MAX_ELEVATION_POINTS = 300;
|
||||||
|
|
||||||
type GarminPolylinePoint = { lat: number; lon: number; altitude?: number };
|
type GarminPolylinePoint = { lat: number; lon: number };
|
||||||
type GarminActivityDetailsResponse = {
|
type GarminActivityDetailsResponse = {
|
||||||
geoPolylineDTO?: { polyline?: GarminPolylinePoint[] };
|
geoPolylineDTO?: { polyline?: GarminPolylinePoint[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchActivityRoutePoints(
|
async function fetchPolyline(
|
||||||
client: GarminConnect,
|
client: GarminConnect,
|
||||||
garminActivityId: number
|
garminActivityId: number
|
||||||
): Promise<RoutePoint[] | null> {
|
): Promise<RoutePoint[] | null> {
|
||||||
@@ -78,13 +85,111 @@ export async function fetchActivityRoutePoints(
|
|||||||
return polyline.map((p) => [p.lat, p.lon] as RoutePoint);
|
return polyline.map((p) => [p.lat, p.lon] as RoutePoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCredentials(): { username: string; password: string } {
|
type GpxPoint = { lat: number; lon: number; ele: number };
|
||||||
const username = process.env.GARMIN_EMAIL;
|
|
||||||
const password = process.env.GARMIN_PASSWORD;
|
async function fetchGpxPoints(
|
||||||
if (!username || !password) {
|
client: GarminConnect,
|
||||||
throw new Error("Brak danych logowania do Garmin Connect (GARMIN_EMAIL / GARMIN_PASSWORD).");
|
garminActivityId: number
|
||||||
|
): Promise<GpxPoint[] | null> {
|
||||||
|
const gpxUrl = `${GC_API}/download-service/export/gpx/activity/${garminActivityId}`;
|
||||||
|
const gpxText = await client.get<string>(gpxUrl);
|
||||||
|
if (!gpxText || typeof gpxText !== "string") return null;
|
||||||
|
|
||||||
|
const trkptRe = /<trkpt\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>[\s\S]*?<ele>([^<]+)<\/ele>/g;
|
||||||
|
const points: GpxPoint[] = [];
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = trkptRe.exec(gpxText)) !== null) {
|
||||||
|
points.push({ lat: parseFloat(m[1]), lon: parseFloat(m[2]), ele: parseFloat(m[3]) });
|
||||||
}
|
}
|
||||||
return { username, password };
|
return points.length > 0 ? points : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downsample<T>(arr: T[], maxLen: number): T[] {
|
||||||
|
if (arr.length <= maxLen) return arr;
|
||||||
|
const step = arr.length / maxLen;
|
||||||
|
return Array.from({ length: maxLen }, (_, i) => arr[Math.round(i * step)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchActivityRoutePoints(
|
||||||
|
client: GarminConnect,
|
||||||
|
garminActivityId: number
|
||||||
|
): Promise<{ points: RoutePoint[]; elevationProfile: number[] } | null> {
|
||||||
|
const [polylinePoints, gpxPoints] = await Promise.allSettled([
|
||||||
|
fetchPolyline(client, garminActivityId),
|
||||||
|
fetchGpxPoints(client, garminActivityId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const routePoints =
|
||||||
|
polylinePoints.status === "fulfilled" ? polylinePoints.value : null;
|
||||||
|
const gpx = gpxPoints.status === "fulfilled" ? gpxPoints.value : null;
|
||||||
|
|
||||||
|
if (!routePoints && !gpx) return null;
|
||||||
|
|
||||||
|
const points = routePoints ?? (gpx ? downsample(gpx.map((p) => [p.lat, p.lon] as RoutePoint), MAX_POLYLINE_POINTS) : []);
|
||||||
|
const elevationProfile = gpx
|
||||||
|
? downsample(gpx, MAX_ELEVATION_POINTS).map((p) => p.ele)
|
||||||
|
: (routePoints ? routePoints.map(() => 0) : []);
|
||||||
|
|
||||||
|
return { points, elevationProfile };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchActivityRunMetrics(
|
||||||
|
client: GarminConnect,
|
||||||
|
garminActivityId: number
|
||||||
|
): Promise<RunMetrics | null> {
|
||||||
|
const url = `${GC_API}/activity-service/activity/${garminActivityId}/details`;
|
||||||
|
|
||||||
|
type Descriptor = { key: string; metricsIndex: number };
|
||||||
|
type Row = { metrics: (number | null)[] };
|
||||||
|
type Response = { metricDescriptors?: Descriptor[]; activityDetailMetrics?: Row[] };
|
||||||
|
|
||||||
|
const data = await client.get<Response>(url);
|
||||||
|
const descriptors = data?.metricDescriptors ?? [];
|
||||||
|
const rows = data?.activityDetailMetrics ?? [];
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const idx: Record<string, number> = {};
|
||||||
|
for (const d of descriptors) idx[d.key] = d.metricsIndex;
|
||||||
|
|
||||||
|
const get = (row: Row, key: string): number | null => {
|
||||||
|
const i = idx[key];
|
||||||
|
return i !== undefined ? (row.metrics[i] ?? null) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX = 300;
|
||||||
|
const step = Math.max(1, Math.floor(rows.length / MAX));
|
||||||
|
const sampled = rows.filter((_, i) => i % step === 0 || i === rows.length - 1);
|
||||||
|
|
||||||
|
const distKey = ["directDistance", "sumDistance", "directCumulativeDistance"].find(
|
||||||
|
(k) => sampled.some((row) => get(row, k) !== null && get(row, k)! > 0)
|
||||||
|
);
|
||||||
|
const distanceKm = sampled.map((row) => {
|
||||||
|
const d = distKey ? get(row, distKey) : null;
|
||||||
|
return d !== null ? Math.round(d / 10) / 100 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const series = (key: string, decimals = 0): number[] | undefined => {
|
||||||
|
const values = sampled.map((row) => {
|
||||||
|
const v = get(row, key);
|
||||||
|
if (v === null) return 0;
|
||||||
|
return decimals > 0 ? Math.round(v * 10 ** decimals) / 10 ** decimals : Math.round(v);
|
||||||
|
});
|
||||||
|
return values.some((v) => v > 0) ? values : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const speedSeries = series("directSpeed", 3);
|
||||||
|
const paceSec = speedSeries
|
||||||
|
? speedSeries.map((v) => (v > 0.5 ? Math.round(1000 / v) : 0))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
distanceKm,
|
||||||
|
hrBpm: series("directHeartRate"),
|
||||||
|
cadenceSpm: series("directDoubleCadence"),
|
||||||
|
gctMs: series("directGroundContactTime"),
|
||||||
|
gcbLeftPct: series("directGroundContactBalanceLeft", 1),
|
||||||
|
paceSec,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exchangeOauth1Token(client: GarminConnect, oauth1Token: IOauth1Token): Promise<void> {
|
async function exchangeOauth1Token(client: GarminConnect, oauth1Token: IOauth1Token): Promise<void> {
|
||||||
@@ -102,12 +207,8 @@ async function exchangeOauth1Token(client: GarminConnect, oauth1Token: IOauth1To
|
|||||||
await http.exchange({ oauth, token: oauth1Token });
|
await http.exchange({ oauth, token: oauth1Token });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getAuthorizedClient(userId: string): Promise<GarminConnect> {
|
||||||
* Returns a client authenticated using a previously saved OAuth1 token
|
const saved = await getSavedOauth1Token(userId);
|
||||||
* (long-lived, survives across syncs) - no MFA needed if it's still valid.
|
|
||||||
*/
|
|
||||||
export async function getAuthorizedClient(): Promise<GarminConnect> {
|
|
||||||
const saved = await getSavedOauth1Token();
|
|
||||||
if (!saved) {
|
if (!saved) {
|
||||||
throw new GarminLoginRequiredError();
|
throw new GarminLoginRequiredError();
|
||||||
}
|
}
|
||||||
@@ -129,16 +230,16 @@ async function establishClientFromTicket(ticket: string): Promise<{ client: Garm
|
|||||||
return { client, oauth1Token: oauth1.token };
|
return { client, oauth1Token: oauth1.token };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function beginGarminLogin(
|
||||||
* Starts a fresh SSO login using env credentials. If the account requires
|
userId: string
|
||||||
* MFA, returns the pending state needed to complete it via
|
): Promise<
|
||||||
* `completeGarminMfaLogin` once the user supplies the emailed code.
|
| { client: GarminConnect; oauth1Token: IOauth1Token }
|
||||||
*/
|
| { mfaRequired: true; pendingState: GarminPendingMfa }
|
||||||
export async function beginGarminLogin(): Promise<
|
|
||||||
{ client: GarminConnect; oauth1Token: IOauth1Token } | { mfaRequired: true; pendingState: GarminPendingMfa }
|
|
||||||
> {
|
> {
|
||||||
const { username, password } = getCredentials();
|
const creds = await getGarminCredentials(userId);
|
||||||
const result = await loginAndGetTicket(username, password);
|
if (!creds) throw new GarminCredentialsMissingError();
|
||||||
|
|
||||||
|
const result = await loginAndGetTicket(creds.email, creds.password);
|
||||||
if ("mfaRequired" in result) return result;
|
if ("mfaRequired" in result) return result;
|
||||||
return establishClientFromTicket(result.ticket);
|
return establishClientFromTicket(result.ticket);
|
||||||
}
|
}
|
||||||
@@ -151,14 +252,7 @@ export async function completeGarminMfaLogin(
|
|||||||
return establishClientFromTicket(ticket);
|
return establishClientFromTicket(ticket);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all recent running activities (mapped), regardless of `since` -
|
|
||||||
* callers should upsert all of them so previously-synced activities get
|
|
||||||
* backfilled with newly added metric fields, but can use `since` to decide
|
|
||||||
* which ones are "new" for reporting purposes.
|
|
||||||
*/
|
|
||||||
export async function fetchRunningActivities(client: GarminConnect): Promise<RunningActivityInput[]> {
|
export async function fetchRunningActivities(client: GarminConnect): Promise<RunningActivityInput[]> {
|
||||||
const activities = await client.getActivities(0, FETCH_LIMIT);
|
const activities = await client.getActivities(0, FETCH_LIMIT);
|
||||||
|
|
||||||
return activities.filter(isRunningActivity).map(mapActivity);
|
return activities.filter(isRunningActivity).map(mapActivity);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getDb } from "@/lib/db";
|
|||||||
export type AiAnalysisTargetType = "running" | "strength" | "dashboard";
|
export type AiAnalysisTargetType = "running" | "strength" | "dashboard";
|
||||||
|
|
||||||
export type AiAnalysisInput = {
|
export type AiAnalysisInput = {
|
||||||
|
userId: string;
|
||||||
targetType: AiAnalysisTargetType;
|
targetType: AiAnalysisTargetType;
|
||||||
targetId: ObjectId;
|
targetId: ObjectId;
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -40,6 +41,8 @@ export function serializeAnalysis(analysis: AiAnalysis): SerializedAiAnalysis {
|
|||||||
|
|
||||||
const COLLECTION = "ai_analyses";
|
const COLLECTION = "ai_analyses";
|
||||||
|
|
||||||
|
const DASHBOARD_TARGET_ID = new ObjectId("000000000000000000000001");
|
||||||
|
|
||||||
async function getCollection() {
|
async function getCollection() {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
return db.collection<AiAnalysis>(COLLECTION);
|
return db.collection<AiAnalysis>(COLLECTION);
|
||||||
@@ -53,32 +56,34 @@ export async function saveAiAnalysis(input: AiAnalysisInput): Promise<AiAnalysis
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getLatestAnalysisForTarget(
|
export async function getLatestAnalysisForTarget(
|
||||||
|
userId: string,
|
||||||
targetType: AiAnalysisTargetType,
|
targetType: AiAnalysisTargetType,
|
||||||
targetId: ObjectId
|
targetId: ObjectId
|
||||||
): Promise<AiAnalysis | null> {
|
): Promise<AiAnalysis | null> {
|
||||||
const collection = await getCollection();
|
const collection = await getCollection();
|
||||||
return collection.findOne({ targetType, targetId }, { sort: { createdAt: -1 } });
|
return collection.findOne({ userId, targetType, targetId }, { sort: { createdAt: -1 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLatestAnalysis(): Promise<AiAnalysis | null> {
|
export async function getDashboardAnalysis(userId: string): Promise<AiAnalysis | null> {
|
||||||
const collection = await getCollection();
|
|
||||||
return collection.findOne({}, { sort: { createdAt: -1 } });
|
|
||||||
}
|
|
||||||
|
|
||||||
const DASHBOARD_TARGET_ID = new ObjectId("000000000000000000000001");
|
|
||||||
|
|
||||||
export async function getDashboardAnalysis(): Promise<AiAnalysis | null> {
|
|
||||||
const collection = await getCollection();
|
const collection = await getCollection();
|
||||||
return collection.findOne(
|
return collection.findOne(
|
||||||
{ targetType: "dashboard", targetId: DASHBOARD_TARGET_ID },
|
{ userId, targetType: "dashboard", targetId: DASHBOARD_TARGET_ID },
|
||||||
{ sort: { createdAt: -1 } }
|
{ sort: { createdAt: -1 } }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveDashboardAnalysis(
|
export async function saveDashboardAnalysis(
|
||||||
|
userId: string,
|
||||||
summary: string,
|
summary: string,
|
||||||
tips: string[],
|
tips: string[],
|
||||||
model: string
|
model: string
|
||||||
): Promise<AiAnalysis> {
|
): Promise<AiAnalysis> {
|
||||||
return saveAiAnalysis({ targetType: "dashboard", targetId: DASHBOARD_TARGET_ID, summary, tips, model });
|
return saveAiAnalysis({
|
||||||
|
userId,
|
||||||
|
targetType: "dashboard",
|
||||||
|
targetId: DASHBOARD_TARGET_ID,
|
||||||
|
summary,
|
||||||
|
tips,
|
||||||
|
model,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,37 +4,61 @@ import type { GarminPendingMfa } from "@/lib/garmin/sso";
|
|||||||
|
|
||||||
const AUTH_COLLECTION = "garmin_auth";
|
const AUTH_COLLECTION = "garmin_auth";
|
||||||
const PENDING_COLLECTION = "garmin_login_pending";
|
const PENDING_COLLECTION = "garmin_login_pending";
|
||||||
|
const CREDENTIALS_COLLECTION = "garmin_credentials";
|
||||||
|
|
||||||
type GarminAuthDoc = { _id: "tokens"; oauth1Token: IOauth1Token; updatedAt: Date };
|
type GarminAuthDoc = { _id: string; oauth1Token: IOauth1Token; updatedAt: Date };
|
||||||
type GarminPendingDoc = { _id: "pending"; state: GarminPendingMfa; createdAt: Date };
|
type GarminPendingDoc = { _id: string; state: GarminPendingMfa; createdAt: Date };
|
||||||
|
type GarminCredentialsDoc = { _id: string; email: string; password: string };
|
||||||
|
|
||||||
export async function getSavedOauth1Token(): Promise<IOauth1Token | null> {
|
export async function getSavedOauth1Token(userId: string): Promise<IOauth1Token | null> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const doc = await db.collection<GarminAuthDoc>(AUTH_COLLECTION).findOne({ _id: "tokens" });
|
const doc = await db.collection<GarminAuthDoc>(AUTH_COLLECTION).findOne({ _id: userId });
|
||||||
return doc?.oauth1Token ?? null;
|
return doc?.oauth1Token ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveOauth1Token(oauth1Token: IOauth1Token): Promise<void> {
|
export async function saveOauth1Token(userId: string, oauth1Token: IOauth1Token): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
await db
|
await db
|
||||||
.collection<GarminAuthDoc>(AUTH_COLLECTION)
|
.collection<GarminAuthDoc>(AUTH_COLLECTION)
|
||||||
.updateOne({ _id: "tokens" }, { $set: { oauth1Token, updatedAt: new Date() } }, { upsert: true });
|
.updateOne({ _id: userId }, { $set: { oauth1Token, updatedAt: new Date() } }, { upsert: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function savePendingMfaState(state: GarminPendingMfa): Promise<void> {
|
export async function savePendingMfaState(userId: string, state: GarminPendingMfa): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
await db
|
await db
|
||||||
.collection<GarminPendingDoc>(PENDING_COLLECTION)
|
.collection<GarminPendingDoc>(PENDING_COLLECTION)
|
||||||
.updateOne({ _id: "pending" }, { $set: { state, createdAt: new Date() } }, { upsert: true });
|
.updateOne({ _id: userId }, { $set: { state, createdAt: new Date() } }, { upsert: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPendingMfaState(): Promise<GarminPendingMfa | null> {
|
export async function getPendingMfaState(userId: string): Promise<GarminPendingMfa | null> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const doc = await db.collection<GarminPendingDoc>(PENDING_COLLECTION).findOne({ _id: "pending" });
|
const doc = await db.collection<GarminPendingDoc>(PENDING_COLLECTION).findOne({ _id: userId });
|
||||||
return doc?.state ?? null;
|
return doc?.state ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearPendingMfaState(): Promise<void> {
|
export async function clearPendingMfaState(userId: string): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
await db.collection<GarminPendingDoc>(PENDING_COLLECTION).deleteOne({ _id: "pending" });
|
await db.collection<GarminPendingDoc>(PENDING_COLLECTION).deleteOne({ _id: userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGarminCredentials(
|
||||||
|
userId: string
|
||||||
|
): Promise<{ email: string; password: string } | null> {
|
||||||
|
const db = await getDb();
|
||||||
|
const doc = await db
|
||||||
|
.collection<GarminCredentialsDoc>(CREDENTIALS_COLLECTION)
|
||||||
|
.findOne({ _id: userId });
|
||||||
|
if (!doc) return null;
|
||||||
|
return { email: doc.email, password: doc.password };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveGarminCredentials(
|
||||||
|
userId: string,
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
await db
|
||||||
|
.collection<GarminCredentialsDoc>(CREDENTIALS_COLLECTION)
|
||||||
|
.updateOne({ _id: userId }, { $set: { email, password } }, { upsert: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,10 +34,22 @@ export type RunningActivityInput = z.infer<typeof runningActivitySchema>;
|
|||||||
|
|
||||||
export type RoutePoint = [number, number];
|
export type RoutePoint = [number, number];
|
||||||
|
|
||||||
|
export type RunMetrics = {
|
||||||
|
distanceKm: number[];
|
||||||
|
hrBpm?: number[];
|
||||||
|
cadenceSpm?: number[];
|
||||||
|
gctMs?: number[];
|
||||||
|
gcbLeftPct?: number[];
|
||||||
|
paceSec?: number[];
|
||||||
|
};
|
||||||
|
|
||||||
export type RunningActivity = RunningActivityInput & {
|
export type RunningActivity = RunningActivityInput & {
|
||||||
_id: ObjectId;
|
_id: ObjectId;
|
||||||
|
userId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
routePoints?: RoutePoint[];
|
routePoints?: RoutePoint[];
|
||||||
|
elevationProfile?: number[];
|
||||||
|
runMetrics?: RunMetrics;
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLLECTION = "running_activities";
|
const COLLECTION = "running_activities";
|
||||||
@@ -46,51 +58,73 @@ const SYNC_STATE_COLLECTION = "sync_state";
|
|||||||
async function getCollection() {
|
async function getCollection() {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const collection = db.collection<RunningActivity>(COLLECTION);
|
const collection = db.collection<RunningActivity>(COLLECTION);
|
||||||
await collection.createIndex({ garminActivityId: 1 }, { unique: true });
|
await collection.createIndex({ userId: 1, garminActivityId: 1 }, { unique: true });
|
||||||
return collection;
|
return collection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertRunningActivity(activity: RunningActivityInput): Promise<void> {
|
export async function upsertRunningActivity(
|
||||||
|
userId: string,
|
||||||
|
activity: RunningActivityInput
|
||||||
|
): Promise<void> {
|
||||||
const collection = await getCollection();
|
const collection = await getCollection();
|
||||||
await collection.updateOne(
|
await collection.updateOne(
|
||||||
{ garminActivityId: activity.garminActivityId },
|
{ userId, garminActivityId: activity.garminActivityId },
|
||||||
{
|
{
|
||||||
$set: activity,
|
$set: { ...activity, userId },
|
||||||
$setOnInsert: { createdAt: new Date() },
|
$setOnInsert: { createdAt: new Date() },
|
||||||
},
|
},
|
||||||
{ upsert: true }
|
{ upsert: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRunningActivities(): Promise<RunningActivity[]> {
|
export async function listRunningActivities(userId: string): Promise<RunningActivity[]> {
|
||||||
const collection = await getCollection();
|
const collection = await getCollection();
|
||||||
return collection.find().sort({ startTime: -1 }).toArray();
|
return collection.find({ userId }).sort({ startTime: -1 }).toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRunningActivity(id: string): Promise<RunningActivity | null> {
|
export async function getRunningActivity(
|
||||||
|
userId: string,
|
||||||
|
id: string
|
||||||
|
): Promise<RunningActivity | null> {
|
||||||
const collection = await getCollection();
|
const collection = await getCollection();
|
||||||
return collection.findOne({ _id: new ObjectId(id) });
|
return collection.findOne({ _id: new ObjectId(id), userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRunningActivityMetrics(
|
||||||
|
userId: string,
|
||||||
|
garminActivityId: number,
|
||||||
|
metrics: RunMetrics
|
||||||
|
): Promise<void> {
|
||||||
|
const collection = await getCollection();
|
||||||
|
await collection.updateOne({ userId, garminActivityId }, { $set: { runMetrics: metrics } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setRunningActivityRoutePoints(
|
export async function setRunningActivityRoutePoints(
|
||||||
|
userId: string,
|
||||||
garminActivityId: number,
|
garminActivityId: number,
|
||||||
points: RoutePoint[]
|
points: RoutePoint[],
|
||||||
|
elevationProfile: number[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const collection = await getCollection();
|
const collection = await getCollection();
|
||||||
await collection.updateOne({ garminActivityId }, { $set: { routePoints: points } });
|
await collection.updateOne(
|
||||||
|
{ userId, garminActivityId },
|
||||||
|
{ $set: { routePoints: points, elevationProfile } }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SyncState = { _id: "garmin"; lastSyncAt: Date };
|
type SyncState = { _id: string; lastSyncAt: Date };
|
||||||
|
|
||||||
export async function getLastSyncAt(): Promise<Date | null> {
|
export async function getLastSyncAt(userId: string): Promise<Date | null> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const state = await db.collection<SyncState>(SYNC_STATE_COLLECTION).findOne({ _id: "garmin" });
|
const state = await db
|
||||||
|
.collection<SyncState>(SYNC_STATE_COLLECTION)
|
||||||
|
.findOne({ _id: userId });
|
||||||
return state?.lastSyncAt ?? null;
|
return state?.lastSyncAt ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setLastSyncAt(date: Date): Promise<void> {
|
export async function setLastSyncAt(userId: string, date: Date): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
await db
|
await db
|
||||||
.collection<SyncState>(SYNC_STATE_COLLECTION)
|
.collection<SyncState>(SYNC_STATE_COLLECTION)
|
||||||
.updateOne({ _id: "garmin" }, { $set: { lastSyncAt: date } }, { upsert: true });
|
.updateOne({ _id: userId }, { $set: { lastSyncAt: date } }, { upsert: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type StrengthWorkoutInput = z.infer<typeof strengthWorkoutSchema>;
|
|||||||
|
|
||||||
export type StrengthWorkout = StrengthWorkoutInput & {
|
export type StrengthWorkout = StrengthWorkoutInput & {
|
||||||
_id: ObjectId;
|
_id: ObjectId;
|
||||||
|
userId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,30 +38,34 @@ const COLLECTION = "strength_workouts";
|
|||||||
async function getCollection() {
|
async function getCollection() {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const collection = db.collection<StrengthWorkout>(COLLECTION);
|
const collection = db.collection<StrengthWorkout>(COLLECTION);
|
||||||
await collection.createIndex({ sourceKey: 1 }, { unique: true });
|
await collection.createIndex({ userId: 1, sourceKey: 1 }, { unique: true });
|
||||||
return collection;
|
return collection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertStrengthWorkout(
|
export async function upsertStrengthWorkout(
|
||||||
|
userId: string,
|
||||||
workout: StrengthWorkoutInput
|
workout: StrengthWorkoutInput
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const collection = await getCollection();
|
const collection = await getCollection();
|
||||||
await collection.updateOne(
|
await collection.updateOne(
|
||||||
{ sourceKey: workout.sourceKey },
|
{ userId, sourceKey: workout.sourceKey },
|
||||||
{
|
{
|
||||||
$set: workout,
|
$set: { ...workout, userId },
|
||||||
$setOnInsert: { createdAt: new Date() },
|
$setOnInsert: { createdAt: new Date() },
|
||||||
},
|
},
|
||||||
{ upsert: true }
|
{ upsert: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listStrengthWorkouts(): Promise<StrengthWorkout[]> {
|
export async function listStrengthWorkouts(userId: string): Promise<StrengthWorkout[]> {
|
||||||
const collection = await getCollection();
|
const collection = await getCollection();
|
||||||
return collection.find().sort({ date: -1 }).toArray();
|
return collection.find({ userId }).sort({ date: -1 }).toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStrengthWorkout(id: string): Promise<StrengthWorkout | null> {
|
export async function getStrengthWorkout(
|
||||||
|
userId: string,
|
||||||
|
id: string
|
||||||
|
): Promise<StrengthWorkout | null> {
|
||||||
const collection = await getCollection();
|
const collection = await getCollection();
|
||||||
return collection.findOne({ _id: new ObjectId(id) });
|
return collection.findOne({ _id: new ObjectId(id), userId });
|
||||||
}
|
}
|
||||||
|
|||||||
8
lib/session.ts
Normal file
8
lib/session.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
|
export async function getCurrentUserId(): Promise<string> {
|
||||||
|
const session = await auth();
|
||||||
|
const id = session?.user?.id;
|
||||||
|
if (!id) throw new Error("Użytkownik nie jest zalogowany.");
|
||||||
|
return id;
|
||||||
|
}
|
||||||
23
middleware.ts
Normal file
23
middleware.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export default auth((req) => {
|
||||||
|
const isAuthenticated = !!req.auth;
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
|
if (!isAuthenticated && pathname !== "/login") {
|
||||||
|
return NextResponse.redirect(new URL("/login", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated && pathname === "/login") {
|
||||||
|
return NextResponse.redirect(new URL("/", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
});
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/((?!api/auth|_next/static|_next/image|favicon.ico|icon.svg|logo.svg|public/).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
"mongodb": "^7.3.0",
|
"mongodb": "^7.3.0",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
|
"next-auth": "5.0.0-beta.31",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
|
|||||||
76
pnpm-lock.yaml
generated
76
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: 16.2.9
|
specifier: 16.2.9
|
||||||
version: 16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
next-auth:
|
||||||
|
specifier: 5.0.0-beta.31
|
||||||
|
version: 5.0.0-beta.31(next@16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
|
||||||
react:
|
react:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
@@ -91,6 +94,20 @@ packages:
|
|||||||
zod:
|
zod:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@auth/core@0.41.2':
|
||||||
|
resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@simplewebauthn/browser': ^9.0.1
|
||||||
|
'@simplewebauthn/server': ^9.0.2
|
||||||
|
nodemailer: ^7.0.7
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@simplewebauthn/browser':
|
||||||
|
optional: true
|
||||||
|
'@simplewebauthn/server':
|
||||||
|
optional: true
|
||||||
|
nodemailer:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@babel/code-frame@7.29.7':
|
'@babel/code-frame@7.29.7':
|
||||||
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
|
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -464,6 +481,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
engines: {node: '>=12.4.0'}
|
||||||
|
|
||||||
|
'@panva/hkdf@1.2.1':
|
||||||
|
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
||||||
|
|
||||||
'@react-leaflet/core@3.0.0':
|
'@react-leaflet/core@3.0.0':
|
||||||
resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
|
resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1598,6 +1618,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jose@6.2.3:
|
||||||
|
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@@ -1832,6 +1855,22 @@ packages:
|
|||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
|
|
||||||
|
next-auth@5.0.0-beta.31:
|
||||||
|
resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@simplewebauthn/browser': ^9.0.1
|
||||||
|
'@simplewebauthn/server': ^9.0.2
|
||||||
|
next: ^14.0.0-0 || ^15.0.0 || ^16.0.0
|
||||||
|
nodemailer: ^7.0.7
|
||||||
|
react: ^18.2.0 || ^19.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@simplewebauthn/browser':
|
||||||
|
optional: true
|
||||||
|
'@simplewebauthn/server':
|
||||||
|
optional: true
|
||||||
|
nodemailer:
|
||||||
|
optional: true
|
||||||
|
|
||||||
next@16.2.9:
|
next@16.2.9:
|
||||||
resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==}
|
resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
@@ -1864,6 +1903,9 @@ packages:
|
|||||||
oauth-1.0a@2.2.6:
|
oauth-1.0a@2.2.6:
|
||||||
resolution: {integrity: sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==}
|
resolution: {integrity: sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==}
|
||||||
|
|
||||||
|
oauth4webapi@3.8.6:
|
||||||
|
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1950,6 +1992,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
preact-render-to-string@6.5.11:
|
||||||
|
resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
|
||||||
|
peerDependencies:
|
||||||
|
preact: '>=10'
|
||||||
|
|
||||||
|
preact@10.24.3:
|
||||||
|
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
|
||||||
|
|
||||||
prelude-ls@1.2.1:
|
prelude-ls@1.2.1:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -2338,6 +2388,14 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
zod: 4.4.3
|
zod: 4.4.3
|
||||||
|
|
||||||
|
'@auth/core@0.41.2':
|
||||||
|
dependencies:
|
||||||
|
'@panva/hkdf': 1.2.1
|
||||||
|
jose: 6.2.3
|
||||||
|
oauth4webapi: 3.8.6
|
||||||
|
preact: 10.24.3
|
||||||
|
preact-render-to-string: 6.5.11(preact@10.24.3)
|
||||||
|
|
||||||
'@babel/code-frame@7.29.7':
|
'@babel/code-frame@7.29.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-validator-identifier': 7.29.7
|
'@babel/helper-validator-identifier': 7.29.7
|
||||||
@@ -2694,6 +2752,8 @@ snapshots:
|
|||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@nolyfill/is-core-module@1.0.39': {}
|
||||||
|
|
||||||
|
'@panva/hkdf@1.2.1': {}
|
||||||
|
|
||||||
'@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
'@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
leaflet: 1.9.4
|
leaflet: 1.9.4
|
||||||
@@ -3969,6 +4029,8 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.7.0: {}
|
jiti@2.7.0: {}
|
||||||
|
|
||||||
|
jose@6.2.3: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@4.2.0:
|
js-yaml@4.2.0:
|
||||||
@@ -4139,6 +4201,12 @@ snapshots:
|
|||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
|
next-auth@5.0.0-beta.31(next@16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
'@auth/core': 0.41.2
|
||||||
|
next: 16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
next@16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
next@16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.2.9
|
'@next/env': 16.2.9
|
||||||
@@ -4174,6 +4242,8 @@ snapshots:
|
|||||||
|
|
||||||
oauth-1.0a@2.2.6: {}
|
oauth-1.0a@2.2.6: {}
|
||||||
|
|
||||||
|
oauth4webapi@3.8.6: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
@@ -4269,6 +4339,12 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
preact-render-to-string@6.5.11(preact@10.24.3):
|
||||||
|
dependencies:
|
||||||
|
preact: 10.24.3
|
||||||
|
|
||||||
|
preact@10.24.3: {}
|
||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
|
|||||||
16
types/next-auth.d.ts
vendored
Normal file
16
types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import "next-auth";
|
||||||
|
import "next-auth/jwt";
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
idToken?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "next-auth/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
keycloakId?: string;
|
||||||
|
idToken?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user