Skip to content

Daylight windows, a goal-time solver, course character, and a pace band - #3

Merged
Alvaro5 merged 5 commits into
mainfrom
pre-launch-polish
Jul 25, 2026
Merged

Daylight windows, a goal-time solver, course character, and a pace band#3
Alvaro5 merged 5 commits into
mainfrom
pre-launch-polish

Conversation

@Alvaro5

@Alvaro5 Alvaro5 commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Four commits aimed at what a trail runner sees in the first thirty seconds, plus one real model gap and one bug.

Daylight and headlamp windows

New src/lib/sun.ts: closed-form sunrise-equation astronomy, no API and no key, so it works months out, which is when a race is planned and exactly when a forecast cannot help. It keys off civil twilight, not sunset, because that is when you actually stop seeing the trail.

Set a race date and a start time and the plan says which kilometres are run in the dark: indigo bands behind the elevation profile, a "night" legend chip that only appears when there is night, a daylight line under the stats, and sunrise/sunset plus a headlamp stat on the PDF sheet. darkSpans handles pre-dawn starts, overnight races and multi-day events; days not supplied are assumed light, so an under-supplied caller can lose darkness it never computed but never invent it.

Verified against published tables (London midsummer, Paris midwinter, NYC equinox) to within ~2 min, which the module header states rather than over-claims. Live on Imperial, 19 Sep 2026, 06:00 start: "Headlamp: the start to 10.22 km, until 07:02 (1:02)", band ending at km 10.2, civil dawn 31 min before the 07:33 sunrise.

Timezone comes from the forecast's utc_offset_seconds inside the weather horizon (a browser cannot map lat/lon to a timezone), else the device's offset for that date.

The model, backwards

src/lib/goal.ts. A goal finish is solved by bisection against the same forward engine, so a goal and the plan it produces cannot disagree. Round trip verified: goal 7:00 → 5:26/km → finish 6:59:26.

Honest failure mode: above the hike gate the model power-hikes at fixed VAM and that time does not shrink, so every course has a floor. An out-of-reach goal reports what the climbs alone cost instead of a fantasy pace. The applied pace floors rather than rounds, so "use it" lands at or just under the goal.

The same solver on the course truncated at a station now tells a missed cutoff what it would actually take: "R3 would need 4:56/km on the flat" (25 min over 47 km ≈ 30 s/km, consistent).

Flat pace from a road result

The other half of the north star. Calibration measures the terrain factor for anyone with a timestamped GPX; this measures the flat pace for the much larger group who just know a 10 km time. Riegel, piecewise (1.06 road, 1.10 past the marathon, since one exponent does not cover 10 km to 100 km). Riegel's average already prices the fatigue of a race that long and the engine adds its own fade on top, so the fade is divided back out using Riegel's own predicted duration.

Course character

src/lib/course.ts: km-effort and a difficulty band from climb density, both description only and never inputs to the time model. Bands validated against real races before shipping: road marathon → flat, Imperial → rolling, 25 Bosses and UTMB → mountainous, an 80 m/km skyrace → alpine. Plus total descent, measured with the same 5 m hysteresis as D+ so the two are comparable. Both ride as sub-lines on existing cards, no new clutter.

Cut-out pace band on the PDF

Round distances plus every aid station, elapsed time and wall clock, dashed border to cut out and tape to a bottle. Not the 69-row splits table, which nobody reads at km 55. Aid cells show arrival, not departure: arrival is the moment you glance at the band and ask whether you are on schedule. Step scales with race length so the strip stays 12-20 cells from a 20 km race to a 200 km one.

Share card

"81 km-effort · rolling" under the course title and night bands behind the profile. Computed inside the card from numbers it already had, so no new parameter, and it stays English like the rest of the card. This is the image that travels to X.


Two things worth a second look

Altitude moves the headline, but only above 1000 m. Minetti's cost data is sea-level data and so is every input the user gives, so nothing in the pipeline knew a race lived at 2500 m. Now 3%/1000 m above 1000 m, capped 12%, keyed to distance-weighted mean elevation (a course that touches 2500 m once is not a 2500 m course). Folded into the terrain multiplier rather than added at the end, because that multiplier already covers all moving time including the power-hiked climbs, and it keeps splits, ETAs, nutrition, replay and the headline consistent by construction. Fontainebleau and every other lowland course are untouched, so the landing number is still 7:43:26. One constant to veto.

Known interaction, documented in code: someone who calibrates on runs already at altitude would double count.

og.png was advertising a different plan. scripts/gen-og.mjs never got the late-race fade default, so the social preview said 7:35:30 while the app said 7:43:26. Fixed, regenerated, and the script now says out loud that a default changed in App.tsx has to be changed here too.

