Compare commits
5 Commits
ee178feff0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf8624c954 | ||
|
|
63cb8b4933 | ||
|
|
115d56cd12 | ||
|
|
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 { generateAnalysis, generateDashboardAnalysis } from "@/lib/ai/claude";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import type { AiAnalysisTargetType } from "@/lib/models/analysis";
|
||||
|
||||
export type GenerateAnalysisState = { error: string } | { success: true } | null;
|
||||
@@ -10,8 +11,9 @@ export async function generateAnalysisAction(
|
||||
targetType: AiAnalysisTargetType,
|
||||
targetId: string
|
||||
): Promise<GenerateAnalysisState> {
|
||||
const userId = await getCurrentUserId();
|
||||
try {
|
||||
await generateAnalysis(targetType, targetId);
|
||||
await generateAnalysis(userId, targetType, targetId);
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Nie udało się wygenerować analizy." };
|
||||
}
|
||||
@@ -22,8 +24,9 @@ export async function generateAnalysisAction(
|
||||
}
|
||||
|
||||
export async function generateDashboardAnalysisAction(): Promise<GenerateAnalysisState> {
|
||||
const userId = await getCurrentUserId();
|
||||
try {
|
||||
await generateDashboardAnalysis();
|
||||
await generateDashboardAnalysis(userId);
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Nie udało się wygenerować analizy." };
|
||||
}
|
||||
|
||||
3
app/api/auth/[...nextauth]/route.ts
Normal file
3
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Nav } from "@/components/nav";
|
||||
import { auth } from "@/auth";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -18,18 +19,20 @@ export const metadata: Metadata = {
|
||||
description: "Analiza treningów biegowych i siłowych",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="pl"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col bg-bg text-fg">
|
||||
<Nav />
|
||||
{session && <Nav />}
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6 sm:px-6">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
36
app/login/page.tsx
Normal file
36
app/login/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { signIn } from "@/auth";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-10">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="KNUR"
|
||||
width={160}
|
||||
height={160}
|
||||
className="rounded-3xl shadow-2xl ring-2 ring-surface"
|
||||
/>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-fg">KNUR</h1>
|
||||
<p className="text-sm text-fg/50">
|
||||
Książka Notowań Udźwigów i Rezultatów
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signIn("keycloak", { redirectTo: "/" });
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-accent px-8 py-3 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
||||
>
|
||||
Zaloguj
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,14 +8,17 @@ import { formatDateShort, formatDistance, formatDuration, formatPace } from "@/l
|
||||
import { getDashboardAnalysis, serializeAnalysis } from "@/lib/models/analysis";
|
||||
import { listRunningActivities } from "@/lib/models/running";
|
||||
import { listStrengthWorkouts } from "@/lib/models/strength";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Home() {
|
||||
const userId = await getCurrentUserId();
|
||||
|
||||
const [runs, strengthWorkouts, dashboardAnalysis] = await Promise.all([
|
||||
listRunningActivities(),
|
||||
listStrengthWorkouts(),
|
||||
getDashboardAnalysis(),
|
||||
listRunningActivities(userId),
|
||||
listStrengthWorkouts(userId),
|
||||
getDashboardAnalysis(userId),
|
||||
]);
|
||||
|
||||
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
|
||||
|
||||
@@ -1,63 +1,140 @@
|
||||
import { Suspense } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AiAnalysisCard } from "@/components/ai-analysis-card";
|
||||
import { ElevationChart } from "@/components/elevation-chart";
|
||||
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 { formatDate, formatDistance, formatDuration, formatPace } from "@/lib/format";
|
||||
import { fetchActivityRoutePoints, getAuthorizedClient } from "@/lib/garmin/client";
|
||||
import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis";
|
||||
import { getRunningActivity, setRunningActivityRoutePoints, type RunningActivity } from "@/lib/models/running";
|
||||
import {
|
||||
getRunningActivity,
|
||||
type RunningActivity,
|
||||
} from "@/lib/models/running";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function RouteMapFetcher({ activity }: { activity: RunningActivity }) {
|
||||
let routePoints = activity.routePoints;
|
||||
const CHART_SAMPLES = 200;
|
||||
|
||||
if (!routePoints && activity.hasRoute) {
|
||||
try {
|
||||
const client = await getAuthorizedClient();
|
||||
const points = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
||||
if (points) {
|
||||
await setRunningActivityRoutePoints(activity.garminActivityId, points);
|
||||
routePoints = points;
|
||||
}
|
||||
} catch {
|
||||
// GPS fetch failed silently
|
||||
}
|
||||
function interpolate(sorted: { dist: number; value: number }[], target: number): number | null {
|
||||
if (sorted.length === 0) return null;
|
||||
if (target <= sorted[0].dist) return sorted[0].value;
|
||||
if (target >= sorted[sorted.length - 1].dist) return sorted[sorted.length - 1].value;
|
||||
let lo = 0;
|
||||
let hi = sorted.length - 1;
|
||||
while (lo + 1 < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (sorted[mid].dist <= target) lo = mid; else hi = mid;
|
||||
}
|
||||
const t = (target - sorted[lo].dist) / (sorted[hi].dist - sorted[lo].dist);
|
||||
return sorted[lo].value + t * (sorted[hi].value - sorted[lo].value);
|
||||
}
|
||||
|
||||
function buildGrid(maxDistKm: number): number[] {
|
||||
return Array.from({ length: CHART_SAMPLES }, (_, i) =>
|
||||
Math.round((i / (CHART_SAMPLES - 1)) * maxDistKm * 100) / 100
|
||||
);
|
||||
}
|
||||
|
||||
function RouteMap({ activity }: { activity: RunningActivity }) {
|
||||
const routePoints = activity.routePoints;
|
||||
return (
|
||||
<div className="col-span-2 row-span-3 min-h-[240px] overflow-hidden rounded-lg border border-muted/40">
|
||||
{routePoints && routePoints.length > 0 ? (
|
||||
<RouteMapSection points={routePoints} />
|
||||
) : (
|
||||
<div className="flex h-full min-h-[240px] items-center justify-center bg-surface">
|
||||
<span className="text-sm text-fg/30">Brak danych GPS</span>
|
||||
<span className="text-sm text-fg/30">Brak danych GPS — zsynchronizuj ponownie</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapSkeleton() {
|
||||
return (
|
||||
<div className="col-span-2 row-span-3 min-h-[240px] animate-pulse overflow-hidden rounded-lg border border-muted/40 bg-surface" />
|
||||
);
|
||||
function buildChartData(activity: RunningActivity) {
|
||||
const maxDistKm = Math.round(activity.distanceM / 10) / 100;
|
||||
const grid = buildGrid(maxDistKm);
|
||||
|
||||
const elevProfile = activity.elevationProfile;
|
||||
const metrics = activity.runMetrics;
|
||||
|
||||
const elevSorted = elevProfile
|
||||
? elevProfile
|
||||
.map((altM, i) => ({ dist: (i / elevProfile.length) * maxDistKm, value: altM }))
|
||||
.filter((p) => p.value > 0)
|
||||
: [];
|
||||
|
||||
const metricsDistKm = metrics
|
||||
? (() => {
|
||||
const raw = metrics.distanceKm;
|
||||
const max = Math.max(...raw);
|
||||
return max > 0
|
||||
? raw
|
||||
: raw.map((_, i) => Math.round(((i / (raw.length - 1)) * maxDistKm) * 100) / 100);
|
||||
})()
|
||||
: [];
|
||||
|
||||
const hrSorted = metrics?.hrBpm
|
||||
? metricsDistKm
|
||||
.map((d, i) => ({ dist: d, value: metrics.hrBpm![i] ?? 0 }))
|
||||
.filter((p) => p.value > 0)
|
||||
: [];
|
||||
|
||||
const paceSorted = metrics?.paceSec
|
||||
? metricsDistKm
|
||||
.map((d, i) => ({ dist: d, value: metrics.paceSec![i] ?? 0 }))
|
||||
.filter((p) => p.value > 0 && p.value < 1800)
|
||||
: [];
|
||||
|
||||
const gcbSorted = metrics?.gcbLeftPct
|
||||
? metricsDistKm
|
||||
.map((d, i) => ({ dist: d, value: metrics.gcbLeftPct![i] ?? 0 }))
|
||||
.filter((p) => p.value > 0)
|
||||
: [];
|
||||
|
||||
const hasElev = elevSorted.length >= 2;
|
||||
const hasHr = hrSorted.length >= 2;
|
||||
const hasGcb = gcbSorted.length >= 2;
|
||||
|
||||
const elevData = hasElev
|
||||
? grid.map((distanceKm) => {
|
||||
const altM = interpolate(elevSorted, distanceKm) ?? 0;
|
||||
const paceSec = paceSorted.length >= 2 ? interpolate(paceSorted, distanceKm) ?? undefined : undefined;
|
||||
return { distanceKm, altM, paceSec };
|
||||
})
|
||||
: null;
|
||||
|
||||
const hrData = hasHr
|
||||
? grid.map((distanceKm) => ({ distanceKm, value: interpolate(hrSorted, distanceKm) ?? 0 }))
|
||||
: null;
|
||||
|
||||
const gcbData = hasGcb
|
||||
? grid.map((distanceKm) => {
|
||||
const left = Math.round((interpolate(gcbSorted, distanceKm) ?? 50) * 10) / 10;
|
||||
return { distanceKm, left, right: Math.round((100 - left) * 10) / 10 };
|
||||
})
|
||||
: null;
|
||||
|
||||
return { elevData, hrData, gcbData };
|
||||
}
|
||||
|
||||
|
||||
export default async function RunningActivityPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const activity = await getRunningActivity(id);
|
||||
const userId = await getCurrentUserId();
|
||||
const activity = await getRunningActivity(userId, id);
|
||||
|
||||
if (!activity) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const analysis = await getLatestAnalysisForTarget("running", activity._id);
|
||||
const analysis = await getLatestAnalysisForTarget(userId, "running", activity._id);
|
||||
const { elevData, hrData, gcbData } = buildChartData(activity);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -67,22 +144,16 @@ export default async function RunningActivityPage({
|
||||
</div>
|
||||
|
||||
<section className="grid grid-cols-3 gap-4">
|
||||
{/* Map: cols 1–2, rows 1–3 — streamed in after page skeleton */}
|
||||
<Suspense fallback={<MapSkeleton />}>
|
||||
<RouteMapFetcher activity={activity} />
|
||||
</Suspense>
|
||||
<RouteMap activity={activity} />
|
||||
|
||||
{/* Col 3, rows 1–3: key pace stats */}
|
||||
<StatCard highlight label="Dystans" value={formatDistance(activity.distanceM)} />
|
||||
<StatCard highlight label="Czas" value={formatDuration(activity.durationSec)} />
|
||||
<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="Kalorie" value={activity.calories ? `${Math.round(activity.calories)} kcal` : "—"} />
|
||||
<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.elevationGainM ? <StatCard label="Podejście" value={`${Math.round(activity.elevationGainM)} m`} /> : null}
|
||||
{activity.vo2Max ? <StatCard label="VO2max" value={`${Math.round(activity.vo2Max)}`} /> : null}
|
||||
@@ -117,7 +188,21 @@ export default async function RunningActivityPage({
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<AiAnalysisCard targetType="running" targetId={activity._id.toString()} analysis={analysis ? serializeAnalysis(analysis) : null} />
|
||||
{elevData && <ElevationChart data={elevData} syncId="run-detail" />}
|
||||
{(hrData || gcbData) && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{hrData && (
|
||||
<RunMetricChart data={hrData} label="Tętno" unit="bpm" color="var(--color-accent)" syncId="run-detail" />
|
||||
)}
|
||||
{gcbData && <GcbChart data={gcbData} syncId="run-detail" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AiAnalysisCard
|
||||
targetType="running"
|
||||
targetId={activity._id.toString()}
|
||||
analysis={analysis ? serializeAnalysis(analysis) : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
||||
import type { GarminConnect } from "garmin-connect";
|
||||
import {
|
||||
GarminLoginRequiredError,
|
||||
GarminCredentialsMissingError,
|
||||
beginGarminLogin,
|
||||
completeGarminMfaLogin,
|
||||
fetchActivityRoutePoints,
|
||||
@@ -13,29 +14,66 @@ import {
|
||||
import {
|
||||
getLastSyncAt,
|
||||
getRunningActivity,
|
||||
listRunningActivities,
|
||||
setLastSyncAt,
|
||||
setRunningActivityMetrics,
|
||||
setRunningActivityRoutePoints,
|
||||
upsertRunningActivity,
|
||||
} from "@/lib/models/running";
|
||||
import { fetchActivityRunMetrics } from "@/lib/garmin/client";
|
||||
import {
|
||||
clearPendingMfaState,
|
||||
getPendingMfaState,
|
||||
saveOauth1Token,
|
||||
savePendingMfaState,
|
||||
} from "@/lib/models/garmin-auth";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export type SyncGarminState = { error: string } | { success: string } | { mfaRequired: true } | null;
|
||||
|
||||
async function syncWithClient(client: GarminConnect): Promise<SyncGarminState> {
|
||||
const since = await getLastSyncAt();
|
||||
// How many activities without data to enrich per sync (limits API call volume)
|
||||
const ENRICH_PER_SYNC = 10;
|
||||
|
||||
async function syncWithClient(userId: string, client: GarminConnect): Promise<SyncGarminState> {
|
||||
const since = await getLastSyncAt(userId);
|
||||
const activities = await fetchRunningActivities(client);
|
||||
const newCount = activities.filter((activity) => !since || activity.startTime > since).length;
|
||||
|
||||
for (const activity of activities) {
|
||||
await upsertRunningActivity(activity);
|
||||
await upsertRunningActivity(userId, activity);
|
||||
}
|
||||
|
||||
await setLastSyncAt(new Date());
|
||||
await setLastSyncAt(userId, new Date());
|
||||
|
||||
// Enrich activities missing route/metrics — fetched during sync so page loads are instant
|
||||
const all = await listRunningActivities(userId);
|
||||
const needsEnrich = all
|
||||
.filter((a) => a.hasRoute && (!a.routePoints?.length || !a.runMetrics?.paceSec))
|
||||
.slice(0, ENRICH_PER_SYNC);
|
||||
|
||||
for (const activity of needsEnrich) {
|
||||
try {
|
||||
if (!activity.routePoints?.length) {
|
||||
const route = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
||||
if (route) {
|
||||
await setRunningActivityRoutePoints(
|
||||
userId,
|
||||
activity.garminActivityId,
|
||||
route.points,
|
||||
route.elevationProfile
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!activity.runMetrics?.paceSec) {
|
||||
const metrics = await fetchActivityRunMetrics(client, activity.garminActivityId);
|
||||
if (metrics) {
|
||||
await setRunningActivityMetrics(userId, activity.garminActivityId, metrics);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Rate limited or activity has no GPS — skip silently
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/running");
|
||||
revalidatePath("/settings");
|
||||
@@ -45,39 +83,45 @@ async function syncWithClient(client: GarminConnect): Promise<SyncGarminState> {
|
||||
}
|
||||
|
||||
export async function syncGarminActivities(): Promise<SyncGarminState> {
|
||||
const userId = await getCurrentUserId();
|
||||
|
||||
try {
|
||||
const client = await getAuthorizedClient();
|
||||
return await syncWithClient(client);
|
||||
const client = await getAuthorizedClient(userId);
|
||||
return await syncWithClient(userId, client);
|
||||
} catch (error) {
|
||||
if (error instanceof GarminCredentialsMissingError) {
|
||||
return { error: error.message };
|
||||
}
|
||||
if (!(error instanceof GarminLoginRequiredError)) {
|
||||
return { error: error instanceof Error ? error.message : "Synchronizacja z Garmin nie powiodła się." };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await beginGarminLogin();
|
||||
const result = await beginGarminLogin(userId);
|
||||
if ("mfaRequired" in result) {
|
||||
await savePendingMfaState(result.pendingState);
|
||||
await savePendingMfaState(userId, result.pendingState);
|
||||
return { mfaRequired: true };
|
||||
}
|
||||
await saveOauth1Token(result.oauth1Token);
|
||||
return await syncWithClient(result.client);
|
||||
await saveOauth1Token(userId, result.oauth1Token);
|
||||
return await syncWithClient(userId, result.client);
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Logowanie do Garmin nie powiodło się." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitGarminMfaCode(code: string): Promise<SyncGarminState> {
|
||||
const pending = await getPendingMfaState();
|
||||
const userId = await getCurrentUserId();
|
||||
const pending = await getPendingMfaState(userId);
|
||||
if (!pending) {
|
||||
return { error: "Sesja logowania do Garmin wygasła. Kliknij \"Synchronizuj z Garmin\" ponownie." };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await completeGarminMfaLogin(pending, code);
|
||||
await saveOauth1Token(result.oauth1Token);
|
||||
await clearPendingMfaState();
|
||||
return await syncWithClient(result.client);
|
||||
await saveOauth1Token(userId, result.oauth1Token);
|
||||
await clearPendingMfaState(userId);
|
||||
return await syncWithClient(userId, result.client);
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Weryfikacja kodu MFA nie powiodła się." };
|
||||
}
|
||||
@@ -86,20 +130,21 @@ export async function submitGarminMfaCode(code: string): Promise<SyncGarminState
|
||||
export type LoadRouteState = { error: string } | { success: true } | null;
|
||||
|
||||
export async function loadActivityRoute(activityMongoId: string): Promise<LoadRouteState> {
|
||||
const activity = await getRunningActivity(activityMongoId);
|
||||
const userId = await getCurrentUserId();
|
||||
const activity = await getRunningActivity(userId, activityMongoId);
|
||||
if (!activity) return { error: "Nie znaleziono aktywności." };
|
||||
|
||||
let client: GarminConnect;
|
||||
try {
|
||||
client = await getAuthorizedClient();
|
||||
client = await getAuthorizedClient(userId);
|
||||
} catch {
|
||||
return { error: "Brak połączenia z Garmin Connect. Wykonaj synchronizację." };
|
||||
}
|
||||
|
||||
try {
|
||||
const points = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
||||
if (!points) return { error: "Brak danych GPS dla tej aktywności." };
|
||||
await setRunningActivityRoutePoints(activity.garminActivityId, points);
|
||||
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
||||
if (!result) return { error: "Brak danych GPS dla tej aktywności." };
|
||||
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
|
||||
revalidatePath(`/running/${activityMongoId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,11 +4,13 @@ import { EmptyState } from "@/components/empty-state";
|
||||
import { SyncButton } from "@/components/sync-button";
|
||||
import { formatDateShort, formatDistance, formatDuration, formatPace } from "@/lib/format";
|
||||
import { listRunningActivities } from "@/lib/models/running";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RunningPage() {
|
||||
const activities = await listRunningActivities();
|
||||
const userId = await getCurrentUserId();
|
||||
const activities = await listRunningActivities(userId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
28
app/settings/actions.ts
Normal file
28
app/settings/actions.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { saveGarminCredentials } from "@/lib/models/garmin-auth";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export type SaveGarminCredentialsState = { error: string } | { success: true } | null;
|
||||
|
||||
export async function saveGarminCredentialsAction(
|
||||
_prevState: SaveGarminCredentialsState,
|
||||
formData: FormData
|
||||
): Promise<SaveGarminCredentialsState> {
|
||||
const email = formData.get("email");
|
||||
const password = formData.get("password");
|
||||
|
||||
if (typeof email !== "string" || !email.includes("@")) {
|
||||
return { error: "Podaj prawidłowy adres e-mail." };
|
||||
}
|
||||
if (typeof password !== "string" || password.length < 1) {
|
||||
return { error: "Podaj hasło." };
|
||||
}
|
||||
|
||||
const userId = await getCurrentUserId();
|
||||
await saveGarminCredentials(userId, email.trim(), password);
|
||||
|
||||
revalidatePath("/settings");
|
||||
return { success: true };
|
||||
}
|
||||
58
app/settings/garmin-credentials-form.tsx
Normal file
58
app/settings/garmin-credentials-form.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { saveGarminCredentialsAction, type SaveGarminCredentialsState } from "./actions";
|
||||
|
||||
export function GarminCredentialsForm({ savedEmail }: { savedEmail: string | null }) {
|
||||
const [state, action, pending] = useActionState<SaveGarminCredentialsState, FormData>(
|
||||
saveGarminCredentialsAction,
|
||||
null
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={action} className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="garmin-email" className="text-sm text-fg/70">
|
||||
E-mail Garmin Connect
|
||||
</label>
|
||||
<input
|
||||
id="garmin-email"
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={savedEmail ?? ""}
|
||||
placeholder="twoj@email.com"
|
||||
required
|
||||
className="rounded-md border border-muted/40 bg-bg px-3 py-2 text-sm text-fg placeholder:text-fg/30 focus:border-accent/60 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="garmin-password" className="text-sm text-fg/70">
|
||||
Hasło Garmin Connect
|
||||
</label>
|
||||
<input
|
||||
id="garmin-password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="rounded-md border border-muted/40 bg-bg px-3 py-2 text-sm text-fg placeholder:text-fg/30 focus:border-accent/60 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state && "error" in state && (
|
||||
<p className="text-sm text-red-400">{state.error}</p>
|
||||
)}
|
||||
{state && "success" in state && (
|
||||
<p className="text-sm text-accent">Zapisano dane logowania.</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="self-start rounded-md bg-accent px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Zapisywanie…" : "Zapisz"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -2,22 +2,30 @@ import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import { SyncButton } from "@/components/sync-button";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { getLastSyncAt } from "@/lib/models/running";
|
||||
import { getGarminCredentials, getSavedOauth1Token } from "@/lib/models/garmin-auth";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { GarminCredentialsForm } from "./garmin-credentials-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function ConfigRow({ label, configured }: { label: string; configured: boolean }) {
|
||||
function StatusRow({ label, ok, okLabel = "Skonfigurowano", failLabel = "Brak konfiguracji" }: {
|
||||
label: string;
|
||||
ok: boolean;
|
||||
okLabel?: string;
|
||||
failLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border border-muted/40 bg-surface p-4">
|
||||
<span className="text-fg">{label}</span>
|
||||
{configured ? (
|
||||
{ok ? (
|
||||
<span className="flex items-center gap-1.5 text-sm text-fg/70">
|
||||
<CheckCircle2 size={16} className="text-accent" />
|
||||
Skonfigurowano
|
||||
{okLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-sm text-fg/50">
|
||||
<XCircle size={16} />
|
||||
Brak w .env.local
|
||||
{failLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -25,31 +33,52 @@ function ConfigRow({ label, configured }: { label: string; configured: boolean }
|
||||
}
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const lastSyncAt = await getLastSyncAt();
|
||||
|
||||
const mongoConfigured = Boolean(process.env.MONGODB_URI);
|
||||
const garminConfigured = Boolean(process.env.GARMIN_EMAIL && process.env.GARMIN_PASSWORD);
|
||||
const claudeConfigured = Boolean(process.env.ANTHROPIC_API_KEY);
|
||||
const userId = await getCurrentUserId();
|
||||
const [lastSyncAt, garminCreds, garminToken] = await Promise.all([
|
||||
getLastSyncAt(userId),
|
||||
getGarminCredentials(userId),
|
||||
getSavedOauth1Token(userId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-fg">Ustawienia</h1>
|
||||
<p className="mt-1 text-sm text-fg/60">Status konfiguracji i synchronizacja Garmin.</p>
|
||||
<p className="mt-1 text-sm text-fg/60">Konfiguracja konta i synchronizacja Garmin.</p>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold text-fg">Konfiguracja</h2>
|
||||
<ConfigRow label="MongoDB" configured={mongoConfigured} />
|
||||
<ConfigRow label="Garmin Connect" configured={garminConfigured} />
|
||||
<ConfigRow label="Claude API" configured={claudeConfigured} />
|
||||
<h2 className="text-lg font-semibold text-fg">Status</h2>
|
||||
<StatusRow label="MongoDB" ok={Boolean(process.env.MONGODB_URI)} />
|
||||
<StatusRow
|
||||
label="Garmin Connect — token sesji"
|
||||
ok={Boolean(garminToken)}
|
||||
okLabel="Aktywny (synchronizacja działa)"
|
||||
failLabel="Brak tokenu — wymagane logowanie"
|
||||
/>
|
||||
<StatusRow
|
||||
label="Garmin Connect — dane logowania"
|
||||
ok={Boolean(garminCreds)}
|
||||
okLabel={`Zapisano (${garminCreds?.email})`}
|
||||
failLabel="Brak — potrzebne gdy token wygaśnie"
|
||||
/>
|
||||
<StatusRow label="Claude API" ok={Boolean(process.env.ANTHROPIC_API_KEY)} />
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold text-fg">Konto Garmin Connect</h2>
|
||||
<div className="rounded-lg border border-muted/40 bg-surface p-4">
|
||||
<GarminCredentialsForm savedEmail={garminCreds?.email ?? null} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold text-fg">Synchronizacja z Garmin</h2>
|
||||
<div className="flex items-center justify-between rounded-lg border border-muted/40 bg-surface p-4">
|
||||
<span className="text-sm text-fg/70">
|
||||
{lastSyncAt ? `Ostatnia synchronizacja: ${formatDate(lastSyncAt)}` : "Jeszcze nie zsynchronizowano"}
|
||||
{lastSyncAt
|
||||
? `Ostatnia synchronizacja: ${formatDate(lastSyncAt)}`
|
||||
: "Jeszcze nie zsynchronizowano"}
|
||||
</span>
|
||||
<SyncButton />
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { formatDate, formatDateShort } from "@/lib/format";
|
||||
import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis";
|
||||
import { getStrengthWorkout, listStrengthWorkouts } from "@/lib/models/strength";
|
||||
import { exerciseE1rm, getExerciseHistory } from "@/lib/strength/stats";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -17,14 +18,15 @@ export default async function StrengthWorkoutPage({
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const workout = await getStrengthWorkout(id);
|
||||
const userId = await getCurrentUserId();
|
||||
const workout = await getStrengthWorkout(userId, id);
|
||||
|
||||
if (!workout) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const analysis = await getLatestAnalysisForTarget("strength", workout._id);
|
||||
const allWorkouts = await listStrengthWorkouts();
|
||||
const analysis = await getLatestAnalysisForTarget(userId, "strength", workout._id);
|
||||
const allWorkouts = await listStrengthWorkouts(userId);
|
||||
const pastWorkouts = allWorkouts.filter((w) => w.date <= workout.date);
|
||||
|
||||
const exercisesWithHistory = workout.exercises.map((exercise) => ({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { parseStrongShareText } from "@/lib/strong/parser";
|
||||
import { upsertStrengthWorkout } from "@/lib/models/strength";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export type ImportStrongWorkoutState = { error: string } | null;
|
||||
|
||||
@@ -27,8 +28,9 @@ export async function importStrongWorkout(
|
||||
return { error: "Nie znaleziono żadnego treningu w podanym tekście." };
|
||||
}
|
||||
|
||||
const userId = await getCurrentUserId();
|
||||
for (const workout of workouts) {
|
||||
await upsertStrengthWorkout(workout);
|
||||
await upsertStrengthWorkout(userId, workout);
|
||||
}
|
||||
|
||||
revalidatePath("/strength");
|
||||
|
||||
@@ -5,13 +5,15 @@ import { VolumeChart } from "@/components/volume-chart";
|
||||
import { formatDateShort } from "@/lib/format";
|
||||
import { listStrengthWorkouts } from "@/lib/models/strength";
|
||||
import { workoutVolumeKg } from "@/lib/strength/stats";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const VOLUME_CHART_LIMIT = 12;
|
||||
|
||||
export default async function StrengthPage() {
|
||||
const workouts = await listStrengthWorkouts();
|
||||
const userId = await getCurrentUserId();
|
||||
const workouts = await listStrengthWorkouts(userId);
|
||||
|
||||
const volumeData = workouts
|
||||
.slice(0, VOLUME_CHART_LIMIT)
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
141
components/elevation-chart.tsx
Normal file
141
components/elevation-chart.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"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[];
|
||||
syncId?: string;
|
||||
};
|
||||
|
||||
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, syncId }: 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="w-full 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 syncId={syncId} 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>
|
||||
);
|
||||
}
|
||||
111
components/gcb-chart.tsx
Normal file
111
components/gcb-chart.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"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[];
|
||||
syncId?: string;
|
||||
};
|
||||
|
||||
export function GcbChart({ data, syncId }: 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 syncId={syncId} 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 { Activity, Dumbbell, LayoutDashboard, Settings } from "lucide-react";
|
||||
import { auth, signOut } from "@/auth";
|
||||
import { SignOutButton } from "./sign-out-button";
|
||||
|
||||
const links = [
|
||||
{ href: "/", label: "Panel", icon: LayoutDashboard },
|
||||
@@ -8,7 +10,14 @@ const links = [
|
||||
{ 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 (
|
||||
<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">
|
||||
@@ -39,6 +48,12 @@ export function Nav() {
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</Link>
|
||||
))}
|
||||
{session?.user?.name && (
|
||||
<span className="hidden px-2 text-xs text-fg/40 sm:inline">
|
||||
{session.user.name}
|
||||
</span>
|
||||
)}
|
||||
<SignOutButton action={signOutAction} />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
122
components/run-metric-chart.tsx
Normal file
122
components/run-metric-chart.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"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;
|
||||
syncId?: string;
|
||||
};
|
||||
|
||||
export function RunMetricChart({
|
||||
data,
|
||||
label,
|
||||
unit,
|
||||
color = "var(--color-accent)",
|
||||
referenceLine,
|
||||
decimals = 0,
|
||||
format,
|
||||
reversed = false,
|
||||
syncId,
|
||||
}: 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 syncId={syncId} 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 { fetchRecentWellness, type DayWellness } from "@/lib/garmin/wellness";
|
||||
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 {
|
||||
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 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 {
|
||||
const lines = [
|
||||
`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.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) {
|
||||
lines.push(``, `Poprzednie biegi (od najnowszego):`);
|
||||
for (const { run, analysis } of previousRuns) {
|
||||
@@ -133,6 +195,7 @@ function parseAnalysisResponse(text: string): { summary: string; tips: string[]
|
||||
}
|
||||
|
||||
export async function generateAnalysis(
|
||||
userId: string,
|
||||
targetType: AiAnalysisTargetType,
|
||||
targetId: string
|
||||
): Promise<AiAnalysis> {
|
||||
@@ -143,28 +206,28 @@ export async function generateAnalysis(
|
||||
|
||||
let prompt: string;
|
||||
if (targetType === "running") {
|
||||
const activity = await getRunningActivity(targetId);
|
||||
const activity = await getRunningActivity(userId, targetId);
|
||||
if (!activity) throw new Error("Nie znaleziono biegu.");
|
||||
const previousRuns = (await listRunningActivities())
|
||||
const previousRuns = (await listRunningActivities(userId))
|
||||
.filter((run) => run.startTime < activity.startTime)
|
||||
.slice(0, PREVIOUS_RUNS_LIMIT);
|
||||
const previousRunsWithAnalysis: PreviousRun[] = await Promise.all(
|
||||
previousRuns.map(async (run) => ({
|
||||
run,
|
||||
analysis: await getLatestAnalysisForTarget("running", run._id),
|
||||
analysis: await getLatestAnalysisForTarget(userId, "running", run._id),
|
||||
}))
|
||||
);
|
||||
prompt = buildRunningPrompt(activity, previousRunsWithAnalysis);
|
||||
} else {
|
||||
const workout = await getStrengthWorkout(targetId);
|
||||
const workout = await getStrengthWorkout(userId, targetId);
|
||||
if (!workout) throw new Error("Nie znaleziono treningu.");
|
||||
const previousWorkouts = (await listStrengthWorkouts())
|
||||
const previousWorkouts = (await listStrengthWorkouts(userId))
|
||||
.filter((previous) => previous.date < workout.date)
|
||||
.slice(0, PREVIOUS_WORKOUTS_LIMIT);
|
||||
const previousWorkoutsWithAnalysis: PreviousWorkout[] = await Promise.all(
|
||||
previousWorkouts.map(async (previous) => ({
|
||||
workout: previous,
|
||||
analysis: await getLatestAnalysisForTarget("strength", previous._id),
|
||||
analysis: await getLatestAnalysisForTarget(userId, "strength", previous._id),
|
||||
}))
|
||||
);
|
||||
prompt = buildStrengthPrompt(workout, previousWorkoutsWithAnalysis);
|
||||
@@ -183,6 +246,7 @@ export async function generateAnalysis(
|
||||
const { summary, tips } = parseAnalysisResponse(text);
|
||||
|
||||
return saveAiAnalysis({
|
||||
userId,
|
||||
targetType,
|
||||
targetId: new ObjectId(targetId),
|
||||
summary,
|
||||
@@ -281,18 +345,18 @@ function buildDashboardPrompt(
|
||||
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;
|
||||
if (!apiKey) throw new Error("Brak klucza ANTHROPIC_API_KEY w konfiguracji.");
|
||||
|
||||
const [runs, workouts] = await Promise.all([
|
||||
listRunningActivities().then((r) => r.slice(0, DASHBOARD_RUNS_LIMIT)),
|
||||
listStrengthWorkouts().then((w) => w.slice(0, DASHBOARD_WORKOUTS_LIMIT)),
|
||||
listRunningActivities(userId).then((r) => r.slice(0, DASHBOARD_RUNS_LIMIT)),
|
||||
listStrengthWorkouts(userId).then((w) => w.slice(0, DASHBOARD_WORKOUTS_LIMIT)),
|
||||
]);
|
||||
|
||||
let wellness: DayWellness[] = [];
|
||||
try {
|
||||
const garminClient = await getAuthorizedClient();
|
||||
const garminClient = await getAuthorizedClient(userId);
|
||||
wellness = await fetchRecentWellness(garminClient, DASHBOARD_WELLNESS_DAYS);
|
||||
} catch {
|
||||
// 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 text = textBlock && textBlock.type === "text" ? textBlock.text : "";
|
||||
const { summary, tips } = parseAnalysisResponse(text);
|
||||
return saveDashboardAnalysis(summary, tips, model);
|
||||
return saveDashboardAnalysis(userId, summary, tips, model);
|
||||
}
|
||||
|
||||
33
lib/crypto.ts
Normal file
33
lib/crypto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_BYTES = 12;
|
||||
const TAG_BYTES = 16;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const hex = process.env.GARMIN_ENCRYPTION_KEY;
|
||||
if (!hex || hex.length !== 64) {
|
||||
throw new Error("Brak lub nieprawidłowy GARMIN_ENCRYPTION_KEY w konfiguracji.");
|
||||
}
|
||||
return Buffer.from(hex, "hex");
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string): string {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(IV_BYTES);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
// Format: iv(hex):tag(hex):ciphertext(hex)
|
||||
return `${iv.toString("hex")}:${tag.toString("hex")}:${encrypted.toString("hex")}`;
|
||||
}
|
||||
|
||||
export function decrypt(stored: string): string {
|
||||
const parts = stored.split(":");
|
||||
if (parts.length !== 3) throw new Error("Nieprawidłowy format zaszyfrowanego hasła.");
|
||||
const [ivHex, tagHex, dataHex] = parts;
|
||||
const key = getKey();
|
||||
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, "hex"));
|
||||
decipher.setAuthTag(Buffer.from(tagHex, "hex"));
|
||||
return decipher.update(Buffer.from(dataHex, "hex")).toString("utf8") + decipher.final("utf8");
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { GarminConnect } from "garmin-connect";
|
||||
import type { IActivity } from "garmin-connect/dist/garmin/types/activity";
|
||||
import type { IOauth1Token } from "garmin-connect/dist/garmin/types";
|
||||
import type { RoutePoint, RunningActivityInput } from "@/lib/models/running";
|
||||
import { getSavedOauth1Token } from "@/lib/models/garmin-auth";
|
||||
import type { RoutePoint, RunMetrics, RunningActivityInput } from "@/lib/models/running";
|
||||
import { getGarminCredentials, getSavedOauth1Token } from "@/lib/models/garmin-auth";
|
||||
import { completeMfaAndGetTicket, loginAndGetTicket, type GarminPendingMfa } from "@/lib/garmin/sso";
|
||||
|
||||
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 {
|
||||
return new Date(`${value.replace(" ", "T")}Z`);
|
||||
}
|
||||
@@ -61,13 +67,14 @@ function mapActivity(activity: IActivity): RunningActivityInput {
|
||||
|
||||
const GC_API = "https://connectapi.garmin.com";
|
||||
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 = {
|
||||
geoPolylineDTO?: { polyline?: GarminPolylinePoint[] };
|
||||
};
|
||||
|
||||
export async function fetchActivityRoutePoints(
|
||||
async function fetchPolyline(
|
||||
client: GarminConnect,
|
||||
garminActivityId: number
|
||||
): Promise<RoutePoint[] | null> {
|
||||
@@ -78,13 +85,111 @@ export async function fetchActivityRoutePoints(
|
||||
return polyline.map((p) => [p.lat, p.lon] as RoutePoint);
|
||||
}
|
||||
|
||||
function getCredentials(): { username: string; password: string } {
|
||||
const username = process.env.GARMIN_EMAIL;
|
||||
const password = process.env.GARMIN_PASSWORD;
|
||||
if (!username || !password) {
|
||||
throw new Error("Brak danych logowania do Garmin Connect (GARMIN_EMAIL / GARMIN_PASSWORD).");
|
||||
type GpxPoint = { lat: number; lon: number; ele: number };
|
||||
|
||||
async function fetchGpxPoints(
|
||||
client: GarminConnect,
|
||||
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> {
|
||||
@@ -102,12 +207,8 @@ async function exchangeOauth1Token(client: GarminConnect, oauth1Token: IOauth1To
|
||||
await http.exchange({ oauth, token: oauth1Token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a client authenticated using a previously saved OAuth1 token
|
||||
* (long-lived, survives across syncs) - no MFA needed if it's still valid.
|
||||
*/
|
||||
export async function getAuthorizedClient(): Promise<GarminConnect> {
|
||||
const saved = await getSavedOauth1Token();
|
||||
export async function getAuthorizedClient(userId: string): Promise<GarminConnect> {
|
||||
const saved = await getSavedOauth1Token(userId);
|
||||
if (!saved) {
|
||||
throw new GarminLoginRequiredError();
|
||||
}
|
||||
@@ -129,16 +230,16 @@ async function establishClientFromTicket(ticket: string): Promise<{ client: Garm
|
||||
return { client, oauth1Token: oauth1.token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a fresh SSO login using env credentials. If the account requires
|
||||
* MFA, returns the pending state needed to complete it via
|
||||
* `completeGarminMfaLogin` once the user supplies the emailed code.
|
||||
*/
|
||||
export async function beginGarminLogin(): Promise<
|
||||
{ client: GarminConnect; oauth1Token: IOauth1Token } | { mfaRequired: true; pendingState: GarminPendingMfa }
|
||||
export async function beginGarminLogin(
|
||||
userId: string
|
||||
): Promise<
|
||||
| { client: GarminConnect; oauth1Token: IOauth1Token }
|
||||
| { mfaRequired: true; pendingState: GarminPendingMfa }
|
||||
> {
|
||||
const { username, password } = getCredentials();
|
||||
const result = await loginAndGetTicket(username, password);
|
||||
const creds = await getGarminCredentials(userId);
|
||||
if (!creds) throw new GarminCredentialsMissingError();
|
||||
|
||||
const result = await loginAndGetTicket(creds.email, creds.password);
|
||||
if ("mfaRequired" in result) return result;
|
||||
return establishClientFromTicket(result.ticket);
|
||||
}
|
||||
@@ -151,14 +252,7 @@ export async function completeGarminMfaLogin(
|
||||
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[]> {
|
||||
const activities = await client.getActivities(0, FETCH_LIMIT);
|
||||
|
||||
return activities.filter(isRunningActivity).map(mapActivity);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getDb } from "@/lib/db";
|
||||
export type AiAnalysisTargetType = "running" | "strength" | "dashboard";
|
||||
|
||||
export type AiAnalysisInput = {
|
||||
userId: string;
|
||||
targetType: AiAnalysisTargetType;
|
||||
targetId: ObjectId;
|
||||
summary: string;
|
||||
@@ -40,6 +41,8 @@ export function serializeAnalysis(analysis: AiAnalysis): SerializedAiAnalysis {
|
||||
|
||||
const COLLECTION = "ai_analyses";
|
||||
|
||||
const DASHBOARD_TARGET_ID = new ObjectId("000000000000000000000001");
|
||||
|
||||
async function getCollection() {
|
||||
const db = await getDb();
|
||||
return db.collection<AiAnalysis>(COLLECTION);
|
||||
@@ -53,32 +56,34 @@ export async function saveAiAnalysis(input: AiAnalysisInput): Promise<AiAnalysis
|
||||
}
|
||||
|
||||
export async function getLatestAnalysisForTarget(
|
||||
userId: string,
|
||||
targetType: AiAnalysisTargetType,
|
||||
targetId: ObjectId
|
||||
): Promise<AiAnalysis | null> {
|
||||
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> {
|
||||
const collection = await getCollection();
|
||||
return collection.findOne({}, { sort: { createdAt: -1 } });
|
||||
}
|
||||
|
||||
const DASHBOARD_TARGET_ID = new ObjectId("000000000000000000000001");
|
||||
|
||||
export async function getDashboardAnalysis(): Promise<AiAnalysis | null> {
|
||||
export async function getDashboardAnalysis(userId: string): Promise<AiAnalysis | null> {
|
||||
const collection = await getCollection();
|
||||
return collection.findOne(
|
||||
{ targetType: "dashboard", targetId: DASHBOARD_TARGET_ID },
|
||||
{ userId, targetType: "dashboard", targetId: DASHBOARD_TARGET_ID },
|
||||
{ sort: { createdAt: -1 } }
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveDashboardAnalysis(
|
||||
userId: string,
|
||||
summary: string,
|
||||
tips: string[],
|
||||
model: string
|
||||
): 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,40 +1,65 @@
|
||||
import type { IOauth1Token } from "garmin-connect/dist/garmin/types";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import type { GarminPendingMfa } from "@/lib/garmin/sso";
|
||||
|
||||
const AUTH_COLLECTION = "garmin_auth";
|
||||
const PENDING_COLLECTION = "garmin_login_pending";
|
||||
const CREDENTIALS_COLLECTION = "garmin_credentials";
|
||||
|
||||
type GarminAuthDoc = { _id: "tokens"; oauth1Token: IOauth1Token; updatedAt: Date };
|
||||
type GarminPendingDoc = { _id: "pending"; state: GarminPendingMfa; createdAt: Date };
|
||||
type GarminAuthDoc = { _id: string; oauth1Token: IOauth1Token; updatedAt: 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 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;
|
||||
}
|
||||
|
||||
export async function saveOauth1Token(oauth1Token: IOauth1Token): Promise<void> {
|
||||
export async function saveOauth1Token(userId: string, oauth1Token: IOauth1Token): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db
|
||||
.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();
|
||||
await db
|
||||
.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 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;
|
||||
}
|
||||
|
||||
export async function clearPendingMfaState(): Promise<void> {
|
||||
export async function clearPendingMfaState(userId: string): Promise<void> {
|
||||
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: decrypt(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: encrypt(password) } }, { upsert: true });
|
||||
}
|
||||
|
||||
@@ -34,10 +34,22 @@ export type RunningActivityInput = z.infer<typeof runningActivitySchema>;
|
||||
|
||||
export type RoutePoint = [number, number];
|
||||
|
||||
export type RunMetrics = {
|
||||
distanceKm: number[];
|
||||
hrBpm?: number[];
|
||||
cadenceSpm?: number[];
|
||||
gctMs?: number[];
|
||||
gcbLeftPct?: number[];
|
||||
paceSec?: number[];
|
||||
};
|
||||
|
||||
export type RunningActivity = RunningActivityInput & {
|
||||
_id: ObjectId;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
routePoints?: RoutePoint[];
|
||||
elevationProfile?: number[];
|
||||
runMetrics?: RunMetrics;
|
||||
};
|
||||
|
||||
const COLLECTION = "running_activities";
|
||||
@@ -46,51 +58,73 @@ const SYNC_STATE_COLLECTION = "sync_state";
|
||||
async function getCollection() {
|
||||
const db = await getDb();
|
||||
const collection = db.collection<RunningActivity>(COLLECTION);
|
||||
await collection.createIndex({ garminActivityId: 1 }, { unique: true });
|
||||
await collection.createIndex({ userId: 1, garminActivityId: 1 }, { unique: true });
|
||||
return collection;
|
||||
}
|
||||
|
||||
export async function upsertRunningActivity(activity: RunningActivityInput): Promise<void> {
|
||||
export async function upsertRunningActivity(
|
||||
userId: string,
|
||||
activity: RunningActivityInput
|
||||
): Promise<void> {
|
||||
const collection = await getCollection();
|
||||
await collection.updateOne(
|
||||
{ garminActivityId: activity.garminActivityId },
|
||||
{ userId, garminActivityId: activity.garminActivityId },
|
||||
{
|
||||
$set: activity,
|
||||
$set: { ...activity, userId },
|
||||
$setOnInsert: { createdAt: new Date() },
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
}
|
||||
|
||||
export async function listRunningActivities(): Promise<RunningActivity[]> {
|
||||
export async function listRunningActivities(userId: string): Promise<RunningActivity[]> {
|
||||
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();
|
||||
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(
|
||||
userId: string,
|
||||
garminActivityId: number,
|
||||
points: RoutePoint[]
|
||||
points: RoutePoint[],
|
||||
elevationProfile: number[]
|
||||
): Promise<void> {
|
||||
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 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;
|
||||
}
|
||||
|
||||
export async function setLastSyncAt(date: Date): Promise<void> {
|
||||
export async function setLastSyncAt(userId: string, date: Date): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db
|
||||
.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 & {
|
||||
_id: ObjectId;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
@@ -37,30 +38,34 @@ const COLLECTION = "strength_workouts";
|
||||
async function getCollection() {
|
||||
const db = await getDb();
|
||||
const collection = db.collection<StrengthWorkout>(COLLECTION);
|
||||
await collection.createIndex({ sourceKey: 1 }, { unique: true });
|
||||
await collection.createIndex({ userId: 1, sourceKey: 1 }, { unique: true });
|
||||
return collection;
|
||||
}
|
||||
|
||||
export async function upsertStrengthWorkout(
|
||||
userId: string,
|
||||
workout: StrengthWorkoutInput
|
||||
): Promise<void> {
|
||||
const collection = await getCollection();
|
||||
await collection.updateOne(
|
||||
{ sourceKey: workout.sourceKey },
|
||||
{ userId, sourceKey: workout.sourceKey },
|
||||
{
|
||||
$set: workout,
|
||||
$set: { ...workout, userId },
|
||||
$setOnInsert: { createdAt: new Date() },
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
}
|
||||
|
||||
export async function listStrengthWorkouts(): Promise<StrengthWorkout[]> {
|
||||
export async function listStrengthWorkouts(userId: string): Promise<StrengthWorkout[]> {
|
||||
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();
|
||||
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",
|
||||
"mongodb": "^7.3.0",
|
||||
"next": "16.2.9",
|
||||
"next-auth": "5.0.0-beta.31",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-leaflet": "^5.0.0",
|
||||
|
||||
76
pnpm-lock.yaml
generated
76
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
||||
next:
|
||||
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)
|
||||
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:
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4
|
||||
@@ -91,6 +94,20 @@ packages:
|
||||
zod:
|
||||
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':
|
||||
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -464,6 +481,9 @@ packages:
|
||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||
engines: {node: '>=12.4.0'}
|
||||
|
||||
'@panva/hkdf@1.2.1':
|
||||
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
||||
|
||||
'@react-leaflet/core@3.0.0':
|
||||
resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
|
||||
peerDependencies:
|
||||
@@ -1598,6 +1618,9 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.2.3:
|
||||
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -1832,6 +1855,22 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
@@ -1864,6 +1903,9 @@ packages:
|
||||
oauth-1.0a@2.2.6:
|
||||
resolution: {integrity: sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==}
|
||||
|
||||
oauth4webapi@3.8.6:
|
||||
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1950,6 +1992,14 @@ packages:
|
||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||
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:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -2338,6 +2388,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
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':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
@@ -2694,6 +2752,8 @@ snapshots:
|
||||
|
||||
'@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)':
|
||||
dependencies:
|
||||
leaflet: 1.9.4
|
||||
@@ -3969,6 +4029,8 @@ snapshots:
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
jose@6.2.3: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.2.0:
|
||||
@@ -4139,6 +4201,12 @@ snapshots:
|
||||
|
||||
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):
|
||||
dependencies:
|
||||
'@next/env': 16.2.9
|
||||
@@ -4174,6 +4242,8 @@ snapshots:
|
||||
|
||||
oauth-1.0a@2.2.6: {}
|
||||
|
||||
oauth4webapi@3.8.6: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
@@ -4269,6 +4339,12 @@ snapshots:
|
||||
picocolors: 1.1.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: {}
|
||||
|
||||
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