This commit is contained in:
Dominik Klarkowski
2026-06-18 11:02:31 +02:00
parent d00a5a42ac
commit 047e580da0
32 changed files with 735 additions and 189 deletions

17
.idea/dataSources.xml generated Normal file
View 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>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-tree-configuration">
<option name="data" value="----------------------------------------&#10;1:0:cad974ec-67a1-45af-aa5e-0f2afd65b89b&#10;2:0:25e55397-437c-4c68-99f5-2d21fbba46d2&#10;3:0:d401f651-e4ab-4ff5-9655-e2b6c0b396dc&#10;4:0:ec87ba30-ade3-4e14-8f35-5c06f6cf8662&#10;5:0:e9fca1ad-1d37-4a8c-a7b4-95d6a736484c&#10;6:0:c4b8376d-a287-4fa4-bc44-2d2fac1cd37b&#10;7:0:66be42c8-917f-4bb8-ae8d-4016f762e870&#10;8:0:0b74125a-9a07-4cfa-afd4-c5fccf1496b4&#10;9:0:07d79dcf-bacf-49b0-936e-5cb4c251b3e1&#10;10:0:986e8611-b865-4ec3-8cb6-92fc42c283a7&#10;11:0:70ccb5fc-6aef-4cd5-9f4b-ccbea8e185c6&#10;12:0:d2358406-bd5f-4030-a822-5a1c2f653b55&#10;13:0:52d85314-8e9f-4e42-bd9d-f146df941871&#10;14:0:07d72045-e35c-4264-b612-a64c58c635d7&#10;15:0:c05a40be-4e30-4606-bc06-511d9b109dbb&#10;16:0:810ba5f7-c9cc-4010-a3c3-195abacabb8e&#10;17:0:5da9a988-52c5-4bcf-b758-1677ab67bf26&#10;18:0:7d0a6ab5-b6df-4898-afec-cad19b908728&#10;19:0:bd763ebe-280e-49f8-a248-fcd7c4c8d712&#10;20:0:233739c2-121e-45b9-be8b-613eadecd69f&#10;21:0:e8230f4d-40da-406b-a7ca-bf59ada3230a&#10;22:0:02b1616a-5718-42d3-a31e-22f9f32e5333&#10;23:0:1e611642-8fd8-44d0-876b-9c8c2b4ba00c&#10;" />
<option name="data" value="----------------------------------------&#10;1:0:cad974ec-67a1-45af-aa5e-0f2afd65b89b&#10;2:0:25e55397-437c-4c68-99f5-2d21fbba46d2&#10;3:0:d401f651-e4ab-4ff5-9655-e2b6c0b396dc&#10;4:0:ec87ba30-ade3-4e14-8f35-5c06f6cf8662&#10;5:0:e9fca1ad-1d37-4a8c-a7b4-95d6a736484c&#10;6:0:c4b8376d-a287-4fa4-bc44-2d2fac1cd37b&#10;7:0:66be42c8-917f-4bb8-ae8d-4016f762e870&#10;8:0:0b74125a-9a07-4cfa-afd4-c5fccf1496b4&#10;9:0:07d79dcf-bacf-49b0-936e-5cb4c251b3e1&#10;10:0:986e8611-b865-4ec3-8cb6-92fc42c283a7&#10;11:0:70ccb5fc-6aef-4cd5-9f4b-ccbea8e185c6&#10;12:0:d2358406-bd5f-4030-a822-5a1c2f653b55&#10;13:0:52d85314-8e9f-4e42-bd9d-f146df941871&#10;14:0:07d72045-e35c-4264-b612-a64c58c635d7&#10;15:0:c05a40be-4e30-4606-bc06-511d9b109dbb&#10;16:0:810ba5f7-c9cc-4010-a3c3-195abacabb8e&#10;17:0:5da9a988-52c5-4bcf-b758-1677ab67bf26&#10;18:0:7d0a6ab5-b6df-4898-afec-cad19b908728&#10;19:0:bd763ebe-280e-49f8-a248-fcd7c4c8d712&#10;20:0:233739c2-121e-45b9-be8b-613eadecd69f&#10;21:0:e8230f4d-40da-406b-a7ca-bf59ada3230a&#10;22:0:02b1616a-5718-42d3-a31e-22f9f32e5333&#10;23:0:1e611642-8fd8-44d0-876b-9c8c2b4ba00c&#10;24:0:42f0cf9c-c76e-4f63-bced-ee4de72b5936&#10;" />
</component>
</project>

