Skip to content

Commit 94ea316

Browse files
committed
feat: add GPS location support to Sample class
- Added SampleKind.Location enum value for GPS coordinates - Created Coordinates interface for latitude/longitude/altitude - Enhanced Sample class to support location data - Sample.value can now be a number or Coordinates based on kind - Moved Sample class to a dedicated file for better organization
1 parent 915016c commit 94ea316

File tree

7 files changed

+407
-24
lines changed

7 files changed

+407
-24
lines changed

fixtures/healthkit-cycling.tcx

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<TrainingCenterDatabase
3+
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
4+
xmlns:ldn="https://limulus.net/xsd/tcx/v1"
5+
>
6+
<Activities>
7+
<Activity Sport="Biking">
8+
<Id>2025-03-02T18:38:01Z</Id>
9+
<Lap StartTime="2025-03-02T18:38:01Z">
10+
<TotalTimeSeconds>5179.350522994995</TotalTimeSeconds>
11+
<DistanceMeters>23931.676992251505</DistanceMeters>
12+
<Calories>607</Calories>
13+
<Intensity>Active</Intensity>
14+
<TriggerMethod>Manual</TriggerMethod>
15+
<Track>
16+
<Trackpoint>
17+
<Time>2025-03-02T19:00:33.701</Time>
18+
<HeartRateBpm>
19+
<Value>127</Value>
20+
</HeartRateBpm>
21+
</Trackpoint>
22+
<Trackpoint>
23+
<Time>2025-03-02T19:00:33.999</Time>
24+
<DistanceMeters>7259.418214329607</DistanceMeters>
25+
<Extensions>
26+
<ldn:EndTime>2025-03-02T19:00:34.999</ldn:EndTime>
27+
</Extensions>
28+
</Trackpoint>
29+
<Trackpoint>
30+
<Time>2025-03-02T19:00:33.999</Time>
31+
<Position>
32+
<LatitudeDegrees>32.36415566566089</LatitudeDegrees>
33+
<LongitudeDegrees>-111.01644143335649</LongitudeDegrees>
34+
</Position>
35+
<AltitudeMeters>714.6836803928018</AltitudeMeters>
36+
<Extensions>
37+
<ldn:SpeedMetersSec>6.57443257931449</ldn:SpeedMetersSec>
38+
<ldn:CourseDegrees>213.60538018719149</ldn:CourseDegrees>
39+
<ldn:HorizontalAccuracyMeters>1.525759883433863</ldn:HorizontalAccuracyMeters>
40+
<ldn:VerticalAccuracyMeters>0.8248695266016866</ldn:VerticalAccuracyMeters>
41+
<ldn:SpeedAccuracyMetersSec>0.5539088122958268</ldn:SpeedAccuracyMetersSec>
42+
<ldn:CourseAccuracyDegrees>5.501514511644444</ldn:CourseAccuracyDegrees>
43+
</Extensions>
44+
</Trackpoint>
45+
<Trackpoint>
46+
<Time>2025-03-02T19:00:34.661</Time>
47+
<HeartRateBpm>
48+
<Value>127</Value>
49+
</HeartRateBpm>
50+
</Trackpoint>
51+
<Trackpoint>
52+
<Time>2025-03-02T19:00:34.999</Time>
53+
<DistanceMeters>7265.816238153512</DistanceMeters>
54+
<Extensions>
55+
<ldn:EndTime>2025-03-02T19:00:35.999</ldn:EndTime>
56+
</Extensions>
57+
</Trackpoint>
58+
<Trackpoint>
59+
<Time>2025-03-02T19:00:34.999</Time>
60+
<Position>
61+
<LatitudeDegrees>32.36410654106352</LatitudeDegrees>
62+
<LongitudeDegrees>-111.0164792971803</LongitudeDegrees>
63+
</Position>
64+
<AltitudeMeters>714.6248090686277</AltitudeMeters>
65+
<Extensions>
66+
<ldn:SpeedMetersSec>6.446710548233051</ldn:SpeedMetersSec>
67+
<ldn:CourseDegrees>212.76960844570573</ldn:CourseDegrees>
68+
<ldn:HorizontalAccuracyMeters>1.5427180445495623</ldn:HorizontalAccuracyMeters>
69+
<ldn:VerticalAccuracyMeters>0.8283335073143913</ldn:VerticalAccuracyMeters>
70+
<ldn:SpeedAccuracyMetersSec>0.5544461153722668</ldn:SpeedAccuracyMetersSec>
71+
<ldn:CourseAccuracyDegrees>5.615053509537127</ldn:CourseAccuracyDegrees>
72+
</Extensions>
73+
</Trackpoint>
74+
<Trackpoint>
75+
<Time>2025-03-02T19:00:35.620</Time>
76+
<HeartRateBpm>
77+
<Value>127</Value>
78+
</HeartRateBpm>
79+
</Trackpoint>
80+
<Trackpoint>
81+
<Time>2025-03-02T19:00:35.999</Time>
82+
<DistanceMeters>7272.2661441064865</DistanceMeters>
83+
<Extensions>
84+
<ldn:EndTime>2025-03-02T19:00:36.999</ldn:EndTime>
85+
</Extensions>
86+
</Trackpoint>
87+
<Trackpoint>
88+
<Time>2025-03-02T19:00:35.999</Time>
89+
<Position>
90+
<LatitudeDegrees>32.36405828546315</LatitudeDegrees>
91+
<LongitudeDegrees>-111.0165162764549</LongitudeDegrees>
92+
</Position>
93+
<AltitudeMeters>714.567170935683</AltitudeMeters>
94+
<Extensions>
95+
<ldn:SpeedMetersSec>6.321835094495622</ldn:SpeedMetersSec>
96+
<ldn:CourseDegrees>213.31531124278766</ldn:CourseDegrees>
97+
<ldn:HorizontalAccuracyMeters>1.5576938692821687</ldn:HorizontalAccuracyMeters>
98+
<ldn:VerticalAccuracyMeters>0.8324757966424772</ldn:VerticalAccuracyMeters>
99+
<ldn:SpeedAccuracyMetersSec>0.555701429469894</ldn:SpeedAccuracyMetersSec>
100+
<ldn:CourseAccuracyDegrees>5.744434121216499</ldn:CourseAccuracyDegrees>
101+
</Extensions>
102+
</Trackpoint>
103+
<Trackpoint>
104+
<Time>2025-03-02T19:00:36.999</Time>
105+
<DistanceMeters>7278.397077740254</DistanceMeters>
106+
<Extensions>
107+
<ldn:EndTime>2025-03-02T19:00:37.999</ldn:EndTime>
108+
</Extensions>
109+
</Trackpoint>
110+
<Trackpoint>
111+
<Time>2025-03-02T19:00:36.999</Time>
112+
<Position>
113+
<LatitudeDegrees>32.36401174863372</LatitudeDegrees>
114+
<LongitudeDegrees>-111.01655316641234</LongitudeDegrees>
115+
</Position>
116+
<AltitudeMeters>714.5109967505559</AltitudeMeters>
117+
<Extensions>
118+
<ldn:SpeedMetersSec>6.120048794120984</ldn:SpeedMetersSec>
119+
<ldn:CourseDegrees>214.5694348766998</ldn:CourseDegrees>
120+
<ldn:HorizontalAccuracyMeters>1.5718881432670073</ldn:HorizontalAccuracyMeters>
121+
<ldn:VerticalAccuracyMeters>0.8370669454537035</ldn:VerticalAccuracyMeters>
122+
<ldn:SpeedAccuracyMetersSec>0.5566821800405904</ldn:SpeedAccuracyMetersSec>
123+
<ldn:CourseAccuracyDegrees>5.952269227377609</ldn:CourseAccuracyDegrees>
124+
</Extensions>
125+
</Trackpoint>
126+
<Trackpoint>
127+
<Time>2025-03-02T19:00:37.061</Time>
128+
<HeartRateBpm>
129+
<Value>126</Value>
130+
</HeartRateBpm>
131+
</Trackpoint>
132+
<Trackpoint>
133+
<Time>2025-03-02T19:00:37.999</Time>
134+
<DistanceMeters>7284.255366230337</DistanceMeters>
135+
<Extensions>
136+
<ldn:EndTime>2025-03-02T19:00:38.999</ldn:EndTime>
137+
</Extensions>
138+
</Trackpoint>
139+
<Trackpoint>
140+
<Time>2025-03-02T19:00:37.999</Time>
141+
<Position>
142+
<LatitudeDegrees>32.363967468014714</LatitudeDegrees>
143+
<LongitudeDegrees>-111.01658967677358</LongitudeDegrees>
144+
</Position>
145+
<AltitudeMeters>714.4564333837479</AltitudeMeters>
146+
<Extensions>
147+
<ldn:SpeedMetersSec>5.868324955639546</ldn:SpeedMetersSec>
148+
<ldn:CourseDegrees>215.41648422742077</ldn:CourseDegrees>
149+
<ldn:HorizontalAccuracyMeters>1.5885147164550815</ldn:HorizontalAccuracyMeters>
150+
<ldn:VerticalAccuracyMeters>0.8418827039502483</ldn:VerticalAccuracyMeters>
151+
<ldn:SpeedAccuracyMetersSec>0.5579247142213264</ldn:SpeedAccuracyMetersSec>
152+
<ldn:CourseAccuracyDegrees>6.228029125593802</ldn:CourseAccuracyDegrees>
153+
</Extensions>
154+
</Trackpoint>
155+
<Trackpoint>
156+
<Time>2025-03-02T19:00:38.021</Time>
157+
<HeartRateBpm>
158+
<Value>125</Value>
159+
</HeartRateBpm>
160+
</Trackpoint>
161+
<Trackpoint>
162+
<Time>2025-03-02T19:00:38.981</Time>
163+
<HeartRateBpm>
164+
<Value>123</Value>
165+
</HeartRateBpm>
166+
</Trackpoint>
167+
<Trackpoint>
168+
<Time>2025-03-02T19:00:38.999</Time>
169+
<DistanceMeters>7289.686701631938</DistanceMeters>
170+
<Extensions>
171+
<ldn:EndTime>2025-03-02T19:00:39.999</ldn:EndTime>
172+
</Extensions>
173+
</Trackpoint>
174+
<Trackpoint>
175+
<Time>2025-03-02T19:00:38.999</Time>
176+
<Position>
177+
<LatitudeDegrees>32.36392537836743</LatitudeDegrees>
178+
<LongitudeDegrees>-111.0166250800671</LongitudeDegrees>
179+
</Position>
180+
<AltitudeMeters>714.4035716522485</AltitudeMeters>
181+
<Extensions>
182+
<ldn:SpeedMetersSec>5.602584026967569</ldn:SpeedMetersSec>
183+
<ldn:CourseDegrees>215.63445709528048</ldn:CourseDegrees>
184+
<ldn:HorizontalAccuracyMeters>1.608625926347874</ldn:HorizontalAccuracyMeters>
185+
<ldn:VerticalAccuracyMeters>0.8466872526448237</ldn:VerticalAccuracyMeters>
186+
<ldn:SpeedAccuracyMetersSec>0.5581765472976272</ldn:SpeedAccuracyMetersSec>
187+
<ldn:CourseAccuracyDegrees>6.528536700712621</ldn:CourseAccuracyDegrees>
188+
</Extensions>
189+
</Trackpoint>
190+
<Trackpoint>
191+
<Time>2025-03-02T19:00:39.941</Time>
192+
<HeartRateBpm>
193+
<Value>122</Value>
194+
</HeartRateBpm>
195+
</Trackpoint>
196+
<Trackpoint>
197+
<Time>2025-03-02T19:00:39.999</Time>
198+
<DistanceMeters>7294.893353771131</DistanceMeters>
199+
<Extensions>
200+
<ldn:EndTime>2025-03-02T19:00:40.999</ldn:EndTime>
201+
</Extensions>
202+
</Trackpoint>
203+
<Trackpoint>
204+
<Time>2025-03-02T19:00:39.999</Time>
205+
<Position>
206+
<LatitudeDegrees>32.36388488201555</LatitudeDegrees>
207+
<LongitudeDegrees>-111.01665932063553</LongitudeDegrees>
208+
</Position>
209+
<AltitudeMeters>714.352493358776</AltitudeMeters>
210+
<Extensions>
211+
<ldn:SpeedMetersSec>5.4535262005255944</ldn:SpeedMetersSec>
212+
<ldn:CourseDegrees>215.69407794324968</ldn:CourseDegrees>
213+
<ldn:HorizontalAccuracyMeters>1.6272394049692853</ldn:HorizontalAccuracyMeters>
214+
<ldn:VerticalAccuracyMeters>0.8512340766895958</ldn:VerticalAccuracyMeters>
215+
<ldn:SpeedAccuracyMetersSec>0.5584622624655049</ldn:SpeedAccuracyMetersSec>
216+
<ldn:CourseAccuracyDegrees>6.708076714402505</ldn:CourseAccuracyDegrees>
217+
</Extensions>
218+
</Trackpoint>
219+
</Track>
220+
</Lap>
221+
</Activity>
222+
</Activities>
223+
</TrainingCenterDatabase>

