Ingestion and processing¶
| Server | |
|---|---|
| Status | ✅ |
| Code | app/jobs/track_segment_ingest_job.rb, app/jobs/track_recompute_job.rb, app/jobs/trip_nights_job.rb, app/services/ingestion/, app/services/sailever/ (FIT and GPX decoders), app/services/fit_upload.rb, app/services/trip_bridges/, app/services/trip_days/, app/services/map_lines/, app/models/{trip,track,track_map_line}.rb, app/services/track_file.rb, lib/tasks/{map_lines,track_files,trip_days}.rake |
Verified against code: 2026-10-26
Purpose¶
Every track reaches the logbook through one of three channels and ends up as the same data: track points, events, totals and names, grouped into a trip. All derived values are recomputed from the stored points, never patched, so reprocessing always lands on the same numbers.
Channels¶
| Channel | Entry | tracks.source |
Kept |
|---|---|---|---|
| SLTB segments from a paired watch | POST /api/v1/tracks/segments |
watch_sync |
The track file; raw segments in track_segments.payload |
| FIT file via the API | POST /api/v1/tracks/upload |
direct_sync |
The track file; the FIT file is not kept |
| FIT and GPX files via the web upload dialog | POST /my/trips/upload |
import |
The track files; each uploaded file is deleted once its tracks are imported |
garmin_activity_api exists in the enum but nothing creates it: the Garmin developer programme is closed.
Points on the 5 s grid¶
Every channel stores its points the same way (ADR 017): normalized to the 5 s grid, then written as the track's file (data-model.md § Track file).
| Rule | |
|---|---|
| Slot | Each point goes to the nearest multiple of 5 s (epoch time). |
| Per slot | The point nearest to the slot's time stays (its position, COG, pressure, state, engine), with the mean SOG of all points in the slot (none if none has one). |
| Gaps | Stay gaps: no point is invented. |
| SLTB | Already every 5 s: one point per slot, nothing is dropped. |
Everything after ingest (metrics, events, map lines, days, the editor, the replay, the GPX export) uses only these points.
SLTB segments¶
The wire contract is in track-sync.md. The controller only stores the segment and queues TrackSegmentIngestJob.
TrackSegmentIngestJob¶
Runs per segment. Segments of one track never run in parallel (limits_concurrency 1, key track:<device_id>:<track_id>, 15 min). Unexpected errors retry 5 times with polynomial backoff.
- Skip unless the segment is
received. - If any segment of the same track is
discarded, mark this onediscarded, empty its payload, stop. - Decode.
CorruptSegment→ segmentquarantinedwith the error, stop. END reason=1→ discard the track (below), stop.- In one transaction:
- Find the track by
(device_id, start_time = track_started_at). If it exists and is notwatch_sync(a FIT import of the same recording), raiseCorruptSegment: the segment is quarantined, the two channels never merge. - Otherwise create it: user and device of the segment, trip (below),
trip_token,device_namefrom the device's name or model,start_time,end_time= last point,sync_status = receiving. - Replace points: with the track row locked, the track file's points between the segment's first and last point time are replaced by the decoded points with SOG and COG (below), on the 5 s grid, and the file is stored again.
engine_on= state isengineormotorsail. - On a
FINALsegment:expected_segments = seq + 1,device_totals= the decodedENDrecord (reason assaved/recovered). - Segment →
decoded,point_count,processed_at, linked to the track. - Queue
TrackRecomputeJob.
Re-running a segment replaces exactly its own points, so out-of-order and repeated processing is harmless.
Trip grouping¶
| Case | Trip |
|---|---|
trip_token present |
The user's trip with that token, created if missing (create_or_find_by, unique (user_id, trip_token)) |
No trip_token |
A new trip for this track |
A new trip gets the title Sailing Trip <DD Mon YYYY> (track start date). Every trip is private until the owner shares it (sharing.md). There are no time-based heuristics.
Completeness¶
sync_status |
When |
|---|---|
receiving |
No FINAL segment decoded yet |
complete |
FINAL decoded and decoded segments ≥ expected_segments |
incomplete |
FINAL decoded, segments missing (Track#missing_segment_seqs) |
| null | FIT tracks |
A late segment triggers a recompute and can move a track from incomplete to complete. While receiving, only the start of the track can be trimmed. How the web shows these states: trip.md § Tracks table.
Discard¶
END reason=1 destroys the watch_sync track with its points and events, sets every segment of that track to discarded (payload emptied, unlinked) and recomputes the trip. The emptied segments stay as tombstones, so a late or repeated upload of the track is discarded too. The trip itself stays, even when it is now empty.
Quarantine¶
A segment is quarantined when its header or CRC fails at upload (202), when header and envelope disagree, when the body fails to decode, or when its start time collides with a FIT track. Admin → Quarantined segments (Admin::TrackSegmentsController) lists the newest 200 with their error. Reprocess sets the segment back to received and queues the job again; the payload is kept.
Sweep¶
TrackSegmentSweepJob runs every 15 min and queues the ingest job again for segments still received 15 min after upload (failed enqueue or exhausted retries).
TrackRecomputeJob¶
Rebuilds every derived value of one track from its points, read from its track file (Ingestion::TrackRecompute). Runs after every decoded segment and after a trim. One at a time per track (key track-recompute:<id>, 15 min), 5 retries. In one transaction:
end_time= last kept point (else last point, else start).- Delete the track's events and reset
storm_warning. - Metrics from the kept points (below), with the device distance.
- Trimmed track with device distance →
distance_source = device_scaled. - Events and the map line (below).
- After a trim: clear generated names (below).
- Names: start name once
seq 0is decoded; end name once the track iscomplete. FIT tracks always qualify. - Name inheritance from the previous track.
max_speed_knots= rolling max (below), capped at 999.99.sync_status(watch tracks only),processed_at.trip.recompute_aggregates!.
After the transaction: trip.notify_created!, then a Turbo refresh of open trip pages.
FIT files¶
Decoder (Sailever::Fit::Decoder)¶
Reads record (20) and session (18) messages, normal and compressed headers, little- and big-endian definitions.
| Value | Source |
|---|---|
| Position | record fields 0, 1 (semicircles); records without a valid position are skipped |
| SOG | record field 73 (enhanced speed), else 6; mm/s → kn; none above 100 kn |
| COG | not in a FIT record: computed from the positions as for SLTB (SOG and COG: bearing between the neighbouring points, none below 0.3 kn; the gap limit is three times the file's median interval) |
| Pressure | record field 77, else 28; ÷ 100 → hPa; none outside 800–1100 hPa |
| Time | record field 253 (FIT epoch) |
| Vessel state | developer field 6, wire values as in vessel-state.md; default sail |
engine_on |
developer field 0 = 1, or state engine / motorsail |
sail_main |
developer field 1: OFF, FULL, REEF1, REEF2; decoded but not stored |
| Distance | session field 9 (cm) |
| Max speed | session field 125, else 15 (mm/s); values over 200 m/s are dropped |
| Start / end | session fields 2 / 253; else first / last point |
trip_token |
session developer field 4 (string) |
app_version |
session developer field 5 |
Every field is read at the size its definition gives (1, 2 or 4 bytes); a value with all bits set is FIT's "invalid" and counts as missing. Before 2026-10-20, COG was read from fields 78 and 2, which are altitudes, as 4 bytes whatever their size: on older watches (16-bit altitude) that gave values in the millions and the import failed; on others the altitude was stored as the course (one value, or a handful, for the whole track). bin/rails courses:repair (TrackCourseRepair) recomputes the course of such tracks from their positions; SOG and states stay, and it changes nothing on a second run. The CRC is not checked. Developer fields are matched by field number only.
GPX files¶
Decoder (Sailever::Gpx::Decoder)¶
Web upload only. A file is GPX when its first 1 KB contains <gpx; everything else goes to the FIT decoder. GPX 1.0 and 1.1, namespaces ignored. Every <trk> is one track (its <trkseg>s joined); <rte> and <wpt> are ignored. A track needs at least two points with different times; one with fewer is left out (a file can't hold two points at one time: of points with the same time only one is kept). A file left without a track says why (logbook.md § Upload dialog): only a route or waypoints, positions without times, times in a format it can't read, or all points at one time (routes drawn or re-exported in an app such as AllTrails). Broken XML or another root than <gpx> → "unreadable". The file is read in one streaming pass (Nokogiri::XML::Reader, no DOM, no network, no entity expansion): a 100 MB file (about 760 000 points) decodes in about 7 s with about 1.4 GB peak memory on a developer machine. The kamal-proxy response timeout is 90 s (config/deploy.yml) so the preview of such a file isn't cut off on the app VM; the proxy buffers the request body (up to 1 GB), so upload time doesn't count.
| Value | Source |
|---|---|
| Position | lat, lon of each <trkpt>; points outside ±90 / ±180 or without a <time> are dropped, points with the same time kept once, sorted by time |
| Time | <time>: ISO 8601, also with a space for the T (2023-09-27 08:20:53, as Galileo / Guru Maps writes it); a time without a zone is UTC, as GPX defines it. A point whose time can't be read is dropped |
| SOG | the first speed element of the point (GPX 1.0 on the point, or Garmin TrackPointExtension v2 in <extensions>) in m/s → kn, none above 100 kn; if no point of the track has one, computed from the positions as for SLTB (SOG and COG, gap limit three times the track's median interval) |
| COG | computed from the positions, as for FIT |
| Pressure | none |
Vessel state, engine_on |
sail, false |
| Distance | none: the pipeline computes it (distance_source = computed) |
| Max speed | highest SOG of the track (after the median-of-3 filter when computed) |
| Start / end | first / last point |
device_name |
"GPX file" |
Ingestion::Pipeline¶
Used by the API upload and the web import (FIT and GPX). Synchronous, in one transaction:
- Idempotency: an existing track with the same
(source, external_activity_id), or with the same(device_id, start_time), is returned unchanged. - Trip: the one passed in (web import), else by
trip_tokenas for SLTB, else a new trip. - Track with
start_time,end_time, session max speed,device_totals = {"reason":"fit","distance_m":…}when the file has a distance. - Points as decoded (SOG from the file, COG computed by the decoder), on the 5 s grid, stored as the track file.
- Metrics with the session distance, the map line, events, names (start and end), name inheritance.
- Trip aggregates and the trip email, unless a trip was passed in.
Web upload dialog¶
POST /my/trips/upload has two steps (UI: logbook.md § Upload dialog).
| Step | What happens |
|---|---|
| Preview (one request per file) | File ≤ 100 MB. Decoded on its own (FIT: one track; GPX: one per <trk>, tracks without two points at different times dropped); unreadable → "unreadable", no track → the reason (below in logbook.md § Upload dialog). Stored as an unattached blob (application/x-fit or application/gpx+xml) with the start time of the first track (fit_start_time) and of every track (track_start_times) in the metadata. Flags seen_before when, for every track of the file, the user has a track starting within ±60 s. |
Commit (step=commit) |
Up to 20 signed blob ids. Target trip: the chosen existing trip (its bridge setting can be changed here) or a new one (title from the form or the localised default with the first file's date, bridge setting from the form, private). Files whose tracks were all seen before are skipped and purged. The rest raise trips.pending_uploads and go to FitBatchImportJob. |
FitBatchImportJob |
One at a time per trip (30 min). Gives the trip a trip_token (UUID) if it has none. Imports the files in start-time order, each track of a file in start order, with source import, the trip's token (the file's own token is ignored) and external_activity_id = "upload:<user_id>:<track start unix>"; then deletes the file (also one that couldn't be decoded). A track seen before by then (±60 s) is skipped, the file's other tracks still go in. A file that can't be decoded is skipped; a track that fails to import for any other reason is skipped and reported to Sentry; the others still go in. Always ends with: lower pending_uploads, recompute_aggregates!, notify_created!, Turbo refresh of the trip and the logbook. |
UnattachedBlobCleanupJob (daily at 04:00) purges blobs that were never attached and are older than a day.
Derived values¶
Track metrics (Ingestion::Derivation)¶
Computed from the kept points (between trim IN and OUT), point pair by point pair.
| Value | Rule |
|---|---|
distance_nm |
Device distance when > 0 (distance_source = device); else haversine sum (computed). Device distance = END.distance_m for reason saved, or the FIT session distance. A recovered track uses computed. |
| Trimmed track | Device distance × (haversine of kept points ÷ haversine of all points), device_scaled |
engine_distance_nm, sailing_distance_nm |
Haversine per pair, by the first point: engine when engine_on or state engine / motorsail, otherwise sailing (stationary states count as sailing). Both are scaled by distance_nm ÷ haversine sum, so together they never exceed the distance (they are less when the track has gaps). |
engine_hours, sailing_hours |
Pair durations by the same rule. Sailing time leaves out pairs whose first point has SOG < 0.5 kn. |
| Gaps | Pairs more than 300 s apart count toward distance but not toward the split or the hours. |
avg_speed_knots |
Mean SOG over all kept points (missing SOG counts as 0) |
max_speed_knots |
FIT import: session max speed. After any recompute: max of a 30 s rolling mean of SOG. |
Haversine radius 3440.065 NM.
Map lines¶
The line the trip map draws for a track (Track#store_map_line!, MapLines), rebuilt with every metrics computation:
| Step | Rule |
|---|---|
| Vertices | Every kept point as latitude and longitude rounded to 5 decimals (~1 m), plus engine: engine_on or state engine / motorsail |
| Simplification | Douglas–Peucker, tolerance 5 m (MapLines::PAGE), distances on a flat projection around the first point. The first and last point and both points of every engine switch are always kept. At most 10 000 points per track; over that, the points furthest off the line stay. |
| Storage | One row in track_map_lines, packed by MapLines::Codec (data-model.md) |
| Backfill | bin/rails map_lines:backfill stores the line of every track that has none and then the card lines of their trips (and of trips without one). Safe to re-run; runs after every deploy (deploy:after). |
A track without a stored line is drawn from every n-th kept point (at most 2000).
SOG and COG¶
SLTB points get them at ingest (Sailever::Sltb::Kinematics), per segment:
- SOG: central difference
distance(pᵢ₋₁, pᵢ₊₁) / (tᵢ₊₁ − tᵢ₋₁), one-sided at segment ends and across gaps > 3 × interval. Over 100 kn → no value. Then a median-of-3 filter. Earth radius 6 371 008.8 m. Rounded to 0.01 kn. - COG: initial bearing over the same pair, 0.1°. Null when SOG < 0.3 kn.
FIT points keep the watch's own SOG; their COG is computed the same way by the decoder. GPX points: GPX files.
Events¶
Rebuilt with the metrics. Stored in track_events.
| Kind | When | Details |
|---|---|---|
vessel_state_change |
State differs from the previous point | from, to; for a stationary target state also location_name, island_name, feature_type; note when the track has a note within 60 s (tracks.state_notes) |
engine_on, engine_off |
engine_on flips |
engine_on |
storm_warning |
Pressure drops > 1.5 hPa within 3 h; first such point. Also sets tracks.storm_warning. |
drop_hpa: 1.5, window_hours: 3 |
anchor_drift_alert |
Never created |
Stationary states: mooring, anchor, buoy, shoreline, adrift. The storm rule and how it differs from the watch alert are defined in calculations.md.
Names (start_name, end_name)¶
Rules: ADR 002. Sailever::Geo::MaritimeLocationNamingService looks the position up in our own copy of OpenStreetMap (OSM places, ADR 013), rounded to 3 decimals (about 100 m), and caches the answer per import for 30 days. Until the first import nothing is named: name fields stay empty and places:rename fills them later.
| Situation | Name |
|---|---|
| Harbour or marina within 1.0 NM | Its name |
| Anchorage or bay within 1.5 NM | Its name; an anchorage without "anchorage" in it gets " Anchorage" |
| No berth, island shore within 2.5 NM (and nearer than the mainland coast) | <Island> Coast |
| On an island | <Island>, <place> (island first), or just the island when the place already starts with it |
| Nothing near, cape or town within 25 NM | <n> NM Off <landmark> (n ≥ 1, rounded) |
| Nothing within 25 NM | The position, e.g. 43°25.7'N 16°11.3'E |
| No places imported yet | Nothing (empty) |
| Invalid coordinates | Position (<lat>, <lon>) |
- The start name comes from the first kept point, the end name from the last. A name is only set when empty.
- Inheritance: a track takes the previous track's (by start time, same trip)
end_nameas itsstart_namewhen its own is empty or still inherited (start_name_inherited). - After a trim: generated end names are cleared; the start name is cleared unless typed by the user or inherited from a named previous track. They are then generated again from the new ends. Typed names (
start_name_manual,end_name_manual) always stay. - Rename (
bin/rails places:rename, on the server the jobplaces:rename_later, after an import): every track is recomputed oldest first with its generated names cleared, as after a trim, so start and end names, stop events and trip days are named again from the current places. Typed names stay; no trip mail goes out.
OSM places¶
The places behind the names are a slice of OpenStreetMap (ODbL) in our own PostGIS, so a lookup takes milliseconds and needs no outside service. They are refreshed only on demand, with bin/rails osm:refresh (on the server as a background job, osm:refresh_later; how to run it: DEPLOYMENT.md); bin/rails osm:status shows the current import.
- Source: the Geofabrik extracts of every country with a coast on the map except the Black Sea's (
OsmPlaces::Refresh::COUNTRIES;OSM_COUNTRIESfor fewer): the Mediterranean and the Adriatic (Spain to Turkey, Cyprus, North Africa from Morocco to Egypt, Israel, Lebanon, Syria), the Atlantic (Portugal, Ireland, Great Britain, the Isle of Man, the Channel Islands, the Faroes), the North Sea (Belgium, the Netherlands, Germany's coastal states Lower Saxony, Bremen, Hamburg, Schleswig-Holstein and Mecklenburg-Western Pomerania, Denmark, Norway) and the Baltic (Sweden, Finland, Estonia, Latvia, Lithuania, Poland, Kaliningrad, Russia's northwest), one at a time, each checked against its MD5; a download that stalls, breaks off or fails its MD5 is tried up to three times. Only what lies within 11° W – 36.5° E, 30–71.5° N is kept. Positions outside the imported countries are named by coordinates or the nearest landmark that is imported. - Filter (
osmium tags-filter, thenosmium exportwith islands and islets as areas):seamark:typeharbour, marina, anchorage, mooring, harbour_basin;leisuremarina, slipway;naturalbay;natural=capeandplacetown, city (nodes);placeisland, islet;waterway=fuel;natural=coastline(ways). - Kinds (first match wins): island / islet (areas only), anchorage (
seamark:type=anchorage), bay, cape, town, city (nodes only), harbour (seamark:typeharbour or marina, orleisure=marina), mooring, harbour_basin, fuel, slipway. Only named places are kept; the name isname:enwhere there is one, elsename. - Tables (rebuilt by every import, never edited, not in
schema.rband not in the nightly backup):osm_places(kind, name, tags, geometry; for islands and isletsat_sea: OpenStreetMap's coastline runs along the outline, so the island lies in the sea and not in a lake or river),osm_shores(the outline of every named island, in pieces of at most 64 points),osm_coastline(the coastline without the shores of named islands and of all islets: the mainland),osm_imports(version, countries, counts per kind). - Import: the features go into staging tables; the new tables are built next to the live ones and swapped in one transaction, so lookups never see half an import. A failed import leaves the live tables as they were. One import at a time (advisory lock). On the worker VM the import also waits for the tile builds' lock (
HEAVY_LOCK), as the extracts share their disk; the job tries again every 30 minutes for two days. - Island export: after every import, the named islands and islets in the sea go to the worker VM as GeoJSON lines, one point each (the centre of the largest circle inside the island) with the name, the area and the zoom the name starts at, for the island names on the map.
- Lookup: harbours, anchorages and bays whose bounds touch the box of 2.5 NM around the position, capes, towns and cities in the box of 25 NM, and the shores and coastline cut to the 2.5 NM box; then the rules above.
- Later (POI search): the mooring, harbour basin, fuel and slipway kinds and the name index (
pg_trgm) are there for a search of places by name and the nearest marina or fuel berth; not built yet.
Trip aggregates¶
Trip#recompute_aggregates! runs after every track recompute, FIT import, trim, track move or deletion, discard and bridge switch:
- Rebuild bridges (below).
- Set the trip's time zone from its first point if empty (offline
wheretzlookup; at sea the nautical zone by longitude,Etc/GMT±n). total_distance_nm= sum of track distances + bridged distance.engine_distance_nm,sailing_distance_nm,engine_hours,sailing_hours= sums over tracks.bridged_distance_nm= sum of bridges.- Card line (
trips.map_line_card): the tracks' map lines in start order, simplified together to 50 m and at most 600 points (MapLines::CARD), each track still its own line. - Rebuild the days (below).
Totals are sums, never patched.
Bridges (TripBridges::Rebuild)¶
- Only when the trip's
bridge_gapsis on. Switching it rebuilds at once. - All bridges are deleted and rebuilt. Tracks are ordered by their kept start.
- One bridge from each track's last kept point to the next track's first kept point, great-circle distance, when the next track starts after the previous one ends (no overlap) and the gap is ≥ 0.05 NM.
- Bridges count as distance, never as time, and their
propulsionisunknown: they are in the total but in neither the sailing nor the engine split.
Days (TripDays::Rebuild)¶
A day is a local calendar day (trip time zone) with at least one kept point.
- Each point pair is split at local midnight by time share. Sailed and motored distance and hours follow the track rules.
- Day distance = the track's distance spread by the day's share of the track's GPS length, so days add up to the track. Bridged miles count on the day the bridge ends.
day_numbercounts days with points, from 1.start_name/end_name: the track's name when the day starts or ends at a track end, else the name of the position at that time.bounds= bounding box of the day's points;track_ids= tracks that touch the day.- Night stay for every day but the last, from the points between 22:00 and 04:00 local:
| Situation | night_stay |
|---|---|
| A point with SOG ≥ 0.5 kn or state engine / motorsail / sail | at_sea |
| Points, all stationary | The most frequent state (adrift → anchor) |
| No points, last point of the day and first of the next < 0.5 NM apart | The last state if stationary; else from the place type: marina / harbour / port → mooring, bay / cove / anchorage → anchor; else unknown |
| Otherwise | unknown |
night_name = the day's end name or the place name (not for at_sea). Only night_stay_override (set by the user) survives a rebuild.
Night miles¶
night_distance_nm and night_hours: the day's point pairs that are underway (SOG ≥ 0.5 kn, engine on, or state engine / motorsail) and whose midpoint is in nautical night. Nautical night means the sun is more than 12° below the horizon at that place and time (Sailever::Sun, after SunCalc, the same formula the watch apps use). Pairs over 5 minutes apart and bridges don't count. Night distance is scaled like the day distance, so it never exceeds it. Days built before night miles existed hold NULL until trip_days:nights (run by deploy:after) queues a TripNightsJob that rebuilds their trip.
Wind¶
Every track gets its hourly model wind stored with it (tracks.wind), for the track editor:
- When a track is processed (
processed_atchanges: first import, every recompute) and its stored wind doesn't cover it yet,TrackWindJobis queued. "Covered" means the stored hours reach the track's last hour and no value is missing (the newest hours arrive later in the model). TrackWindJobruns one at a time (limits_concurrencykeytrack-wind). It callsOpenMeteoWindService.store: the 0.25° cells along the track and the ring around each (at most 120), one request for all of them and every UTC day of the track (track-editor.md § Wind endpoint). An Open-Meteo error is retried up to 5 times, with longer waits each time.- Backfill: migration
BackfillTrackWindqueued the job for every track without wind, 2 s apart, so at most 1800 requests an hour reached Open-Meteo. MigrationBackfillTrackWindBand(2026-10-26) queues it again for every track whose wind is still the old line of cells (no"band": 2), newest first, 22 minutes apart: at most 120 locations each, so at most about 8000 Open-Meteo calls a day, inside the free tier's 10 000. - Switched off in tests (
config.x.track_wind); a test switches it on where needed.
Tides¶
Provider and caching follow ADR 001; delivery is only in the segment upload response (track-sync.md).
OpenMeteoTideServiceasks the Open-Meteo Marine API for hourlysea_level_height_msl, today and tomorrow (UTC), at the position rounded to 0.1°.- High and low waters: local maxima and minima of the hourly series, refined by 3-point quadratic interpolation (offset clamped to ±0.5 h).
- Cache key
tides:<lat>:<lon>:<date>on the 0.1° grid, 6 h, inRails.cache(Solid Cache). - Open-Meteo gets only the rounded position, no user or device id.
- Inline lookups time out after 2 s;
TideWarmJobfetches with 5 s and is dropped on failure.
Trip-created email¶
Trip#notify_created! runs after every track recompute and at the end of every FIT import (step 11 above, Ingestion::Pipeline, FitBatchImportJob). When it sends and why only once: emails.md § New trip.
Recurring jobs¶
From config/recurring.yml (production):
| Job | Schedule |
|---|---|
TrackSegmentSweepJob |
every 15 minutes |
UnattachedBlobCleanupJob |
every day at 04:00 |
Known deviations¶
- A segment that arrives after the user deleted its track on the web creates a new track with only the points of that segment.
- A discarded track leaves its trip behind, even when the trip has no tracks left.
POST /api/v1/tracks/uploadimports files without any points as an empty track (the web dialog rejects them).- FIT decoder: for big-endian definitions the position is read unsigned, so southern and western positions come out wrong. A 2-byte speed or heading field (field 6 or 2) is read as 4 bytes.