This commit is contained in:
Dominik Klarkowski
2026-06-16 09:43:48 +02:00
parent f0e87d8d11
commit 36407f534b
52 changed files with 3211 additions and 100 deletions

314
lib/ai/claude.ts Normal file
View File

@@ -0,0 +1,314 @@
import Anthropic from "@anthropic-ai/sdk";
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 { getStrengthWorkout, listStrengthWorkouts, type StrengthWorkout } from "@/lib/models/strength";
import {
getLatestAnalysisForTarget,
saveAiAnalysis,
saveDashboardAnalysis,
type AiAnalysis,
type AiAnalysisTargetType,
} from "@/lib/models/analysis";
const DEFAULT_MODEL = "claude-sonnet-4-6";
const PREVIOUS_RUNS_LIMIT = 5;
const PREVIOUS_WORKOUTS_LIMIT = 2;
const DASHBOARD_RUNS_LIMIT = 6;
const DASHBOARD_WORKOUTS_LIMIT = 4;
const DASHBOARD_WELLNESS_DAYS = 7;
const PROMPT_INSTRUCTIONS = `Odpowiedz wyłącznie w formacie JSON (bez dodatkowego tekstu, bez markdown):
{"summary": "krótkie podsumowanie treningu po polsku (2-3 zdania)", "tips": ["wskazówka 1", "wskazówka 2", "wskazówka 3"]}
Podaj od 2 do 4 konkretnych, praktycznych wskazówek na kolejne treningi.
Jeśli podano dane z poprzednich treningów, odnieś się do progresu (np. zmiana dystansu, tempa, ciężarów czy powtórzeń względem poprzednich sesji).`;
type PreviousRun = { run: RunningActivity; analysis: AiAnalysis | null };
type PreviousWorkout = { workout: StrengthWorkout; analysis: AiAnalysis | null };
function buildRunningPrompt(activity: RunningActivity, previousRuns: PreviousRun[]): string {
const lines = [
`Przeanalizuj poniższy bieg i podaj krótkie podsumowanie oraz wskazówki potreningowe.`,
``,
`Nazwa: ${activity.name}`,
`Data: ${formatDate(activity.startTime)}`,
`Dystans: ${formatDistance(activity.distanceM)}`,
`Czas: ${formatDuration(activity.durationSec)}`,
`Tempo: ${formatPace(activity.avgPaceSecPerKm)}`,
];
if (activity.avgHr) lines.push(`Średnie tętno: ${Math.round(activity.avgHr)} bpm`);
if (activity.maxHr) lines.push(`Maksymalne tętno: ${Math.round(activity.maxHr)} bpm`);
if (activity.avgCadence) lines.push(`Kadencja: ${Math.round(activity.avgCadence)} kroków/min`);
if (activity.elevationGainM) lines.push(`Suma podejść: ${Math.round(activity.elevationGainM)} m`);
if (activity.calories) lines.push(`Spalone kalorie: ${Math.round(activity.calories)} kcal`);
if (activity.vo2Max) lines.push(`VO2max: ${Math.round(activity.vo2Max)}`);
if (activity.avgGroundContactTimeMs) lines.push(`Czas kontaktu z podłożem: ${Math.round(activity.avgGroundContactTimeMs)} ms`);
if (activity.avgVerticalOscillationCm) lines.push(`Oscylacja wertykalna: ${activity.avgVerticalOscillationCm.toFixed(1)} cm`);
if (activity.avgVerticalRatioPct) lines.push(`Wskaźnik wertykalny: ${activity.avgVerticalRatioPct.toFixed(1)}%`);
if (activity.avgStrideLengthCm) lines.push(`Długość kroku: ${activity.avgStrideLengthCm.toFixed(0)} cm`);
if (activity.avgGroundContactBalanceLeftPct) {
lines.push(
`Balans kontaktu z podłożem (L/P): ${activity.avgGroundContactBalanceLeftPct.toFixed(1)}% / ${(100 - activity.avgGroundContactBalanceLeftPct).toFixed(1)}%`
);
}
if (activity.avgPowerW) lines.push(`Moc średnia: ${Math.round(activity.avgPowerW)} W`);
if (activity.avgRespirationRate) lines.push(`Częstość oddechów: ${activity.avgRespirationRate.toFixed(1)}/min`);
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 (previousRuns.length > 0) {
lines.push(``, `Poprzednie biegi (od najnowszego):`);
for (const { run, analysis } of previousRuns) {
lines.push(
`- ${formatDateShort(run.startTime)}: ${formatDistance(run.distanceM)}, ${formatDuration(run.durationSec)}, tempo ${formatPace(run.avgPaceSecPerKm)}`
);
if (analysis) {
lines.push(` Poprzednia analiza AI: ${analysis.summary}`);
if (analysis.tips.length > 0) {
lines.push(` Wskazówki z poprzedniej analizy: ${analysis.tips.join(" | ")}`);
}
}
}
}
lines.push(``, PROMPT_INSTRUCTIONS);
return lines.join("\n");
}
function formatExerciseSets(exercise: StrengthWorkout["exercises"][number]): string {
return exercise.sets
.map((set) => {
const weight = set.weightKg !== undefined ? `${set.weightKg} kg` : "bez obciążenia";
return `${weight} × ${set.reps ?? "?"}`;
})
.join(", ");
}
function buildStrengthPrompt(workout: StrengthWorkout, previousWorkouts: PreviousWorkout[]): string {
const lines = [
`Przeanalizuj poniższy trening siłowy i podaj krótkie podsumowanie oraz wskazówki potreningowe.`,
``,
`Nazwa: ${workout.name}`,
`Data: ${formatDate(workout.date)}`,
];
if (workout.notes) lines.push(`Notatki: ${workout.notes}`);
lines.push(``, `Ćwiczenia:`);
for (const exercise of workout.exercises) {
lines.push(`- ${exercise.name}: ${formatExerciseSets(exercise)}`);
if (exercise.notes) lines.push(` Notatka: ${exercise.notes}`);
}
if (previousWorkouts.length > 0) {
lines.push(``, `Poprzednie treningi (od najnowszego):`);
for (const { workout: previous, analysis } of previousWorkouts) {
lines.push(`${formatDateShort(previous.date)} - ${previous.name}:`);
for (const exercise of previous.exercises) {
lines.push(` - ${exercise.name}: ${formatExerciseSets(exercise)}`);
}
if (analysis) {
lines.push(` Poprzednia analiza AI: ${analysis.summary}`);
if (analysis.tips.length > 0) {
lines.push(` Wskazówki z poprzedniej analizy: ${analysis.tips.join(" | ")}`);
}
}
}
}
lines.push(``, PROMPT_INSTRUCTIONS);
return lines.join("\n");
}
function parseAnalysisResponse(text: string): { summary: string; tips: string[] } {
try {
const match = text.match(/\{[\s\S]*\}/);
const parsed = JSON.parse(match ? match[0] : text);
const summary = typeof parsed.summary === "string" ? parsed.summary : text;
const tips = Array.isArray(parsed.tips) ? parsed.tips.filter((tip: unknown) => typeof tip === "string") : [];
return { summary, tips };
} catch {
return { summary: text, tips: [] };
}
}
export async function generateAnalysis(
targetType: AiAnalysisTargetType,
targetId: string
): Promise<AiAnalysis> {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
throw new Error("Brak klucza ANTHROPIC_API_KEY w konfiguracji.");
}
let prompt: string;
if (targetType === "running") {
const activity = await getRunningActivity(targetId);
if (!activity) throw new Error("Nie znaleziono biegu.");
const previousRuns = (await listRunningActivities())
.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),
}))
);
prompt = buildRunningPrompt(activity, previousRunsWithAnalysis);
} else {
const workout = await getStrengthWorkout(targetId);
if (!workout) throw new Error("Nie znaleziono treningu.");
const previousWorkouts = (await listStrengthWorkouts())
.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),
}))
);
prompt = buildStrengthPrompt(workout, previousWorkoutsWithAnalysis);
}
const model = process.env.ANTHROPIC_MODEL ?? DEFAULT_MODEL;
const client = new Anthropic({ apiKey });
const message = await client.messages.create({
model,
max_tokens: 1024,
messages: [{ role: "user", content: prompt }],
});
const textBlock = message.content.find((block) => block.type === "text");
const text = textBlock && textBlock.type === "text" ? textBlock.text : "";
const { summary, tips } = parseAnalysisResponse(text);
return saveAiAnalysis({
targetType,
targetId: new ObjectId(targetId),
summary,
tips,
model,
});
}
function formatHrvStatus(status: string): string {
const map: Record<string, string> = {
BALANCED: "zrównoważone",
UNBALANCED: "niezrównoważone",
LOW: "niskie",
POOR: "złe",
};
return map[status] ?? status;
}
function buildDashboardPrompt(
runs: RunningActivity[],
workouts: StrengthWorkout[],
wellness: DayWellness[]
): string {
const lines = [
`Jesteś asystentem sportowym analizującym pełny obraz kondycji i stanu treningowego zawodnika.`,
`Na podstawie poniższych danych oceń: poziom zmęczenia i regeneracji, balans między treningiem siłowym a biegowym, trendy wydolnościowe oraz gotowość do kolejnych treningów.`,
``,
];
if (runs.length > 0) {
lines.push(`BIEGI (${runs.length} ostatnich sesji, od najnowszej):`);
for (const run of runs) {
const parts = [
formatDateShort(run.startTime),
formatDistance(run.distanceM),
`tempo ${formatPace(run.avgPaceSecPerKm)}`,
];
if (run.avgHr) parts.push(`HR śr. ${Math.round(run.avgHr)} bpm`);
if (run.vo2Max) parts.push(`VO2max ${Math.round(run.vo2Max)}`);
if (run.aerobicTrainingEffect) parts.push(`TE aerobowy ${run.aerobicTrainingEffect.toFixed(1)}`);
lines.push(`- ${parts.join(", ")}`);
}
lines.push(``);
}
if (workouts.length > 0) {
lines.push(`TRENINGI SIŁOWE (${workouts.length} ostatnich sesji, od najnowszej):`);
for (const workout of workouts) {
lines.push(`- ${formatDateShort(workout.date)}${workout.name}:`);
for (const exercise of workout.exercises) {
const topSet = exercise.sets.reduce(
(best, set) => (set.weightKg ?? 0) > (best.weightKg ?? 0) ? set : best,
exercise.sets[0]
);
const summary = topSet
? `maks. ${topSet.weightKg ?? "—"} kg × ${topSet.reps ?? "?"} (${exercise.sets.length} serie)`
: `${exercise.sets.length} serie`;
lines.push(` · ${exercise.name}: ${summary}`);
}
}
lines.push(``);
}
const wellnessWithData = wellness.filter(
(d) => d.sleepScore || d.avgOvernightHrv || d.sleepDurationMin
);
if (wellnessWithData.length > 0) {
lines.push(`SEN I HRV (ostatnie ${wellness.length} dni):`);
for (const day of wellness) {
const parts: string[] = [day.date];
if (day.sleepDurationMin) {
const h = Math.floor(day.sleepDurationMin / 60);
const m = day.sleepDurationMin % 60;
parts.push(`sen ${h}h ${m}min`);
}
if (day.sleepScore) parts.push(`wynik snu ${day.sleepScore}/100`);
if (day.avgOvernightHrv) {
parts.push(`HRV ${Math.round(day.avgOvernightHrv)} ms${day.hrvStatus ? ` (${formatHrvStatus(day.hrvStatus)})` : ""}`);
}
if (day.restingHr) parts.push(`HR spoczynkowe ${day.restingHr} bpm`);
if (typeof day.bodyBatteryChange === "number") {
parts.push(`Body Battery ${day.bodyBatteryChange > 0 ? "+" : ""}${day.bodyBatteryChange}`);
}
if (parts.length > 1) lines.push(`- ${parts.join(", ")}`);
}
lines.push(``);
}
lines.push(
`Odpowiedz wyłącznie w formacie JSON (bez dodatkowego tekstu, bez markdown):`,
`{"summary": "ocena ogólnego stanu kondycji i regeneracji po polsku (3-4 zdania)", "tips": ["wskazówka 1", "wskazówka 2", "wskazówka 3"]}`,
`Podaj 3-5 konkretnych, praktycznych wskazówek dotyczących planowania kolejnych treningów, regeneracji i zdrowia.`,
`Uwzględnij trendy HRV i jakości snu przy ocenie gotowości do wysiłku.`
);
return lines.join("\n");
}
export async function generateDashboardAnalysis(): 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)),
]);
let wellness: DayWellness[] = [];
try {
const garminClient = await getAuthorizedClient();
wellness = await fetchRecentWellness(garminClient, DASHBOARD_WELLNESS_DAYS);
} catch {
// Wellness data not available, proceed without it
}
const prompt = buildDashboardPrompt(runs, workouts, wellness);
const model = process.env.ANTHROPIC_MODEL ?? DEFAULT_MODEL;
const anthropic = new Anthropic({ apiKey });
const message = await anthropic.messages.create({
model,
max_tokens: 1024,
messages: [{ role: "user", content: prompt }],
});
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);
}