One bug caught by looking, not by testing. The cold-start estimator reused the goal field's parser, which reads two components as H:MM, so a 45 minute 10 km became a 45 hour one and the panel calmly offered 69:16/km. Same digits, opposite meaning: a goal "7:30" is hours and minutes, a road result "45:00" is minutes and seconds. Two parsers now rather than one that has to guess. Every unit test passed while this was broken because they feed seconds straight in.


Verification

  • 216 unit tests (was 158), 7 e2e (was 5), lint and build green.
  • New e2e specs guard the daylight bands and the goal solver round trip.
  • Checked in both themes, at 390 px and desktop, in FR and EN.
  • All new copy in five languages.

Deliberately not done

Pre-filling a race date and start time on the auto-loaded example so the headlamp feature shows on landing. It would only show darkness with an invented pre-dawn start, and inventing race details to demo a feature is not what this app does. Owner's call.

Alvaro5 added 4 commits July 25, 2026 12:14
Three additions aimed at a first-time visitor, plus one model gap.

Daylight (src/lib/sun.ts): closed-form sunrise-equation astronomy, no API
and no key, so it works months out rather than only inside a forecast
horizon. Civil twilight, not geometric sunset, is the threshold: that is
when you stop seeing the trail. darkSpans intersects the plan's timeline
with the night, covering pre-dawn starts, overnight races and multi-day
events. Indigo bands behind the elevation profile, a headlamp line under
the stats, and sunrise/sunset on the PDF sheet. Verified against
published tables (London midsummer, Paris midwinter, NYC equinox) to
within ~2 min, which the module states rather than over-claiming.

Goal-time solver (src/lib/goal.ts): bisection on flat pace against the
SAME forward engine, so a goal and the plan it produces cannot disagree.
Above the hike gate the model power-hikes at fixed VAM, so every course
has a floor; an out-of-reach goal reports what the climbs alone cost
instead of a fantasy pace. The same solver, run on the course truncated
at a station, now tells a missed cutoff what pace would have made it.

Flat pace from a road result (Riegel, piecewise 1.06 / 1.10 past the
marathon): the other half of the north star. Calibration measures the
terrain factor for anyone with a timestamped GPX; this measures the flat
pace for everyone who just knows a 10 km time. The fade the engine will
re-add is divided back out so the same fatigue is not counted twice.

Course character (src/lib/course.ts): km-effort and a difficulty band
from climb density, both description only, validated against real races
before shipping. Plus total descent, measured with the same hysteresis
as D+ so the two are comparable.

Altitude: Minetti's data is sea-level data and so is every input the
user gives, so nothing knew a race lived at 2500 m. 3%/1000 m above
1000 m, capped 12%, on distance-weighted mean elevation, folded into the
terrain multiplier so splits, ETAs, nutrition and the headline stay
consistent. Silent below 1000 m, so lowland courses including the landing
example are unchanged.

210 unit tests (was 158), 7 e2e (was 5). All copy in five languages.
Pace band: a dashed-border strip of round distances plus every aid
station, with elapsed time and wall clock, sized to cut out and tape to a
bottle. Not the 69-row splits table, which nobody reads at km 55. Aid
cells show arrival rather than departure, because arrival is the moment
you look at the band and ask whether you are on schedule. Aid amber,
finish emerald, dark stretches underlined indigo. Grid rather than flex
so a wrapped last row keeps the same cell width. The step scales with
race length, so the strip stays 12-20 cells from a 20 km race to a 200 km
one.

Share card: the course character line ("81 km-effort · rolling") under
the title and night bands behind the profile. Computed inside the card
from numbers it already had, so no new parameter and it stays English
like the rest of the card. The image that travels to X now shows what
separates this from a flat-pace calculator.

og.png: the generator never got the late-race fade default, so the social
preview advertised 7:35:30 while the app said 7:43:26. Fixed, file
regenerated, and the script now says out loud that a default changed in
App.tsx has to be changed here too.

212 unit tests, 7 e2e.
The cold-start estimator reused the goal field's parser, which reads two
components as hours and minutes. So "45:00" for a 10 km was taken as 45
hours and the panel confidently offered a 69:16/km flat pace: a wrong
number that still looks like a number, which is the failure mode this app
exists to avoid.

Same digits, opposite meaning. A goal finish "7:30" is seven and a half
hours; a road result "45:00" is forty-five minutes. Two parsers now,
rather than one that has to guess which you meant, each tested against
the other.

