diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..398c7f1 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,7 @@ +# Shows a "Sponsor" button on the repo. Fill in the handles you actually use; +# delete the lines you don't. GitHub validates these, so a wrong handle just +# hides that entry rather than breaking the page. +github: [Alvaro5] # requires GitHub Sponsors enabled on your account +# ko_fi: your-handle +# buy_me_a_coffee: your-handle +# custom: ["https://gradepace.vercel.app"] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..8f67cb6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,42 @@ +name: Bug report +description: Something in the pacing plan or UI is wrong or broken. +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for the report. GradePace runs entirely in your browser, so the + more you can tell me about the GPX and your setup, the faster this gets fixed. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you see, and what did you expect instead? + placeholder: "The projected finish was 3:12 but the course is 70 km, so that can't be right..." + validations: + required: true + - type: textarea + id: gpx + attributes: + label: The GPX file + description: > + If you can share the course (a link, or drag the file into the comment + box after creating the issue), that makes it reproducible. Note if it's a + recorded track (with times) or a route export. + validations: + required: false + - type: input + id: settings + attributes: + label: Settings + description: Flat pace, VAM, hike gate, terrain factor, units, language, if relevant. + placeholder: "6:00/km, VAM 750, gate 18%, terrain x1.04, metric, EN" + validations: + required: false + - type: input + id: env + attributes: + label: Browser / device + placeholder: "Chrome 141 on macOS, or Safari on iPhone" + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..6962fef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Try GradePace + url: https://gradepace.vercel.app + about: The live app. Load an example course to see the full output before filing. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..13345a0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,25 @@ +name: Feature request +description: Suggest something that would make the pacing plan more honest or the app more useful. +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: What are you trying to do? + description: Describe the racing/planning problem, not just the feature. What can't you do today? + validations: + required: true + - type: textarea + id: idea + attributes: + label: Your idea + description: How would you like it to work? Rough is fine. + validations: + required: false + - type: checkboxes + id: thesis + attributes: + label: Fit check + description: GradePace is deliberately about honest self-calibration, not false precision. (Optional.) + options: + - label: This is about a more honest or personal prediction, not just more knobs to tune. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..b820fa4 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ + + +## What and why + + + +## Checklist + +- [ ] `npm run lint && npm test && npm run build` all pass +- [ ] Added or updated tests for any engine/behavior change +- [ ] If this moves the projected finish time, I've called that out explicitly +- [ ] STATUS.md / README updated if the change affects them diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fbb2613 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# Cancel superseded runs on the same ref (e.g. rapid pushes to a PR). +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + name: Lint, test, build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + # `npm install`, not `npm ci`: sharp and the tailwind-oxide / rolldown + # wasm fallbacks pull nested, platform-specific optional deps that a + # macOS-generated lockfile can't fully describe for a Linux runner, so + # `npm ci`'s strict lockfile check fails. `npm install` reconciles the + # tree for whatever platform the runner is. + - name: Install dependencies + run: npm install --no-audit --no-fund + + - name: Lint + run: npm run lint + + - name: Test (Vitest) + run: npm test + + - name: Build + run: npm run build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..deea2ba --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# Contributing to GradePace + +Thanks for taking a look. GradePace is a small, opinionated project with a real +race behind it, so contributions are welcome but the bar is "does this make the +pacing plan more honest or the app more useful," not "more features." + +## Ground rules + +- **The engine is the asset.** `src/lib/pacing.ts` is pure TypeScript: no React, + no DOM, fully unit-tested. Domain logic lives there and stays testable. UI + glue lives in `src/App.tsx` and the component files. +- **Verify against reality, not vibes.** Distances should match known race + lengths, D+ should match published figures, and the Minetti cost model must + match the 2002 paper's measured anchors. There are tests that lock these. +- **Keep the tests green.** `npm test` must pass. A coefficient typo should fail + loudly, so please add a test when you add behavior. +- **Honest uncertainty over false precision.** The product presents a finish + *range* on purpose. Changes that pretend to more accuracy than a GPX-only + prediction can deliver will be pushed back on. + +## Getting set up + +```sh +npm install +npm run dev # local dev server +npm run test # engine + app tests (Vitest) +npm run lint +npm run build # production build (also what CI runs) +``` + +## Proposing a change + +1. Open an issue first for anything non-trivial, so we can agree on the shape + before you write code. Bug reports with the GPX file (or a link) and what you + expected vs. saw are the most useful. +2. Keep pull requests small and focused. One idea per PR. +3. Don't bundle a model/behavior change (anything that moves the projected + finish time) into an unrelated PR. Call it out so it can be reviewed on its + own merits. +4. Run `npm run lint && npm test && npm run build` before pushing. + +## Where things are + +The current technical state, decisions, and roadmap live in +[STATUS.md](./STATUS.md). Read it first. The high-level architecture is in the +[README](./README.md#project-structure). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2ea6f99 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Alvaro Serero + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index c02f8d8..cc12824 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,99 @@ # GradePace -Grade-adjusted pacing plans for trail races — live at -[gradepace.vercel.app](https://gradepace.vercel.app/). +**Grade-adjusted pacing plans for trail races.** Upload a course GPX, get a +per-kilometer plan that knows steep climbs are power-hikes, not runs, and an +honest finish *range* instead of a false-precision single number. Everything +runs in your browser. Your GPX never leaves your device. + +[![CI](https://github.com/Alvaro5/grade-pace/actions/workflows/ci.yml/badge.svg)](https://github.com/Alvaro5/grade-pace/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-10b981)](./LICENSE) +[![Live demo](https://img.shields.io/badge/live-gradepace.vercel.app-10b981)](https://gradepace.vercel.app) +[![Client-side only](https://img.shields.io/badge/backend-none-1f2937)](#how-it-works) + +### **[▸ Open the live app](https://gradepace.vercel.app)**  ·  no signup, no upload, load an example and go + +

