Skip to main content

Reading activities

readActivity takes the bytes of a FIT, GPX or TCX file and gives back an Activity. It tells the format from the bytes, so you don't say which it is.

import { readFileSync } from 'node:fs';
import { readActivity, activityFormat } from '@pond-ts/fit/files';

const bytes = readFileSync('interval-run.fit');
console.log(activityFormat(bytes));

const run = readActivity(bytes, { name: 'Six by 800 m' });
console.log(run.name, run.sport, run.sportType);
console.log(run.startedAt.toISOString(), run.hasRoute, run.laps.length);
console.log(run.device);
fit
Six by 800 m run Run
2026-06-16T18:00:00.000Z true 13
{ manufacturer: 'development', product: 0, name: 'pond fixture' }

The readers live in @pond-ts/fit/files, apart from the rest of the package, so an app that never reads files doesn't carry the FIT decoder.

In a browser, read a dropped or picked file the same way:

const file = input.files[0];
const activity = readActivity(new Uint8Array(await file.arrayBuffer()));

A gzipped file (.fit.gz, as Strava's bulk export ships them) has to be unpacked first; readActivity throws on anything that isn't FIT, GPX or TCX.

What you get​

PropertyWhat it holds
nameThe name you pass (FIT files don't carry one; 'Untitled' otherwise)
sportThe family: 'run', 'ride', 'swim', 'walk', 'hike', 'other'
sportTypeThe file's own label, such as 'Run', 'TrailRun', 'VirtualRide'
startedAtA Date, the start of the recording
deviceMaker and product as the file states them, or null
hasRouteWhether there are GPS positions
lapsThe laps the device recorded (see Sections)
environmentFor a swim: 'pool' or 'open-water'; otherwise null

What the reader cleans up​

Files are messy, so the reader does the tidying once, at the door, and everything after it can trust the activity:

  • Dropouts stay missing. A heart-rate strap that loses contact, or a power meter that drops out, leaves a gap. The gap is kept as missing data, never filled with zeros or joined across.
  • Junk values go. Devices write sentinel values (255 bpm, 65535 W) for "no reading"; those become missing too.
  • Samples before GPS lock count. A watch that starts before it finds satellites keeps counting distance from its motion sensor; those minutes and metres are kept, measured by the watch, and the map line starts at the first fix.
  • The timer is kept as the file states it. A FIT session records how long its timer ran; when a file doesn't say, the timer time is null, not a guess. See Clocks.

Your own data​

The readers produce a plain record (ImportedActivity): the activity's details, its streams as parallel arrays, its laps. Activity.from takes one of those, so data from anywhere else (an API, a database, a CSV) becomes an activity the same way. A treadmill run with no GPS needs only time, distance and whatever else was measured:

import { Activity } from '@pond-ts/fit';

const seconds = Array.from({ length: 1201 }, (_, i) => i);
const treadmill = Activity.from({
activity: {
id: 'treadmill:1',
source: 'manual',
externalId: '1',
name: 'Treadmill 20 min',
startTimeUtc: '2026-06-20T07:00:00Z',
distanceMeters: 0,
movingTimeSeconds: 0,
elapsedTimeSeconds: 0,
elevationGainMeters: 0,
sportType: 'Run',
},
streams: {
latlng: [],
timeSeconds: seconds,
distanceMeters: seconds.map((s) => s * 3.2),
heartrate: seconds.map((s) => Math.round(120 + s / 30)),
},
});

const tread = treadmill.stats();
console.log(tread.distance?.format('km'), tread.pace.average?.format());
console.log(treadmill.hasRoute, tread.heartRate.max?.format());
3.84 km 5:13/km
false 160 bpm

The distanceMeters, movingTimeSeconds and other totals in activity are what a source claims; the stats are worked out from the streams, so zeros there are fine.