21
lib/db.ts Normal file
View File

@@ -0,0 +1,21 @@
import { MongoClient, type Db } from "mongodb";
const uri = process.env.MONGODB_URI ?? "mongodb://localhost:27017";
const dbName = process.env.MONGODB_DB ?? "knur";
declare global {
var _mongoClientPromise: Promise<MongoClient> | undefined;
}
function getClientPromise(): Promise<MongoClient> {
if (!global._mongoClientPromise) {
const client = new MongoClient(uri);
global._mongoClientPromise = client.connect();
}
return global._mongoClientPromise;
}
export async function getDb(): Promise<Db> {
const client = await getClientPromise();
return client.db(dbName);
}

30
lib/format.ts Normal file
View File

@@ -0,0 +1,30 @@
import { format } from "date-fns";
import { pl } from "date-fns/locale";
export function formatDate(date: Date): string {
return format(date, "d MMMM yyyy, HH:mm", { locale: pl });
}
export function formatDateShort(date: Date): string {
return format(date, "d MMM yyyy", { locale: pl });
}
export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
return `${m}:${String(s).padStart(2, "0")}`;
}
export function formatDistance(meters: number): string {
return `${(meters / 1000).toFixed(2)} km`;
}
export function formatPace(secPerKm: number): string {
const m = Math.floor(secPerKm / 60);
const s = Math.round(secPerKm % 60);
return `${m}:${String(s).padStart(2, "0")} /km`;
}

