@swimedge/metrics 1.0.0 → 1.0.2

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 CHANGED
@@ -54,6 +54,7 @@ export interface SwimSession extends SwimSessionLaps {
54
54
  perceivedEffort?: string;
55
55
  equipment?: string[];
56
56
  avgDistancePerStroke?: number;
57
+ routeCoordinates?: number[][];
57
58
  }
58
59
  export interface AggregatedSwimMetrics {
59
60
  totalDistance: number;
@@ -88,6 +89,12 @@ export declare enum MetricCategory {
88
89
  TECHNIQUE = "technique",
89
90
  ENERGY = "energy"
90
91
  }
92
+ export declare const formatNumber: (value: number, decimals?: number) => string;
93
+ export declare const formatPace: (pace: string | number) => string;
94
+ export declare const formatTime: (seconds: number) => string;
95
+ export declare const formatHeartRate: (bpm: number) => string;
96
+ export declare const formatDistance: (meters: number) => string;
97
+ export declare const formatAverageTurnTime: (session: SwimSession) => string;
91
98
  export interface MetricDefinition {
92
99
  key: string;
93
100
  label: string;
@@ -100,10 +107,16 @@ export interface MetricDefinition {
100
107
  formula?: string;
101
108
  source?: string;
102
109
  healthKitSource?: string;
110
+ isHealthKit?: boolean;
111
+ decimals?: number;
103
112
  availableFrom: {
104
113
  watch: boolean;
105
114
  manual: boolean;
106
115
  };
116
+ swimTypes: SwimType[];
117
+ formatter?: (session: SwimSession) => string | number | null;
118
+ lapFormatter?: (value: any) => string;
119
+ condition?: (session: SwimSession) => boolean;
107
120
  }
108
121
  export declare const METRICS_REGISTRY: Record<keyof SwimSession, MetricDefinition>;
109
122
  export declare const MetricsHelper: {
@@ -111,5 +124,8 @@ export declare const MetricsHelper: {
111
124
  getSessionMetrics: () => MetricDefinition[];
112
125
  getLapMetrics: () => MetricDefinition[];
113
126
  getMetricsByCategory: (category: MetricCategory) => MetricDefinition[];
127
+ getMetricsBySwimType: (swimType: SwimType) => MetricDefinition[];
128
+ getIndoorMetrics: () => MetricDefinition[];
129
+ getOpenWaterMetrics: () => MetricDefinition[];
114
130
  getAllMetricKeys: () => string[];
115
131
  };
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,75 @@ 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
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
73
+ formatter: (s) => (0, exports.formatDistance)(s.distance),
32
74
  },
33
75
  time: {
34
76
  key: 'time',
35
77
  label: 'Duration',
36
78
  unit: 's',
37
79
  category: MetricCategory.PERFORMANCE,
38
- description: 'Total workout duration',
80
+ description: 'Total time spent swimming, including active swimming and rest periods.',
39
81
  icon: 'time-outline',
40
82
  isPerSession: true,
41
83
  source: 'HealthKit: HKWorkout duration',
42
84
  healthKitSource: 'HKWorkout.duration',
85
+ isHealthKit: true,
43
86
  availableFrom: { watch: true, manual: true },
87
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
88
+ formatter: (s) => (0, exports.formatTime)(s.time),
44
89
  },
45
90
  activeTime: {
46
91
  key: 'activeTime',
@@ -50,211 +95,279 @@ exports.METRICS_REGISTRY = {
50
95
  description: 'Time actively swimming',
51
96
  icon: 'play-outline',
52
97
  isPerSession: true,
53
- source: 'Calculated from stroke detection with 5-second timeout',
98
+ source: 'HealthKit: HKWorkoutBuilder elapsedTime (excludes rest periods)',
99
+ healthKitSource: 'HKWorkoutBuilder.elapsedTime',
100
+ isHealthKit: true,
54
101
  availableFrom: { watch: true, manual: false },
102
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
55
103
  },
56
104
  restTime: {
57
105
  key: 'restTime',
58
106
  label: 'Rest Time',
59
107
  unit: 's',
60
108
  category: MetricCategory.PERFORMANCE,
61
- description: 'Time resting',
109
+ description: 'Total time spent resting between swimming intervals.',
62
110
  icon: 'pause-outline',
63
111
  isPerSession: true,
64
112
  formula: 'Total Duration - Active Time',
65
113
  availableFrom: { watch: true, manual: false },
114
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
115
+ formatter: (s) => (0, exports.formatTime)(s.restTime),
116
+ condition: (s) => !!s.restTime,
66
117
  },
67
118
  laps: {
68
119
  key: 'laps',
69
120
  label: 'Laps',
70
121
  unit: '',
71
122
  category: MetricCategory.PERFORMANCE,
72
- description: 'Pool lengths',
123
+ description: 'Number of pool lengths completed during the swimming session.',
73
124
  icon: 'layers-outline',
74
125
  isPerSession: true,
75
126
  formula: 'Distance ÷ Pool Length',
76
127
  availableFrom: { watch: true, manual: true },
128
+ swimTypes: [SwimType.INDOOR],
129
+ formatter: (s) => String(s.laps),
130
+ condition: (s) => !!s.laps,
77
131
  },
78
132
  avgPace: {
79
133
  key: 'avgPace',
80
134
  label: 'Pace',
81
135
  unit: '/100m',
82
136
  category: MetricCategory.PERFORMANCE,
83
- description: 'Average pace',
137
+ description: 'Average time per 100 meters. Lower values indicate faster swimming speed.',
84
138
  icon: 'speedometer-outline',
85
139
  isPerSession: true,
86
140
  formula: '(Active Time ÷ Distance) × 100',
87
141
  availableFrom: { watch: true, manual: true },
142
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
143
+ formatter: (s) => (0, exports.formatPace)(s.avgPace),
144
+ condition: (s) => !!s.avgPace,
88
145
  },
89
146
  poolLength: {
90
147
  key: 'poolLength',
91
148
  label: 'Pool Length',
92
149
  unit: 'm',
93
150
  category: MetricCategory.PERFORMANCE,
94
- description: 'Pool length',
151
+ description: 'Length of the swimming pool used for this session.',
95
152
  icon: 'resize-outline',
96
153
  isPerSession: true,
97
154
  source: 'User configuration setting',
98
155
  availableFrom: { watch: true, manual: true },
156
+ swimTypes: [SwimType.INDOOR],
157
+ formatter: (s) => `${s.poolLength}m`,
158
+ condition: (s) => !!s.poolLength,
99
159
  },
100
160
  swimType: {
101
161
  key: 'swimType',
102
- label: 'Swim Type',
162
+ label: 'Swimming Type',
103
163
  unit: '',
104
164
  category: MetricCategory.PERFORMANCE,
105
- description: 'Indoor/Open Water',
165
+ description: 'Type of swimming activity (pool, open water, etc.).',
106
166
  icon: 'water-outline',
107
167
  isPerSession: true,
108
168
  source: 'User selection or workout location type',
109
169
  availableFrom: { watch: true, manual: true },
170
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
171
+ formatter: (s) => s.swimType,
172
+ condition: (s) => !!s.swimType,
110
173
  },
111
174
  waterTemp: {
112
175
  key: 'waterTemp',
113
176
  label: 'Water Temp',
114
177
  unit: '°C',
115
178
  category: MetricCategory.PERFORMANCE,
116
- description: 'Water temperature',
179
+ description: 'Temperature of the water during the swimming session.',
117
180
  icon: 'thermometer-outline',
118
181
  isPerSession: true,
119
182
  source: 'Apple Watch water temperature sensor',
183
+ isHealthKit: true,
120
184
  availableFrom: { watch: true, manual: false },
185
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
186
+ formatter: (s) => `${(0, exports.formatNumber)(s.waterTemp, 1)}°C`,
187
+ condition: (s) => !!s.waterTemp,
121
188
  },
122
189
  avgHeartRate: {
123
190
  key: 'avgHeartRate',
124
191
  label: 'Avg Heart Rate',
125
192
  unit: 'bpm',
126
193
  category: MetricCategory.HEALTH,
127
- description: 'Average heart rate',
194
+ description: 'Average heart rate throughout the swimming session.',
128
195
  icon: 'heart-outline',
129
196
  isPerSession: true,
130
197
  source: 'HealthKit: HKQuantityTypeIdentifierHeartRate',
131
198
  healthKitSource: 'HKQuantityTypeIdentifierHeartRate',
199
+ isHealthKit: true,
132
200
  availableFrom: { watch: true, manual: false },
201
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
202
+ formatter: (s) => (0, exports.formatHeartRate)(s.avgHeartRate),
203
+ condition: (s) => !!s.avgHeartRate,
133
204
  },
134
205
  maxHeartRate: {
135
206
  key: 'maxHeartRate',
136
207
  label: 'Max Heart Rate',
137
208
  unit: 'bpm',
138
209
  category: MetricCategory.HEALTH,
139
- description: 'Maximum heart rate',
210
+ description: 'Highest heart rate recorded during the session.',
140
211
  icon: 'trending-up-outline',
141
212
  isPerSession: true,
142
213
  source: 'HealthKit: HKQuantityTypeIdentifierHeartRate',
143
214
  healthKitSource: 'HKQuantityTypeIdentifierHeartRate',
215
+ isHealthKit: true,
144
216
  availableFrom: { watch: true, manual: false },
217
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
218
+ formatter: (s) => (0, exports.formatHeartRate)(s.maxHeartRate),
219
+ condition: (s) => !!s.maxHeartRate,
145
220
  },
146
221
  minHeartRate: {
147
222
  key: 'minHeartRate',
148
223
  label: 'Min Heart Rate',
149
224
  unit: 'bpm',
150
225
  category: MetricCategory.HEALTH,
151
- description: 'Minimum heart rate',
226
+ description: 'Lowest heart rate recorded during the session.',
152
227
  icon: 'trending-down-outline',
153
228
  isPerSession: true,
154
229
  source: 'HealthKit: HKQuantityTypeIdentifierHeartRate',
155
230
  healthKitSource: 'HKQuantityTypeIdentifierHeartRate',
231
+ isHealthKit: true,
156
232
  availableFrom: { watch: true, manual: false },
233
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
234
+ formatter: (s) => (0, exports.formatHeartRate)(s.minHeartRate),
235
+ condition: (s) => !!s.minHeartRate,
157
236
  },
158
237
  calories: {
159
238
  key: 'calories',
160
239
  label: 'Calories',
161
240
  unit: 'kcal',
162
241
  category: MetricCategory.ENERGY,
163
- description: 'Total calories',
242
+ description: 'Total calories burned during the swimming session.',
164
243
  icon: 'flame-outline',
165
244
  isPerSession: true,
166
245
  source: 'HealthKit: HKQuantityTypeIdentifierActiveEnergyBurned',
167
246
  healthKitSource: 'HKQuantityTypeIdentifierActiveEnergyBurned',
247
+ isHealthKit: true,
168
248
  availableFrom: { watch: true, manual: true },
249
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
250
+ formatter: (s) => String(s.calories),
251
+ condition: (s) => !!s.calories,
169
252
  },
170
253
  totalStrokeCount: {
171
254
  key: 'totalStrokeCount',
172
255
  label: 'Total Strokes',
173
256
  unit: '',
174
257
  category: MetricCategory.TECHNIQUE,
175
- description: 'Total strokes',
258
+ description: 'Total number of arm strokes taken during the session.',
176
259
  icon: 'hand-left-outline',
177
260
  isPerSession: true,
178
- source: 'Apple Watch accelerometer and gyroscope stroke detection',
261
+ source: 'HealthKit: HKQuantityTypeIdentifierSwimmingStrokeCount',
262
+ healthKitSource: 'HKQuantityTypeIdentifierSwimmingStrokeCount',
263
+ isHealthKit: true,
179
264
  availableFrom: { watch: true, manual: false },
265
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
266
+ formatter: (s) => String(s.totalStrokeCount || 0),
267
+ condition: (s) => !!s.totalStrokeCount,
180
268
  },
181
269
  avgStrokeRate: {
182
270
  key: 'avgStrokeRate',
183
271
  label: 'Stroke Rate',
184
272
  unit: 'SPM',
185
273
  category: MetricCategory.TECHNIQUE,
186
- description: 'Strokes per minute',
274
+ description: 'Average number of strokes per minute throughout the session.',
187
275
  icon: 'repeat-outline',
188
276
  isPerSession: true,
189
277
  formula: 'Total Strokes ÷ (Active Time in Minutes)',
190
278
  availableFrom: { watch: true, manual: true },
279
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
280
+ formatter: (s) => `${(0, exports.formatNumber)(s.avgStrokeRate, 1)} SPM`,
281
+ condition: (s) => !!s.avgStrokeRate,
191
282
  },
192
283
  avgDistancePerStroke: {
193
284
  key: 'avgDistancePerStroke',
194
285
  label: 'Distance/Stroke',
195
286
  unit: 'm',
196
287
  category: MetricCategory.TECHNIQUE,
197
- description: 'Distance per stroke',
288
+ description: 'Average distance covered per stroke - indicates stroke efficiency.',
198
289
  icon: 'arrow-forward-outline',
199
290
  isPerSession: true,
200
291
  formula: 'Distance ÷ Total Strokes',
201
292
  availableFrom: { watch: true, manual: true },
293
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
294
+ formatter: (s) => `${(0, exports.formatNumber)(s.avgDistancePerStroke, 2)}m`,
295
+ condition: (s) => !!s.avgDistancePerStroke,
202
296
  },
203
297
  avgSwolfScore: {
204
298
  key: 'avgSwolfScore',
205
- label: 'SWOLF',
299
+ label: 'SWOLF Score',
206
300
  unit: '',
207
301
  category: MetricCategory.TECHNIQUE,
208
- description: 'Efficiency score',
302
+ description: 'Swimming efficiency score combining time and stroke count per length.',
209
303
  icon: 'trophy-outline',
210
304
  isPerSession: true,
211
305
  formula: 'Average of (Lap Time in Seconds + Lap Stroke Count) for each lap',
212
306
  availableFrom: { watch: true, manual: true },
307
+ swimTypes: [SwimType.INDOOR],
308
+ formatter: (s) => (0, exports.formatNumber)(s.avgSwolfScore, 0),
309
+ condition: (s) => !!s.avgSwolfScore,
213
310
  },
214
311
  primaryStrokeType: {
215
312
  key: 'primaryStrokeType',
216
313
  label: 'Primary Stroke',
217
314
  unit: '',
218
315
  category: MetricCategory.TECHNIQUE,
219
- description: 'Main stroke',
316
+ description: 'Main swimming stroke used during this session.',
220
317
  icon: 'hand-right-outline',
221
318
  isPerSession: true,
222
- source: 'Apple Watch motion pattern recognition',
319
+ source: 'HealthKit: HKWorkoutEvent with HKMetadataKeySwimmingStrokeStyle',
320
+ healthKitSource: 'HKWorkoutEvent.metadata[HKMetadataKeySwimmingStrokeStyle]',
321
+ isHealthKit: true,
223
322
  availableFrom: { watch: true, manual: true },
323
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
324
+ formatter: (s) => s.primaryStrokeType,
325
+ condition: (s) => !!s.primaryStrokeType,
224
326
  },
225
327
  lapSplitTimes: {
226
328
  key: 'lapSplitTimes',
227
- label: 'Split Times',
329
+ label: 'Time',
228
330
  unit: 's',
229
331
  category: MetricCategory.PERFORMANCE,
230
- description: 'Lap times',
332
+ description: 'Individual lap times recorded during the session.',
231
333
  icon: 'stopwatch-outline',
232
334
  isPerLap: true,
233
335
  source: 'Tracked per lap completion',
234
336
  availableFrom: { watch: true, manual: false },
337
+ swimTypes: [SwimType.INDOOR],
338
+ formatter: (s) => `${s.lapSplitTimes.length} splits`,
339
+ lapFormatter: (v) => (0, exports.formatTime)(v),
340
+ condition: (s) => !!s.lapSplitTimes?.length,
235
341
  },
236
342
  lapHeartRates: {
237
343
  key: 'lapHeartRates',
238
- label: 'Lap HR',
344
+ label: 'HR',
239
345
  unit: 'bpm',
240
346
  category: MetricCategory.HEALTH,
241
347
  description: 'HR per lap',
242
348
  icon: 'heart-outline',
243
349
  isPerLap: true,
350
+ decimals: 0,
244
351
  source: 'HealthKit: HKQuantityTypeIdentifierHeartRate sampled at lap completion',
245
352
  healthKitSource: 'HKQuantityTypeIdentifierHeartRate',
353
+ isHealthKit: true,
246
354
  availableFrom: { watch: true, manual: false },
355
+ swimTypes: [SwimType.INDOOR],
247
356
  },
248
357
  lapStrokeCounts: {
249
358
  key: 'lapStrokeCounts',
250
- label: 'Lap Strokes',
359
+ label: 'Strokes',
251
360
  unit: '',
252
361
  category: MetricCategory.TECHNIQUE,
253
362
  description: 'Strokes per lap',
254
363
  icon: 'hand-left-outline',
255
364
  isPerLap: true,
256
- source: 'Apple Watch accelerometer stroke detection per lap',
365
+ decimals: 0,
366
+ source: 'HealthKit: HKQuantityTypeIdentifierSwimmingStrokeCount per lap',
367
+ healthKitSource: 'HKQuantityTypeIdentifierSwimmingStrokeCount',
368
+ isHealthKit: true,
257
369
  availableFrom: { watch: true, manual: false },
370
+ swimTypes: [SwimType.INDOOR],
258
371
  },
259
372
  id: {
260
373
  key: 'id',
@@ -263,6 +376,7 @@ exports.METRICS_REGISTRY = {
263
376
  description: 'Session identifier',
264
377
  isPerSession: true,
265
378
  availableFrom: { watch: false, manual: false },
379
+ swimTypes: [SwimType.INDOOR],
266
380
  },
267
381
  watchSessionId: {
268
382
  key: 'watchSessionId',
@@ -271,6 +385,7 @@ exports.METRICS_REGISTRY = {
271
385
  description: 'Watch session identifier',
272
386
  isPerSession: true,
273
387
  availableFrom: { watch: true, manual: false },
388
+ swimTypes: [SwimType.INDOOR],
274
389
  },
275
390
  date: {
276
391
  key: 'date',
@@ -279,6 +394,7 @@ exports.METRICS_REGISTRY = {
279
394
  description: 'Session date',
280
395
  isPerSession: true,
281
396
  availableFrom: { watch: true, manual: true },
397
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
282
398
  },
283
399
  source: {
284
400
  key: 'source',
@@ -287,41 +403,55 @@ exports.METRICS_REGISTRY = {
287
403
  description: 'Data source',
288
404
  isPerSession: true,
289
405
  availableFrom: { watch: false, manual: false },
406
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
290
407
  },
291
408
  lapTurnTimes: {
292
409
  key: 'lapTurnTimes',
293
- label: 'Turn Times',
410
+ label: 'Turn',
294
411
  unit: 's',
295
412
  category: MetricCategory.TECHNIQUE,
296
- description: 'Turn times per lap',
413
+ description: 'Time spent turning at pool wall per lap',
414
+ icon: 'refresh-circle-outline',
297
415
  isPerLap: true,
416
+ decimals: 2,
298
417
  availableFrom: { watch: true, manual: false },
418
+ swimTypes: [SwimType.INDOOR],
419
+ formatter: exports.formatAverageTurnTime,
420
+ condition: (s) => !!s.lapTurnTimes?.length,
299
421
  },
300
422
  lapStrokeTypes: {
301
423
  key: 'lapStrokeTypes',
302
- label: 'Stroke Types',
424
+ label: 'Stroke',
303
425
  category: MetricCategory.TECHNIQUE,
304
- description: 'Stroke types per lap',
426
+ description: 'Swimming stroke used per lap',
427
+ icon: 'list-outline',
305
428
  isPerLap: true,
306
429
  availableFrom: { watch: true, manual: false },
430
+ swimTypes: [SwimType.INDOOR],
431
+ formatter: (s) => `${s.lapStrokeTypes.length} types`,
432
+ condition: (s) => !!s.lapStrokeTypes?.length,
307
433
  },
308
434
  lapPaces: {
309
435
  key: 'lapPaces',
310
- label: 'Lap Paces',
436
+ label: 'Pace',
311
437
  unit: '/100m',
312
438
  category: MetricCategory.PERFORMANCE,
313
439
  description: 'Pace per lap',
314
440
  isPerLap: true,
441
+ decimals: 1,
315
442
  availableFrom: { watch: true, manual: false },
443
+ swimTypes: [SwimType.INDOOR],
316
444
  },
317
445
  lapCalories: {
318
446
  key: 'lapCalories',
319
- label: 'Lap Calories',
447
+ label: 'Cal',
320
448
  unit: 'kcal',
321
449
  category: MetricCategory.ENERGY,
322
450
  description: 'Calories per lap',
323
451
  isPerLap: true,
452
+ decimals: 0,
324
453
  availableFrom: { watch: true, manual: false },
454
+ swimTypes: [SwimType.INDOOR],
325
455
  },
326
456
  lapDistances: {
327
457
  key: 'lapDistances',
@@ -331,36 +461,43 @@ exports.METRICS_REGISTRY = {
331
461
  description: 'Distance per lap',
332
462
  isPerLap: true,
333
463
  availableFrom: { watch: true, manual: false },
464
+ swimTypes: [SwimType.INDOOR],
334
465
  },
335
466
  lapSwolfScores: {
336
467
  key: 'lapSwolfScores',
337
- label: 'Lap SWOLF',
468
+ label: 'SWOLF',
338
469
  category: MetricCategory.TECHNIQUE,
339
470
  description: 'SWOLF per lap',
340
471
  isPerLap: true,
472
+ decimals: 0,
341
473
  availableFrom: { watch: true, manual: false },
474
+ swimTypes: [SwimType.INDOOR],
342
475
  },
343
476
  lapStrokeRates: {
344
477
  key: 'lapStrokeRates',
345
- label: 'Lap Stroke Rate',
478
+ label: 'SR',
346
479
  unit: 'SPM',
347
480
  category: MetricCategory.TECHNIQUE,
348
481
  description: 'Strokes per minute per lap',
349
482
  icon: 'repeat-outline',
350
483
  isPerLap: true,
484
+ decimals: 1,
351
485
  formula: 'Lap Strokes ÷ (Lap Time in Minutes)',
352
486
  availableFrom: { watch: true, manual: false },
487
+ swimTypes: [SwimType.INDOOR],
353
488
  },
354
489
  lapDistancesPerStroke: {
355
490
  key: 'lapDistancesPerStroke',
356
- label: 'Lap Distance/Stroke',
491
+ label: 'DPS',
357
492
  unit: 'm',
358
493
  category: MetricCategory.TECHNIQUE,
359
494
  description: 'Distance covered per stroke per lap',
360
495
  icon: 'arrow-forward-outline',
361
496
  isPerLap: true,
497
+ decimals: 2,
362
498
  formula: 'Lap Distance ÷ Lap Strokes',
363
499
  availableFrom: { watch: true, manual: false },
500
+ swimTypes: [SwimType.INDOOR],
364
501
  },
365
502
  poolLocation: {
366
503
  key: 'poolLocation',
@@ -369,6 +506,7 @@ exports.METRICS_REGISTRY = {
369
506
  description: 'Pool location details',
370
507
  isPerSession: true,
371
508
  availableFrom: { watch: false, manual: true },
509
+ swimTypes: [SwimType.INDOOR],
372
510
  },
373
511
  notes: {
374
512
  key: 'notes',
@@ -377,30 +515,55 @@ exports.METRICS_REGISTRY = {
377
515
  description: 'Session notes',
378
516
  isPerSession: true,
379
517
  availableFrom: { watch: false, manual: true },
518
+ swimTypes: [SwimType.INDOOR],
380
519
  },
381
520
  trainingTypes: {
382
521
  key: 'trainingTypes',
383
522
  label: 'Training Types',
384
523
  category: MetricCategory.PERFORMANCE,
385
- description: 'Training types',
524
+ description: 'Types of training focus for this swimming session.',
525
+ icon: 'school-outline',
386
526
  isPerSession: true,
387
527
  availableFrom: { watch: false, manual: true },
528
+ formatter: (s) => s.trainingTypes?.join(', ') || '',
529
+ condition: (s) => !!s.trainingTypes?.length,
530
+ swimTypes: [SwimType.INDOOR],
388
531
  },
389
532
  perceivedEffort: {
390
533
  key: 'perceivedEffort',
391
534
  label: 'Perceived Effort',
392
- category: MetricCategory.PERFORMANCE,
393
- description: 'Effort rating',
535
+ category: MetricCategory.HEALTH,
536
+ description: 'Subjective rating of how hard the session felt.',
537
+ icon: 'body-outline',
394
538
  isPerSession: true,
395
539
  availableFrom: { watch: false, manual: true },
540
+ formatter: (s) => s.perceivedEffort,
541
+ condition: (s) => !!s.perceivedEffort,
542
+ swimTypes: [SwimType.INDOOR],
396
543
  },
397
544
  equipment: {
398
545
  key: 'equipment',
399
546
  label: 'Equipment',
400
- category: MetricCategory.PERFORMANCE,
401
- description: 'Equipment used',
547
+ category: MetricCategory.TECHNIQUE,
548
+ description: 'Swimming equipment used during the session.',
549
+ icon: 'construct-outline',
402
550
  isPerSession: true,
403
551
  availableFrom: { watch: false, manual: true },
552
+ formatter: (s) => s.equipment?.join(', ') || '',
553
+ condition: (s) => !!s.equipment?.length,
554
+ swimTypes: [SwimType.INDOOR, SwimType.OPEN_WATER],
555
+ },
556
+ routeCoordinates: {
557
+ key: 'routeCoordinates',
558
+ label: 'GPS Route',
559
+ category: MetricCategory.PERFORMANCE,
560
+ description: 'GPS coordinates of the swimming route',
561
+ icon: 'map-outline',
562
+ isPerSession: true,
563
+ availableFrom: { watch: true, manual: false },
564
+ swimTypes: [SwimType.OPEN_WATER],
565
+ formatter: (s) => s.routeCoordinates ? `${s.routeCoordinates.length} points` : '',
566
+ condition: (s) => !!s.routeCoordinates?.length,
404
567
  },
405
568
  };
406
569
  exports.MetricsHelper = {
@@ -408,5 +571,8 @@ exports.MetricsHelper = {
408
571
  getSessionMetrics: () => Object.values(exports.METRICS_REGISTRY).filter((m) => m.isPerSession),
409
572
  getLapMetrics: () => Object.values(exports.METRICS_REGISTRY).filter((m) => m.isPerLap),
410
573
  getMetricsByCategory: (category) => Object.values(exports.METRICS_REGISTRY).filter((m) => m.category === category),
574
+ getMetricsBySwimType: (swimType) => Object.values(exports.METRICS_REGISTRY).filter((m) => m.swimTypes?.includes(swimType)),
575
+ getIndoorMetrics: () => Object.values(exports.METRICS_REGISTRY).filter((m) => m.swimTypes?.includes(SwimType.INDOOR)),
576
+ getOpenWaterMetrics: () => Object.values(exports.METRICS_REGISTRY).filter((m) => m.swimTypes?.includes(SwimType.OPEN_WATER)),
411
577
  getAllMetricKeys: () => Object.keys(exports.METRICS_REGISTRY),
412
578
  };
@@ -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.0",
3
+ "version": "1.0.2",
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
+ }