init
This commit is contained in:
@@ -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,50 @@ 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 buildRunMetricsSummary(metrics: RunMetrics, totalDistanceM: number): string[] {
|
||||
const { distanceKm, hrBpm, gcbLeftPct } = metrics;
|
||||
if (!hrBpm && !gcbLeftPct) 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 (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 +102,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) {
|
||||
|
||||
@@ -1,7 +1,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, RunningActivityInput } from "@/lib/models/running";
|
||||
import type { RoutePoint, RunMetrics, RunningActivityInput } from "@/lib/models/running";
|
||||
import { getSavedOauth1Token } from "@/lib/models/garmin-auth";
|
||||
import { completeMfaAndGetTicket, loginAndGetTicket, type GarminPendingMfa } from "@/lib/garmin/sso";
|
||||
|
||||
@@ -61,13 +61,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,6 +79,108 @@ export async function fetchActivityRoutePoints(
|
||||
return polyline.map((p) => [p.lat, p.lon] as RoutePoint);
|
||||
}
|
||||
|
||||
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 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);
|
||||
|
||||
// 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)
|
||||
);
|
||||
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;
|
||||
};
|
||||
|
||||
return {
|
||||
distanceKm,
|
||||
hrBpm: series("directHeartRate"),
|
||||
cadenceSpm: series("directDoubleCadence"),
|
||||
gctMs: series("directGroundContactTime"),
|
||||
gcbLeftPct: series("directGroundContactBalanceLeft", 1),
|
||||
};
|
||||
}
|
||||
|
||||
function getCredentials(): { username: string; password: string } {
|
||||
const username = process.env.GARMIN_EMAIL;
|
||||
const password = process.env.GARMIN_PASSWORD;
|
||||
|
||||
@@ -34,10 +34,20 @@ 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[];
|
||||
};
|
||||
|
||||
export type RunningActivity = RunningActivityInput & {
|
||||
_id: ObjectId;
|
||||
createdAt: Date;
|
||||
routePoints?: RoutePoint[];
|
||||
elevationProfile?: number[];
|
||||
runMetrics?: RunMetrics;
|
||||
};
|
||||
|
||||
const COLLECTION = "running_activities";
|
||||
@@ -72,12 +82,21 @@ export async function getRunningActivity(id: string): Promise<RunningActivity |
|
||||
return collection.findOne({ _id: new ObjectId(id) });
|
||||
}
|
||||
|
||||
export async function setRunningActivityRoutePoints(
|
||||
export async function setRunningActivityMetrics(
|
||||
garminActivityId: number,
|
||||
points: RoutePoint[]
|
||||
metrics: RunMetrics
|
||||
): Promise<void> {
|
||||
const collection = await getCollection();
|
||||
await collection.updateOne({ garminActivityId }, { $set: { routePoints: points } });
|
||||
await collection.updateOne({ garminActivityId }, { $set: { runMetrics: metrics } });
|
||||
}
|
||||
|
||||
export async function setRunningActivityRoutePoints(
|
||||
garminActivityId: number,
|
||||
points: RoutePoint[],
|
||||
elevationProfile: number[]
|
||||
): Promise<void> {
|
||||
const collection = await getCollection();
|
||||
await collection.updateOne({ garminActivityId }, { $set: { routePoints: points, elevationProfile } });
|
||||
}
|
||||
|
||||
type SyncState = { _id: "garmin"; lastSyncAt: Date };
|
||||
|
||||
Reference in New Issue
Block a user