164
lib/garmin/client.ts Normal file
View File

@@ -0,0 +1,164 @@
import { GarminConnect } from "garmin-connect";
import type { IActivity } from "garmin-connect/dist/garmin/types/activity";
import type { IOauth1Token } from "garmin-connect/dist/garmin/types";
import type { RoutePoint, RunningActivityInput } from "@/lib/models/running";
import { getSavedOauth1Token } from "@/lib/models/garmin-auth";
import { completeMfaAndGetTicket, loginAndGetTicket, type GarminPendingMfa } from "@/lib/garmin/sso";
const FETCH_LIMIT = 50;
export class GarminLoginRequiredError extends Error {
constructor() {
super("Wymagane logowanie do Garmin Connect.");
}
}
function parseGarminDate(value: string): Date {
return new Date(`${value.replace(" ", "T")}Z`);
}
function isRunningActivity(activity: IActivity): boolean {
return activity.activityType?.typeKey?.toLowerCase().includes("running") ?? false;
}
function toNumber(value: unknown): number | undefined {
return typeof value === "number" && value > 0 ? value : undefined;
}
function toText(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function mapActivity(activity: IActivity): RunningActivityInput {
return {
garminActivityId: activity.activityId,
name: activity.activityName,
startTime: parseGarminDate(activity.startTimeGMT),
durationSec: activity.duration,
distanceM: activity.distance,
avgPaceSecPerKm: activity.averageSpeed > 0 ? 1000 / activity.averageSpeed : 0,
avgHr: activity.averageHR || undefined,
maxHr: activity.maxHR || undefined,
calories: activity.calories || undefined,
elevationGainM: activity.elevationGain || undefined,
avgCadence: activity.averageRunningCadenceInStepsPerMinute || undefined,
avgVerticalOscillationCm: toNumber(activity.avgVerticalOscillation),
avgGroundContactTimeMs: toNumber(activity.avgGroundContactTime),
avgStrideLengthCm: activity.avgStrideLength || undefined,
avgGroundContactBalanceLeftPct: toNumber(activity.avgGroundContactBalance),
avgVerticalRatioPct: toNumber(activity.avgVerticalRatio),
vo2Max: activity.vO2MaxValue || undefined,
aerobicTrainingEffect: toNumber(activity.aerobicTrainingEffect),
anaerobicTrainingEffect: toNumber(activity.anaerobicTrainingEffect),
trainingEffectLabel: toText(activity.trainingEffectLabel),
avgPowerW: toNumber(activity.avgPower),
maxPowerW: toNumber(activity.maxPower),
normPowerW: toNumber(activity.normPower),
avgRespirationRate: toNumber(activity.avgRespirationRate),
hasRoute: activity.hasPolyline || undefined,
};
}
const GC_API = "https://connectapi.garmin.com";
const MAX_POLYLINE_POINTS = 500;
type GarminPolylinePoint = { lat: number; lon: number; altitude?: number };
type GarminActivityDetailsResponse = {
geoPolylineDTO?: { polyline?: GarminPolylinePoint[] };
};
export async function fetchActivityRoutePoints(
client: GarminConnect,
garminActivityId: number
): Promise<RoutePoint[] | null> {
const url = `${GC_API}/activity-service/activity/${garminActivityId}/details?maxPolylineSize=${MAX_POLYLINE_POINTS}`;
const data = await client.get<GarminActivityDetailsResponse>(url);
const polyline = data?.geoPolylineDTO?.polyline;
if (!Array.isArray(polyline) || polyline.length === 0) return null;
return polyline.map((p) => [p.lat, p.lon] as RoutePoint);
}
function getCredentials(): { username: string; password: string } {
const username = process.env.GARMIN_EMAIL;
const password = process.env.GARMIN_PASSWORD;
if (!username || !password) {
throw new Error("Brak danych logowania do Garmin Connect (GARMIN_EMAIL / GARMIN_PASSWORD).");
}
return { username, password };
}
async function exchangeOauth1Token(client: GarminConnect, oauth1Token: IOauth1Token): Promise<void> {
const http = client.client;
if (!http.OAUTH_CONSUMER) {
await http.fetchOauthConsumer();
}
const consumer = http.OAUTH_CONSUMER;
if (!consumer) {
throw new Error("Nie udało się pobrać konfiguracji OAuth Garmin.");
}
const oauth = http.getOauthClient(consumer);
http.oauth1Token = oauth1Token;
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();
if (!saved) {
throw new GarminLoginRequiredError();
}
const client = new GarminConnect({ username: "", password: "" });
try {
await exchangeOauth1Token(client, saved);
} catch {
throw new GarminLoginRequiredError();
}
return client;
}
async function establishClientFromTicket(ticket: string): Promise<{ client: GarminConnect; oauth1Token: IOauth1Token }> {
const client = new GarminConnect({ username: "", password: "" });
await client.client.fetchOauthConsumer();
const oauth1 = await client.client.getOauth1Token(ticket);
await client.client.exchange(oauth1);
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 }
> {
const { username, password } = getCredentials();
const result = await loginAndGetTicket(username, password);
if ("mfaRequired" in result) return result;
return establishClientFromTicket(result.ticket);
}
export async function completeGarminMfaLogin(
pendingState: GarminPendingMfa,
code: string
): Promise<{ client: GarminConnect; oauth1Token: IOauth1Token }> {
const ticket = await completeMfaAndGetTicket(pendingState, code);
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);
}