src/index.spec.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'
33
import { TCXReader, Sample, SampleKind } from './index.js'
44

55
describe('index', () => {
6-
it('should export TCXReader, Sample, and SampleKind', () => {
6+
it('should export TCXReader, Sample, and SampleKind with location support', () => {
77
// Verify the exports exist
88
expect(TCXReader).toBeDefined()
99
expect(Sample).toBeDefined()
@@ -14,5 +14,13 @@ describe('index', () => {
1414
expect(SampleKind.Distance).toBe('distance')
1515
expect(SampleKind.Cadence).toBe('cadence')
1616
expect(SampleKind.Power).toBe('power')
17+
expect(SampleKind.Location).toBe('location')
18+
19+
// Create a sample with location data to verify the type works
20+
const coords = { latitude: 47.6062, longitude: -122.3321 }
21+
const sample = new Sample(new Date(), SampleKind.Location, coords)
22+
23+
expect(sample.kind).toBe(SampleKind.Location)
24+
expect(sample.value).toEqual(coords)
1725
})
1826
})

src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
import { TCXReader, Sample, SampleKind } from './lib/tcx-reader.js'
1+
import { Sample, SampleKind, type Coordinates } from './lib/sample.js'
2+
import { TCXReader } from './lib/tcx-reader.js'
23

34
export { TCXReader, Sample, SampleKind }
5+
export type { Coordinates }

src/lib/sample.spec.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, it, expect } from 'vitest'
2+
3+
import { Sample, SampleKind, Coordinates } from './sample.js'
4+
5+
describe('Sample', () => {
6+
describe('constructor', () => {
7+
it('should create a sample for numeric values', () => {
8+
const time = new Date()
9+
const sample = new Sample(time, SampleKind.HeartRate, 150)
10+
11+
expect(sample.time).toBe(time)
12+
expect(sample.kind).toBe(SampleKind.HeartRate)
13+
expect(sample.value).toBe(150)
14+
})
15+
16+
it('should create a sample for location values', () => {
17+
const time = new Date()
18+
const coordinates: Coordinates = {
19+
latitude: 47.6062,
20+
longitude: -122.3321,
21+
altitude: 100,
22+
}
23+
const sample = new Sample(time, SampleKind.Location, coordinates)
24+
25+
expect(sample.time).toBe(time)
26+
expect(sample.kind).toBe(SampleKind.Location)
27+
expect(sample.value).toBe(coordinates)
28+
expect(sample.value.latitude).toBe(47.6062)
29+
expect(sample.value.longitude).toBe(-122.3321)
30+
expect(sample.value.altitude).toBe(100)
31+
})
32+
33+
it('should support location sample without altitude', () => {
34+
const time = new Date()
35+
const coordinates: Coordinates = {
36+
latitude: 47.6062,
37+
longitude: -122.3321,
38+
}
39+
const sample = new Sample(time, SampleKind.Location, coordinates)
40+
41+
expect(sample.time).toBe(time)
42+
expect(sample.kind).toBe(SampleKind.Location)
43+
expect(sample.value).toBe(coordinates)
44+
expect(sample.value.latitude).toBe(47.6062)
45+
expect(sample.value.longitude).toBe(-122.3321)
46+
expect(sample.value.altitude).toBeUndefined()
47+
})
48+
})
49+
50+
describe('SampleKind', () => {
51+
it('should have the correct enum values', () => {
52+
expect(SampleKind.HeartRate).toBe('heartRate')
53+
expect(SampleKind.Distance).toBe('distance')
54+
expect(SampleKind.Cadence).toBe('cadence')
55+
expect(SampleKind.Power).toBe('power')
56+
expect(SampleKind.Location).toBe('location')
57+
})
58+
})
59+
})

src/lib/sample.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export enum SampleKind {
2+
HeartRate = 'heartRate',
3+
Distance = 'distance',
4+
Cadence = 'cadence',
5+
Power = 'power',
6+
Location = 'location',
7+
}
8+
9+
export interface Coordinates {
10+
latitude: number
11+
longitude: number
12+
altitude?: number
13+
}
14+
15+
export type SampleValue<T extends SampleKind> = T extends SampleKind.Location
16+
? Coordinates
17+
: number
18+
19+
export class Sample<T extends SampleKind = SampleKind> {
20+
constructor(
21+
public readonly time: Date,
22+
public readonly kind: T,
23+
public readonly value: SampleValue<T>
24+
) {}
25+
}

0 commit comments

Comments
 (0)