feat: radar

This commit is contained in:
skytek_xinliang
2026-09-21 15:13:46 +08:00
parent be1cf7d978
commit 261c5516ef
9 changed files with 426 additions and 178 deletions
@@ -23,7 +23,7 @@
<polygon
fill="rgba(33, 150, 243, 0.12)"
:points="getPolygonPoints(65)"
:points="getPolygonPoints(50)"
stroke="#2196F3"
stroke-dasharray="1 1"
stroke-width="0.5"
@@ -55,7 +55,7 @@
x="60"
y="8"
>
心肺 ({{ radarData.cardio }})
心肺耐力 ({{ radarData.cardio }})
</text>
<text
@@ -66,7 +66,7 @@
x="110"
y="44"
>
肌力 ({{ radarData.strength }})
上肢肌力 ({{ radarData.upperBodyStrength }})
</text>
<text
@@ -77,7 +77,7 @@
x="94"
y="104"
>
平衡 ({{ radarData.balance }})
下肢肌耐力 ({{ radarData.lowerBodyStrengthAndEndurance }})
</text>
<text
@@ -88,7 +88,7 @@
x="26"
y="104"
>
柔軟 ({{ radarData.flexibility }})
柔軟度 ({{ radarData.flexibility }})
</text>
<text
@@ -99,7 +99,7 @@
x="10"
y="44"
>
敏捷 ({{ radarData.agility }})
平衡、敏捷與行動 ({{ radarData.balanceAgilityAndMobility }})
</text>
</svg>
@@ -119,17 +119,10 @@
<script setup lang="ts">
import { computed } from "vue";
interface RadarData {
strength: number;
balance: number;
flexibility: number;
agility: number;
cardio: number;
}
import type { PhysicalFitnessRadarData } from "@/utils/physical-fitness-radar";
const { radarData } = defineProps<{
radarData: RadarData;
radarData: PhysicalFitnessRadarData;
}>();
const centerX = 60;
@@ -160,10 +153,10 @@ function getPolygonPoints(percent: number) {
const userDataCoordArray = computed(() => {
const values = [
radarData.cardio,
radarData.strength,
radarData.balance,
radarData.upperBodyStrength,
radarData.lowerBodyStrengthAndEndurance,
radarData.flexibility,
radarData.agility,
radarData.balanceAgilityAndMobility,
];
return values.map((value, index) => {
@@ -6,7 +6,9 @@
<v-app-bar-nav-icon variant="text" @click="drawer = !drawer" />
</template>
<v-app-bar-title class="font-weight-bold flex-grow-1"> 運動玩轉健康力 </v-app-bar-title>
<v-app-bar-title class="font-weight-bold flex-grow-1">
運動玩轉健康力
</v-app-bar-title>
<template #append>
<div class="d-flex align-center">
@@ -19,7 +21,10 @@
variant="text"
@click="isLargeText = !isLargeText"
>
<v-icon :color="isLargeText ? 'accent' : 'white'" icon="mdi-format-size" />
<v-icon
:color="isLargeText ? 'accent' : 'white'"
icon="mdi-format-size"
/>
</v-btn>
</div>
</template>
@@ -37,7 +42,7 @@
<template #prepend>
<v-avatar color="accent" size="48">
<span class="text-white font-weight-bold text-h6">
{{ (store.userProfile.name || '姓名').substring(0, 1) }}
{{ (store.userProfile.name || "姓名").substring(0, 1) }}
</span>
</v-avatar>
</template>
@@ -58,7 +63,9 @@
</v-avatar>
</template>
<v-list-item-subtitle class="text-white-50">未登入訪客</v-list-item-subtitle>
<v-list-item-subtitle class="text-white-50"
>未登入訪客</v-list-item-subtitle
>
</v-list-item>
<v-divider />
@@ -71,7 +78,12 @@
<v-list-item-title>大字體模式</v-list-item-title>
<template #append>
<v-switch v-model="isLargeText" color="secondary" density="compact" hide-details />
<v-switch
v-model="isLargeText"
color="secondary"
density="compact"
hide-details
/>
</template>
</v-list-item>
@@ -172,7 +184,10 @@
</v-navigation-drawer>
<!-- 主要內容區 -->
<v-main class="bg-white min-vh-100" :class="{ 'pb-16': BOTTOM_NAVIGATION_ENABLED }">
<v-main
class="bg-white min-vh-100"
:class="{ 'pb-16': BOTTOM_NAVIGATION_ENABLED }"
>
<v-container class="px-4 py-2">
<slot name="main" />
</v-container>
@@ -189,11 +204,17 @@
tag="div"
>
<v-btn to="/" value="home">
<v-icon :color="getBottomNavIconColor('home')">mdi-view-dashboard</v-icon>
<v-icon :color="getBottomNavIconColor('home')"
>mdi-view-dashboard</v-icon
>
<span class="bottom-nav-text">首頁</span>
</v-btn>
<v-btn v-if="SELF_ASSESSMENT_ENABLED" to="/self-assessment" value="self-assessment">
<v-btn
v-if="SELF_ASSESSMENT_ENABLED"
to="/self-assessment"
value="self-assessment"
>
<v-icon :color="getBottomNavIconColor('self-assessment')">
mdi-clipboard-text-search
</v-icon>
@@ -201,13 +222,25 @@
<span class="bottom-nav-text">自評</span>
</v-btn>
<v-btn v-if="CHECKIN_ENABLED && store.isLoggedIn" to="/checkin" value="checkin">
<v-icon :color="getBottomNavIconColor('checkin')">mdi-qrcode-scan</v-icon>
<v-btn
v-if="CHECKIN_ENABLED && store.isLoggedIn"
to="/checkin"
value="checkin"
>
<v-icon :color="getBottomNavIconColor('checkin')"
>mdi-qrcode-scan</v-icon
>
<span class="bottom-nav-text">量測</span>
</v-btn>
<v-btn v-if="APPOINTMENT_ENABLED && store.isLoggedIn" to="/appointment" value="appointment">
<v-icon :color="getBottomNavIconColor('appointment')">mdi-calendar-check</v-icon>
<v-btn
v-if="APPOINTMENT_ENABLED && store.isLoggedIn"
to="/appointment"
value="appointment"
>
<v-icon :color="getBottomNavIconColor('appointment')"
>mdi-calendar-check</v-icon
>
<span class="bottom-nav-text">預約</span>
</v-btn>
</v-bottom-navigation>
@@ -215,7 +248,9 @@
<!-- 隱私授權對話框 -->
<v-dialog v-model="showPrivacy" max-width="500">
<v-card class="rounded-xl">
<v-card-title class="bg-primary text-white font-weight-bold d-flex align-center">
<v-card-title
class="bg-primary text-white font-weight-bold d-flex align-center"
>
<v-icon class="mr-2" icon="mdi-shield-check" />
個資同意與資料授權
</v-card-title>
@@ -232,7 +267,7 @@
</ul>
<p class="text-caption text-grey">
授權狀態:{{ store.userProfile.consent ? '已同意授權' : '未授權' }}
授權狀態:{{ store.userProfile.consent ? "已同意授權" : "未授權" }}
</p>
</v-card-text>
@@ -252,57 +287,58 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDisplayUserName } from '@/composables/useDisplayUserName'
import { ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useDisplayUserName } from "@/composables/useDisplayUserName";
import {
APPOINTMENT_ENABLED,
BODY_COMPOSITION_ENABLED,
BOTTOM_NAVIGATION_ENABLED,
CHECKIN_ENABLED,
SELF_ASSESSMENT_ENABLED,
} from '@/config/features'
import { useAppStore } from '@/stores/app'
import { useCourseStore } from '@/stores/course'
} from "@/config/features";
import { useAppStore } from "@/stores/app";
import { useCourseStore } from "@/stores/course";
const store = useAppStore()
const courseStore = useCourseStore()
const router = useRouter()
const route = useRoute()
const displayUserName = useDisplayUserName(store.userProfile)
const store = useAppStore();
const courseStore = useCourseStore();
const router = useRouter();
const route = useRoute();
const displayUserName = useDisplayUserName(store.userProfile);
const drawer = ref(false)
const activeTab = ref('home')
const showPrivacy = ref(false)
const drawer = ref(false);
const activeTab = ref("home");
const showPrivacy = ref(false);
// 無障礙狀態 (可持久化儲存於 localStorage)
const isLargeText = ref(localStorage.getItem('mode-large-text') === 'true')
const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
watch(isLargeText, (newVal) => {
localStorage.setItem('mode-large-text', String(newVal))
})
localStorage.setItem("mode-large-text", String(newVal));
});
// 同步底部導航狀態與當前路由
watch(
() => route.path,
(path) => {
if (path === '/') activeTab.value = 'home'
else if (path.startsWith('/self-assessment')) activeTab.value = 'self-assessment'
else if (path.startsWith('/appointment')) activeTab.value = 'appointment'
else if (path.startsWith('/checkin')) activeTab.value = 'checkin'
if (path === "/") activeTab.value = "home";
else if (path.startsWith("/self-assessment"))
activeTab.value = "self-assessment";
else if (path.startsWith("/appointment")) activeTab.value = "appointment";
else if (path.startsWith("/checkin")) activeTab.value = "checkin";
},
{ immediate: true }
)
{ immediate: true },
);
function getBottomNavIconColor(value: string) {
return activeTab.value === value ? 'secondary' : 'secondary-darken-1'
return activeTab.value === value ? "secondary" : "secondary-darken-1";
}
async function handleLogout() {
await store.logout()
courseStore.resetGroup()
await router.push('/login')
drawer.value = false
await store.logout();
courseStore.resetGroup();
await router.push("/login");
drawer.value = false;
}
</script>
+7 -7
View File
@@ -1,7 +1,7 @@
export const APPOINTMENT_ENABLED = false
export const BODY_COMPOSITION_ENABLED = false
export const BOTTOM_NAVIGATION_ENABLED = false
export const CHECKIN_ENABLED = false
export const EXERCISE_DURATION_RECOMMENDATION_ENABLED = false
export const RADAR_ENABLED = false
export const SELF_ASSESSMENT_ENABLED = true
export const APPOINTMENT_ENABLED = false;
export const BODY_COMPOSITION_ENABLED = false;
export const BOTTOM_NAVIGATION_ENABLED = false;
export const CHECKIN_ENABLED = false;
export const EXERCISE_DURATION_RECOMMENDATION_ENABLED = false;
export const RADAR_ENABLED = true;
export const SELF_ASSESSMENT_ENABLED = true;
@@ -0,0 +1,57 @@
[
{
"fitnessCode": "UPGO_244M",
"fitnessName": "繞物行走 2.44m(秒)",
"average": "5.042"
},
{
"fitnessCode": "SIT_STAND_30S",
"fitnessName": "坐站次數 30 秒(次)",
"average": "19.125"
},
{
"fitnessCode": "SIT_REACH_L",
"fitnessName": "左腳椅子坐姿體前彎(cm)",
"average": "-0.492"
},
{
"fitnessCode": "SIT_REACH_R",
"fitnessName": "右腳椅子坐姿體前彎(cm)",
"average": "2.463"
},
{
"fitnessCode": "ARM_CURL_L",
"fitnessName": "左手肱二頭肌手臂屈舉(次)",
"average": "17.8"
},
{
"fitnessCode": "ARM_CURL_R",
"fitnessName": "右手肱二頭肌手臂屈舉(次)",
"average": "17.154"
},
{
"fitnessCode": "BACK_SCRATCH_L",
"fitnessName": "左手抓背測驗(cm)",
"average": "-5.338"
},
{
"fitnessCode": "BACK_SCRATCH_R",
"fitnessName": "右手抓背測驗(cm)",
"average": "-2.065"
},
{
"fitnessCode": "ONE_LEG_STAND_L",
"fitnessName": "靜態平衡開眼左足立(秒)",
"average": "29.861"
},
{
"fitnessCode": "ONE_LEG_STAND_R",
"fitnessName": "靜態平衡開眼右足立(秒)",
"average": "43.06"
},
{
"fitnessCode": "STEP_TEST_30S",
"fitnessName": "心肺耐力原地抬膝踏步(次)",
"average": "92.539"
}
]
@@ -0,0 +1,23 @@
import fitnessAverages from "./average.json";
export type FitnessCode =
| "UPGO_244M"
| "SIT_STAND_30S"
| "SIT_REACH_L"
| "SIT_REACH_R"
| "ARM_CURL_L"
| "ARM_CURL_R"
| "BACK_SCRATCH_L"
| "BACK_SCRATCH_R"
| "ONE_LEG_STAND_L"
| "ONE_LEG_STAND_R"
| "STEP_TEST_30S";
export interface FitnessAverageItem {
readonly fitnessCode: FitnessCode;
readonly fitnessName: string;
readonly average: string;
}
export const FITNESS_AVERAGES =
fitnessAverages as readonly FitnessAverageItem[];
@@ -359,7 +359,6 @@ function simulateAllMeasurements() {
}
function generateReport() {
store.generateFinalReport();
reportSuccessDialog.value = true;
}
+48 -41
View File
@@ -51,37 +51,19 @@
</v-btn>
</v-card-subtitle>
<!-- 四項核心能力數值簡覽 -->
<v-row v-if="RADAR_ENABLED" class="mt-4" no-gutters>
<v-col class="text-center border pa-2" cols="3">
<div class="">肌力</div>
<v-row v-if="RADAR_ENABLED && physicalFitnessRadarData" class="mt-4" no-gutters>
<v-col
v-for="summary in radarSummaries"
:key="summary.label"
class="text-center border pa-2"
cols="12"
sm="6"
md="4"
>
<div>{{ summary.label }}</div>
<div class="font-weight-black">
{{ store.radarData.strength }}
</div>
</v-col>
<v-col class="text-center border pa-2" cols="3">
<div class="">平衡</div>
<div class="font-weight-black">
{{ store.radarData.balance }}
</div>
</v-col>
<v-col class="text-center border pa-2" cols="3">
<div class="">敏捷</div>
<div class="font-weight-black">
{{ store.radarData.agility }}
</div>
</v-col>
<v-col class="text-center border pa-2" cols="3">
<div class="">心肺</div>
<div class="font-weight-black">
{{ store.radarData.cardio }}
{{ summary.score }}
</div>
</v-col>
</v-row>
@@ -121,10 +103,19 @@
<v-card-title> 個人五維健康力指標 </v-card-title>
<v-card-subtitle>
綜合 11 項現場普測數值計算得出,<br />藍色為同齡正常參考值區間。
綜合 11 項現場普測數值計算得出,<br />藍色為目前樣本平均基準。
</v-card-subtitle>
<HealthRadarChart :radar-data="store.radarData" />
<v-alert
v-if="!physicalFitnessRadarData"
class="ma-4"
density="compact"
type="info"
variant="tonal"
>
體適能資料不足,完成 11 項測驗後即可計算五維健康力指標。
</v-alert>
<HealthRadarChart v-else :radar-data="physicalFitnessRadarData" />
</v-card>
<ModuleRouterCard
v-if="BODY_COMPOSITION_ENABLED"
@@ -142,15 +133,6 @@
@refresh="loadPhysicalFitness"
/>
<ModuleRouterCard
v-if="SELF_ASSESSMENT_ENABLED"
description="完成線上自評,取得初步風險分級。"
icon="mdi-clipboard-text-search"
icon-color="accent"
title="自評"
to="/self-assessment"
/>
<ModuleRouterCard
v-if="CHECKIN_ENABLED"
description="完成數值紀錄,建立個人功能基線。"
@@ -244,7 +226,6 @@
</div>
</div>
</v-card>
</template>
<IntroductionCarousel v-if="!store.isLoggedIn" />
@@ -382,6 +363,7 @@ import {
import { useAppStore } from '@/stores/app'
import { useCourseStore } from '@/stores/course'
import { getApiErrorMessage } from '@/utils/api-error'
import { calculatePhysicalFitnessRadar } from '@/utils/physical-fitness-radar'
const store = useAppStore()
const courseStore = useCourseStore()
@@ -397,6 +379,31 @@ const physicalFitnessLoading = ref(false)
const physicalFitnessError = ref('')
const displayUserName = useDisplayUserName(store.userProfile)
const physicalFitnessRadarData = computed(() =>
calculatePhysicalFitnessRadar(physicalFitnessItems.value)
)
const radarSummaries = computed(() => {
if (!physicalFitnessRadarData.value) {
return []
}
return [
{
label: '上肢肌力',
score: physicalFitnessRadarData.value.upperBodyStrength,
},
{
label: '下肢肌耐力',
score: physicalFitnessRadarData.value.lowerBodyStrengthAndEndurance,
},
{ label: '心肺耐力', score: physicalFitnessRadarData.value.cardio },
{
label: '平衡、敏捷與行動',
score: physicalFitnessRadarData.value.balanceAgilityAndMobility,
},
{ label: '柔軟度', score: physicalFitnessRadarData.value.flexibility },
]
})
const MISSING_GROUP_DIALOG_KEY = 'missing-fitness-group-dialog-shown'
-69
View File
@@ -305,16 +305,6 @@ function createDefaultMeasurements(): Record<string, MeasurementStation> {
};
}
function createDefaultRadarData() {
return {
strength: 65,
balance: 60,
flexibility: 55,
agility: 70,
cardio: 62,
};
}
export const useAppStore = defineStore("app", () => {
const isLoggedIn = ref(false);
const isAuthInitialized = ref(false);
@@ -327,8 +317,6 @@ export const useAppStore = defineStore("app", () => {
const measurements = ref<Record<string, MeasurementStation>>(
createDefaultMeasurements(),
);
const radarData = ref(createDefaultRadarData());
const isReportGenerated = ref(false);
const completedStationsCount = computed(() => {
return Object.values(measurements.value).filter(
@@ -625,57 +613,6 @@ export const useAppStore = defineStore("app", () => {
measurement.time = timeStr;
}
function generateFinalReport() {
const currentMeasurements = measurements.value;
let strScore = 65;
if (currentMeasurements.GripStrength.completed) {
const avgGrip =
((currentMeasurements.GripStrength.values.leftHand || 0) +
(currentMeasurements.GripStrength.values.rightHand || 0)) /
2;
const limit = userProfile.value.gender === "男" ? 30 : 20;
strScore = Math.min(95, Math.max(50, Math.round((avgGrip / limit) * 75)));
}
let balScore = 60;
if (currentMeasurements.OneLegTest.completed) {
const maxSec = Math.max(
currentMeasurements.OneLegTest.values.leftSeconds || 0,
currentMeasurements.OneLegTest.values.rightSeconds || 0,
);
balScore = Math.min(98, Math.max(40, Math.round((maxSec / 30) * 90)));
}
let flexScore = 55;
if (currentMeasurements.SitReachTest.completed) {
const dist = currentMeasurements.SitReachTest.values.distanceCm || 0;
flexScore = Math.min(95, Math.max(45, 60 + dist * 1.5));
}
let agiScore = 70;
if (currentMeasurements.UpGo8ft.completed) {
const sec = currentMeasurements.UpGo8ft.values.durationSeconds || 10;
agiScore = Math.min(95, Math.max(40, Math.round(100 - sec * 5)));
}
let cardScore = 62;
if (currentMeasurements.StepTest.completed) {
const count = currentMeasurements.StepTest.values.count || 60;
cardScore = Math.min(95, Math.max(45, Math.round((count / 80) * 80)));
}
radarData.value = {
strength: strScore,
balance: balScore,
flexibility: flexScore,
agility: agiScore,
cardio: cardScore,
};
isReportGenerated.value = true;
}
function resetMeasurements() {
for (const [key, measurement] of Object.entries(measurements.value)) {
if (key !== "BloodPressure" && key !== "BodyFat") {
@@ -686,9 +623,6 @@ export const useAppStore = defineStore("app", () => {
}
}
}
isReportGenerated.value = false;
radarData.value = createDefaultRadarData();
}
return {
@@ -699,8 +633,6 @@ export const useAppStore = defineStore("app", () => {
selfAssessment,
appointments,
measurements,
radarData,
isReportGenerated,
completedStationsCount,
totalStationsCount,
progressPercent,
@@ -719,7 +651,6 @@ export const useAppStore = defineStore("app", () => {
cancelAppointment,
checkinAppointment,
saveMeasurement,
generateFinalReport,
resetMeasurements,
};
});
@@ -0,0 +1,202 @@
import type { PhysicalFitnessItem } from "@/api/types";
import { FITNESS_AVERAGES, type FitnessCode } from "@/data/fitnessAverages";
export interface PhysicalFitnessRadarData {
upperBodyStrength: number;
lowerBodyStrengthAndEndurance: number;
cardio: number;
balanceAgilityAndMobility: number;
flexibility: number;
}
type RadarDimension = keyof PhysicalFitnessRadarData;
interface FitnessTestDefinition {
code: FitnessCode;
dimension: RadarDimension;
direction: 1 | -1;
scale: "average" | "flexibility";
}
const FITNESS_TEST_DEFINITIONS: readonly FitnessTestDefinition[] = [
{
code: "ARM_CURL_L",
dimension: "upperBodyStrength",
direction: 1,
scale: "average",
},
{
code: "ARM_CURL_R",
dimension: "upperBodyStrength",
direction: 1,
scale: "average",
},
{
code: "SIT_STAND_30S",
dimension: "lowerBodyStrengthAndEndurance",
direction: 1,
scale: "average",
},
{
code: "STEP_TEST_30S",
dimension: "cardio",
direction: 1,
scale: "average",
},
{
code: "ONE_LEG_STAND_L",
dimension: "balanceAgilityAndMobility",
direction: 1,
scale: "average",
},
{
code: "ONE_LEG_STAND_R",
dimension: "balanceAgilityAndMobility",
direction: 1,
scale: "average",
},
{
code: "UPGO_244M",
dimension: "balanceAgilityAndMobility",
direction: -1,
scale: "average",
},
{
code: "BACK_SCRATCH_L",
dimension: "flexibility",
direction: 1,
scale: "flexibility",
},
{
code: "BACK_SCRATCH_R",
dimension: "flexibility",
direction: 1,
scale: "flexibility",
},
{
code: "SIT_REACH_L",
dimension: "flexibility",
direction: 1,
scale: "flexibility",
},
{
code: "SIT_REACH_R",
dimension: "flexibility",
direction: 1,
scale: "flexibility",
},
];
const FLEXIBILITY_SCALE = 10;
function parseFiniteNumber(value: string | null): number | null {
if (value === null || value.trim() === "") {
return null;
}
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : null;
}
function createFitnessScoreMap(
items: readonly PhysicalFitnessItem[],
): Map<FitnessCode, number> | null {
const scores = new Map<FitnessCode, number>();
for (const item of items) {
const definition = FITNESS_TEST_DEFINITIONS.find(
({ code }) => code === item.fitnessCode,
);
if (!definition) {
continue;
}
const score = parseFiniteNumber(item.fitnessScore);
if (score === null || scores.has(definition.code)) {
return null;
}
scores.set(definition.code, score);
}
return scores;
}
function calculateRelativeScore(
score: number,
average: number,
direction: 1 | -1,
scale: FitnessTestDefinition["scale"],
): number | null {
const comparisonScale =
scale === "flexibility" ? FLEXIBILITY_SCALE : Math.abs(average);
if (comparisonScale === 0) {
return null;
}
const difference = (direction * (score - average)) / comparisonScale;
return 50 + 50 * Math.tanh(difference);
}
export function calculatePhysicalFitnessRadar(
items: readonly PhysicalFitnessItem[],
): PhysicalFitnessRadarData | null {
const fitnessScores = createFitnessScoreMap(items);
if (!fitnessScores) {
return null;
}
const averages = new Map(
FITNESS_AVERAGES.map((item) => [item.fitnessCode, Number(item.average)]),
);
const dimensionScores: Record<RadarDimension, number[]> = {
upperBodyStrength: [],
lowerBodyStrengthAndEndurance: [],
cardio: [],
balanceAgilityAndMobility: [],
flexibility: [],
};
for (const definition of FITNESS_TEST_DEFINITIONS) {
const score = fitnessScores.get(definition.code);
const average = averages.get(definition.code);
if (
score === undefined ||
average === undefined ||
!Number.isFinite(average)
) {
return null;
}
const relativeScore = calculateRelativeScore(
score,
average,
definition.direction,
definition.scale,
);
if (relativeScore === null) {
return null;
}
dimensionScores[definition.dimension].push(relativeScore);
}
return {
upperBodyStrength: Math.round(
averageDimensionScore(dimensionScores.upperBodyStrength),
),
lowerBodyStrengthAndEndurance: Math.round(
averageDimensionScore(dimensionScores.lowerBodyStrengthAndEndurance),
),
cardio: Math.round(averageDimensionScore(dimensionScores.cardio)),
balanceAgilityAndMobility: Math.round(
averageDimensionScore(dimensionScores.balanceAgilityAndMobility),
),
flexibility: Math.round(averageDimensionScore(dimensionScores.flexibility)),
};
}
function averageDimensionScore(scores: readonly number[]): number {
return scores.reduce((total, score) => total + score, 0) / scores.length;
}