176
lib/garmin/sso.ts Normal file
View File

@@ -0,0 +1,176 @@
const GARMIN_SSO_ORIGIN = "https://sso.garmin.com";
const GARMIN_SSO = `${GARMIN_SSO_ORIGIN}/sso`;
const GARMIN_SSO_EMBED = `${GARMIN_SSO}/embed`;
const GC_MODERN = "https://connect.garmin.com/modern";
const SIGNIN_URL = `${GARMIN_SSO}/signin`;
const USER_AGENT_BROWSER =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36";
const TICKET_RE = /ticket=([^"]+)"/;
const CSRF_RE = /name="_csrf"\s+value="(.+?)"/;
const SIGNIN_PARAMS: Record<string, string | boolean> = {
id: "gauth-widget",
embedWidget: true,
clientId: "GarminConnect",
locale: "en",
gauthHost: GARMIN_SSO_EMBED,
service: GARMIN_SSO_EMBED,
source: GARMIN_SSO_EMBED,
redirectAfterAccountLoginUrl: GARMIN_SSO_EMBED,
redirectAfterAccountCreationUrl: GARMIN_SSO_EMBED,
};
export type GarminPendingMfa = {
cookies: [string, string][];
mfaUrl: string;
csrf: string;
};
export type GarminLoginResult = { ticket: string } | { mfaRequired: true; pendingState: GarminPendingMfa };
function toQueryString(params: Record<string, string | boolean>): string {
return Object.entries(params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
.join("&");
}
class CookieJar {
private cookies = new Map<string, string>();
constructor(initial?: [string, string][]) {
if (initial) {
for (const [key, value] of initial) this.cookies.set(key, value);
}
}
apply(response: Response): void {
const setCookies = response.headers.getSetCookie?.() ?? [];
for (const cookie of setCookies) {
const [pair] = cookie.split(";");
const idx = pair.indexOf("=");
this.cookies.set(pair.slice(0, idx), pair.slice(idx + 1));
}
}
header(): string {
return Array.from(this.cookies.entries())
.map(([key, value]) => `${key}=${value}`)
.join("; ");
}
entries(): [string, string][] {
return Array.from(this.cookies.entries());
}
}
async function request(jar: CookieJar, url: string, init: RequestInit = {}): Promise<{ response: Response; body: string }> {
const response = await fetch(url, {
...init,
redirect: "manual",
headers: {
"User-Agent": USER_AGENT_BROWSER,
Cookie: jar.header(),
...(init.headers ?? {}),
},
});
jar.apply(response);
const body = await response.text();
return { response, body };
}
/**
* Replays the Garmin SSO web login flow (garmin-connect's HttpClient has no
* cookie jar and a no-op handleMFA, so it cannot complete login when the
* account has email-based MFA enabled).
*/
export async function loginAndGetTicket(username: string, password: string): Promise<GarminLoginResult> {
const jar = new CookieJar();
const embedUrl = `${GARMIN_SSO_EMBED}?${toQueryString({ clientId: "GarminConnect", locale: "en", service: GC_MODERN })}`;
await request(jar, embedUrl);
const signinPageUrl = `${SIGNIN_URL}?${toQueryString({
id: "gauth-widget",
embedWidget: true,
locale: "en",
gauthHost: GARMIN_SSO_EMBED,
})}`;
const signinPage = await request(jar, signinPageUrl);
const csrfMatch = signinPage.body.match(CSRF_RE);
if (!csrfMatch) {
throw new Error("Logowanie do Garmin nie powiodło się (brak tokenu CSRF na stronie logowania).");
}
const signinUrl = `${SIGNIN_URL}?${toQueryString(SIGNIN_PARAMS)}`;
const credentialsForm = new URLSearchParams({ username, password, embed: "true", _csrf: csrfMatch[1] });
const credentialsResult = await request(jar, signinUrl, {
method: "POST",
body: credentialsForm.toString(),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Dnt: "1",
Origin: GARMIN_SSO_ORIGIN,
Referer: SIGNIN_URL,
},
});
const redirectLocation = credentialsResult.response.headers.get("location");
if (redirectLocation?.includes("verifyMFA")) {
const mfaPage = await request(jar, redirectLocation, { headers: { Referer: signinUrl } });
const mfaCsrfMatch = mfaPage.body.match(CSRF_RE);
if (!mfaCsrfMatch) {
throw new Error("Logowanie do Garmin nie powiodło się (nie znaleziono formularza kodu MFA).");
}
return {
mfaRequired: true,
pendingState: { cookies: jar.entries(), mfaUrl: redirectLocation, csrf: mfaCsrfMatch[1] },
};
}
const ticketMatch = credentialsResult.body.match(TICKET_RE);
if (!ticketMatch) {
throw new Error("Logowanie do Garmin nie powiodło się (Ticket not found or MFA), sprawdź login i hasło.");
}
return { ticket: ticketMatch[1] };
}
export async function completeMfaAndGetTicket(pendingState: GarminPendingMfa, code: string): Promise<string> {
const jar = new CookieJar(pendingState.cookies);
const mfaForm = new URLSearchParams({
"mfa-code": code.trim(),
embed: "true",
_csrf: pendingState.csrf,
fromPage: "setupEnterMfaCode",
});
const verifyResult = await request(jar, pendingState.mfaUrl, {
method: "POST",
body: mfaForm.toString(),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Dnt: "1",
Origin: GARMIN_SSO_ORIGIN,
Referer: pendingState.mfaUrl,
},
});
let body = verifyResult.body;
let location = verifyResult.response.headers.get("location");
let previousUrl = pendingState.mfaUrl;
let hops = 0;
while (location && hops < 5) {
const next = await request(jar, location, { headers: { Referer: previousUrl } });
body = next.body;
previousUrl = location;
location = next.response.headers.get("location");
hops += 1;
}
const ticketMatch = body.match(TICKET_RE);
if (!ticketMatch) {
throw new Error("Weryfikacja kodu MFA nie powiodła się - sprawdź kod i spróbuj ponownie.");
}
return ticketMatch[1];
}

52
lib/garmin/wellness.ts Normal file
View File

@@ -0,0 +1,52 @@
import type { GarminConnect } from "garmin-connect";
export type DayWellness = {
date: string;
sleepDurationMin?: number;
sleepScore?: number;
deepSleepMin?: number;
remSleepMin?: number;
avgOvernightHrv?: number;
hrvStatus?: string;
restingHr?: number;
bodyBatteryChange?: number;
};
export async function fetchRecentWellness(
client: GarminConnect,
days: number
): Promise<DayWellness[]> {
const today = new Date();
const dates = Array.from({ length: days }, (_, i) => {
const d = new Date(today);
d.setDate(d.getDate() - i);
return d;
});
const results = await Promise.allSettled(
dates.map((date) => client.getSleepData(date))
);
return results
.map((result, i) => {
const dateStr = dates[i].toISOString().slice(0, 10);
if (result.status === "rejected" || !result.value?.dailySleepDTO) {
return { date: dateStr };
}
const { value: data } = result;
const dto = data.dailySleepDTO;
return {
date: dateStr,
sleepDurationMin: dto.sleepTimeSeconds ? Math.round(dto.sleepTimeSeconds / 60) : undefined,
sleepScore: dto.sleepScores?.overall?.value ?? undefined,
deepSleepMin: dto.deepSleepSeconds ? Math.round(dto.deepSleepSeconds / 60) : undefined,
remSleepMin: dto.remSleepSeconds ? Math.round(dto.remSleepSeconds / 60) : undefined,
avgOvernightHrv: data.avgOvernightHrv || undefined,
hrvStatus: data.hrvStatus || undefined,
restingHr: data.restingHeartRate || undefined,
bodyBatteryChange: typeof data.bodyBatteryChange === "number" ? data.bodyBatteryChange : undefined,
};
})
.sort((a, b) => a.date.localeCompare(b.date));
}

62
lib/models/analysis.ts Normal file
View File

@@ -0,0 +1,62 @@
import { ObjectId } from "mongodb";
import { getDb } from "@/lib/db";
export type AiAnalysisTargetType = "running" | "strength" | "dashboard";
export type AiAnalysisInput = {
targetType: AiAnalysisTargetType;
targetId: ObjectId;
summary: string;
tips: string[];
model: string;
};
export type AiAnalysis = AiAnalysisInput & {
_id: ObjectId;
createdAt: Date;
};
const COLLECTION = "ai_analyses";
async function getCollection() {
const db = await getDb();
return db.collection<AiAnalysis>(COLLECTION);
}
export async function saveAiAnalysis(input: AiAnalysisInput): Promise<AiAnalysis> {
const collection = await getCollection();
const doc = { ...input, _id: new ObjectId(), createdAt: new Date() };
await collection.insertOne(doc);
return doc;
}
export async function getLatestAnalysisForTarget(
targetType: AiAnalysisTargetType,
targetId: ObjectId
): Promise<AiAnalysis | null> {
const collection = await getCollection();
return collection.findOne({ 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> {
const collection = await getCollection();
return collection.findOne(
{ targetType: "dashboard", targetId: DASHBOARD_TARGET_ID },
{ sort: { createdAt: -1 } }
);
}
export async function saveDashboardAnalysis(
summary: string,
tips: string[],
model: string
): Promise<AiAnalysis> {
return saveAiAnalysis({ targetType: "dashboard", targetId: DASHBOARD_TARGET_ID, summary, tips, model });
}

40
lib/models/garmin-auth.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { IOauth1Token } from "garmin-connect/dist/garmin/types";
import { getDb } from "@/lib/db";
import type { GarminPendingMfa } from "@/lib/garmin/sso";
const AUTH_COLLECTION = "garmin_auth";
const PENDING_COLLECTION = "garmin_login_pending";
type GarminAuthDoc = { _id: "tokens"; oauth1Token: IOauth1Token; updatedAt: Date };
type GarminPendingDoc = { _id: "pending"; state: GarminPendingMfa; createdAt: Date };
export async function getSavedOauth1Token(): Promise<IOauth1Token | null> {
const db = await getDb();
const doc = await db.collection<GarminAuthDoc>(AUTH_COLLECTION).findOne({ _id: "tokens" });
return doc?.oauth1Token ?? null;
}
export async function saveOauth1Token(oauth1Token: IOauth1Token): Promise<void> {
const db = await getDb();
await db
.collection<GarminAuthDoc>(AUTH_COLLECTION)
.updateOne({ _id: "tokens" }, { $set: { oauth1Token, updatedAt: new Date() } }, { upsert: true });
}
export async function savePendingMfaState(state: GarminPendingMfa): Promise<void> {
const db = await getDb();
await db
.collection<GarminPendingDoc>(PENDING_COLLECTION)
.updateOne({ _id: "pending" }, { $set: { state, createdAt: new Date() } }, { upsert: true });
}
export async function getPendingMfaState(): Promise<GarminPendingMfa | null> {
const db = await getDb();
const doc = await db.collection<GarminPendingDoc>(PENDING_COLLECTION).findOne({ _id: "pending" });
return doc?.state ?? null;
}
export async function clearPendingMfaState(): Promise<void> {
const db = await getDb();
await db.collection<GarminPendingDoc>(PENDING_COLLECTION).deleteOne({ _id: "pending" });
}

96
lib/models/running.ts Normal file
View File

@@ -0,0 +1,96 @@
import { ObjectId } from "mongodb";
import { z } from "zod";
import { getDb } from "@/lib/db";
export const runningActivitySchema = z.object({
garminActivityId: z.number().int(),
name: z.string().min(1),
startTime: z.date(),
durationSec: z.number().positive(),
distanceM: z.number().nonnegative(),
avgPaceSecPerKm: z.number().nonnegative(),
avgHr: z.number().positive().optional(),
maxHr: z.number().positive().optional(),
calories: z.number().nonnegative().optional(),
elevationGainM: z.number().nonnegative().optional(),
avgCadence: z.number().nonnegative().optional(),
avgVerticalOscillationCm: z.number().nonnegative().optional(),
avgGroundContactTimeMs: z.number().nonnegative().optional(),
avgStrideLengthCm: z.number().nonnegative().optional(),
avgGroundContactBalanceLeftPct: z.number().nonnegative().optional(),
avgVerticalRatioPct: z.number().nonnegative().optional(),
vo2Max: z.number().nonnegative().optional(),
aerobicTrainingEffect: z.number().nonnegative().optional(),
anaerobicTrainingEffect: z.number().nonnegative().optional(),
trainingEffectLabel: z.string().optional(),
avgPowerW: z.number().nonnegative().optional(),
maxPowerW: z.number().nonnegative().optional(),
normPowerW: z.number().nonnegative().optional(),
avgRespirationRate: z.number().nonnegative().optional(),
hasRoute: z.boolean().optional(),
});
export type RunningActivityInput = z.infer<typeof runningActivitySchema>;
export type RoutePoint = [number, number];
export type RunningActivity = RunningActivityInput & {
_id: ObjectId;
createdAt: Date;
routePoints?: RoutePoint[];
};
const COLLECTION = "running_activities";
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 });
return collection;
}
export async function upsertRunningActivity(activity: RunningActivityInput): Promise<void> {
const collection = await getCollection();
await collection.updateOne(
{ garminActivityId: activity.garminActivityId },
{
$set: activity,
$setOnInsert: { createdAt: new Date() },
},
{ upsert: true }
);
}
export async function listRunningActivities(): Promise<RunningActivity[]> {
const collection = await getCollection();
return collection.find().sort({ startTime: -1 }).toArray();
}
export async function getRunningActivity(id: string): Promise<RunningActivity | null> {
const collection = await getCollection();
return collection.findOne({ _id: new ObjectId(id) });
}
export async function setRunningActivityRoutePoints(
garminActivityId: number,
points: RoutePoint[]
): Promise<void> {
const collection = await getCollection();
await collection.updateOne({ garminActivityId }, { $set: { routePoints: points } });
}
type SyncState = { _id: "garmin"; lastSyncAt: Date };
export async function getLastSyncAt(): Promise<Date | null> {
const db = await getDb();
const state = await db.collection<SyncState>(SYNC_STATE_COLLECTION).findOne({ _id: "garmin" });
return state?.lastSyncAt ?? null;
}
export async function setLastSyncAt(date: Date): Promise<void> {
const db = await getDb();
await db
.collection<SyncState>(SYNC_STATE_COLLECTION)
.updateOne({ _id: "garmin" }, { $set: { lastSyncAt: date } }, { upsert: true });
}

66
lib/models/strength.ts Normal file
View File

@@ -0,0 +1,66 @@
import { ObjectId } from "mongodb";
import { z } from "zod";
import { getDb } from "@/lib/db";
export const strengthSetSchema = z.object({
order: z.number().int().positive(),
weightKg: z.number().positive().optional(),
reps: z.number().int().positive().optional(),
});
export const strengthExerciseSchema = z.object({
name: z.string().min(1),
notes: z.string().optional(),
sets: z.array(strengthSetSchema),
});
export const strengthWorkoutSchema = z.object({
date: z.date(),
name: z.string().min(1),
notes: z.string().optional(),
exercises: z.array(strengthExerciseSchema),
sourceUrl: z.string().optional(),
sourceKey: z.string().min(1),
});
export type StrengthSet = z.infer<typeof strengthSetSchema>;
export type StrengthExercise = z.infer<typeof strengthExerciseSchema>;
export type StrengthWorkoutInput = z.infer<typeof strengthWorkoutSchema>;
export type StrengthWorkout = StrengthWorkoutInput & {
_id: ObjectId;
createdAt: Date;
};
const COLLECTION = "strength_workouts";
async function getCollection() {
const db = await getDb();
const collection = db.collection<StrengthWorkout>(COLLECTION);
await collection.createIndex({ sourceKey: 1 }, { unique: true });
return collection;
}
export async function upsertStrengthWorkout(
workout: StrengthWorkoutInput
): Promise<void> {
const collection = await getCollection();
await collection.updateOne(
{ sourceKey: workout.sourceKey },
{
$set: workout,
$setOnInsert: { createdAt: new Date() },
},
{ upsert: true }
);
}
export async function listStrengthWorkouts(): Promise<StrengthWorkout[]> {
const collection = await getCollection();
return collection.find().sort({ date: -1 }).toArray();
}
export async function getStrengthWorkout(id: string): Promise<StrengthWorkout | null> {
const collection = await getCollection();
return collection.findOne({ _id: new ObjectId(id) });
}

46
lib/strength/stats.ts Normal file
View File

@@ -0,0 +1,46 @@
import type { StrengthExercise, StrengthWorkout } from "@/lib/models/strength";
export function exerciseVolumeKg(exercise: StrengthExercise): number {
return exercise.sets.reduce((sum, set) => sum + (set.weightKg ?? 0) * (set.reps ?? 0), 0);
}
export function exerciseTopWeightKg(exercise: StrengthExercise): number | undefined {
const weights = exercise.sets
.map((set) => set.weightKg)
.filter((weight): weight is number => weight !== undefined);
return weights.length > 0 ? Math.max(...weights) : undefined;
}
export function workoutVolumeKg(workout: StrengthWorkout): number {
return workout.exercises.reduce((sum, exercise) => sum + exerciseVolumeKg(exercise), 0);
}
export type ExerciseHistoryPoint = {
date: Date;
volumeKg: number;
topWeightKg?: number;
};
/**
* History of a single exercise across past workouts (oldest first, including
* the workout it was found in), used to chart progression.
*/
export function getExerciseHistory(
exerciseName: string,
workouts: StrengthWorkout[],
limit: number
): ExerciseHistoryPoint[] {
const points: ExerciseHistoryPoint[] = [];
for (const workout of workouts) {
const exercise = workout.exercises.find((e) => e.name === exerciseName);
if (!exercise) continue;
points.push({
date: workout.date,
volumeKg: exerciseVolumeKg(exercise),
topWeightKg: exerciseTopWeightKg(exercise),
});
}
points.sort((a, b) => a.date.getTime() - b.date.getTime());
return points.slice(-limit);
}

97
lib/strong/parser.ts Normal file
View File

@@ -0,0 +1,97 @@
import { createHash } from "crypto";
import { parse } from "date-fns";
import { enUS } from "date-fns/locale";
import type { StrengthWorkoutInput } from "@/lib/models/strength";
const DATE_FORMAT = "EEEE, d MMMM yyyy 'at' HH:mm";
const HEADER_DATE_RE = /^[A-Za-z]+,\s+\d{1,2}\s+[A-Za-z]+\s+\d{4}\s+at\s+\d{1,2}:\d{2}$/;
const SET_RE = /^Set\s+\d+:\s*(?:([\d.,]+)\s*kg\s*[×x]\s*)?(\d+)$/i;
const SOURCE_URL_RE = /^https:\/\/link\.strong\.app\/\S+$/;
type ParsedBlock = string[];
function splitBlocks(text: string): ParsedBlock[] {
return text
.replace(/\r\n/g, "\n")
.split(/\n\s*\n+/)
.map((block) =>
block
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
)
.filter((block) => block.length > 0);
}
function parseWeight(raw: string | undefined): number | undefined {
if (!raw) return undefined;
const value = Number.parseFloat(raw.replace(",", "."));
return Number.isFinite(value) ? value : undefined;
}
function makeSourceKey(workout: Omit<StrengthWorkoutInput, "sourceKey">): string {
if (workout.sourceUrl) return workout.sourceUrl;
return createHash("sha256")
.update(`${workout.date.toISOString()}|${workout.name}`)
.digest("hex");
}
export function parseStrongShareText(text: string): StrengthWorkoutInput[] {
const blocks = splitBlocks(text);
const workouts: Omit<StrengthWorkoutInput, "sourceKey">[] = [];
for (const block of blocks) {
const isHeader = block.length === 2 && HEADER_DATE_RE.test(block[1]);
if (isHeader) {
const date = parse(block[1], DATE_FORMAT, new Date(), { locale: enUS });
workouts.push({ date, name: block[0], exercises: [] });
continue;
}
const current = workouts[workouts.length - 1];
if (!current) {
throw new Error(`Nieoczekiwany blok przed nagłówkiem treningu: "${block[0]}"`);
}
const lines = [...block];
const lastLine = lines[lines.length - 1];
if (SOURCE_URL_RE.test(lastLine)) {
current.sourceUrl = lastLine;
lines.pop();
}
if (lines.length === 0) continue;
if (/^Notes:/i.test(lines[0])) {
const note = lines.join(" ").replace(/^Notes:\s*/i, "");
const lastExercise = current.exercises[current.exercises.length - 1];
if (lastExercise) {
lastExercise.notes = note;
} else {
current.notes = note;
}
continue;
}
const [exerciseName, ...setLines] = lines;
const sets = setLines
.map((line, index) => {
const match = SET_RE.exec(line);
if (!match) return null;
return {
order: index + 1,
weightKg: parseWeight(match[1]),
reps: Number.parseInt(match[2], 10),
};
})
.filter((set): set is NonNullable<typeof set> => set !== null);
current.exercises.push({ name: exerciseName, sets });
}
return workouts.map((workout) => ({
...workout,
sourceKey: makeSourceKey(workout),
}));
}