@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 +16 -0
- package/dist/index.js +208 -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
|
@@ -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
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
|
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: '
|
|
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: '
|
|
162
|
+
label: 'Swimming Type',
|
|
103
163
|
unit: '',
|
|
104
164
|
category: MetricCategory.PERFORMANCE,
|
|
105
|
-
description: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
329
|
+
label: 'Time',
|
|
228
330
|
unit: 's',
|
|
229
331
|
category: MetricCategory.PERFORMANCE,
|
|
230
|
-
description: '
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
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
|
|
410
|
+
label: 'Turn',
|
|
294
411
|
unit: 's',
|
|
295
412
|
category: MetricCategory.TECHNIQUE,
|
|
296
|
-
description: '
|
|
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
|
|
424
|
+
label: 'Stroke',
|
|
303
425
|
category: MetricCategory.TECHNIQUE,
|
|
304
|
-
description: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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.
|
|
393
|
-
description: '
|
|
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.
|
|
401
|
-
description: '
|
|
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.
|
|
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
|
+
}
|