import axios, { AxiosInstance } from 'axios'; const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api'; const TOKEN_KEY = 'ghl_token'; /** Fired on this tab whenever the session is created or cleared. */ export const AUTH_EVENT = 'ghl:auth'; // The Flask backend returns bare JSON (an array, or the object itself) and // signals failure with `{ error: "..." }` plus a non-2xx status. There is no // {success, data} envelope, so responses are read as `response.data` directly. export interface AuthResponse { id: string; username: string; token: string; } export interface SleepDetail { duration: number; quality: number | null; deepSeconds: number | null; lightSeconds: number | null; remSeconds: number | null; awakeSeconds: number | null; } /** One day, with every metric the sync stores. All are nullable: a device * that does not record a metric leaves it null rather than zero. */ export interface HealthDay { date: string; steps: number | null; stepGoal: number | null; distanceMeters: number | null; caloriesBurned: number | null; activeCalories: number | null; bmrCalories: number | null; floorsAscended: number | null; floorsDescended: number | null; intensityMinutes: number | null; sedentarySeconds: number | null; activeSeconds: number | null; heartRate: number | null; heartRateMax: number | null; heartRateMin: number | null; heartRateVariability: number | null; stress: number | null; stressMax: number | null; bodyBatteryHigh: number | null; bodyBatteryLow: number | null; bodyBatteryCharged: number | null; bodyBatteryDrained: number | null; spo2Avg: number | null; spo2Min: number | null; respirationAvg: number | null; respirationMin: number | null; respirationMax: number | null; sleepDuration: number | null; sleepQuality: number | null; sleepSpo2Avg: number | null; sleepRespirationAvg: number | null; sleepStressAvg: number | null; trainingReadiness: number | null; vo2max: number | null; hillScore: number | null; hydrationMl: number | null; hydrationGoalMl: number | null; sweatLossMl: number | null; weightKg: number | null; bodyFatPct: number | null; bmi: number | null; enduranceScore: number | null; sleep: SleepDetail | null; } export interface Badge { id: string; badge_key: string | null; name: string | null; category_id: number | null; difficulty_id: number | null; earned_date: string | null; earned_count: number | null; points: number | null; } export interface PersonalRecord { id: string; type_id: number | null; activity_id: string | null; activity_name: string | null; activity_type: string | null; value: number | null; achieved_at: string | null; } export interface Activity { id: string; activity_type: string; start_time: string; end_time: string; duration: number | null; distance: number | null; calories: number | null; heart_rate_average: number | null; heart_rate_max: number | null; } export interface SyncStatus { status: 'idle' | 'syncing' | 'error' | 'rate_limited'; lastSyncTime: string | null; recordsSynced: number; totalDays: number; lastError: string | null; /** Days completed / requested. A backfill runs for many minutes. */ progressCurrent: number | null; progressTotal: number | null; startedAt: string | null; /** Which part of the sync is running, e.g. 每日数据 2026-08-01. A bare * "0 / 730 天" says nothing about what is happening for several minutes. */ stage?: string | null; } export interface SyncResult { status: 'success' | 'error' | 'rate_limited'; recordsSynced: number; activitiesSynced?: number; message: string; /** Set when the account has two-factor auth and no token is stored yet. */ mfaRequired?: boolean; lastSyncTime: string; } export interface Recommendation { id: string; category: string; recommendation: string; priority: 'high' | 'medium' | 'low'; basedOn: string[]; source?: 'ai'; } export interface AiRecommendations { recommendations: Recommendation[]; meta: { source: 'ai' | 'rules'; model: string | null; provider?: string; upstream?: string | null; days?: number; fallbackFrom?: string[]; reason?: string; /** True when served from the stored answer rather than freshly generated. */ cached?: boolean; generatedAt?: string; }; } export type GarminLoginStatus = | 'starting' | 'awaiting_code' | 'finishing' | 'done' | 'failed'; export interface ModelInfo { id: string; model: string; provider: string; contextWindow: number; configured: boolean; default: boolean; } export interface UserSettings { heightCm: number | null; weightKg: number | null; birthDate: string | null; sex: 'male' | 'female' | 'other' | null; units: 'metric' | 'imperial'; autoSync: boolean; autoSyncMinutes: number; /** 0 means "everything Garmin has". */ historyDays: number; age: number | null; bmi: number | null; } export interface SettingsOptions { sexes: string[]; units: string[]; autoSyncMinutes: number[]; historyDays: number[]; } export interface BasisStep { name: string; detail: string; source: string; } export interface RatingBasis { fitnessAge: { title: string; summary: string; steps: BasisStep[]; caveat: string; }; bands: Array<{ metric: string; bands: string; source: string }>; note: string; } export interface FitnessAge { value: number | null; chronologicalAge?: number; delta?: number; clamped?: boolean; steps?: Array<{ label: string; input: string; years: number; kind: string; /** Base step only: the undamped figure, and the factor applied to it. */ raw?: number; damping?: number; }>; missing: string[]; basis: RatingBasis['fitnessAge']; } export interface DetailSyncStatus { running: boolean; /** Which part of the backfill is running: 运动详情 / 每日曲线. */ stage?: string | null; done: number; total: number; error?: string | null; } export interface BodyCompositionDay { date: string; weightKg: number | null; bmi: number | null; bodyFatPct: number | null; bodyWaterPct: number | null; boneMassKg: number | null; muscleMassKg: number | null; physiqueRating: number | null; visceralFat: number | null; metabolicAge: number | null; } export interface BloodPressureReading { measuredAt: string; systolic: number | null; diastolic: number | null; pulse: number | null; note: string | null; } /** Predicted finishing times, in seconds. */ export interface RacePrediction { date: string; time5k: number | null; time10k: number | null; timeHalf: number | null; timeMarathon: number | null; } /** [timestamp, value] pairs, thinned to at most 240 points per day. */ export type DailySeries = Record>; export interface Challenge { uuid: string; kind: string; name: string | null; status: string | null; startDate: string | null; endDate: string | null; payload: Record; } export interface Device { deviceId: string; name: string | null; model: string | null; serial: string | null; softwareVersion: string | null; lastUsedAt: string | null; } export interface AutoSyncStatus { enabled: boolean; intervalSeconds: number; tickSeconds: number; days: number; lastRunAt: string | null; nextRunAt: string | null; running: boolean; account?: { autoSync: boolean; intervalMinutes: number; dueAt: string | null; }; } /** One activity in full. Shapes mirror Garmin's own payload, which is why the * summary is left loosely typed — it carries dozens of optional fields that * differ by sport. */ export interface ActivityDetail { activityId: string; activityName: string | null; activityType: string | null; summary: Record; laps: Array<{ index: number; duration: number | null; movingDuration: number | null; distance: number | null; averageSpeed: number | null; maxSpeed: number | null; calories: number | null; averageHR: number | null; maxHR: number | null; elevationGain: number | null; elevationLoss: number | null; }>; hrZones: Array<{ zone: number; seconds: number; lowBoundary: number | null }>; weather: Record; gear: Array>; exerciseSets: Array>; series: Record>; cached: boolean; } export interface TrendPoint { date: string; value: number; } // --- AI coach ------------------------------------------------------------- /** How far one of today's metrics sits from the user's own recent baseline. */ export interface Deviation { metric: string; label: string; unit: string; value: number; baselineMean: number | null; sd: number | null; baselineDays?: number; z: number | null; verdict: string; } export interface TrendSummary { metric: string; label: string; unit: string; days: number; samples: number; firstMean: number; lastMean: number; delta: number; slopePer30d: number | null; direction?: string; } /** The computed features a briefing was derived from — the same numbers the * card quotes, so the UI can show them without a second request. */ export interface BriefingContext { snapshotDate: string; userProfile: Record; todayMetrics: { sleep: Record | null; autonomicNervous: Record; recovery: Record; activityToday: Record; }; deviations: Deviation[]; trends: TrendSummary[]; activityShift: Record; recentActivities: Array>; dataQuality: { totalDays: number; firstDate: string; lastDate: string; staleDays: number }; } export interface Briefing { status: string; headline: string | null; diagnosis: Array<{ title: string; detail: string }>; shortfall: string | null; prescription: { intensity: string | null; hrZone: string | null; suggestion: string | null; durationMin: number | null; avoid: string | null; }; actions: string[]; } /** `source` says who answered: the model, or the rule engine standing in for * it. `pending` means the card on screen is the placeholder and the model's * version is still being generated — poll again. */ export interface InsightMeta { source: 'ai' | 'rules' | 'none'; model?: string | null; upstream?: string | null; cached?: boolean; pending?: boolean; generating?: boolean; generatedAt?: string | null; reason?: string; } export interface BriefingResponse { briefing: Briefing | null; context: BriefingContext | null; meta: InsightMeta; } export interface TrendInsight { summary: string | null; drivers: Array<{ factor: string; detail: string }>; caution: string | null; confidence: 'high' | 'medium' | 'low'; } export interface TrendInsightResponse { insight: TrendInsight | null; window: Record | null; meta: InsightMeta; } export interface CopilotTurn { role: 'user' | 'assistant'; content: string; } /** * Parse a timestamp the backend wrote with `datetime.utcnow()` — i.e. UTC but * with no offset in the string. JavaScript reads such a value as *local* time, * which showed sync times eight hours in the past here. */ export function parseUtc(value: string | null | undefined): Date | null { if (!value) return null; const normalised = value.replace(' ', 'T'); const withZone = /[Zz]|[+-]\d{2}:?\d{2}$/.test(normalised) ? normalised : `${normalised}Z`; const date = new Date(withZone); return Number.isNaN(date.getTime()) ? null : date; } /** Pull a human-readable message out of an axios error. */ export function errorMessage(err: any, fallback = '请求失败'): string { return err?.response?.data?.error || err?.message || fallback; } class ApiClient { private client: AxiosInstance; constructor() { this.client = axios.create({ baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json' }, }); // Attach the saved JWT to every request. this.client.interceptors.request.use((config) => { const token = localStorage.getItem(TOKEN_KEY); if (token) { config.headers = config.headers || {}; config.headers.Authorization = `Bearer ${token}`; } return config; }); // On 401 drop the session and bounce to login, so an expired token does // not leave the user staring at empty pages. this.client.interceptors.response.use( (resp) => resp, (error) => { if (error.response?.status === 401) { localStorage.removeItem(TOKEN_KEY); if (window.location.pathname !== '/login') { window.location.href = '/login'; } } return Promise.reject(error); } ); } // --- session --- /* `storage` only fires in *other* tabs, so a same-tab login or logout needs its own signal for the shell to notice. */ private announce() { window.dispatchEvent(new Event(AUTH_EVENT)); } setSession(token: string) { localStorage.setItem(TOKEN_KEY, token); this.announce(); } clearSession() { localStorage.removeItem(TOKEN_KEY); this.announce(); } isAuthenticated(): boolean { return Boolean(localStorage.getItem(TOKEN_KEY)); } // --- auth --- async logout() { try { await this.client.post('/auth/logout'); } finally { this.clearSession(); } } // --- auth-hub OAuth2 --- async authHubStart() { const { data } = await this.client.post<{ auth_url: string; code_verifier: string; state: string; }>('/auth/auth-hub/start', {}); return data; } async authHubCallback(code: string, codeVerifier: string) { const { data } = await this.client.get('/auth/callback', { params: { code, code_verifier: codeVerifier } }); return data; } // --- garmin --- /** * With a stored OAuth token no password is needed. Without one, the * plaintext password must be supplied because only a hash is kept — and an * MFA-protected account cannot log in this way at all (see garmin_login.py). */ /** Starts a sync in the background; poll getGarminSyncStatus for progress. */ async syncGarminData(days?: number, garminPassword?: string) { const { data } = await this.client.post<{ status: string; days?: number; message?: string; retryAfterSeconds?: number; }>( '/garmin/sync', { // `days` must survive being 0 — that is "全部历史", not "unset". // `days ? …` dropped it, so the backend fell back to its 7-day // default and a full backfill silently pulled a week. ...(days === undefined || days === null ? {} : { days }), ...(garminPassword ? { garminPassword } : {}), } ); return data; } /** Whether a stored Garmin token exists (then sync needs no password). */ async getGarminAuthStatus() { const { data } = await this.client.get<{ hasToken: boolean }>( '/garmin/auth-status' ); return data.hasToken; } /** Drop the stored Garmin OAuth token; the next sync must re-authenticate. */ async disconnectGarmin() { const { data } = await this.client.post<{ ok: boolean; message?: string }>( '/garmin/disconnect' ); return data; } /** * Start an interactive Garmin login. Returns a session id; the login runs * in the background and parks if Garmin asks for a two-factor code. */ async startGarminLogin(garminPassword: string, garminEmail?: string) { const { data } = await this.client.post<{ session: string }>('/garmin/login', { garminPassword, ...(garminEmail ? { garminEmail } : {}), }); return data.session; } async getGarminLoginStatus(session: string) { const { data } = await this.client.get<{ session: string; status: GarminLoginStatus; error: string | null; }>('/garmin/login-status', { params: { session } }); return data; } async submitGarminMfa(session: string, code: string) { const { data } = await this.client.post<{ ok: boolean; message: string }>( '/garmin/mfa', { session, code } ); return data; } async cancelGarminLogin(session: string) { await this.client.delete('/garmin/login', { params: { session } }); } async getGarminSyncStatus() { const { data } = await this.client.get('/garmin/status'); return data; } // --- health --- private range(startDate?: string, endDate?: string) { return { params: { startDate, endDate } }; } async getHealthSummary(startDate?: string, endDate?: string) { const { data } = await this.client.get( '/health/summary', this.range(startDate, endDate) ); return data; } async getActivities(startDate?: string, endDate?: string) { const { data } = await this.client.get( '/health/activities', this.range(startDate, endDate) ); return data; } // --- settings --- async getSettings() { const { data } = await this.client.get('/settings'); return data; } async saveSettings(patch: Partial) { const { data } = await this.client.put('/settings', patch); return data; } async getSettingsOptions() { const { data } = await this.client.get('/settings/options'); return data; } async getRatingBasis() { const { data } = await this.client.get('/settings/rating-basis'); return data; } async getFitnessAge() { const { data } = await this.client.get('/health/fitness-age'); return data; } async getAutoSyncStatus() { const { data } = await this.client.get('/garmin/auto-sync'); return data; } /** Pull the last few days inline — fast enough to await, unlike a backfill. */ async syncLatest(days = 2) { const { data } = await this.client.post( '/garmin/sync-latest', { days } ); return data; } /** A local read: details are stored during the sync, not fetched on tap. */ async getActivityDetail(activityId: string) { const { data } = await this.client.get( `/garmin/activities/${activityId}/detail` ); return data; } async startDetailSync(limit?: number) { const { data } = await this.client.post( '/garmin/sync-details', limit ? { limit } : {} ); return data; } async getDetailSyncStatus() { const { data } = await this.client.get('/garmin/sync-details'); return data; } async getBodyComposition(startDate?: string, endDate?: string) { const { data } = await this.client.get( '/health/body-composition', this.range(startDate, endDate) ); return data; } async getBloodPressure() { const { data } = await this.client.get( '/health/blood-pressure' ); return data; } async getRacePredictions() { const { data } = await this.client.get( '/health/race-predictions' ); return data; } async getDailySeries(date: string) { const { data } = await this.client.get( '/health/series', { params: { date } } ); return data; } async getChallenges() { const { data } = await this.client.get('/health/challenges'); return data; } async getDevices() { const { data } = await this.client.get('/health/devices'); return data; } async getBadges() { const { data } = await this.client.get('/health/badges'); return data; } async getPersonalRecords() { const { data } = await this.client.get( '/health/personal-records' ); return data; } // --- analysis --- async getTrends(metricType: string, startDate?: string, endDate?: string) { const { data } = await this.client.get('/analysis/trends', { params: { metricType, startDate, endDate }, }); return data; } async getRecommendations() { const { data } = await this.client.get('/analysis/recommendations'); return data; } async getModels() { const { data } = await this.client.get('/analysis/models'); return data; } /** * Served from the stored answer unless `refresh` is set or a `model` is * named. A fresh generation can take minutes, so the caller should show a * long-running state for those two cases. */ async getAiRecommendations(model?: string, refresh?: boolean, days?: number) { const { data } = await this.client.get( '/analysis/ai-recommendations', { params: { model, days, ...(refresh ? { refresh: 1 } : {}) }, // A cold generation runs well past axios's default timeout. timeout: 240_000, } ); return data; } // --- AI coach --- /** * 晨间简报 for one day, plus the computed context behind it. * * Returns immediately. When no stored model answer matches the current data * the reply is the rule-based briefing with `meta.pending`, and the model's * version is generated in the background — call again to pick it up. */ async getBriefing(opts: { date?: string; refresh?: boolean; model?: string } = {}) { const { data } = await this.client.get('/analysis/briefing', { params: { date: opts.date, model: opts.model, ...(opts.refresh ? { refresh: 1 } : {}), }, }); return data; } /** Attribution for one metric over a selected span. Blocking: a cold * generation runs well past axios's default timeout. */ async getTrendInsight( metric: string, startDate: string, endDate: string, refresh?: boolean ) { const { data } = await this.client.get( '/analysis/trend-insight', { params: { metric, startDate, endDate, ...(refresh ? { refresh: 1 } : {}) }, timeout: 300_000, } ); return data; } /** * Ask the Copilot, streamed. * * `fetch` rather than axios or EventSource: axios buffers the whole body * before resolving, and EventSource cannot send an Authorization header — * which would mean moving the JWT into the query string, where it would be * logged by every proxy in the path. * * `onDelta` is called with each fragment as it arrives. Pass `signal` to * abort; the promise resolves with the full text. */ async streamCopilot( question: string, opts: { history?: CopilotTurn[]; date?: string; model?: string; signal?: AbortSignal; onDelta?: (text: string) => void; } = {} ): Promise<{ text: string; upstream: string | null }> { const token = localStorage.getItem(TOKEN_KEY); const resp = await fetch(`${API_BASE_URL}/analysis/copilot`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ question, history: opts.history ?? [], date: opts.date, model: opts.model, }), signal: opts.signal, }); if (!resp.ok || !resp.body) { let message = `请求失败 (${resp.status})`; try { message = (await resp.json()).error || message; } catch { // A non-JSON error body (a proxy's HTML 502) leaves the status text. } throw new Error(message); } const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let text = ''; let upstream: string | null = null; let failure: string | null = null; // SSE frames are separated by a blank line and can split across chunks, // so the tail of the buffer is kept until its terminator arrives. for (;;) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let split = buffer.indexOf('\n\n'); while (split !== -1) { const frame = buffer.slice(0, split); buffer = buffer.slice(split + 2); split = buffer.indexOf('\n\n'); let event = 'message'; const dataLines: string[] = []; for (const line of frame.split('\n')) { if (line.startsWith('event:')) event = line.slice(6).trim(); else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim()); } if (!dataLines.length) continue; let payload: any; try { payload = JSON.parse(dataLines.join('\n')); } catch { continue; } if (event === 'delta' && payload.text) { text += payload.text; opts.onDelta?.(payload.text); } else if (event === 'done') { upstream = payload.upstream ?? null; } else if (event === 'error') { // Recorded rather than thrown here: the stream still has to be // drained, and the server closes it right after this frame. failure = payload.message || '生成失败'; } } } if (failure) throw new Error(failure); return { text, upstream }; } } export const apiClient = new ApiClient();