115 lines
9.8 KiB
TypeScript
115 lines
9.8 KiB
TypeScript
export type AnalysisPeriod = '7d' | '14d' | '21d' | '30d' | '90d' | 'all'
|
|
export type MeasurementMetric = 'weight' | 'neck' | 'shoulders' | 'chest' | 'waist' | 'hips' |
|
|
'left_arm' | 'right_arm' | 'left_forearm' | 'right_forearm' |
|
|
'left_thigh' | 'right_thigh' | 'left_calf' | 'right_calf'
|
|
|
|
export const ANALYSIS_PERIODS: readonly AnalysisPeriod[] = ['7d', '14d', '21d', '30d', '90d', 'all']
|
|
export const MEASUREMENT_METRICS: readonly MeasurementMetric[] = [
|
|
'weight', 'neck', 'shoulders', 'chest', 'waist', 'hips', 'left_arm', 'right_arm',
|
|
'left_forearm', 'right_forearm', 'left_thigh', 'right_thigh', 'left_calf', 'right_calf',
|
|
]
|
|
|
|
export interface AnalysisActivityDay { date: string; sessions: number; sets: number; exercises: number }
|
|
export interface AnalysisExerciseGroupItem { exercise_id: string; name: string; occurrences: number; sessions: number; sets: number; measured_seconds: number; measured_occurrences: number }
|
|
export interface AnalysisExerciseGroup { zone_id: string; label: string; exercises: AnalysisExerciseGroupItem[] }
|
|
export interface AnalysisExerciseOption { exercise_id: string; name: string; tracking_mode: 'reps' | 'duration'; recording_mode: 'sets' | 'continuous' }
|
|
export interface AnalysisExercisePoint {
|
|
session_id: string; timestamp: string; load_mode: 'none' | 'external' | 'assistance'
|
|
sets: number; reps: number | null; set_duration_seconds: number | null
|
|
external_load_kg: number | null; external_volume_kg: number | null; continuous_duration_seconds: number | null
|
|
distance_km: number | null; speed_kmh: number | null; explicit_max_kg: number | null
|
|
}
|
|
export interface AnalysisExercise extends AnalysisExerciseOption { points: AnalysisExercisePoint[] }
|
|
export interface AnalysisZone { zone_id: string; label: string; exposures: number; associated_sets: number }
|
|
export interface MeasurementSummary { metric: MeasurementMetric; unit: 'kg' | 'cm'; count: number; first: number | null; last: number | null; delta: number | null }
|
|
export interface MeasurementPoint { timestamp: string; value: number }
|
|
export interface MeasurementSeries {
|
|
metric: MeasurementMetric
|
|
unit: 'kg' | 'cm'
|
|
points: MeasurementPoint[]
|
|
}
|
|
export interface ActiveProgram {
|
|
program_id: string
|
|
title: string
|
|
total_sessions: number
|
|
completed_sessions: number
|
|
next_session_title: string | null
|
|
next_session_id: string | null
|
|
next_session_planned_for: string | null
|
|
}
|
|
|
|
export interface AnalysisSnapshot {
|
|
api_version: 1
|
|
period: AnalysisPeriod
|
|
overview: { sessions: number; sets: number; duration_seconds: number | null; distinct_exercises: number }
|
|
activity: AnalysisActivityDay[]
|
|
exercise_groups: AnalysisExerciseGroup[]
|
|
exercises: AnalysisExerciseOption[]
|
|
exercise: AnalysisExercise | null
|
|
body_zones: AnalysisZone[]
|
|
measurements: {
|
|
summaries: MeasurementSummary[]
|
|
series: MeasurementSeries[]
|
|
selected_metric: MeasurementMetric
|
|
unit: 'kg' | 'cm'
|
|
points: MeasurementPoint[]
|
|
}
|
|
active_program: ActiveProgram | null
|
|
meta: { partial: boolean; reference_unix_second: number }
|
|
}
|
|
|
|
const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
|
|
const count = (value: unknown): value is number => finite(value) && Number.isSafeInteger(value) && value >= 0
|
|
const nullableFinite = (value: unknown): value is number | null => value === null || finite(value)
|
|
const period = (value: unknown): value is AnalysisPeriod => typeof value === 'string' && ANALYSIS_PERIODS.includes(value as AnalysisPeriod)
|
|
const metric = (value: unknown): value is MeasurementMetric => typeof value === 'string' && MEASUREMENT_METRICS.includes(value as MeasurementMetric)
|
|
|
|
/** CONTRACT: malformed or unbounded API shapes are rejected before rendering;
|
|
* missing observations remain null and are never coerced to zero. */
|
|
export function parseAnalysis(value: unknown): AnalysisSnapshot {
|
|
if (!record(value) || value.api_version !== 1 || !period(value.period) || !record(value.overview) ||
|
|
!Array.isArray(value.activity) || !Array.isArray(value.exercises) || !Array.isArray(value.body_zones) ||
|
|
!record(value.measurements) || !record(value.meta)) throw new TypeError('analysis_invalid')
|
|
if (!count(value.overview.sessions) || !count(value.overview.sets) || !nullableFinite(value.overview.duration_seconds) || (value.overview.distinct_exercises !== undefined && !count(value.overview.distinct_exercises))) throw new TypeError('analysis_overview_invalid')
|
|
value.overview.distinct_exercises = Number(value.overview.distinct_exercises ?? 0)
|
|
if (value.activity.length > 90 || !value.activity.every((item) => record(item) && typeof item.date === 'string' && count(item.sessions) && count(item.sets) && (item.exercises === undefined || count(item.exercises)))) throw new TypeError('analysis_activity_invalid')
|
|
value.activity = value.activity.map((item) => ({ ...item, exercises: Number(item.exercises ?? 0) }))
|
|
if (value.exercise_groups === undefined) value.exercise_groups = []
|
|
if (!Array.isArray(value.exercise_groups) || !value.exercise_groups.every((group) => record(group) && typeof group.zone_id === 'string' && typeof group.label === 'string' && Array.isArray(group.exercises) && group.exercises.every((item) => record(item) && typeof item.exercise_id === 'string' && typeof item.name === 'string' && count(item.occurrences) && count(item.sessions) && count(item.sets) && count(item.measured_seconds) && count(item.measured_occurrences)))) throw new TypeError('analysis_exercise_groups_invalid')
|
|
if (value.exercises.length > 128 || !value.exercises.every((item) => record(item) && typeof item.exercise_id === 'string' && typeof item.name === 'string' && (item.tracking_mode === 'reps' || item.tracking_mode === 'duration') && (item.recording_mode === 'sets' || item.recording_mode === 'continuous'))) throw new TypeError('analysis_exercises_invalid')
|
|
if (value.exercise !== null) {
|
|
if (!record(value.exercise) || typeof value.exercise.exercise_id !== 'string' || typeof value.exercise.name !== 'string' ||
|
|
(value.exercise.tracking_mode !== 'reps' && value.exercise.tracking_mode !== 'duration') ||
|
|
(value.exercise.recording_mode !== 'sets' && value.exercise.recording_mode !== 'continuous') ||
|
|
!Array.isArray(value.exercise.points) || value.exercise.points.length > 90 ||
|
|
!value.exercise.points.every((point) => record(point) && typeof point.session_id === 'string' && typeof point.timestamp === 'string' &&
|
|
(point.load_mode === 'none' || point.load_mode === 'external' || point.load_mode === 'assistance') && count(point.sets) &&
|
|
nullableFinite(point.reps) && nullableFinite(point.set_duration_seconds) && nullableFinite(point.external_load_kg) && nullableFinite(point.external_volume_kg) &&
|
|
nullableFinite(point.continuous_duration_seconds) && nullableFinite(point.distance_km) && nullableFinite(point.speed_kmh) && nullableFinite(point.explicit_max_kg))) throw new TypeError('analysis_exercise_invalid')
|
|
}
|
|
if (!value.body_zones.every((item) => record(item) && typeof item.zone_id === 'string' && typeof item.label === 'string' && count(item.exposures) && count(item.associated_sets))) throw new TypeError('analysis_zones_invalid')
|
|
const summaries = value.measurements.summaries
|
|
const series = value.measurements.series
|
|
const points = value.measurements.points
|
|
if (!Array.isArray(summaries) || summaries.length !== MEASUREMENT_METRICS.length || !summaries.every((item) => record(item) && metric(item.metric) && (item.unit === 'kg' || item.unit === 'cm') && count(item.count) && nullableFinite(item.first) && nullableFinite(item.last) && nullableFinite(item.delta)) ||
|
|
!Array.isArray(series) || series.length > MEASUREMENT_METRICS.length ||
|
|
!series.every((item) => record(item) && metric(item.metric) && (item.unit === 'kg' || item.unit === 'cm') &&
|
|
Array.isArray(item.points) && item.points.length >= 2 && item.points.length <= 90 &&
|
|
item.points.every((point) => record(point) && typeof point.timestamp === 'string' && finite(point.value))) ||
|
|
!metric(value.measurements.selected_metric) || (value.measurements.unit !== 'kg' && value.measurements.unit !== 'cm') || !Array.isArray(points) || points.length > 90 || !points.every((item) => record(item) && typeof item.timestamp === 'string' && finite(item.value))) throw new TypeError('analysis_measurements_invalid')
|
|
if (value.active_program !== null && (!record(value.active_program) || typeof value.active_program.program_id !== 'string' || typeof value.active_program.title !== 'string' || !count(value.active_program.total_sessions) || !count(value.active_program.completed_sessions) || !(value.active_program.next_session_title === null || typeof value.active_program.next_session_title === 'string') || !(value.active_program.next_session_id === null || typeof value.active_program.next_session_id === 'string') || !(value.active_program.next_session_planned_for === null || typeof value.active_program.next_session_planned_for === 'string'))) throw new TypeError('analysis_program_invalid')
|
|
if (typeof value.meta.partial !== 'boolean' || !count(value.meta.reference_unix_second)) throw new TypeError('analysis_meta_invalid')
|
|
return value as unknown as AnalysisSnapshot
|
|
}
|
|
|
|
export interface AnalysisQuery { period?: AnalysisPeriod; exerciseId?: string; metric?: MeasurementMetric }
|
|
|
|
export async function fetchAnalysis(query: AnalysisQuery = {}, signal?: AbortSignal): Promise<AnalysisSnapshot> {
|
|
const parameters = new URLSearchParams({ period: query.period ?? '30d', page: '1' })
|
|
if (query.exerciseId) parameters.set('exercise_id', query.exerciseId)
|
|
if (query.metric) parameters.set('metric', query.metric)
|
|
const response = await fetch(`/api/v1/analysis?${parameters}`, { headers: { Accept: 'application/json' }, signal })
|
|
if (!response.ok) throw new Error(`analysis HTTP ${response.status}`)
|
|
return parseAnalysis(await response.json())
|
|
}
|