@swimedge/metrics 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +11 -0
- package/dist/index.js +155 -42
- package/dist/index.spec.d.ts +1 -0
- package/dist/index.spec.js +105 -0
- package/package.json +24 -3
package/dist/index.d.ts
CHANGED
|
@@ -88,6 +88,12 @@ export declare enum MetricCategory {
|
|
|
88
88
|
TECHNIQUE = "technique",
|
|
89
89
|
ENERGY = "energy"
|
|
90
90
|
}
|
|
91
|
+
export declare const formatNumber: (value: number, decimals?: number) => string;
|
|
92
|
+
export declare const formatPace: (pace: string | number) => string;
|
|
93
|
+
export declare const formatTime: (seconds: number) => string;
|
|
94
|
+
export declare const formatHeartRate: (bpm: number) => string;
|
|
95
|
+
export declare const formatDistance: (meters: number) => string;
|
|
96
|
+
export declare const formatAverageTurnTime: (session: SwimSession) => string;
|
|
91
97
|
export interface MetricDefinition {
|
|
92
98
|
key: string;
|
|
93
99
|
label: string;
|
|
@@ -100,10 +106,15 @@ export interface MetricDefinition {
|
|
|
100
106
|
formula?: string;
|
|
101
107
|
source?: string;
|
|
102
108
|
healthKitSource?: string;
|
|
109
|
+
isHealthKit?: boolean;
|
|
110
|
+
decimals?: number;
|
|
103
111
|
availableFrom: {
|
|
104
112
|
watch: boolean;
|
|
105
113
|
manual: boolean;
|
|
106
114
|
};
|
|
115
|
+
formatter?: (session: SwimSession) => string | number | null;
|
|
116
|
+
lapFormatter?: (value: any) => string;
|
|
117
|
+
condition?: (session: SwimSession) => boolean;
|
|
107
118
|
}
|
|
108
119
|
export declare const METRICS_REGISTRY: Record<keyof SwimSession, MetricDefinition>;
|
|
109
120
|
export declare const MetricsHelper: {
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Shared metrics registry for frontend, backend, and watch app
|
|
5
5
|
*/
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
-
exports.MetricsHelper = exports.METRICS_REGISTRY = exports.MetricCategory = exports.SwimType = void 0;
|
|
7
|
+
exports.MetricsHelper = exports.METRICS_REGISTRY = exports.formatAverageTurnTime = exports.formatDistance = exports.formatHeartRate = exports.formatTime = exports.formatPace = exports.formatNumber = exports.MetricCategory = exports.SwimType = void 0;
|
|
8
8
|
var SwimType;
|
|
9
9
|
(function (SwimType) {
|
|
10
10
|
SwimType["INDOOR"] = "indoor";
|
|
@@ -17,30 +17,73 @@ var MetricCategory;
|
|
|
17
17
|
MetricCategory["TECHNIQUE"] = "technique";
|
|
18
18
|
MetricCategory["ENERGY"] = "energy";
|
|
19
19
|
})(MetricCategory || (exports.MetricCategory = MetricCategory = {}));
|
|
20
|
+
// Formatting helpers
|
|
21
|
+
const formatNumber = (value, decimals = 2) => {
|
|
22
|
+
return Number(value).toFixed(decimals);
|
|
23
|
+
};
|
|
24
|
+
exports.formatNumber = formatNumber;
|
|
25
|
+
const formatPace = (pace) => {
|
|
26
|
+
if (typeof pace === 'string')
|
|
27
|
+
return pace;
|
|
28
|
+
const minutes = Math.floor(pace / 60);
|
|
29
|
+
const seconds = Math.round(pace % 60);
|
|
30
|
+
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
|
31
|
+
};
|
|
32
|
+
exports.formatPace = formatPace;
|
|
33
|
+
const formatTime = (seconds) => {
|
|
34
|
+
const mins = Math.floor(seconds / 60);
|
|
35
|
+
const secs = Math.floor(seconds % 60);
|
|
36
|
+
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
37
|
+
};
|
|
38
|
+
exports.formatTime = formatTime;
|
|
39
|
+
const formatHeartRate = (bpm) => {
|
|
40
|
+
return `${Math.round(bpm)} bpm`;
|
|
41
|
+
};
|
|
42
|
+
exports.formatHeartRate = formatHeartRate;
|
|
43
|
+
const formatDistance = (meters) => {
|
|
44
|
+
return meters >= 1000
|
|
45
|
+
? `${(meters / 1000).toFixed(1)}km`
|
|
46
|
+
: `${Math.round(meters)}m`;
|
|
47
|
+
};
|
|
48
|
+
exports.formatDistance = formatDistance;
|
|
49
|
+
const formatAverageTurnTime = (session) => {
|
|
50
|
+
if (!session.lapTurnTimes?.length)
|
|
51
|
+
return '';
|
|
52
|
+
const validTurnTimes = session.lapTurnTimes.filter(t => !isNaN(t) && t > 0);
|
|
53
|
+
if (!validTurnTimes.length)
|
|
54
|
+
return '';
|
|
55
|
+
const avgTime = validTurnTimes.reduce((a, b) => a + b, 0) / validTurnTimes.length;
|
|
56
|
+
return `${(0, exports.formatNumber)(avgTime, 1)}s`;
|
|
57
|
+
};
|
|
58
|
+
exports.formatAverageTurnTime = formatAverageTurnTime;
|
|
20
59
|
exports.METRICS_REGISTRY = {
|
|
21
60
|
distance: {
|
|
22
61
|
key: 'distance',
|
|
23
62
|
label: 'Distance',
|
|
24
63
|
unit: 'm',
|
|
25
64
|
category: MetricCategory.PERFORMANCE,
|
|
26
|
-
description: 'Total distance swum',
|
|
65
|
+
description: 'Total distance swum in this session. Measured in meters or kilometers.',
|
|
27
66
|
icon: 'resize-outline',
|
|
28
67
|
isPerSession: true,
|
|
29
68
|
source: 'HealthKit: HKWorkout distance',
|
|
30
69
|
healthKitSource: 'HKWorkout.totalDistance',
|
|
70
|
+
isHealthKit: true,
|
|
31
71
|
availableFrom: { watch: true, manual: true },
|
|
72
|
+
formatter: (s) => (0, exports.formatDistance)(s.distance),
|
|
32
73
|
},
|
|
33
74
|
time: {
|
|
34
75
|
key: 'time',
|
|
35
76
|
label: 'Duration',
|
|
36
77
|
unit: 's',
|
|
37
78
|
category: MetricCategory.PERFORMANCE,
|
|
38
|
-
description: 'Total
|
|
79
|
+
description: 'Total time spent swimming, including active swimming and rest periods.',
|
|
39
80
|
icon: 'time-outline',
|
|
40
81
|
isPerSession: true,
|
|
41
82
|
source: 'HealthKit: HKWorkout duration',
|
|
42
83
|
healthKitSource: 'HKWorkout.duration',
|
|
84
|
+
isHealthKit: true,
|
|
43
85
|
availableFrom: { watch: true, manual: true },
|
|
86
|
+
formatter: (s) => (0, exports.formatTime)(s.time),
|
|
44
87
|
},
|
|
45
88
|
activeTime: {
|
|
46
89
|
key: 'activeTime',
|
|
@@ -50,7 +93,9 @@ exports.METRICS_REGISTRY = {
|
|
|
50
93
|
description: 'Time actively swimming',
|
|
51
94
|
icon: 'play-outline',
|
|
52
95
|
isPerSession: true,
|
|
53
|
-
source: '
|
|
96
|
+
source: 'HealthKit: HKWorkoutBuilder elapsedTime (excludes rest periods)',
|
|
97
|
+
healthKitSource: 'HKWorkoutBuilder.elapsedTime',
|
|
98
|
+
isHealthKit: true,
|
|
54
99
|
availableFrom: { watch: true, manual: false },
|
|
55
100
|
},
|
|
56
101
|
restTime: {
|
|
@@ -58,202 +103,249 @@ exports.METRICS_REGISTRY = {
|
|
|
58
103
|
label: 'Rest Time',
|
|
59
104
|
unit: 's',
|
|
60
105
|
category: MetricCategory.PERFORMANCE,
|
|
61
|
-
description: '
|
|
106
|
+
description: 'Total time spent resting between swimming intervals.',
|
|
62
107
|
icon: 'pause-outline',
|
|
63
108
|
isPerSession: true,
|
|
64
109
|
formula: 'Total Duration - Active Time',
|
|
65
110
|
availableFrom: { watch: true, manual: false },
|
|
111
|
+
formatter: (s) => (0, exports.formatTime)(s.restTime),
|
|
112
|
+
condition: (s) => !!s.restTime,
|
|
66
113
|
},
|
|
67
114
|
laps: {
|
|
68
115
|
key: 'laps',
|
|
69
116
|
label: 'Laps',
|
|
70
117
|
unit: '',
|
|
71
118
|
category: MetricCategory.PERFORMANCE,
|
|
72
|
-
description: '
|
|
119
|
+
description: 'Number of pool lengths completed during the swimming session.',
|
|
73
120
|
icon: 'layers-outline',
|
|
74
121
|
isPerSession: true,
|
|
75
122
|
formula: 'Distance ÷ Pool Length',
|
|
76
123
|
availableFrom: { watch: true, manual: true },
|
|
124
|
+
formatter: (s) => String(s.laps),
|
|
125
|
+
condition: (s) => !!s.laps,
|
|
77
126
|
},
|
|
78
127
|
avgPace: {
|
|
79
128
|
key: 'avgPace',
|
|
80
129
|
label: 'Pace',
|
|
81
130
|
unit: '/100m',
|
|
82
131
|
category: MetricCategory.PERFORMANCE,
|
|
83
|
-
description: 'Average
|
|
132
|
+
description: 'Average time per 100 meters. Lower values indicate faster swimming speed.',
|
|
84
133
|
icon: 'speedometer-outline',
|
|
85
134
|
isPerSession: true,
|
|
86
135
|
formula: '(Active Time ÷ Distance) × 100',
|
|
87
136
|
availableFrom: { watch: true, manual: true },
|
|
137
|
+
formatter: (s) => (0, exports.formatPace)(s.avgPace),
|
|
138
|
+
condition: (s) => !!s.avgPace,
|
|
88
139
|
},
|
|
89
140
|
poolLength: {
|
|
90
141
|
key: 'poolLength',
|
|
91
142
|
label: 'Pool Length',
|
|
92
143
|
unit: 'm',
|
|
93
144
|
category: MetricCategory.PERFORMANCE,
|
|
94
|
-
description: '
|
|
145
|
+
description: 'Length of the swimming pool used for this session.',
|
|
95
146
|
icon: 'resize-outline',
|
|
96
147
|
isPerSession: true,
|
|
97
148
|
source: 'User configuration setting',
|
|
98
149
|
availableFrom: { watch: true, manual: true },
|
|
150
|
+
formatter: (s) => `${s.poolLength}m`,
|
|
151
|
+
condition: (s) => !!s.poolLength,
|
|
99
152
|
},
|
|
100
153
|
swimType: {
|
|
101
154
|
key: 'swimType',
|
|
102
|
-
label: '
|
|
155
|
+
label: 'Swimming Type',
|
|
103
156
|
unit: '',
|
|
104
157
|
category: MetricCategory.PERFORMANCE,
|
|
105
|
-
description: '
|
|
158
|
+
description: 'Type of swimming activity (pool, open water, etc.).',
|
|
106
159
|
icon: 'water-outline',
|
|
107
160
|
isPerSession: true,
|
|
108
161
|
source: 'User selection or workout location type',
|
|
109
162
|
availableFrom: { watch: true, manual: true },
|
|
163
|
+
formatter: (s) => s.swimType,
|
|
164
|
+
condition: (s) => !!s.swimType,
|
|
110
165
|
},
|
|
111
166
|
waterTemp: {
|
|
112
167
|
key: 'waterTemp',
|
|
113
168
|
label: 'Water Temp',
|
|
114
169
|
unit: '°C',
|
|
115
170
|
category: MetricCategory.PERFORMANCE,
|
|
116
|
-
description: '
|
|
171
|
+
description: 'Temperature of the water during the swimming session.',
|
|
117
172
|
icon: 'thermometer-outline',
|
|
118
173
|
isPerSession: true,
|
|
119
174
|
source: 'Apple Watch water temperature sensor',
|
|
175
|
+
isHealthKit: true,
|
|
120
176
|
availableFrom: { watch: true, manual: false },
|
|
177
|
+
formatter: (s) => `${(0, exports.formatNumber)(s.waterTemp, 1)}°C`,
|
|
178
|
+
condition: (s) => !!s.waterTemp,
|
|
121
179
|
},
|
|
122
180
|
avgHeartRate: {
|
|
123
181
|
key: 'avgHeartRate',
|
|
124
182
|
label: 'Avg Heart Rate',
|
|
125
183
|
unit: 'bpm',
|
|
126
184
|
category: MetricCategory.HEALTH,
|
|
127
|
-
description: 'Average heart rate',
|
|
185
|
+
description: 'Average heart rate throughout the swimming session.',
|
|
128
186
|
icon: 'heart-outline',
|
|
129
187
|
isPerSession: true,
|
|
130
188
|
source: 'HealthKit: HKQuantityTypeIdentifierHeartRate',
|
|
131
189
|
healthKitSource: 'HKQuantityTypeIdentifierHeartRate',
|
|
190
|
+
isHealthKit: true,
|
|
132
191
|
availableFrom: { watch: true, manual: false },
|
|
192
|
+
formatter: (s) => (0, exports.formatHeartRate)(s.avgHeartRate),
|
|
193
|
+
condition: (s) => !!s.avgHeartRate,
|
|
133
194
|
},
|
|
134
195
|
maxHeartRate: {
|
|
135
196
|
key: 'maxHeartRate',
|
|
136
197
|
label: 'Max Heart Rate',
|
|
137
198
|
unit: 'bpm',
|
|
138
199
|
category: MetricCategory.HEALTH,
|
|
139
|
-
description: '
|
|
200
|
+
description: 'Highest heart rate recorded during the session.',
|
|
140
201
|
icon: 'trending-up-outline',
|
|
141
202
|
isPerSession: true,
|
|
142
203
|
source: 'HealthKit: HKQuantityTypeIdentifierHeartRate',
|
|
143
204
|
healthKitSource: 'HKQuantityTypeIdentifierHeartRate',
|
|
205
|
+
isHealthKit: true,
|
|
144
206
|
availableFrom: { watch: true, manual: false },
|
|
207
|
+
formatter: (s) => (0, exports.formatHeartRate)(s.maxHeartRate),
|
|
208
|
+
condition: (s) => !!s.maxHeartRate,
|
|
145
209
|
},
|
|
146
210
|
minHeartRate: {
|
|
147
211
|
key: 'minHeartRate',
|
|
148
212
|
label: 'Min Heart Rate',
|
|
149
213
|
unit: 'bpm',
|
|
150
214
|
category: MetricCategory.HEALTH,
|
|
151
|
-
description: '
|
|
215
|
+
description: 'Lowest heart rate recorded during the session.',
|
|
152
216
|
icon: 'trending-down-outline',
|
|
153
217
|
isPerSession: true,
|
|
154
218
|
source: 'HealthKit: HKQuantityTypeIdentifierHeartRate',
|
|
155
219
|
healthKitSource: 'HKQuantityTypeIdentifierHeartRate',
|
|
220
|
+
isHealthKit: true,
|
|
156
221
|
availableFrom: { watch: true, manual: false },
|
|
222
|
+
formatter: (s) => (0, exports.formatHeartRate)(s.minHeartRate),
|
|
223
|
+
condition: (s) => !!s.minHeartRate,
|
|
157
224
|
},
|
|
158
225
|
calories: {
|
|
159
226
|
key: 'calories',
|
|
160
227
|
label: 'Calories',
|
|
161
228
|
unit: 'kcal',
|
|
162
229
|
category: MetricCategory.ENERGY,
|
|
163
|
-
description: 'Total calories',
|
|
230
|
+
description: 'Total calories burned during the swimming session.',
|
|
164
231
|
icon: 'flame-outline',
|
|
165
232
|
isPerSession: true,
|
|
166
233
|
source: 'HealthKit: HKQuantityTypeIdentifierActiveEnergyBurned',
|
|
167
234
|
healthKitSource: 'HKQuantityTypeIdentifierActiveEnergyBurned',
|
|
235
|
+
isHealthKit: true,
|
|
168
236
|
availableFrom: { watch: true, manual: true },
|
|
237
|
+
formatter: (s) => String(s.calories),
|
|
238
|
+
condition: (s) => !!s.calories,
|
|
169
239
|
},
|
|
170
240
|
totalStrokeCount: {
|
|
171
241
|
key: 'totalStrokeCount',
|
|
172
242
|
label: 'Total Strokes',
|
|
173
243
|
unit: '',
|
|
174
244
|
category: MetricCategory.TECHNIQUE,
|
|
175
|
-
description: 'Total strokes',
|
|
245
|
+
description: 'Total number of arm strokes taken during the session.',
|
|
176
246
|
icon: 'hand-left-outline',
|
|
177
247
|
isPerSession: true,
|
|
178
|
-
source: '
|
|
248
|
+
source: 'HealthKit: HKQuantityTypeIdentifierSwimmingStrokeCount',
|
|
249
|
+
healthKitSource: 'HKQuantityTypeIdentifierSwimmingStrokeCount',
|
|
250
|
+
isHealthKit: true,
|
|
179
251
|
availableFrom: { watch: true, manual: false },
|
|
252
|
+
formatter: (s) => String(s.totalStrokeCount || 0),
|
|
253
|
+
condition: (s) => !!s.totalStrokeCount,
|
|
180
254
|
},
|
|
181
255
|
avgStrokeRate: {
|
|
182
256
|
key: 'avgStrokeRate',
|
|
183
257
|
label: 'Stroke Rate',
|
|
184
258
|
unit: 'SPM',
|
|
185
259
|
category: MetricCategory.TECHNIQUE,
|
|
186
|
-
description: '
|
|
260
|
+
description: 'Average number of strokes per minute throughout the session.',
|
|
187
261
|
icon: 'repeat-outline',
|
|
188
262
|
isPerSession: true,
|
|
189
263
|
formula: 'Total Strokes ÷ (Active Time in Minutes)',
|
|
190
264
|
availableFrom: { watch: true, manual: true },
|
|
265
|
+
formatter: (s) => `${(0, exports.formatNumber)(s.avgStrokeRate, 1)} SPM`,
|
|
266
|
+
condition: (s) => !!s.avgStrokeRate,
|
|
191
267
|
},
|
|
192
268
|
avgDistancePerStroke: {
|
|
193
269
|
key: 'avgDistancePerStroke',
|
|
194
270
|
label: 'Distance/Stroke',
|
|
195
271
|
unit: 'm',
|
|
196
272
|
category: MetricCategory.TECHNIQUE,
|
|
197
|
-
description: '
|
|
273
|
+
description: 'Average distance covered per stroke - indicates stroke efficiency.',
|
|
198
274
|
icon: 'arrow-forward-outline',
|
|
199
275
|
isPerSession: true,
|
|
200
276
|
formula: 'Distance ÷ Total Strokes',
|
|
201
277
|
availableFrom: { watch: true, manual: true },
|
|
278
|
+
formatter: (s) => `${(0, exports.formatNumber)(s.avgDistancePerStroke, 2)}m`,
|
|
279
|
+
condition: (s) => !!s.avgDistancePerStroke,
|
|
202
280
|
},
|
|
203
281
|
avgSwolfScore: {
|
|
204
282
|
key: 'avgSwolfScore',
|
|
205
|
-
label: 'SWOLF',
|
|
283
|
+
label: 'SWOLF Score',
|
|
206
284
|
unit: '',
|
|
207
285
|
category: MetricCategory.TECHNIQUE,
|
|
208
|
-
description: '
|
|
286
|
+
description: 'Swimming efficiency score combining time and stroke count per length.',
|
|
209
287
|
icon: 'trophy-outline',
|
|
210
288
|
isPerSession: true,
|
|
211
289
|
formula: 'Average of (Lap Time in Seconds + Lap Stroke Count) for each lap',
|
|
212
290
|
availableFrom: { watch: true, manual: true },
|
|
291
|
+
formatter: (s) => (0, exports.formatNumber)(s.avgSwolfScore, 0),
|
|
292
|
+
condition: (s) => !!s.avgSwolfScore,
|
|
213
293
|
},
|
|
214
294
|
primaryStrokeType: {
|
|
215
295
|
key: 'primaryStrokeType',
|
|
216
296
|
label: 'Primary Stroke',
|
|
217
297
|
unit: '',
|
|
218
298
|
category: MetricCategory.TECHNIQUE,
|
|
219
|
-
description: 'Main stroke',
|
|
299
|
+
description: 'Main swimming stroke used during this session.',
|
|
220
300
|
icon: 'hand-right-outline',
|
|
221
301
|
isPerSession: true,
|
|
222
|
-
source: '
|
|
302
|
+
source: 'HealthKit: HKWorkoutEvent with HKMetadataKeySwimmingStrokeStyle',
|
|
303
|
+
healthKitSource: 'HKWorkoutEvent.metadata[HKMetadataKeySwimmingStrokeStyle]',
|
|
304
|
+
isHealthKit: true,
|
|
223
305
|
availableFrom: { watch: true, manual: true },
|
|
306
|
+
formatter: (s) => s.primaryStrokeType,
|
|
307
|
+
condition: (s) => !!s.primaryStrokeType,
|
|
224
308
|
},
|
|
225
309
|
lapSplitTimes: {
|
|
226
310
|
key: 'lapSplitTimes',
|
|
227
|
-
label: '
|
|
311
|
+
label: 'Time',
|
|
228
312
|
unit: 's',
|
|
229
313
|
category: MetricCategory.PERFORMANCE,
|
|
230
|
-
description: '
|
|
314
|
+
description: 'Individual lap times recorded during the session.',
|
|
231
315
|
icon: 'stopwatch-outline',
|
|
232
316
|
isPerLap: true,
|
|
233
317
|
source: 'Tracked per lap completion',
|
|
234
318
|
availableFrom: { watch: true, manual: false },
|
|
319
|
+
formatter: (s) => `${s.lapSplitTimes.length} splits`,
|
|
320
|
+
lapFormatter: (v) => (0, exports.formatTime)(v),
|
|
321
|
+
condition: (s) => !!s.lapSplitTimes?.length,
|
|
235
322
|
},
|
|
236
323
|
lapHeartRates: {
|
|
237
324
|
key: 'lapHeartRates',
|
|
238
|
-
label: '
|
|
325
|
+
label: 'HR',
|
|
239
326
|
unit: 'bpm',
|
|
240
327
|
category: MetricCategory.HEALTH,
|
|
241
328
|
description: 'HR per lap',
|
|
242
329
|
icon: 'heart-outline',
|
|
243
330
|
isPerLap: true,
|
|
331
|
+
decimals: 0,
|
|
244
332
|
source: 'HealthKit: HKQuantityTypeIdentifierHeartRate sampled at lap completion',
|
|
245
333
|
healthKitSource: 'HKQuantityTypeIdentifierHeartRate',
|
|
334
|
+
isHealthKit: true,
|
|
246
335
|
availableFrom: { watch: true, manual: false },
|
|
247
336
|
},
|
|
248
337
|
lapStrokeCounts: {
|
|
249
338
|
key: 'lapStrokeCounts',
|
|
250
|
-
label: '
|
|
339
|
+
label: 'Strokes',
|
|
251
340
|
unit: '',
|
|
252
341
|
category: MetricCategory.TECHNIQUE,
|
|
253
342
|
description: 'Strokes per lap',
|
|
254
343
|
icon: 'hand-left-outline',
|
|
255
344
|
isPerLap: true,
|
|
256
|
-
|
|
345
|
+
decimals: 0,
|
|
346
|
+
source: 'HealthKit: HKQuantityTypeIdentifierSwimmingStrokeCount per lap',
|
|
347
|
+
healthKitSource: 'HKQuantityTypeIdentifierSwimmingStrokeCount',
|
|
348
|
+
isHealthKit: true,
|
|
257
349
|
availableFrom: { watch: true, manual: false },
|
|
258
350
|
},
|
|
259
351
|
id: {
|
|
@@ -290,37 +382,46 @@ exports.METRICS_REGISTRY = {
|
|
|
290
382
|
},
|
|
291
383
|
lapTurnTimes: {
|
|
292
384
|
key: 'lapTurnTimes',
|
|
293
|
-
label: 'Turn
|
|
385
|
+
label: 'Turn',
|
|
294
386
|
unit: 's',
|
|
295
387
|
category: MetricCategory.TECHNIQUE,
|
|
296
|
-
description: '
|
|
388
|
+
description: 'Time spent turning at pool wall per lap',
|
|
389
|
+
icon: 'refresh-circle-outline',
|
|
297
390
|
isPerLap: true,
|
|
391
|
+
decimals: 2,
|
|
298
392
|
availableFrom: { watch: true, manual: false },
|
|
393
|
+
formatter: exports.formatAverageTurnTime,
|
|
394
|
+
condition: (s) => !!s.lapTurnTimes?.length,
|
|
299
395
|
},
|
|
300
396
|
lapStrokeTypes: {
|
|
301
397
|
key: 'lapStrokeTypes',
|
|
302
|
-
label: 'Stroke
|
|
398
|
+
label: 'Stroke',
|
|
303
399
|
category: MetricCategory.TECHNIQUE,
|
|
304
|
-
description: '
|
|
400
|
+
description: 'Swimming stroke used per lap',
|
|
401
|
+
icon: 'list-outline',
|
|
305
402
|
isPerLap: true,
|
|
306
403
|
availableFrom: { watch: true, manual: false },
|
|
404
|
+
formatter: (s) => `${s.lapStrokeTypes.length} types`,
|
|
405
|
+
condition: (s) => !!s.lapStrokeTypes?.length,
|
|
307
406
|
},
|
|
308
407
|
lapPaces: {
|
|
309
408
|
key: 'lapPaces',
|
|
310
|
-
label: '
|
|
409
|
+
label: 'Pace',
|
|
311
410
|
unit: '/100m',
|
|
312
411
|
category: MetricCategory.PERFORMANCE,
|
|
313
412
|
description: 'Pace per lap',
|
|
314
413
|
isPerLap: true,
|
|
414
|
+
decimals: 1,
|
|
315
415
|
availableFrom: { watch: true, manual: false },
|
|
316
416
|
},
|
|
317
417
|
lapCalories: {
|
|
318
418
|
key: 'lapCalories',
|
|
319
|
-
label: '
|
|
419
|
+
label: 'Cal',
|
|
320
420
|
unit: 'kcal',
|
|
321
421
|
category: MetricCategory.ENERGY,
|
|
322
422
|
description: 'Calories per lap',
|
|
323
423
|
isPerLap: true,
|
|
424
|
+
decimals: 0,
|
|
324
425
|
availableFrom: { watch: true, manual: false },
|
|
325
426
|
},
|
|
326
427
|
lapDistances: {
|
|
@@ -334,31 +435,34 @@ exports.METRICS_REGISTRY = {
|
|
|
334
435
|
},
|
|
335
436
|
lapSwolfScores: {
|
|
336
437
|
key: 'lapSwolfScores',
|
|
337
|
-
label: '
|
|
438
|
+
label: 'SWOLF',
|
|
338
439
|
category: MetricCategory.TECHNIQUE,
|
|
339
440
|
description: 'SWOLF per lap',
|
|
340
441
|
isPerLap: true,
|
|
442
|
+
decimals: 0,
|
|
341
443
|
availableFrom: { watch: true, manual: false },
|
|
342
444
|
},
|
|
343
445
|
lapStrokeRates: {
|
|
344
446
|
key: 'lapStrokeRates',
|
|
345
|
-
label: '
|
|
447
|
+
label: 'SR',
|
|
346
448
|
unit: 'SPM',
|
|
347
449
|
category: MetricCategory.TECHNIQUE,
|
|
348
450
|
description: 'Strokes per minute per lap',
|
|
349
451
|
icon: 'repeat-outline',
|
|
350
452
|
isPerLap: true,
|
|
453
|
+
decimals: 1,
|
|
351
454
|
formula: 'Lap Strokes ÷ (Lap Time in Minutes)',
|
|
352
455
|
availableFrom: { watch: true, manual: false },
|
|
353
456
|
},
|
|
354
457
|
lapDistancesPerStroke: {
|
|
355
458
|
key: 'lapDistancesPerStroke',
|
|
356
|
-
label: '
|
|
459
|
+
label: 'DPS',
|
|
357
460
|
unit: 'm',
|
|
358
461
|
category: MetricCategory.TECHNIQUE,
|
|
359
462
|
description: 'Distance covered per stroke per lap',
|
|
360
463
|
icon: 'arrow-forward-outline',
|
|
361
464
|
isPerLap: true,
|
|
465
|
+
decimals: 2,
|
|
362
466
|
formula: 'Lap Distance ÷ Lap Strokes',
|
|
363
467
|
availableFrom: { watch: true, manual: false },
|
|
364
468
|
},
|
|
@@ -382,25 +486,34 @@ exports.METRICS_REGISTRY = {
|
|
|
382
486
|
key: 'trainingTypes',
|
|
383
487
|
label: 'Training Types',
|
|
384
488
|
category: MetricCategory.PERFORMANCE,
|
|
385
|
-
description: '
|
|
489
|
+
description: 'Types of training focus for this swimming session.',
|
|
490
|
+
icon: 'school-outline',
|
|
386
491
|
isPerSession: true,
|
|
387
492
|
availableFrom: { watch: false, manual: true },
|
|
493
|
+
formatter: (s) => s.trainingTypes?.join(', ') || '',
|
|
494
|
+
condition: (s) => !!s.trainingTypes?.length,
|
|
388
495
|
},
|
|
389
496
|
perceivedEffort: {
|
|
390
497
|
key: 'perceivedEffort',
|
|
391
498
|
label: 'Perceived Effort',
|
|
392
|
-
category: MetricCategory.
|
|
393
|
-
description: '
|
|
499
|
+
category: MetricCategory.HEALTH,
|
|
500
|
+
description: 'Subjective rating of how hard the session felt.',
|
|
501
|
+
icon: 'body-outline',
|
|
394
502
|
isPerSession: true,
|
|
395
503
|
availableFrom: { watch: false, manual: true },
|
|
504
|
+
formatter: (s) => s.perceivedEffort,
|
|
505
|
+
condition: (s) => !!s.perceivedEffort,
|
|
396
506
|
},
|
|
397
507
|
equipment: {
|
|
398
508
|
key: 'equipment',
|
|
399
509
|
label: 'Equipment',
|
|
400
|
-
category: MetricCategory.
|
|
401
|
-
description: '
|
|
510
|
+
category: MetricCategory.TECHNIQUE,
|
|
511
|
+
description: 'Swimming equipment used during the session.',
|
|
512
|
+
icon: 'construct-outline',
|
|
402
513
|
isPerSession: true,
|
|
403
514
|
availableFrom: { watch: false, manual: true },
|
|
515
|
+
formatter: (s) => s.equipment?.join(', ') || '',
|
|
516
|
+
condition: (s) => !!s.equipment?.length,
|
|
404
517
|
},
|
|
405
518
|
};
|
|
406
519
|
exports.MetricsHelper = {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const index_1 = require("./index");
|
|
4
|
+
describe('Metrics Formatting Functions', () => {
|
|
5
|
+
describe('formatNumber', () => {
|
|
6
|
+
it('should format number with default 2 decimals', () => {
|
|
7
|
+
expect((0, index_1.formatNumber)(123.456)).toBe('123.46');
|
|
8
|
+
});
|
|
9
|
+
it('should format number with custom decimals', () => {
|
|
10
|
+
expect((0, index_1.formatNumber)(123.456, 1)).toBe('123.5');
|
|
11
|
+
expect((0, index_1.formatNumber)(123.456, 0)).toBe('123');
|
|
12
|
+
expect((0, index_1.formatNumber)(123.456, 3)).toBe('123.456');
|
|
13
|
+
});
|
|
14
|
+
it('should handle integers', () => {
|
|
15
|
+
expect((0, index_1.formatNumber)(100, 2)).toBe('100.00');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
describe('formatPace', () => {
|
|
19
|
+
it('should return string pace as-is', () => {
|
|
20
|
+
expect((0, index_1.formatPace)('2:30')).toBe('2:30');
|
|
21
|
+
});
|
|
22
|
+
it('should format numeric pace in seconds to mm:ss', () => {
|
|
23
|
+
expect((0, index_1.formatPace)(150)).toBe('2:30');
|
|
24
|
+
expect((0, index_1.formatPace)(90)).toBe('1:30');
|
|
25
|
+
expect((0, index_1.formatPace)(65)).toBe('1:05');
|
|
26
|
+
});
|
|
27
|
+
it('should handle zero seconds', () => {
|
|
28
|
+
expect((0, index_1.formatPace)(0)).toBe('0:00');
|
|
29
|
+
});
|
|
30
|
+
it('should pad seconds with leading zero', () => {
|
|
31
|
+
expect((0, index_1.formatPace)(125)).toBe('2:05');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('formatTime', () => {
|
|
35
|
+
it('should format time in seconds to mm:ss', () => {
|
|
36
|
+
expect((0, index_1.formatTime)(125)).toBe('2:05');
|
|
37
|
+
expect((0, index_1.formatTime)(90)).toBe('1:30');
|
|
38
|
+
expect((0, index_1.formatTime)(3665)).toBe('61:05');
|
|
39
|
+
});
|
|
40
|
+
it('should handle zero', () => {
|
|
41
|
+
expect((0, index_1.formatTime)(0)).toBe('0:00');
|
|
42
|
+
});
|
|
43
|
+
it('should pad seconds with leading zero', () => {
|
|
44
|
+
expect((0, index_1.formatTime)(65)).toBe('1:05');
|
|
45
|
+
expect((0, index_1.formatTime)(5)).toBe('0:05');
|
|
46
|
+
});
|
|
47
|
+
it('should handle fractional seconds by flooring', () => {
|
|
48
|
+
expect((0, index_1.formatTime)(65.7)).toBe('1:05');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('formatHeartRate', () => {
|
|
52
|
+
it('should format heart rate with bpm unit', () => {
|
|
53
|
+
expect((0, index_1.formatHeartRate)(145)).toBe('145 bpm');
|
|
54
|
+
expect((0, index_1.formatHeartRate)(72.5)).toBe('73 bpm');
|
|
55
|
+
});
|
|
56
|
+
it('should round to nearest integer', () => {
|
|
57
|
+
expect((0, index_1.formatHeartRate)(145.4)).toBe('145 bpm');
|
|
58
|
+
expect((0, index_1.formatHeartRate)(145.6)).toBe('146 bpm');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('formatDistance', () => {
|
|
62
|
+
it('should format meters for distances under 1000m', () => {
|
|
63
|
+
expect((0, index_1.formatDistance)(500)).toBe('500m');
|
|
64
|
+
expect((0, index_1.formatDistance)(999)).toBe('999m');
|
|
65
|
+
});
|
|
66
|
+
it('should format kilometers for distances 1000m and above', () => {
|
|
67
|
+
expect((0, index_1.formatDistance)(1000)).toBe('1.0km');
|
|
68
|
+
expect((0, index_1.formatDistance)(2500)).toBe('2.5km');
|
|
69
|
+
expect((0, index_1.formatDistance)(1234)).toBe('1.2km');
|
|
70
|
+
});
|
|
71
|
+
it('should round meters to nearest integer', () => {
|
|
72
|
+
expect((0, index_1.formatDistance)(500.7)).toBe('501m');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe('formatAverageTurnTime', () => {
|
|
76
|
+
const createSession = (lapTurnTimes) => ({
|
|
77
|
+
id: '1',
|
|
78
|
+
date: '2024-01-01',
|
|
79
|
+
distance: 1000,
|
|
80
|
+
laps: 20,
|
|
81
|
+
time: 1200,
|
|
82
|
+
calories: 300,
|
|
83
|
+
avgPace: '2:00',
|
|
84
|
+
poolLength: 50,
|
|
85
|
+
source: 'watch',
|
|
86
|
+
lapTurnTimes,
|
|
87
|
+
});
|
|
88
|
+
it('should return empty string when no lap turn times', () => {
|
|
89
|
+
expect((0, index_1.formatAverageTurnTime)(createSession())).toBe('');
|
|
90
|
+
expect((0, index_1.formatAverageTurnTime)(createSession([]))).toBe('');
|
|
91
|
+
});
|
|
92
|
+
it('should calculate average turn time', () => {
|
|
93
|
+
expect((0, index_1.formatAverageTurnTime)(createSession([2.5, 3.0, 2.5]))).toBe('2.7s');
|
|
94
|
+
});
|
|
95
|
+
it('should filter out invalid turn times', () => {
|
|
96
|
+
expect((0, index_1.formatAverageTurnTime)(createSession([2.5, NaN, 3.0, 0, 2.5]))).toBe('2.7s');
|
|
97
|
+
});
|
|
98
|
+
it('should return empty string when all turn times are invalid', () => {
|
|
99
|
+
expect((0, index_1.formatAverageTurnTime)(createSession([NaN, 0, -1]))).toBe('');
|
|
100
|
+
});
|
|
101
|
+
it('should format with 1 decimal place', () => {
|
|
102
|
+
expect((0, index_1.formatAverageTurnTime)(createSession([2.456]))).toBe('2.5s');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swimedge/metrics",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Shared metrics registry for SwimEdge (frontend, backend, watch)",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
"prepublishOnly": "npm run build",
|
|
17
17
|
"build": "tsc",
|
|
18
18
|
"watch": "tsc --watch",
|
|
19
|
-
"export-json": "node scripts/export-json.js"
|
|
19
|
+
"export-json": "node scripts/export-json.js",
|
|
20
|
+
"test": "jest",
|
|
21
|
+
"test:watch": "jest --watch",
|
|
22
|
+
"test:coverage": "jest --coverage"
|
|
20
23
|
},
|
|
21
24
|
"keywords": [
|
|
22
25
|
"metrics",
|
|
@@ -28,8 +31,26 @@
|
|
|
28
31
|
"engines": {
|
|
29
32
|
"node": ">=18.0.0"
|
|
30
33
|
},
|
|
34
|
+
"jest": {
|
|
35
|
+
"preset": "ts-jest",
|
|
36
|
+
"testEnvironment": "node",
|
|
37
|
+
"roots": [
|
|
38
|
+
"<rootDir>/src"
|
|
39
|
+
],
|
|
40
|
+
"testMatch": [
|
|
41
|
+
"**/*.spec.ts"
|
|
42
|
+
],
|
|
43
|
+
"collectCoverageFrom": [
|
|
44
|
+
"src/**/*.ts",
|
|
45
|
+
"!src/**/*.spec.ts",
|
|
46
|
+
"!src/**/*.d.ts"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
31
49
|
"devDependencies": {
|
|
50
|
+
"@types/jest": "^29.5.0",
|
|
32
51
|
"esbuild-register": "^3.6.0",
|
|
52
|
+
"jest": "^29.7.0",
|
|
53
|
+
"ts-jest": "^29.1.0",
|
|
33
54
|
"typescript": "^5.0.0"
|
|
34
55
|
}
|
|
35
|
-
}
|
|
56
|
+
}
|