View File

@@ -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." };
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View File

@@ -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
View 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>
);
}

View File

@@ -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 });

View File

@@ -20,24 +20,29 @@ import {
type RunMetrics,
type RunningActivity,
} from "@/lib/models/running";
import { getCurrentUserId } from "@/lib/session";
export const dynamic = "force-dynamic";
// hasRoute may be undefined for activities synced before the field was added,
// even if routePoints are already in the DB.
function mayHaveRoute(activity: RunningActivity): boolean {
return Boolean(activity.hasRoute) || Boolean(activity.routePoints?.length);
}
async function RouteMapFetcher({ activity }: { activity: RunningActivity }) {
async function RouteMapFetcher({
activity,
userId,
}: {
activity: RunningActivity;
userId: string;
}) {
let routePoints = activity.routePoints;
if ((!routePoints || !activity.elevationProfile) && mayHaveRoute(activity)) {
try {
const client = await getAuthorizedClient();
const client = await getAuthorizedClient(userId);
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
if (result) {
await setRunningActivityRoutePoints(activity.garminActivityId, result.points, result.elevationProfile);
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
routePoints = result.points;
}
} catch {
@@ -62,15 +67,21 @@ function hasValidElevation(profile: number[] | undefined): boolean {
return Array.isArray(profile) && profile.some((v) => v > 0);
}
async function ElevationFetcher({ activity }: { activity: RunningActivity }) {
async function ElevationFetcher({
activity,
userId,
}: {
activity: RunningActivity;
userId: string;
}) {
let elevationProfile = activity.elevationProfile;
if (!hasValidElevation(elevationProfile) && mayHaveRoute(activity)) {
try {
const client = await getAuthorizedClient();
const client = await getAuthorizedClient(userId);
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
if (result) {
await setRunningActivityRoutePoints(activity.garminActivityId, result.points, result.elevationProfile);
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
elevationProfile = result.elevationProfile;
}
} catch {
@@ -80,14 +91,23 @@ async function ElevationFetcher({ activity }: { activity: RunningActivity }) {
if (!elevationProfile || elevationProfile.length < 2) return null;
const data = elevationProfile
const elevData = elevationProfile
.map((altM, i) => ({
distanceKm: Math.round((i / elevationProfile!.length) * activity.distanceM / 10) / 100,
altM,
}))
.filter((p) => p.altM > 0);
if (data.length < 2) return null;
if (elevData.length < 2) return null;
// Merge pace by fractional position (both arrays span the same run, different sample counts)
const paceSrc = activity.runMetrics?.paceSec;
const data = elevData.map((ep, i) => {
if (!paceSrc || paceSrc.length === 0) return ep;
const pi = Math.min(Math.round((i / elevData.length) * paceSrc.length), paceSrc.length - 1);
const v = paceSrc[pi];
return { ...ep, paceSec: v > 0 && v < 1800 ? v : undefined };
});
return <ElevationChart data={data} />;
}
@@ -102,18 +122,27 @@ function toChartData(
.filter((p) => p.value > 0);
}
async function RunMetricsFetcher({ activity }: { activity: RunningActivity }) {
async function RunMetricsFetcher({
activity,
userId,
}: {
activity: RunningActivity;
userId: string;
}) {
let metrics: RunMetrics | undefined = activity.runMetrics;
const missingCadence = activity.avgCadence && !metrics?.cadenceSpm;
const missingGcb = activity.avgGroundContactBalanceLeftPct && !metrics?.gcbLeftPct;
// Re-fetch if paceSec missing or was computed from integer-rounded speeds (< 15 unique values = rounding artifact)
const validPace = metrics?.paceSec?.filter((v) => v > 0) ?? [];
const missingPace = validPace.length === 0 || new Set(validPace).size < 15;
if ((!metrics || missingCadence || missingGcb) && mayHaveRoute(activity)) {
if ((!metrics || missingCadence || missingGcb || missingPace) && mayHaveRoute(activity)) {
try {
const client = await getAuthorizedClient();
const client = await getAuthorizedClient(userId);
const fetched = await fetchActivityRunMetrics(client, activity.garminActivityId);
if (fetched) {
await setRunningActivityMetrics(activity.garminActivityId, fetched);
await setRunningActivityMetrics(userId, activity.garminActivityId, fetched);
metrics = fetched;
}
} catch {
@@ -125,7 +154,6 @@ async function RunMetricsFetcher({ activity }: { activity: RunningActivity }) {
const { hrBpm, gcbLeftPct } = metrics;
// Fall back to evenly-spaced distances if Garmin didn't provide them
const maxDist = Math.max(...metrics.distanceKm);
const distanceKm =
maxDist > 0
@@ -151,12 +179,7 @@ async function RunMetricsFetcher({ activity }: { activity: RunningActivity }) {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{hrData.length > 1 && (
<RunMetricChart
data={hrData}
label="Tętno"
unit="bpm"
color="var(--color-accent)"
/>
<RunMetricChart data={hrData} label="Tętno" unit="bpm" color="var(--color-accent)" />
)}
{gcbData.length > 1 && <GcbChart data={gcbData} />}
</div>
@@ -175,13 +198,14 @@ export default async function RunningActivityPage({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const activity = await getRunningActivity(id);
const userId = await getCurrentUserId();
const activity = await getRunningActivity(userId, id);
if (!activity) {
notFound();
}
const analysis = await getLatestAnalysisForTarget("running", activity._id);
const analysis = await getLatestAnalysisForTarget(userId, "running", activity._id);
return (
<div className="flex flex-col gap-6">
@@ -192,7 +216,7 @@ export default async function RunningActivityPage({
<section className="grid grid-cols-3 gap-4">
<Suspense fallback={<MapSkeleton />}>
<RouteMapFetcher activity={activity} />
<RouteMapFetcher activity={activity} userId={userId} />
</Suspense>
<StatCard highlight label="Dystans" value={formatDistance(activity.distanceM)} />
@@ -238,14 +262,18 @@ export default async function RunningActivityPage({
</section>
<Suspense fallback={null}>
<ElevationFetcher activity={activity} />
<ElevationFetcher activity={activity} userId={userId} />
</Suspense>
<Suspense fallback={null}>
<RunMetricsFetcher activity={activity} />
<RunMetricsFetcher activity={activity} userId={userId} />
</Suspense>
<AiAnalysisCard targetType="running" targetId={activity._id.toString()} analysis={analysis ? serializeAnalysis(analysis) : null} />
<AiAnalysisCard
targetType="running"
targetId={activity._id.toString()}
analysis={analysis ? serializeAnalysis(analysis) : null}
/>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import type { GarminConnect } from "garmin-connect";
import {
GarminLoginRequiredError,
GarminCredentialsMissingError,
beginGarminLogin,
completeGarminMfaLogin,
fetchActivityRoutePoints,
@@ -23,19 +24,20 @@ import {
saveOauth1Token,
savePendingMfaState,
} from "@/lib/models/garmin-auth";
import { getCurrentUserId } from "@/lib/session";
export type SyncGarminState = { error: string } | { success: string } | { mfaRequired: true } | null;
async function syncWithClient(client: GarminConnect): Promise<SyncGarminState> {
const since = await getLastSyncAt();
async function syncWithClient(userId: string, client: GarminConnect): Promise<SyncGarminState> {
const since = await getLastSyncAt(userId);
const activities = await fetchRunningActivities(client);
const newCount = activities.filter((activity) => !since || activity.startTime > since).length;
for (const activity of activities) {
await upsertRunningActivity(activity);
await upsertRunningActivity(userId, activity);
}
await setLastSyncAt(new Date());
await setLastSyncAt(userId, new Date());
revalidatePath("/running");
revalidatePath("/settings");
@@ -45,39 +47,45 @@ async function syncWithClient(client: GarminConnect): Promise<SyncGarminState> {
}
export async function syncGarminActivities(): Promise<SyncGarminState> {
const userId = await getCurrentUserId();
try {
const client = await getAuthorizedClient();
return await syncWithClient(client);
const client = await getAuthorizedClient(userId);
return await syncWithClient(userId, client);
} catch (error) {
if (error instanceof GarminCredentialsMissingError) {
return { error: error.message };
}
if (!(error instanceof GarminLoginRequiredError)) {
return { error: error instanceof Error ? error.message : "Synchronizacja z Garmin nie powiodła się." };
}
}
try {
const result = await beginGarminLogin();
const result = await beginGarminLogin(userId);
if ("mfaRequired" in result) {
await savePendingMfaState(result.pendingState);
await savePendingMfaState(userId, result.pendingState);
return { mfaRequired: true };
}
await saveOauth1Token(result.oauth1Token);
return await syncWithClient(result.client);
await saveOauth1Token(userId, result.oauth1Token);
return await syncWithClient(userId, result.client);
} catch (error) {
return { error: error instanceof Error ? error.message : "Logowanie do Garmin nie powiodło się." };
}
}
export async function submitGarminMfaCode(code: string): Promise<SyncGarminState> {
const pending = await getPendingMfaState();
const userId = await getCurrentUserId();
const pending = await getPendingMfaState(userId);
if (!pending) {
return { error: "Sesja logowania do Garmin wygasła. Kliknij \"Synchronizuj z Garmin\" ponownie." };
}
try {
const result = await completeGarminMfaLogin(pending, code);
await saveOauth1Token(result.oauth1Token);
await clearPendingMfaState();
return await syncWithClient(result.client);
await saveOauth1Token(userId, result.oauth1Token);
await clearPendingMfaState(userId);
return await syncWithClient(userId, result.client);
} catch (error) {
return { error: error instanceof Error ? error.message : "Weryfikacja kodu MFA nie powiodła się." };
}
@@ -86,12 +94,13 @@ export async function submitGarminMfaCode(code: string): Promise<SyncGarminState
export type LoadRouteState = { error: string } | { success: true } | null;
export async function loadActivityRoute(activityMongoId: string): Promise<LoadRouteState> {
const activity = await getRunningActivity(activityMongoId);
const userId = await getCurrentUserId();
const activity = await getRunningActivity(userId, activityMongoId);
if (!activity) return { error: "Nie znaleziono aktywności." };
let client: GarminConnect;
try {
client = await getAuthorizedClient();
client = await getAuthorizedClient(userId);
} catch {
return { error: "Brak połączenia z Garmin Connect. Wykonaj synchronizację." };
}
@@ -99,7 +108,7 @@ export async function loadActivityRoute(activityMongoId: string): Promise<LoadRo
try {
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
if (!result) return { error: "Brak danych GPS dla tej aktywności." };
await setRunningActivityRoutePoints(activity.garminActivityId, result.points, result.elevationProfile);
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
revalidatePath(`/running/${activityMongoId}`);
return { success: true };
} catch (error) {

View File

@@ -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
View 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 };
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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) => ({

View File

@@ -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");

View File

@@ -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
View 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",
},
});

View File

@@ -1,13 +1,31 @@
"use client";
import { useEffect, useState } from "react";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
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: { distanceKm: number; altM: number }[];
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), []);
@@ -18,15 +36,37 @@ export function ElevationChart({ data }: Props) {
const altitudes = data.map((p) => p.altM);
const minAlt = Math.min(...altitudes);
const maxAlt = Math.max(...altitudes);
const pad = Math.max(5, Math.round((maxAlt - minAlt) * 0.15));
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 text-sm text-fg/60">Profil wysokości</div>
<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}>
<AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<ComposedChart data={data} margin={{ top: 4, right: hasPace ? 52 : 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="elevGradient" x1="0" y1="0" x2="0" y2="1">
<linearGradient id={`elev-${uid}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-accent)" stopOpacity={0.25} />
<stop offset="95%" stopColor="var(--color-accent)" stopOpacity={0} />
</linearGradient>
@@ -41,33 +81,59 @@ export function ElevationChart({ data }: Props) {
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 - pad, maxAlt + pad]}
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={{
background: "var(--color-bg)",
border: "1px solid var(--color-muted)",
borderRadius: 8,
fontSize: 12,
color: "var(--color-fg)",
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];
}}
formatter={(value) => [`${Math.round(Number(value))} m n.p.m.`, "Wysokość"]}
labelFormatter={(label) => `${Number(label).toFixed(2)} km`}
labelFormatter={(l) => `${Number(l).toFixed(2)} km`}
/>
<Area
yAxisId="elev"
type="monotone"
dataKey="altM"
name="altM"
stroke="var(--color-accent)"
strokeWidth={2}
fill="url(#elevGradient)"
fill={`url(#elev-${uid})`}
dot={false}
/>
</AreaChart>
{hasPace && (
<Line
yAxisId="pace"
type="monotone"
dataKey="paceSec"
name="paceSec"
stroke="var(--color-sand)"
strokeWidth={1.5}
dot={false}
connectNulls={false}
/>
)}
</ComposedChart>
</ResponsiveContainer>
</div>
);

View File

@@ -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>

View File

@@ -20,6 +20,8 @@ type Props = {
color?: string;
referenceLine?: number;
decimals?: number;
format?: "pace";
reversed?: boolean;
};
export function RunMetricChart({
@@ -29,6 +31,8 @@ export function RunMetricChart({
color = "var(--color-accent)",
referenceLine,
decimals = 0,
format,
reversed = false,
}: Props) {
const uid = useId();
const gradId = `grad-${uid.replace(/:/g, "")}`;
@@ -43,8 +47,14 @@ export function RunMetricChart({
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) =>
decimals > 0 ? `${Number(v).toFixed(decimals)} ${unit}` : `${Math.round(v)} ${unit}`;
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">
@@ -72,7 +82,8 @@ export function RunMetricChart({
fontSize={11}
width={50}
tickFormatter={fmt}
domain={[min - pad, max + pad]}
domain={reversed ? [max + pad, min - pad] : [min - pad, max + pad]}
reversed={reversed}
/>
<Tooltip
contentStyle={{

View 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>
);
}

View File

@@ -28,9 +28,15 @@ 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 } = metrics;
if (!hrBpm && !gcbLeftPct) return [];
const { distanceKm, hrBpm, gcbLeftPct, paceSec } = metrics;
if (!hrBpm && !gcbLeftPct && !paceSec) return [];
const n = distanceKm.length;
if (n < 8) return [];
@@ -60,6 +66,11 @@ function buildRunMetricsSummary(metrics: RunMetrics, totalDistanceM: number): st
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) {
@@ -184,6 +195,7 @@ function parseAnalysisResponse(text: string): { summary: string; tips: string[]
}
export async function generateAnalysis(
userId: string,
targetType: AiAnalysisTargetType,
targetId: string
): Promise<AiAnalysis> {
@@ -194,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);
@@ -234,6 +246,7 @@ export async function generateAnalysis(
const { summary, tips } = parseAnalysisResponse(text);
return saveAiAnalysis({
userId,
targetType,
targetId: new ObjectId(targetId),
summary,
@@ -332,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
@@ -361,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);
}

View File

@@ -2,7 +2,7 @@ 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, 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";
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`);
}
@@ -154,7 +160,6 @@ export async function fetchActivityRunMetrics(
const step = Math.max(1, Math.floor(rows.length / MAX));
const sampled = rows.filter((_, i) => i % step === 0 || i === rows.length - 1);
// Try several known Garmin distance metric keys
const distKey = ["directDistance", "sumDistance", "directCumulativeDistance"].find(
(k) => sampled.some((row) => get(row, k) !== null && get(row, k)! > 0)
);
@@ -172,24 +177,21 @@ export async function fetchActivityRunMetrics(
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,
};
}
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).");
}
return { username, password };
}
async function exchangeOauth1Token(client: GarminConnect, oauth1Token: IOauth1Token): Promise<void> {
const http = client.client;
if (!http.OAUTH_CONSUMER) {
@@ -205,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();
}
@@ -232,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);
}
@@ -254,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);
}

View File

@@ -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,
});
}

View File

@@ -4,37 +4,61 @@ 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: 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 });
}

View File

@@ -40,10 +40,12 @@ export type RunMetrics = {
cadenceSpm?: number[];
gctMs?: number[];
gcbLeftPct?: number[];
paceSec?: number[];
};
export type RunningActivity = RunningActivityInput & {
_id: ObjectId;
userId: string;
createdAt: Date;
routePoints?: RoutePoint[];
elevationProfile?: number[];
@@ -56,60 +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({ garminActivityId }, { $set: { runMetrics: metrics } });
await collection.updateOne({ userId, garminActivityId }, { $set: { runMetrics: metrics } });
}
export async function setRunningActivityRoutePoints(
userId: string,
garminActivityId: number,
points: RoutePoint[],
elevationProfile: number[]
): Promise<void> {
const collection = await getCollection();
await collection.updateOne({ garminActivityId }, { $set: { routePoints: points, elevationProfile } });
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 });
}

View File

@@ -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
View 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
View 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/).*)",
],
};

View File

@@ -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
View File

@@ -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
View 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;
}
}