Compare commits

..

3 Commits

Author SHA1 Message Date
Dominik Klarkowski
bf8624c954 init 2026-06-18 12:23:05 +02:00
Dominik Klarkowski
63cb8b4933 init 2026-06-18 11:24:56 +02:00
Dominik Klarkowski
115d56cd12 init 2026-06-18 11:12:12 +02:00
7 changed files with 175 additions and 172 deletions

View File

@@ -1,4 +1,3 @@
import { Suspense } from "react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { AiAnalysisCard } from "@/components/ai-analysis-card"; import { AiAnalysisCard } from "@/components/ai-analysis-card";
import { ElevationChart } from "@/components/elevation-chart"; import { ElevationChart } from "@/components/elevation-chart";
@@ -7,190 +6,119 @@ import { GcbChart } from "@/components/gcb-chart";
import { RunMetricChart } from "@/components/run-metric-chart"; import { RunMetricChart } from "@/components/run-metric-chart";
import { StatCard } from "@/components/stat-card"; import { StatCard } from "@/components/stat-card";
import { formatDate, formatDistance, formatDuration, formatPace } from "@/lib/format"; import { formatDate, formatDistance, formatDuration, formatPace } from "@/lib/format";
import {
fetchActivityRoutePoints,
fetchActivityRunMetrics,
getAuthorizedClient,
} from "@/lib/garmin/client";
import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis"; import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis";
import { import {
getRunningActivity, getRunningActivity,
setRunningActivityMetrics,
setRunningActivityRoutePoints,
type RunMetrics,
type RunningActivity, type RunningActivity,
} from "@/lib/models/running"; } from "@/lib/models/running";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
function mayHaveRoute(activity: RunningActivity): boolean { const CHART_SAMPLES = 200;
return Boolean(activity.hasRoute) || Boolean(activity.routePoints?.length);
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);
} }
async function RouteMapFetcher({ function buildGrid(maxDistKm: number): number[] {
activity, return Array.from({ length: CHART_SAMPLES }, (_, i) =>
userId, Math.round((i / (CHART_SAMPLES - 1)) * maxDistKm * 100) / 100
}: { );
activity: RunningActivity; }
userId: string;
}) {
let routePoints = activity.routePoints;
if ((!routePoints || !activity.elevationProfile) && mayHaveRoute(activity)) {
try {
const client = await getAuthorizedClient(userId);
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
if (result) {
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
routePoints = result.points;
}
} catch {
// GPS fetch failed silently
}
}
function RouteMap({ activity }: { activity: RunningActivity }) {
const routePoints = activity.routePoints;
return ( return (
<div className="col-span-2 row-span-3 min-h-[240px] overflow-hidden rounded-lg border border-muted/40"> <div className="col-span-2 row-span-3 min-h-[240px] overflow-hidden rounded-lg border border-muted/40">
{routePoints && routePoints.length > 0 ? ( {routePoints && routePoints.length > 0 ? (
<RouteMapSection points={routePoints} /> <RouteMapSection points={routePoints} />
) : ( ) : (
<div className="flex h-full min-h-[240px] items-center justify-center bg-surface"> <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>
)} )}
</div> </div>
); );
} }
function hasValidElevation(profile: number[] | undefined): boolean { function buildChartData(activity: RunningActivity) {
return Array.isArray(profile) && profile.some((v) => v > 0); const maxDistKm = Math.round(activity.distanceM / 10) / 100;
} const grid = buildGrid(maxDistKm);
async function ElevationFetcher({ const elevProfile = activity.elevationProfile;
activity, const metrics = activity.runMetrics;
userId,
}: {
activity: RunningActivity;
userId: string;
}) {
let elevationProfile = activity.elevationProfile;
if (!hasValidElevation(elevationProfile) && mayHaveRoute(activity)) { const elevSorted = elevProfile
try { ? elevProfile
const client = await getAuthorizedClient(userId); .map((altM, i) => ({ dist: (i / elevProfile.length) * maxDistKm, value: altM }))
const result = await fetchActivityRoutePoints(client, activity.garminActivityId); .filter((p) => p.value > 0)
if (result) {
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
elevationProfile = result.elevationProfile;
}
} catch {
// silent
}
}
if (!elevationProfile || elevationProfile.length < 2) return null;
const elevData = elevationProfile
.map((altM, i) => ({
distanceKm: Math.round((i / elevationProfile!.length) * activity.distanceM / 10) / 100,
altM,
}))
.filter((p) => p.altM > 0);
if (elevData.length < 2) return null;
// Merge pace by fractional position (both arrays span the same run, different sample counts)
const paceSrc = activity.runMetrics?.paceSec;
const data = elevData.map((ep, i) => {
if (!paceSrc || paceSrc.length === 0) return ep;
const pi = Math.min(Math.round((i / elevData.length) * paceSrc.length), paceSrc.length - 1);
const v = paceSrc[pi];
return { ...ep, paceSec: v > 0 && v < 1800 ? v : undefined };
});
return <ElevationChart data={data} />;
}
function toChartData(
values: number[] | undefined,
distances: number[]
): { distanceKm: number; value: number }[] {
if (!values) return [];
return distances
.map((distanceKm, i) => ({ distanceKm, value: values[i] ?? 0 }))
.filter((p) => p.value > 0);
}
async function RunMetricsFetcher({
activity,
userId,
}: {
activity: RunningActivity;
userId: string;
}) {
let metrics: RunMetrics | undefined = activity.runMetrics;
const missingCadence = activity.avgCadence && !metrics?.cadenceSpm;
const missingGcb = activity.avgGroundContactBalanceLeftPct && !metrics?.gcbLeftPct;
// Re-fetch if paceSec missing or was computed from integer-rounded speeds (< 15 unique values = rounding artifact)
const validPace = metrics?.paceSec?.filter((v) => v > 0) ?? [];
const missingPace = validPace.length === 0 || new Set(validPace).size < 15;
if ((!metrics || missingCadence || missingGcb || missingPace) && mayHaveRoute(activity)) {
try {
const client = await getAuthorizedClient(userId);
const fetched = await fetchActivityRunMetrics(client, activity.garminActivityId);
if (fetched) {
await setRunningActivityMetrics(userId, activity.garminActivityId, fetched);
metrics = fetched;
}
} catch {
// silent
}
}
if (!metrics || metrics.distanceKm.length === 0) return null;
const { hrBpm, gcbLeftPct } = metrics;
const maxDist = Math.max(...metrics.distanceKm);
const distanceKm =
maxDist > 0
? metrics.distanceKm
: Array.from({ length: metrics.distanceKm.length }, (_, i) =>
Math.round(((i / (metrics.distanceKm.length - 1)) * activity.distanceM) / 10) / 100
);
const hrData = toChartData(hrBpm, distanceKm);
const gcbData = gcbLeftPct
? distanceKm
.map((d, i) => {
const left = gcbLeftPct[i] ?? 0;
return left > 0
? { distanceKm: d, left, right: Math.round((100 - left) * 10) / 10 }
: null;
})
.filter((p): p is NonNullable<typeof p> => p !== null)
: []; : [];
if (!hrData.length && !gcbData.length) return null; 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);
})()
: [];
return ( const hrSorted = metrics?.hrBpm
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> ? metricsDistKm
{hrData.length > 1 && ( .map((d, i) => ({ dist: d, value: metrics.hrBpm![i] ?? 0 }))
<RunMetricChart data={hrData} label="Tętno" unit="bpm" color="var(--color-accent)" /> .filter((p) => p.value > 0)
)} : [];
{gcbData.length > 1 && <GcbChart data={gcbData} />}
</div> 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 };
} }
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" />
);
}
export default async function RunningActivityPage({ export default async function RunningActivityPage({
params, params,
@@ -206,6 +134,7 @@ export default async function RunningActivityPage({
} }
const analysis = await getLatestAnalysisForTarget(userId, "running", activity._id); const analysis = await getLatestAnalysisForTarget(userId, "running", activity._id);
const { elevData, hrData, gcbData } = buildChartData(activity);
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -215,9 +144,7 @@ export default async function RunningActivityPage({
</div> </div>
<section className="grid grid-cols-3 gap-4"> <section className="grid grid-cols-3 gap-4">
<Suspense fallback={<MapSkeleton />}> <RouteMap activity={activity} />
<RouteMapFetcher activity={activity} userId={userId} />
</Suspense>
<StatCard highlight label="Dystans" value={formatDistance(activity.distanceM)} /> <StatCard highlight label="Dystans" value={formatDistance(activity.distanceM)} />
<StatCard highlight label="Czas" value={formatDuration(activity.durationSec)} /> <StatCard highlight label="Czas" value={formatDuration(activity.durationSec)} />
@@ -261,13 +188,15 @@ export default async function RunningActivityPage({
) : null} ) : null}
</section> </section>
<Suspense fallback={null}> {elevData && <ElevationChart data={elevData} syncId="run-detail" />}
<ElevationFetcher activity={activity} userId={userId} /> {(hrData || gcbData) && (
</Suspense> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{hrData && (
<Suspense fallback={null}> <RunMetricChart data={hrData} label="Tętno" unit="bpm" color="var(--color-accent)" syncId="run-detail" />
<RunMetricsFetcher activity={activity} userId={userId} /> )}
</Suspense> {gcbData && <GcbChart data={gcbData} syncId="run-detail" />}
</div>
)}
<AiAnalysisCard <AiAnalysisCard
targetType="running" targetType="running"

View File

@@ -14,10 +14,13 @@ import {
import { import {
getLastSyncAt, getLastSyncAt,
getRunningActivity, getRunningActivity,
listRunningActivities,
setLastSyncAt, setLastSyncAt,
setRunningActivityMetrics,
setRunningActivityRoutePoints, setRunningActivityRoutePoints,
upsertRunningActivity, upsertRunningActivity,
} from "@/lib/models/running"; } from "@/lib/models/running";
import { fetchActivityRunMetrics } from "@/lib/garmin/client";
import { import {
clearPendingMfaState, clearPendingMfaState,
getPendingMfaState, getPendingMfaState,
@@ -28,6 +31,9 @@ import { getCurrentUserId } from "@/lib/session";
export type SyncGarminState = { error: string } | { success: string } | { mfaRequired: true } | null; export type SyncGarminState = { error: string } | { success: string } | { mfaRequired: true } | null;
// 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> { async function syncWithClient(userId: string, client: GarminConnect): Promise<SyncGarminState> {
const since = await getLastSyncAt(userId); const since = await getLastSyncAt(userId);
const activities = await fetchRunningActivities(client); const activities = await fetchRunningActivities(client);
@@ -39,6 +45,36 @@ async function syncWithClient(userId: string, client: GarminConnect): Promise<Sy
await setLastSyncAt(userId, 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("/running");
revalidatePath("/settings"); revalidatePath("/settings");
revalidatePath("/"); revalidatePath("/");

View File

@@ -16,6 +16,7 @@ type Point = { distanceKm: number; altM: number; paceSec?: number };
type Props = { type Props = {
data: Point[]; data: Point[];
syncId?: string;
}; };
function fmtPace(sec: number): string { function fmtPace(sec: number): string {
@@ -24,7 +25,7 @@ function fmtPace(sec: number): string {
return `${m}:${s.toString().padStart(2, "0")} /km`; return `${m}:${s.toString().padStart(2, "0")} /km`;
} }
export function ElevationChart({ data }: Props) { export function ElevationChart({ data, syncId }: Props) {
const uid = useId(); const uid = useId();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []); useEffect(() => setMounted(true), []);
@@ -53,7 +54,7 @@ export function ElevationChart({ data }: Props) {
}; };
return ( return (
<div className="rounded-lg border border-muted/40 bg-surface p-4"> <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"> <div className="mb-2 flex items-center gap-4 text-sm text-fg/60">
<span>Profil wysokości</span> <span>Profil wysokości</span>
{hasPace && ( {hasPace && (
@@ -64,7 +65,7 @@ export function ElevationChart({ data }: Props) {
)} )}
</div> </div>
<ResponsiveContainer width="100%" height={110}> <ResponsiveContainer width="100%" height={110}>
<ComposedChart data={data} margin={{ top: 4, right: hasPace ? 52 : 8, left: 0, bottom: 0 }}> <ComposedChart syncId={syncId} data={data} margin={{ top: 4, right: hasPace ? 52 : 8, left: 0, bottom: 0 }}>
<defs> <defs>
<linearGradient id={`elev-${uid}`} x1="0" y1="0" x2="0" y2="1"> <linearGradient id={`elev-${uid}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-accent)" stopOpacity={0.25} /> <stop offset="5%" stopColor="var(--color-accent)" stopOpacity={0.25} />

View File

@@ -16,9 +16,10 @@ type GcbPoint = { distanceKm: number; left: number; right: number };
type Props = { type Props = {
data: GcbPoint[]; data: GcbPoint[];
syncId?: string;
}; };
export function GcbChart({ data }: Props) { export function GcbChart({ data, syncId }: Props) {
const uid = useId(); const uid = useId();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []); useEffect(() => setMounted(true), []);
@@ -41,7 +42,7 @@ export function GcbChart({ data }: Props) {
</span> </span>
</div> </div>
<ResponsiveContainer width="100%" height={120}> <ResponsiveContainer width="100%" height={120}>
<AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}> <AreaChart syncId={syncId} data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs> <defs>
<linearGradient id={`gcb-l-${uid}`} x1="0" y1="0" x2="0" y2="1"> <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="5%" stopColor="var(--color-accent)" stopOpacity={0.2} />

View File

@@ -22,6 +22,7 @@ type Props = {
decimals?: number; decimals?: number;
format?: "pace"; format?: "pace";
reversed?: boolean; reversed?: boolean;
syncId?: string;
}; };
export function RunMetricChart({ export function RunMetricChart({
@@ -33,6 +34,7 @@ export function RunMetricChart({
decimals = 0, decimals = 0,
format, format,
reversed = false, reversed = false,
syncId,
}: Props) { }: Props) {
const uid = useId(); const uid = useId();
const gradId = `grad-${uid.replace(/:/g, "")}`; const gradId = `grad-${uid.replace(/:/g, "")}`;
@@ -60,7 +62,7 @@ export function RunMetricChart({
<div className="rounded-lg border border-muted/40 bg-surface p-4"> <div className="rounded-lg border border-muted/40 bg-surface p-4">
<div className="mb-2 text-sm text-fg/60">{label}</div> <div className="mb-2 text-sm text-fg/60">{label}</div>
<ResponsiveContainer width="100%" height={120}> <ResponsiveContainer width="100%" height={120}>
<AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}> <AreaChart syncId={syncId} data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs> <defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1"> <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.25} /> <stop offset="5%" stopColor={color} stopOpacity={0.25} />

33
lib/crypto.ts Normal file
View 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");
}

View File

@@ -1,5 +1,6 @@
import type { IOauth1Token } from "garmin-connect/dist/garmin/types"; import type { IOauth1Token } from "garmin-connect/dist/garmin/types";
import { getDb } from "@/lib/db"; import { getDb } from "@/lib/db";
import { encrypt, decrypt } from "@/lib/crypto";
import type { GarminPendingMfa } from "@/lib/garmin/sso"; import type { GarminPendingMfa } from "@/lib/garmin/sso";
const AUTH_COLLECTION = "garmin_auth"; const AUTH_COLLECTION = "garmin_auth";
@@ -49,7 +50,7 @@ export async function getGarminCredentials(
.collection<GarminCredentialsDoc>(CREDENTIALS_COLLECTION) .collection<GarminCredentialsDoc>(CREDENTIALS_COLLECTION)
.findOne({ _id: userId }); .findOne({ _id: userId });
if (!doc) return null; if (!doc) return null;
return { email: doc.email, password: doc.password }; return { email: doc.email, password: decrypt(doc.password) };
} }
export async function saveGarminCredentials( export async function saveGarminCredentials(
@@ -60,5 +61,5 @@ export async function saveGarminCredentials(
const db = await getDb(); const db = await getDb();
await db await db
.collection<GarminCredentialsDoc>(CREDENTIALS_COLLECTION) .collection<GarminCredentialsDoc>(CREDENTIALS_COLLECTION)
.updateOne({ _id: userId }, { $set: { email, password } }, { upsert: true }); .updateOne({ _id: userId }, { $set: { email, password: encrypt(password) } }, { upsert: true });
} }