Every unit test passed while this was broken, because they pass seconds
straight in. It took rendering the panel to see it.
The How-it-works card gained a paragraph on the goal solver, the Riegel
projection, and why the dark-kilometre maths uses civil twilight instead
of sunset. The race-date tooltip now leads with sunrise, sunset and the
dark kilometres rather than the forecast: the forecast only exists inside
sixteen days, the sun always does, and the field was the only place a
visitor could learn the feature is there.
Copilot AI review requested due to automatic review settings July 25, 2026 12:20
@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
gradepace Ready Ready Preview, Comment Jul 25, 2026 12:25pm

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR expands GradePace’s pre-race planning surfaces by adding daylight/headlamp computation (closed-form, no API), a backward goal-time solver driven by the existing forward model, and richer course/context outputs (course “character”, altitude adjustment, PDF pace band, share-card updates), plus supporting tests and a fix for OG-image generation drift.

Changes:

  • Add closed-form sun/twilight calculations and use them to shade “night” segments in charts, share cards, and the plan sheet.
  • Add a goal-time solver (bisection over the existing forward model) plus road-result → flat-pace estimation, and use the solver for “missed cutoff required pace”.
  • Add course character (km-effort, difficulty band, mean elevation + altitude slowdown) and include it in UI/PDF; add a cut-out pace band to the PDF output; update OG generation to match App defaults.

Reviewed changes

Copilot reviewed 18 out of 19 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
STATUS.md Project status/log update describing the new planning features and verification.
src/lib/weather.ts Adds API-resolved UTC offset to support accurate daylight computations.
src/lib/weather.test.ts Updates forecast parsing test to cover utc_offset_seconds.
src/lib/sun.ts New closed-form sunrise/sunset/civil twilight module + dark-span computation.
src/lib/sun.test.ts Adds unit tests for sun times, polar cases, and dark-span logic.
src/lib/shareCard.ts Adds course character line and optional night-band shading to the share card SVG.
src/lib/shareCard.test.ts Tests for character line and night-band rendering.
src/lib/planSheet.ts Adds secondary stat line support and a cut-out pace band section + styling.
src/lib/i18n.ts Adds localized copy for course character, altitude, goal solver, daylight, pace band, and cutoff pace.
src/lib/goal.ts New backward solver, goal/race time parsers, and road-result pace estimation (Riegel + fade adjustment).
src/lib/goal.test.ts Adds extensive solver/parser/estimation tests and round-trip verification.
src/lib/course.ts New course character utilities (km-effort, difficulty band, mean elevation, altitude slowdown).
src/lib/course.test.ts Adds tests validating band thresholds and altitude/mean-elevation behavior.
src/ElevationChart.tsx Adds optional “night span” background bands behind the profile.
src/App.tsx Integrates altitude adjustment, course character display, daylight/headlamp UI, goal/estimate tools, cutoff required-pace, pace band export, and night-band wiring.
scripts/gen-og.mjs Aligns OG generation with App defaults (adds fade) and documents drift risk.
README.md Documents daylight planning, backward solver, and pace band; updates src/ map.
e2e/smoke.spec.ts Adds e2e coverage for daylight/night shading and goal-solver round-trip behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/App.tsx
Comment on lines +1607 to +1611
const goalSec = parseDuration(goalText);
const goalSolution = useMemo(() => {
if (goalSec === null || !courseModel) return null;
return solveFlatPace(courseModel, goalSec - totalDwellSec);
}, [goalSec, courseModel, totalDwellSec]);
CI caught this: the same URL showed a headlamp window locally and "you
run the whole race in daylight" on the runner. The daylight maths fell
back to the DEVICE's UTC offset, so a Paris laptop (UTC+2) and a CI box
(UTC) put civil dawn two hours apart on the same course.

That fallback is right when you plan a race at home and quietly wrong
when you fly to one, which is a large share of ultra runners and wrong in
a way nothing on screen would reveal.

fetchUtcOffsetSec resolves the course's real offset from its coordinates
for any race date. The forecast already carried the offset but only
inside its sixteen-day horizon, and races get planned months out. Keyless,
rounded midpoint only, same privacy posture as every other call, and null
on failure so the device fallback still stands. Order is now forecast,
then course lookup, then device.

Playwright pins timezoneId, because a suite whose result depends on the
machine's timezone is not a suite. Verified by re-running under TZ=UTC.

The failing assertion was also loose (getByText matched both the
"Daylight" heading and "…in daylight."), now exact.
@Alvaro5 Alvaro5 closed this Jul 25, 2026
@Alvaro5 Alvaro5 reopened this Jul 25, 2026
@Alvaro5
Alvaro5 merged commit d7fc996 into main Jul 25, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants