init
This commit is contained in:
314
lib/ai/claude.ts
Normal file
314
lib/ai/claude.ts
Normal 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
21
lib/db.ts
Normal 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
30
lib/format.ts
Normal 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
164
lib/garmin/client.ts
Normal 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
176
lib/garmin/sso.ts
Normal 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
52
lib/garmin/wellness.ts
Normal 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
62
lib/models/analysis.ts
Normal 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
40
lib/models/garmin-auth.ts
Normal 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
96
lib/models/running.ts
Normal 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
66
lib/models/strength.ts
Normal 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
46
lib/strength/stats.ts
Normal 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
97
lib/strong/parser.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user