Gaspool is a lightweight activity tracker and peleton companion app built on Cloudflare Workers and Hono.
It helps cyclists, runners, walkers, and hikers record routes, share activity summaries, export GPX files, track peleton members live, and generate cinematic route recap videos.
Gaspool is designed to run serverlessly on Cloudflare using Workers, D1, R2, KV, Turnstile, and an external routing provider for planned routes.
- Record cycling, running, walking, and hiking activities
- GPS-based route tracking
- Planned route tracking with route overlay
- Distance, moving time, speed, pace, elevation, and temperature display
- Activity detail page with map and statistics
- Activity Doctor scanner, Finish Review, and safe auto-repair UI for route JSON, GPS points, long gaps, metadata, and D1 stats
- Offline-friendly PWA shell
- Create a route plan from map points
- Use OpenRouteService Directions for cycling, walking, running, and hiking routes
- Save planned routes to Cloudflare R2 and D1
- Pin favorite planned routes to the top of the route library
- Export saved planned routes as GPX files
- Start tracking from a saved route plan
- Display planned route and actual GPS track together in the tracker
- Voice navigation using the browser Web Speech API
- Basic spoken turn prompts around 300m, 80m, and near the turn point
- Water and food voice reminders for long activities
- Rest block detection for long pauses, sleep, system gaps, and overnight breaks
- Lanjut Nanti / Finish Later mode for continuing an activity later
- Create a live peleton room
- Invite other members to join the same ride room
- Family live tracking link
- Live radar map for peleton monitoring
- Temporary live location sync using Cloudflare KV
- Peleton voice radio for riders inside the tracker room
- Share map card
- Share minimalist stats card
- Export GPX route file
- Export saved route plans as GPX files
- Generate cinematic route recap video
- Peleton video recap with stable rider initials and roster display
- Cloudflare Workers runtime
- Hono router
- Cloudflare D1 for app data
- Cloudflare R2 for route JSON and radio audio files
- Cloudflare KV for live peleton radar
- Cloudflare Turnstile for anti-bot protection
- Cloudflare static assets binding
- OpenRouteService for route planning
- TypeScript
- Hono
- Cloudflare Workers
- Cloudflare D1
- Cloudflare R2
- Cloudflare KV
- Cloudflare Turnstile
- Leaflet
- HTML
- CSS
- Vanilla JavaScript
- PWA
Before running this project, make sure you have:
- Node.js 24 or newer
- npm
- Cloudflare account
- Wrangler CLI
- Cloudflare D1 database
- Cloudflare R2 bucket
- Cloudflare KV namespace
- Cloudflare Turnstile site
- OpenRouteService API key
Clone the repository:
git clone https://github.com/jeannesbryan/gaspool.git
cd gaspoolInstall dependencies:
npm installThis section is the recommended first-time deployment flow for a fresh self-hosted Gaspool instance.
The examples below use these placeholder names:
Worker name : gaspool
D1 database : gaspool-db
R2 bucket : gaspool-media
KV namespace : GASPOOL_RADAR
Custom domain : your-domain.com
Cloudflare zone : your-domain.com
Public profile : /rider
Replace them with your own values.
npx wrangler loginCreate a D1 database:
npx wrangler d1 create gaspool-dbCopy the returned database_id into wrangler.jsonc.
Create an R2 bucket:
npx wrangler r2 bucket create gaspool-mediaRecommended R2 lifecycle rule for temporary peleton radio audio:
npx wrangler r2 bucket lifecycle add gaspool-media delete-peleton-audio gaspool/audio/ --expire-days 1This rule only targets objects whose key starts with:
gaspool/audio/
It does not delete ride JSON or planned route JSON.
Create a KV namespace for live peleton radar:
npx wrangler kv namespace create GASPOOL_RADARCopy the returned KV id into wrangler.jsonc.
Create these values before deployment:
- Cloudflare Turnstile site key and secret key
- OpenRouteService API key
For Turnstile, register the domain that will serve Gaspool, for example:
your-domain.com
For local development, add localhost in the Turnstile dashboard if you want to test login locally.
Copy the example config:
cp wrangler.example.jsonc wrangler.jsoncOn Windows:
copy wrangler.example.jsonc wrangler.jsoncThen edit wrangler.jsonc:
Do not commit your real wrangler.jsonc.
Gaspool can run on the default workers.dev URL, but a custom domain is recommended for real GPS tracking because browser location APIs require HTTPS and the URL is easier to share.
For a Worker custom domain such as:
https://your-domain.com
use:
"routes": [
{
"pattern": "your-domain.com",
"custom_domain": true
}
]Alternative classic route under a Cloudflare zone:
"routes": [
{
"pattern": "your-domain.com/*",
"zone_name": "your-domain.com"
}
]Important:
- Keep route/domain config in
wrangler.jsoncaligned with the Cloudflare Dashboard. wrangler deploycan overwrite remote Worker route settings with your local config.- If Wrangler shows a warning that local routes differ from remote routes, fix
wrangler.jsoncbefore confirming deploy.
Generate a strong JWT secret:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"Store secrets in Cloudflare:
npx wrangler secret put JWT_SECRET
npx wrangler secret put TURNSTILE_SECRET_KEY
npx wrangler secret put ORS_API_KEYNever put these secrets inside wrangler.jsonc.
Apply the project schema before first deploy.
For a fresh Gaspool install, use the bundled schema.sql:
npx wrangler d1 execute gaspool-db --remote --file schema.sqlThis creates the base Gaspool tables:
settingsuserslogin_logsridesplanned_routespersonal_segments
If you are updating an existing instance, you do not need to do anything extra: columns added after the first release (rides.notes, planned_routes.is_favorite) are created on demand by the Worker, which inspects PRAGMA table_info first and only runs the ALTER TABLE when the column is genuinely missing. Re-running is therefore safe.
Earlier revisions of this document told you to apply MIGRATION_ACTIVITY_NOTES.sql and MIGRATION_ROUTE_FAVORITES.sql. Those files were never shipped with the repository, so the step could not be followed; the runtime path above has been doing the work all along.
npm run cf-typegen
npm run deployOpen:
https://your-domain.com/login
The first successful login creates the first captain account automatically if the users table is empty.
After login:
/opens the private dashboard./route_planopens the route planner./routesopens saved routes./:PUBLIC_PROFILE_SLUGopens the public profile.
After deploy, test:
- Login with Turnstile.
- Open
/route_planand search a location. - Generate a route with OpenRouteService.
- Start a tracker from the route.
- Save one short activity.
- Toggle the activity to
PUBLIC. - Open the public profile URL in a private browser window.
Copy the example Wrangler config:
copy wrangler.example.jsonc wrangler.jsonccp wrangler.example.jsonc wrangler.jsoncThen edit wrangler.jsonc and replace all placeholder values with your own Cloudflare resources.
Example resources needed:
D1 database
R2 bucket
KV namespace
Turnstile site key
OpenRouteService API key
Custom domain, optional
Do not commit your real wrangler.jsonc.
If you are using a custom domain such as your-domain.com, make sure the routes block exists locally before running npm run deploy. Wrangler treats the local config as the source of truth.
Gaspool uses these bindings and secrets:
TURNSTILE_SITE_KEY
ROUTING_PROVIDER
PUBLIC_PROFILE_SLUG
PUBLIC_PROFILE_NAME
PUBLIC_PROFILE_AVATAR
R2_PUBLIC_BASE_URL
These can be placed inside wrangler.jsonc under vars.
Recommended value:
ROUTING_PROVIDER=ors
PUBLIC_PROFILE_SLUG=rider
PUBLIC_PROFILE_NAME=Gaspool Rider
PUBLIC_PROFILE_AVATAR=/assets/profile.webp
R2_PUBLIC_BASE_URL=https://pub-xxxxxxxxxxxxxxxxxxxxxxxxxxxx.r2.dev
R2_PUBLIC_BASE_URL is your own bucket's public domain (R2 > your bucket >
Settings > Public access). Route JSON files and peleton radio recordings are
served from it, and it is added to the media-src Content-Security-Policy
directive so the browser will play the recordings.
Set it. If you leave it out, the Worker falls back to the original author's bucket address, which means the routes you save will link to a bucket you do not own.
JWT_SECRET
TURNSTILE_SECRET_KEY
ORS_API_KEY
Set production secrets with Wrangler:
npx wrangler secret put JWT_SECRET
npx wrangler secret put TURNSTILE_SECRET_KEY
npx wrangler secret put ORS_API_KEYGenerate a strong JWT secret:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"Use the generated value for JWT_SECRET.
TURNSTILE_SECRET_KEY must be taken from your Cloudflare Turnstile dashboard.
ORS_API_KEY must be taken from your OpenRouteService dashboard.
Do not put ORS_API_KEY inside wrangler.jsonc.
Gaspool includes a single-owner public profile page for activity sharing.
The private dashboard stays behind login at:
https://your-domain.com/
The public profile is a separate URL controlled by PUBLIC_PROFILE_SLUG.
The public URL is controlled by PUBLIC_PROFILE_SLUG:
https://your-domain.com/PUBLIC_PROFILE_SLUG
Example:
PUBLIC_PROFILE_SLUG=rider
PUBLIC_PROFILE_NAME=Gaspool Rider
PUBLIC_PROFILE_AVATAR=/assets/profile.webp
This makes the public page available at:
https://your-domain.com/rider
For example, on a custom domain:
https://your-domain.com/rider
For open source installs, change these values in wrangler.jsonc so cloned deployments do not all use the same public URL. The default-style URL /rider should be treated as an example, not a required project default.
PUBLIC_PROFILE_AVATAR should point to an image inside /assets/ or an https:// image URL.
Public profile visibility rules:
- New activities are private by default.
- Private activities do not appear on the public profile.
- Public activities can appear on
/:PUBLIC_PROFILE_SLUG. - The dashboard owner can toggle an activity between
PRIVATEandPUBLIC.
Related routes:
GET /:PUBLIC_PROFILE_SLUG
GET /api/public_rides/:PUBLIC_PROFILE_SLUG
Recommended public profile config:
"vars": {
"PUBLIC_PROFILE_SLUG": "yourname",
"PUBLIC_PROFILE_NAME": "Your Name",
"PUBLIC_PROFILE_AVATAR": "/assets/profile.webp"
}After configuring wrangler.jsonc, generate Worker binding types:
npm run cf-typegenThis creates worker-configuration.d.ts.
The file is generated automatically and should not be committed.
npm run cf-typegen:test # generate types without needing a personal wrangler.jsonc
npm run typecheck # tsc --noEmit
npm test # boots the Worker locally and runs the smoke testworker-configuration.d.ts is generated and gitignored, and tsconfig.json
references it, so a fresh clone fails to typecheck until it exists with
error TS2688: Cannot find type definition file for './worker-configuration.d.ts'.
That is expected, not a broken checkout.
If you have your own wrangler.jsonc, npm run cf-typegen generates it from
your real bindings. npm run cf-typegen:test does the same from
tests/wrangler.test.jsonc, which is the variant CI uses because your personal
config is gitignored and therefore absent there.
npm test runs the pure regressions first (test:doctor, test:doctor-core,
test:live, test:clock, test:clock-record, test:stat-rules,
test:notice, test:milestones, test:radar, test:voice), then the smoke
test.
npm run test:notice (node tests/map-notice.mjs) tests
public/assets/map-notice.js, the module used by every page that draws a ride
map: the dashboard, the activity detail page, the heatmap and the video
(video_flex) page. Every ride map fetches its points from R2 in the
browser, so a blocked network, a hijacked DNS answer or an expired object used
to leave the map empty with nothing but a console.error — indistinguishable
from a broken app. The message therefore has to say three things: the map
failed, the numbers are safe, and (when the address is r2.dev) that the
network is the usual suspect.
The heatmap needs its own wording because it draws many rides at once: a partial failure still produces a map that looks complete, which is more misleading than an empty one. It counts how many activities failed and says so.
Two details the tests pin down, both learned by getting them wrong: the notice
never overwrites a container's existing position (the video page keeps its
full-screen control layer at z-index 2000, and flattening it to relative
buried the message underneath), and the video page attaches the notice to that
control layer rather than to the map, for the same reason.
The smoke test then checks that all four pages load the module and that none of their maps swallows a failure silently.
npm run test:stat-rules (node tests/ride-stat-rules.mjs) covers the rules
that decide which numbers get stored: distance follows the shared segment table,
elevation keeps the measured value, an implausible clean distance is refused
rather than stored, and a missing shared table falls back visibly. The figures
in that test come from the real 25 Sep 2026 ride, so it guards the case that
actually happened.
npm run test:clock-record (node tests/live-clock-record.mjs) tests the
storage rules in src/api/live-clock-record.ts: the buckets are summed
server-side instead of trusted from the browser, a record whose numbers do not
add up is flagged rather than silently accepted, and a missing record stays
null instead of becoming a reassuring zero. The smoke test then posts a
live_clock payload built by the same module the tracker page uses, and opens
the stored activity file to confirm the record actually landed there.
npm run test:doctor (node tests/activity-doctor.mjs) tests the pure
moving-time and average-speed mathematics in src/api/activity-doctor-stats.ts
directly: no Worker, no D1, no network. It pins the behaviour that once made
Doctor report a 29.4 km ride at 29.1 km/h when the elapsed time was 1:47:58 —
distance and moving time must come from the same segments, time must use real
fractional deltas instead of Math.floor() output, a standstill must be
recognised even while the recorder keeps logging points, and no second may
vanish without being accounted for. The fixtures are synthetic, so the test
carries no personal GPS data. Point it at a real export to also check your own
ride:
GASPOOL_REAL_GPX=~/Downloads/Gaspool_Route.gpx node tests/activity-doctor.mjstests/smoke.mjs starts the real Worker with Wrangler in
local mode using tests/wrangler.test.jsonc, seeds a throwaway D1 database in a
temporary directory, and checks the behaviour that matters: private notes stay
out of the public feed, the weather proxy validates its input, radar survives
hostile room and user values, ids are validated before a write, protected
endpoints reject anonymous callers, and login attempts get limited.
It needs no Cloudflare account, no API token and no network access. D1, R2 and KV are emulated on disk and the directory is deleted afterwards, so the suite leaves nothing behind.
Because it uses its own config file, the test never reads or creates your real
wrangler.jsonc, and it never touches .dev.vars.
.github/workflows/ci.yml runs the same typecheck, a --dry-run build and this
test suite on every push and pull request. It uses GitHub's runners only, so a
fork gets a working pipeline without configuring a single secret.
Deploy to Cloudflare Workers:
npm run deployGaspool stores route JSON files in Cloudflare R2.
Recommended object prefixes:
gaspool/
gaspool/routes/
gaspool/audio/
Example object key:
gaspool/gaspool_ride_1720000000000_123.json
gaspool/routes/route_1720000000000_123.json
gaspool/audio/ROOM123/radio_RIDER_1720000000000.webm
R2 is used for:
- Route JSON files
- Planned route JSON files
- Peleton radio audio files
The public route JSON URL is stored in D1, so if an object is moved in R2, the related D1 record must also be updated.
Peleton radio audio is temporary. Gaspool tries to clean it up when the captain finishes or aborts a peleton session, but browser/network interruptions can prevent cleanup from running.
Add an R2 lifecycle rule as a safety net:
npx wrangler r2 bucket lifecycle add gaspool-media delete-peleton-audio gaspool/audio/ --expire-days 1This means:
- only objects under
gaspool/audio/are affected, - ride JSON under
gaspool/is kept, - planned route JSON under
gaspool/routes/is kept, - audio leftovers are automatically expired after 1 day.
You can also configure this from the Cloudflare dashboard:
- Open Cloudflare Dashboard.
- Go to R2 Object Storage.
- Select your Gaspool bucket.
- Open Settings.
- Find Object lifecycle rules.
- Add a rule with prefix:
gaspool/audio/
- Set expiration to
1 day. - Save the rule.
Cloudflare lifecycle deletion is not instant. Objects are typically removed within about 24 hours after they become eligible for expiration.
Gaspool uses Cloudflare D1 to store app data such as:
- Users
- Activities
- Ride statistics
- Route references
- Planned route references
- Participants
- Activity metadata
Make sure your D1 database is connected to the Worker using the DB binding in wrangler.jsonc.
Fresh installs should apply the bundled schema:
npx wrangler d1 execute gaspool-db --remote --file schema.sqlThe schema includes current Gaspool tables and columns for:
- private/public activities via
rides.is_public - activity notes via
rides.notes - route planner links via
rides.planned_route_id - saved route favorites via
planned_routes.is_favorite - personal segments via
personal_segments
To generate a fresh schema from a live D1 database:
npx wrangler d1 export gaspool-db --remote --output=./schema.sql --no-data --yReview exported schemas before committing them. They should contain table/index structure only, not user data.
To update only Hono:
npm install hono@latestOr pin the version detected by npm-check-updates:
npm install hono@4.12.28Then verify and deploy:
npm run cf-typegen
npm run deployRoute planner pages and APIs:
GET /route_plan
POST /api/route_plan
GET /api/route_plans
GET /api/route_plan/:id
GET /api/route_plan/:id/gpx
POST /api/route_plan/:id/favorite
Basic route creation flow:
- Open
/route_plan. - Add a start point, destination, and optional waypoints.
- Generate the route.
- Start tracking from the generated route.
- The tracker opens as
/record?type=ride&route=ROUTE_ID.
The route planner stores normalized route data in R2 and metadata in D1. Saved routes can be pinned to the top of the library and exported back to GPX.
Gaspool can prepare saved routes for limited offline use on the same device.
An offline route pack stores:
- route coordinates,
- turn-by-turn instructions,
- route waypoints,
- checkpoint/resupply points,
- route distance, duration, provider, and profile metadata.
Offline route packs are stored locally in the browser with localStorage. They are intended for route guidance when the network drops after the route has already been prepared.
How to prepare one:
- Open
/route_plan, generate or load a route, and wait until it says the route is packed offline. - Or open
/routesand pressPACKon a saved route. - Open the tracker once while online before a long ride so the PWA shell and route page are warmed up.
Important limitations:
- Offline route pack does not generate new routes offline.
- Offline route pack does not include mass-downloaded OpenStreetMap map tiles.
- If the browser storage is cleared, route packs are removed.
- Peleton radar, radio, weather, geocoding, upload, and reroute still need network access.
Supported routing profiles:
cycling-regular
cycling-road
cycling-mountain
cycling-electric
foot-walking
foot-hiking
Gaspool uses the browser Web Speech API for route voice guidance.
The tracker gives spoken prompts when the rider approaches the next instruction:
Around 300 meters
Around 80 meters
Near the turn point
Voice navigation runs locally in the browser. The actual voice quality depends on the rider's device and installed browser voices.
If an Indonesian voice is available, Gaspool tries to use it. Otherwise, the browser default voice is used.
The tracker can give local voice reminders for hydration and food during long activities.
Reminders use moving time and distance, not wall-clock time, so long stops and auto-pause periods should not spam the rider.
Default reminder intervals:
| Activity | Water reminder | Food reminder |
|---|---|---|
| Ride | 20 minutes or 10 km | 60 minutes or 25 km |
| Run | 20 minutes or 4 km | 45 minutes or 10 km |
| Walk | 25 minutes or 2.5 km | 60 minutes or 6 km |
| Hike | 25 minutes or 2 km | 60 minutes or 5 km |
The reminder can be toggled from the tracker. Reminder counts are saved in the activity JSON as nutrition_summary.
Gaspool stores activity point timestamps as ISO/UTC values and sends the activity start_date from the moment tracking starts, not from the upload/finish moment.
This matters for long trips and cross-timezone activities, for example starting in Bali (WITA) and finishing in Banyuwangi (WIB).
The activity JSON stores a metadata.time_context block:
{
"start_date": "2026-07-07T00:30:00.000Z",
"finish_date": "2026-07-07T09:15:00.000Z",
"start_timezone_offset_min": 480,
"finish_timezone_offset_min": 420,
"start_timezone_name": "Asia/Makassar",
"finish_timezone_name": "Asia/Jakarta"
}The D1 rides.start_date column uses the start timestamp, so multi-day uploads should still be sorted and grouped by when the activity began.
Timezone names and offsets come from the browser/device. If the phone does not automatically update timezone while crossing regions, Gaspool still keeps UTC timestamps correctly, but the timezone label follows the device setting.
Gaspool can separate long stops from moving time by recording rest_blocks in the activity JSON.
Rest blocks can come from:
- long auto-pause periods,
- long browser/system gaps,
- resume after a long blackbox gap,
- manual Lanjut Nanti / Finish Later mode.
Lanjut Nanti / Finish Later saves the current blackbox session without uploading the activity. When the user resumes later, Gaspool records the rest block and starts a new etape when appropriate.
No D1 migration is required. Rest blocks are stored inside the R2 activity JSON and shown in the dashboard activity modal.
The finish screen shows what will actually enter the history, and the rules live
in public/assets/ride-stat-rules.js so the page and the server cannot drift
apart:
- Distance uses the shared segment table. GPS drift while standing still is not distance travelled. On the 25 Sep 2026 ride, 1651 m of the reported 55.130 km (3.0%) came from 345 segments classified as stopped, at an average of 0.48 km/h. The stored number is 53.479 km, and the modal shows the difference instead of hiding it.
- Elevation keeps the measured value. This is the opposite direction on purpose. The elevation figure cannot be pinned down from the data: different filter parameters move it from 167 m to 855 m on the same ride, while the gap between the two candidates was only 75 m. When no value is right, swapping one estimate for another only relocates the uncertainty. Recalculation may fill a gap, never overwrite a measurement.
- Moving time keeps the live clock, which is a measurement rather than a geometric inference; recalculation only overrides it when it falls outside ±15%.
Every stored activity records where its numbers came from
(distance_source, distance_declared_km, distance_clean_km,
elevation_source), so a number that was discarded can still be traced. If the
shared table fails to load on the device, the raw figure is kept and
distance_source says tracker — the fallback is visible, never silent.
The tracker keeps its own accounting of where the recorded time went, and stores
it in the activity JSON as metadata.live_clock:
{
"tick_count": 62,
"counted_seconds": 70,
"clamped_seconds": 15,
"dropped_seconds": 300,
"clamped_tick_count": 1,
"dropped_tick_count": 1,
"uncounted_seconds": 315,
"balance_seconds": 70,
"consistent": true,
"issues": []
}Every second that passes during a recording lands in exactly one bucket:
counted_seconds— seconds the live clock counted as moving;clamped_seconds— the part of a late tick that was cut at the maximum delta (the browser postponed the tick, so the clock could not trust all of it);dropped_seconds— ticks so far apart that the whole gap is untrustworthy (screen off, tab suspended).
uncounted_seconds is clamped_seconds + dropped_seconds, summed by the server
rather than copied from the browser. consistent is false when the buckets do
not add up; the numbers are still stored, but they must not be quoted as proof.
A missing record is stored as null, never as zeros: "no instrumentation" and
"no second was lost" are different answers, and only one of them is reassuring.
The activity JSON is the place to look when asking why a ride's live moving time
differs from the recalculated one.
The dashboard includes a monthly calendar view for scanning activity consistency.
The calendar uses the existing rides.start_date data and follows the current dashboard filters where possible.
Lifetime milestones are counted every 1000 km. GET /api/milestones
returns the all-time totals and the progress towards the next milestone:
- the totals come from every ride and deliberately ignore the dashboard filters, because filtering the view to "this month" must not make a lifetime achievement look smaller;
- the milestone itself is computed by
src/milestones.ts, a pure module, so the numbers on a shared card can be tested without a Worker or a database; - the dashboard shows the progress bar and a BAGIKAN KARTU MILESTONE
button, which renders a PNG card (total distance, total activities, moving
time, elevation gain) through
html2canvasand hands it to the system share sheet on phones, or downloads it elsewhere.
tests/milestones.mjs pins the boundaries — exactly on 1000 km, several
milestones crossed by one imported ride, a ride that adds no distance, and the
malformed values a database can return.
Activity Doctor can scan and auto-repair saved activities without manual point editing.
GET /api/activity_doctor/:id
POST /api/activity_doctor/:id/apply
The GET endpoint is a dry-run scanner. It reads the saved route JSON, detects old route formats, invalid or duplicate GPS points, obvious lng/lat coordinate order, extreme GPS jumps, long timestamp gaps, missing metadata, sparse route-node JSON, missing timestamps, and mismatch between D1 stats and route-derived estimates.
The POST apply endpoint only runs when the scan result has safe automatic fixes. Guard v4 uses partial stat trust: distance, moving time, average speed, max speed, and elevation are judged separately. If a field is risky, for example route nodes are sparse, timestamps are missing, max speed looks like a GPS spike, or elevation samples are missing, Doctor preserves the D1 value for that field instead of overwriting it with a bad recalculation. It requires an explicit confirmation payload from the UI, optionally checks that the expected repair action list still matches the latest scan, creates an R2 backup under gaspool/repair-backups/, writes the repaired activity JSON, then updates D1 stats last. The repaired JSON includes normalized points, rest blocks, metadata summaries, stat trust notes, acknowledged repair actions, and repair_history.
Activity Doctor hardening rules:
- GET is dry-run only. It never writes R2 or D1.
- POST apply refuses broken scans, missing confirmation, stale repair plans, and routes with too many raw GPS points.
- Sparse route-node data, missing timestamps, missing elevation samples, and suspicious max-speed spikes no longer force a full repair. Guard v4 switches to safe partial repair and preserves untrusted D1 fields.
- Route payload loading uses the bound R2 object whenever possible. External arbitrary fetch is blocked; only the configured public R2 host,
gaspool/object path, and recognized legacy root activity JSON names are accepted. If an old root URL points to a file now stored undergaspool/, Doctor tries the safe folder fallback first. - Repair writes backup first, repaired JSON second, and D1 stats last.
How Doctor derives moving time and average speed (src/api/activity-doctor-stats.ts):
- One set of segments. Distance and moving time are summed over the same segments, so average speed is always exactly the reported distance divided by the reported moving time. Earlier versions summed distance over every segment but time over only some of them, which inflated average speed without any GPS being wrong.
- Real time deltas. Time is accumulated from the actual millisecond difference between points, not from a value already rounded with
Math.floor(). Points recorded faster than once per second used to contribute distance but zero time; on a real 29.4 km ride that hid 45 minutes. - Standstills are proven by displacement, not by gap length. A point counts as stopped when it sits inside a stretch of at least
DOCTOR_STOP_MIN_SECONDSthat never leaves a small radius. GPS jitter while standing still is therefore neither counted as movement nor added to distance, and a stop is no longer invisible just because the recorder kept logging points during it. - Nothing disappears silently. Every second inside
time_integrity.span_secondsends up inmoving_time,stopped_time, orexcluded_jump_seconds. Whatever is left over is reported asunaccounted_secondsso callers can refuse the result instead of displaying it as if it were measured. - One clock, everywhere. Rest blocks report a cumulative
moving_time, and they are built from the same segment table as the activity statistics. They used to keep a second copy of the arithmetic, so a rest block could show a clock that disagreed with the activity it belonged to. - A disagreement with D1 is treated as a bug in Doctor, not as permission to overwrite. Doctor and the tracker measure the same thing, so their moving times are expected to land close together. If the recalculated moving time differs from the stored one by more than
DOCTOR_MOVING_TIME_RATIO_TOLERANCE(±15%) in either direction, the D1 value is kept and the activity is flagged for manual review instead of being silently rewritten. The earlier guard allowed anything between 0,35x and 1,35x, which is how a 40% shortfall — the actual size of this regression — slipped through.
tests/activity-doctor.mjs locks all of these rules in place.
The activity detail Studio page includes a CEK & PERBAIKI AKTIVITAS INI button for logged-in users. The modal shows Doctor status, a recommendation badge, source shape, point counts, timestamp/elevation sample counts, preview of D1 vs safe proposed stats, issues, planned changes, guardrails, and safe auto-repair actions. The recommendation badge summarizes the decision, for example AMAN DIREPAIR, AMAN DENGAN BACKUP, AMAN SEBAGIAN, JANGAN REPAIR STATISTIK, MANUAL CHECK, or SEHAT. Applying repair reloads the page after the backup and update complete so the refreshed D1 stats are visible.
The tracker also includes a Finish Review screen before a new activity is uploaded. When the captain taps TERMINATE & SAVE, Gaspool pauses the live engines, scans the local GPS points, shows distance, moving time, GPS point count, stages, rest blocks, no-signal logs, privacy, and warning rows, then offers SAVE FINAL or AUTO REPAIR & SAVE when the issue is safe to fix automatically. Finish Review metadata is stored in the R2 activity JSON under metadata.finish_review.
For Strava/Garmin-like moving-time statistics, choose AUTO REPAIR & SAVE from Finish Review when the review says the data is safe. This lets Gaspool exclude rest blocks and genuine standstills from moving time, clean safe GPS anomalies, and recalculate average speed or pace from a distance and a moving time that belong to the same set of segments. When Doctor shows AMAN SEBAGIAN, applying repair is still safe because untrusted fields are preserved. When Doctor shows JANGAN REPAIR STATISTIK or MANUAL CHECK, keep the existing D1 stats instead of applying repair.
Manual trim, split, merge, and point-by-point editing are not part of Activity Doctor v1.
Gaspool is a webapp/PWA, not a native Android or iOS application. This keeps deployment simple and self-hosted, but it also means some behavior is controlled by the browser and operating system.
The project tries to mitigate those limits where possible.
| Limitation | Possible impact | What Gaspool does | What users can do |
|---|---|---|---|
| Browser background tracking | GPS updates may slow down or stop when the screen is off, the tab is hidden, or battery saver is active. | Uses Wake Lock API, stealth mode, local blackbox storage, and resume session. | Use HTTPS, keep the browser/PWA active, avoid force-closing the browser, and disable aggressive battery optimization for the browser. |
| OS battery optimization | Android/iOS can suspend browser work during long rides. | Stealth mode throttles visual rendering while keeping GPS/TTS/session logic running. | Use Android Chrome/PWA for best stability, turn off extreme battery saver, and test a short ride first. |
| Voice navigation depends on browser voices | Indonesian TTS quality varies by device/browser. | Uses the browser Web Speech API and tries to select Indonesian voices when available. | Install or enable Indonesian system voices if available, test voice before a long ride, and keep media volume audible. |
| Nutrition needs are personal | Water and food needs vary by heat, intensity, body size, sweat rate, and terrain. | Provides configurable local reminder timing based on activity type, moving time, and distance. | Treat reminders as prompts, bring enough supplies, and adjust your own fueling plan for long or hot routes. |
| Cross-timezone display | Local date labels may follow the device timezone, especially if the phone does not auto-update timezone while traveling. | Stores UTC start/finish timestamps and browser timezone context in the activity JSON, and saves D1 start_date from tracking start. |
Keep automatic date/time/timezone enabled on the phone when crossing regions. |
| GPS accuracy depends on hardware and placement | Tracks may jump near buildings, under trees, in bad weather, or when the phone is deep inside a bag. | Filters large GPS jumps and records GPS accuracy status. | Place the phone where GPS can breathe, avoid thick bags, and give the device time to lock satellites before starting. |
| Offline behavior is partial | Route generation, geocoding, peleton radar, radio, weather, reroute, and upload need network access. | Stores GPS points locally with IndexedDB blackbox, supports resume after interruption, and can keep prepared route packs for guidance fallback. | Generate and pack routes before riding, open the tracker once while online, keep mobile data available for live features, and verify the saved activity after finishing. |
| No-signal events can happen on long trips | GPS, browser ticks, or network access may disappear in forests, mountains, bad weather, tunnels, or aggressive battery saver. | Records no-signal logs for network offline, GPS error, poor GPS accuracy, and long browser/system gaps. Logs are saved in the activity JSON metadata. | Use expedition mode, pack routes before leaving signal, and review the activity modal after finishing to understand where signal was weak. |
| iOS/Safari restrictions | Wake lock, audio, background behavior, and PWA lifecycle can be stricter than Android Chrome. | Uses progressive browser APIs and falls back where possible. | Prefer Android Chrome/PWA for serious long tracking, or test your exact iOS/Safari setup before relying on it. |
| Upload/network failure | Saving a long activity may fail if the network drops. | Uses chunked upload and local queue patterns so data is not immediately lost. | Do not close the browser immediately after finish; wait until upload completes or retry when the connection is stable. |
- Use the deployed HTTPS custom domain, not an insecure local URL.
- Allow browser location permission.
- Open Gaspool once before the ride and confirm GPS lock.
- Test voice navigation on the same device.
- Turn off aggressive battery saver for the browser/PWA.
- Use stealth mode if you want to save power while riding.
- Do not force-close the browser during tracking.
- After finishing, wait until the save/upload process completes.
For the most reliable long-ride experience, Gaspool is currently best used on:
Android + Chrome + installed PWA/custom domain HTTPS
Other modern browsers can work, but GPS background behavior and TTS support may vary.
Gaspool uses Cloudflare KV for temporary live peleton radar data.
KV binding name:
GASPOOL_RADAR
KV is used for:
- Live peleton member location
- Temporary speed data
- Temporary radar room data
- Temporary peleton radio metadata
Live radar data is temporary and expires automatically.
Gaspool uses Cloudflare Turnstile for bot protection.
Required values:
TURNSTILE_SITE_KEY
TURNSTILE_SECRET_KEY
TURNSTILE_SITE_KEY is public.
TURNSTILE_SECRET_KEY must be stored as a Cloudflare Worker secret.
This repository does not include private Cloudflare resource IDs or production secrets.
To run your own instance, you need to create your own Cloudflare resources and update wrangler.jsonc based on wrangler.example.jsonc.
{ "name": "gaspool", "main": "src/index.ts", "compatibility_date": "2026-05-16", "compatibility_flags": ["nodejs_compat"], "assets": { "binding": "ASSETS", "directory": "./public" }, "d1_databases": [ { "binding": "DB", "database_name": "gaspool-db", "database_id": "YOUR_D1_DATABASE_ID" } ], "r2_buckets": [ { "binding": "R2_BUCKET", "bucket_name": "gaspool-media" } ], "kv_namespaces": [ { "binding": "GASPOOL_RADAR", "id": "YOUR_KV_NAMESPACE_ID" } ], "routes": [ { "pattern": "your-domain.com", "custom_domain": true } ], "vars": { "TURNSTILE_SITE_KEY": "YOUR_CLOUDFLARE_TURNSTILE_SITE_KEY", "ROUTING_PROVIDER": "ors", "PUBLIC_PROFILE_SLUG": "rider", "PUBLIC_PROFILE_NAME": "Gaspool Rider", "PUBLIC_PROFILE_AVATAR": "/assets/profile.webp" } }