+ + Elevation profile of the Imperial Trail colored by what you will do on each section: blue descents, green runnable, amber climbs, rose power-hikes, with R1/R2/R3 aid stations and projected arrival times. + +

+ +--- + +## Why GradePace Most pace planners assume you run every hill. On real trails, steep climbs are -power-hikes. GradePace takes a course GPX (a recorded track *or* a route -export) and produces a plan that admits this: per-km or per-mile splits with -target pace, climb, and run/hike share, plus a projected finish shown as an -honest **range**, not a false-precision single number. Everything runs in the -browser — no backend, and your GPX never leaves your device. - -## What makes it different - -- **Power-hikes are planned, not ignored.** Above a transition grade - (default 18%), iso-effort running is physically unavailable — the plan - switches to hiking at a fixed vertical speed (VAM). The elevation profile - is colored by what you'll *do* (descent / runnable / climb / power-hike). -- **Self-calibration instead of guessed knobs.** Upload a run you recorded - and GradePace inverts its own model against it: it measures your personal - terrain factor (with stopped time filtered out) rather than asking you to - invent one. Route exports with synthetic timestamps are detected by a - plausibility band and refused. -- **Honest uncertainty.** Pre-race prediction can't beat day-of biology - (sleep, heat, fueling swing a 70k by 20–40 min). The finish is a range — - −8%/+10% uncalibrated, −5%/+7% once calibrated — with the model's central - estimate in the middle. +power-hikes, and pretending otherwise makes every plan wrong from the first big +wall. GradePace is built around three ideas the mainstream tools skip: + +- **Power-hikes are planned, not ignored.** Above a transition grade (default + 18%), iso-effort running is physically unavailable, so the plan switches to + hiking at a fixed vertical speed (VAM). The elevation profile is colored by + what you'll actually *do* — descent, runnable, climb, power-hike. +- **Self-calibration instead of guessed knobs.** Upload a run you recorded and + GradePace inverts its own model against it: it *measures* your personal terrain + factor (with stopped time filtered out) rather than asking you to invent one. + Route exports with synthetic timestamps are detected and refused. This is the + moat — Strava has the data to self-calibrate and doesn't. +- **Honest uncertainty.** Pre-race prediction can't beat day-of biology (sleep, + heat, and fueling swing a 70 km race by 20–40 min). The finish is shown as a + range — −8%/+10% uncalibrated, −5%/+7% once calibrated — with the model's + central estimate in the middle. Admitting uncertainty is more state of the art, + not less. + +The [Minetti (2002)](https://doi.org/10.1152/japplphysiol.01177.2001) energy-cost +model is the foundation; the calibration layer is the product. + +## See it + + + + + + + + + + + + + + + + + + +
Stat cards: distance 68.75 km, elevation gain 1,193 m, power-hike 0.98 km, projected finish 7:35:30, expect 6:59 to 8:21.Course map on a topographic basemap with the route colored by grade, amber aid-station markers, and a checkered finish flag.
An honest finish range, not a single fake-precise number, plus distance, D+, and how much of the course you'll walk.A grade-colored course map (terrain / satellite / hybrid basemaps) with aid stations and opt-in water/toilet/viewpoint POIs.
Nutrition plan table with carbs, fluids, sodium, and calories for each segment between aid stations, plus totals.Per-kilometer splits table showing grade, D+, hike fraction, target pace, and elapsed time.
A nutrition plan that puts carbs, fluids, and sodium on each segment between aid stations, sized by projected time, not distance.Per-km (or per-mile) splits: target pace, climb, hike share, and elapsed time, all consistent with the projected finish.
+ +You can share a plan as an image, a link that carries your settings, or a +printable race-day PDF. ## How it works -1. **Parse** — `` (or `` fallback) → lat/lon/ele, optional +1. **Parse** — `` (or `` fallback) → lat/lon/ele, plus optional timestamps for the calibration path. 2. **Distance** — cumulative Haversine. -3. **Resample** — even 10 m stations (kills gradient spikes from - near-coincident GPS fixes). +3. **Resample** — even 10 m stations (kills gradient spikes from near-coincident + GPS fixes). 4. **Smooth** — centered moving average over a fixed 30 m *physical* window. -5. **Gradient** — Δelevation / Δdistance per segment; D+ via a 5 m - hysteresis deadband (density-stable, noise-robust). -6. **Cost → pace** — [Minetti (2002)](https://doi.org/10.1152/japplphysiol.01177.2001) - energy-cost polynomial (clamped to its validated ±45% range) scales your - flat pace; above the transition grade, segments hard-switch to - power-hiking at fixed VAM. -7. **Plan** — aggregate into km or mile splits, project the finish, wrap it - in the uncertainty range. - -Calibration inverts the same forward model: predicted total (at terrain ×1.00) -vs. your actual *moving* time → measured terrain factor, one click to apply. +5. **Gradient** — Δelevation / Δdistance per segment; D+ via a 5 m hysteresis + deadband (density-stable, noise-robust). +6. **Cost → pace** — the Minetti energy-cost polynomial (clamped to its validated + ±45% range) scales your flat pace; above the transition grade, segments + hard-switch to power-hiking at fixed VAM. +7. **Plan** — aggregate into km or mile splits, project the finish, wrap it in the + uncertainty range. + +Calibration inverts the same forward model: predicted total (at terrain ×1.00) vs. +your actual *moving* time → a measured terrain factor, applied with one click. ## Getting started ```sh npm install npm run dev # local dev server -npm run build # production build +npm run build # production build (also what CI runs) npm run test # engine + app tests (Vitest, 119 tests) npm run lint ``` @@ -59,10 +101,10 @@ npm run lint Useful scripts (run with `npx tsx` — the engine uses extensionless TS imports): ```sh -npx tsx scripts/gen-og.mjs # regenerate og.png from the live share card +npx tsx scripts/gen-og.mjs # regenerate og.png from the live share card npx tsx scripts/render-card-preview.mjs a.gpx out.png # preview the share card for any course -node scripts/calibrate-scan.ts efforts/*.gpx # fit terrain factors across recorded runs -node scripts/prior-scan.ts efforts/*.gpx # course-signal vs factor analysis (negative result) +node scripts/calibrate-scan.ts efforts/*.gpx # fit terrain factors across recorded runs +node scripts/prior-scan.ts efforts/*.gpx # course-signal vs factor analysis (negative result) ``` ## Project structure @@ -75,10 +117,10 @@ src/ lib/format.ts Time/pace formatters shared by UI and share card. lib/shareCard.ts Shareable plan image as a self-contained SVG. lib/rasterize.ts SVG → PNG in the browser. - lib/gradeColor.ts Shared grade→color scale (chart + share card). + lib/gradeColor.ts Shared grade→color scale (chart + map + share card). lib/basemaps.ts Basemap catalog: terrain / standard / satellite / hybrid. lib/pois.ts Overpass POIs (water, toilets, viewpoints): bbox query, - endpoint fallback, client-side route-corridor filter. + endpoint race, client-side route-corridor filter. lib/nutrition.ts Nutrition plan: hourly carb/fluid/sodium targets applied to each projected segment between aid stations. lib/logistics.ts Race logistics: aid-station dwell time, wall-clock @@ -95,7 +137,30 @@ src/ ErrorBoundary.tsx Styled fallback instead of a white screen. ``` -## Status +## Tech + +Vite + React 19 + TypeScript, Tailwind v4, Recharts, Leaflet, Vitest. +Client-side only — no backend, no database, no auth. Deployed on Vercel, +auto-deploy from `main`. The pure engine in `src/lib/pacing.ts` is the asset; +its Minetti anchors, clamp, and split invariants are locked by tests. + +## Status & roadmap -Active development. Current technical state and roadmap live in +Active development. Current technical state, decisions, and roadmap live in [STATUS.md](./STATUS.md). + +## Contributing + +Issues and PRs welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md). The short +version: keep the engine pure and tested, verify against reality (known race +lengths, published D+, the Minetti paper), and prefer honest uncertainty over +false precision. + +## License + +[MIT](./LICENSE) © Alvaro Serero + +--- + +Built by [Alvaro Serero](https://x.com/AlvaroSerero) for his own race in the +Fontainebleau forest. If GradePace helps you plan yours, a ⭐ is appreciated. diff --git a/docs/screenshots/map.png b/docs/screenshots/map.png new file mode 100644 index 0000000..3466e8e Binary files /dev/null and b/docs/screenshots/map.png differ diff --git a/docs/screenshots/nutrition.png b/docs/screenshots/nutrition.png new file mode 100644 index 0000000..60a54e5 Binary files /dev/null and b/docs/screenshots/nutrition.png differ diff --git a/docs/screenshots/profile.png b/docs/screenshots/profile.png new file mode 100644 index 0000000..0d9d659 Binary files /dev/null and b/docs/screenshots/profile.png differ diff --git a/docs/screenshots/projection.png b/docs/screenshots/projection.png new file mode 100644 index 0000000..894899a Binary files /dev/null and b/docs/screenshots/projection.png differ diff --git a/docs/screenshots/splits.png b/docs/screenshots/splits.png new file mode 100644 index 0000000..1634413 Binary files /dev/null and b/docs/screenshots/splits.png differ