My Ski Runs
A web app for keeping track of the trails you ski, on each resort's own trail map: every trail is a clickable
overlay on the map, with its name and rating, so you tap the run you just skied and it's logged on your trip. It
works offline on the mountain, installs to a phone's home screen, and can sync your trips between devices with an
optional account. Live at www.myskiruns.app.
Fifty resorts so far, 6,868 trails: Killington, Stowe, Okemo, Sugarbush, Jay Peak, Smugglers' Notch, Mount Snow,
Stratton, Whiteface, Hunter Mountain, Wildcat Mountain, Loon Mountain, Sunday River, Sugarloaf, Snowshoe, Vail,
Beaver Creek, Breckenridge, Keystone, Arapahoe Basin, Copper Mountain, Winter Park, Steamboat, Snowmass, Aspen
Mountain, Buttermilk, Telluride, Crested Butte, Park City, Deer Valley, Snowbasin, Alta, Snowbird, Brighton,
Solitude, Big Sky, Whitefish Mountain, Jackson Hole, Schweitzer, Sun Valley, Palisades Tahoe, Northstar, Heavenly,
Mammoth Mountain, Big Bear, Mt. Bachelor, Mt. Hood Meadows, Crystal Mountain, Stevens Pass and Whistler Blackcomb.
Most of the work in this repository is the trail-map pipeline: getting every trail's overlay onto its own
drawn line, with the right name, on maps that were never made to be machine-read. How it is done, resort by
resort, is in docs/trail-map-playbook.md: start there to add a resort or to
update one for a new season (its Part 1 has the first hour's triage and a recipe for each type of map).
Contents: Quick start · Using the app · Resorts ·
How a map gets its overlays · Repository layout ·
Scripts and checks · How the app works · Accounts ·
Trail conditions · Deploying · Marketing · History ·
What's left
Quick start
npm install
npm run dev # http://localhost:5173 (serves /api/conditions from memory)
npm run build # typecheck + production build into dist/
npm test # the trip merge and sync rules, the resort search (tests/)
npx tsc -b && npx eslint . # before every commit
The pipeline tools are Python 3 (pip install pymupdf pillow numpy opencv-python-headless scikit-image scipy fonttools) and Node; the browser checks use Playwright (in the Claude Code sandbox:
PLAYWRIGHT_PATH=$(npm root -g)/playwright). CLAUDE.md has the rules every change follows.
Using the app
- Resorts. Pick a resort with the button in the header: a search over the names, states and other places (it
finds Heavenly by "nevada" or "lake tahoe"), grouped by state before you type.
?resort=<id> in the URL opens
one; the app remembers the last one on the device.
- Trails at every resort. The same search box finds trails by name across all the resorts ("outer limits":
Heavenly, Killington, Mt. Hood Meadows, Palisades Tahoe, Stevens Pass, Whistler Blackcomb), each with its rating,
resort and state; picking one opens its resort zoomed to the trail, its sheet open (
?resort=<id>&trail=<id>
links there too). The index (trail-index.json, about 80 KB compressed) is built with the app and fetched the
first time a search is typed (src/trailSearch.ts). Some maps come in several panels (Vail's Front Side, Back
Bowls and Blue Sky Basin; Big Sky's main map and two insets), with a switcher on the map (?panel=<id>);
picking a trail in the list opens the panel it is drawn on.
- Trips. What you ski is logged per trip ("Presidents Day weekend"). Start or pick a trip in the header and
mark a trail skied from the map or with its check box in the list; with no trip yet, the first mark starts one
for today. The stats show this trip and all trips; trails skied on an earlier trip are drawn dashed.
- On the map: drag to pan, pinch or scroll to zoom (the corners button shows the whole map). A tap opens a sheet
listing every trail within a finger's width, nearest first, each with a big "Skied it" button, so junctions are
never guessed; a toast offers Undo. Searching the list and tapping a name zooms the map to that trail.
- Trail conditions (👍 / 👎). In a trail's sheet, rate today's conditions and pick up to three tags (groomed,
powder, moguls, icy, ...); votes are shared with everyone for 24 hours, and the list opens with "Good conditions
today". No account is involved: each device sends a random id, one vote per trail.
- Something wrong? Each trail in the sheet has a "Something wrong with ...?" link: wrong name, wrong line on
the map, wrong difficulty or something else, with an optional note; the sheet also has "A trail here is missing
from the map?". The report goes to
/api/report for review (below).
- Trip summary: trails skied, new trails, days, the difficulty mix, progress by area and a day-by-day log;
"Share as text" uses the phone's share sheet.
- Share your day. From the trip summary: a picture of the day, as a story (1080 x 1920) or a square post
(1080 x 1080): the resort's map cropped to the runs skied, drawn in their colours, with the numbers, the
difficulty mix and (on a story) the runs' names. "Share image" sends it through the share sheet (or saves it
where there is none). "Share link" / "Copy link" give a link that opens the day for anyone as a read-only map,
zoomed to the runs, with the list and the numbers, and "Track your own runs" into the app. The trip travels in
the link itself (), so there is no server, no account and nothing stored; opening one
never touches the visitor's own trips.
Resorts
| resort | region | trails (lines + markers) | map | source and route | rebuild |
|---|
| Killington | Vermont | 135 (113 + 22) | 1 | flattened raster PDF; line detector, readers, a person's review | npm run trails:apply -- --resort killington (history) |
| Stowe | Vermont | 125 (114 + 11) | 1 | PDF strokes, outlined names; readers, a person's review | npm run trails:apply -- --resort stowe |
| Okemo | Vermont | 128 (124 + 4) | 1 | PDF strokes, outlined names; readers, trace pass, a person's review of 23 | tools/trailmap/resorts/okemo/regen.sh |
| Sugarbush | Vermont | 138 (111 + 27) | 1 | PDF strokes, outlined labels; readers | tools/trailmap/resorts/sugarbush/regen.sh |
| Jay Peak | Vermont | 88 (65 + 23) | 1 | a painting with no lines, names as text; trace pass | tools/trailmap/resorts/jay-peak/regen.sh |
| Smugglers' Notch | Vermont | 82 (68 + 14) | 1 | PDF strokes, outlined names on label boxes | tools/trailmap/resorts/smugglers-notch/regen.sh |
| Mount Snow | Vermont | 85 (78 + 7) | 1 | this season's map an image only; the 2023-24 export's strokes and the 2024-25 uphill map's names as text registered on it, each run rated by its name's colour | tools/trailmap/resorts/mount-snow/regen.sh |
| Stratton | Vermont | 116 (98 + 18) | 1 | this season's PDF flattened; the 2020-21 export's strokes, text and symbols registered on it, this season's changes read on crops | tools/trailmap/resorts/stratton/regen.sh |
| Whiteface | New York | 98 (95 + 3) | 1 | PDF strokes and text (skimap.org) | tools/trailmap/resorts/whiteface/regen.sh |
| Hunter Mountain | New York |
"Lines" are trails drawn on their own line (or along their printed name, where the map draws no line), "markers"
are trails with no line (bowls, glades, chutes, parks) shown as a clickable marker at their name. IMAGES=1 before
a regen.sh also rewrites the resort's map image; every regen.sh downloads its sources into work/<id>/
(git-ignored) and rebuilds the resort's committed files byte for byte. Killington and Stowe were built before the
pipelines were kept: their proposals and a person's reviews are the record, and trails:apply rebuilds their
overlays from them. The playbook's Part 3 has each resort's source, scripts and
quirks.
Each resort's files:
src/data/resorts/<id>/: trails.ts (the trail list: id, name, rating, area, glade flag), and per map
linePolylines.json (the line pieces), trailProposals.json (each piece's name), trailReviews.json (decisions
that override proposals: a person's, or Claude's marked "by": "claude") and trailPaths.json (what the app
draws, generated by npm run trails:apply; never edited by hand). A map in several panels keeps these in
panels/<panel>/.
public/maps/<id>.jpg (or <id>-<panel>.jpg): the map image the overlays are drawn on (in percent of it).
- An entry in
src/resorts.ts: name, state or province, other places the search finds it by, its panels.
tools/trailmap/resorts/<id>/: how it was built (regen.sh, the map's reading, the decisions settled on crops),
with a README listing its commands and files.
- An ad group in
marketing/google-ads/build.mjs (the build stops without one).
How a map gets its overlays
Every map is different, and the hard part is never the drawing: it's naming. A trail's overlay must lie on that
trail's own drawn line along its whole length and show that trail's name anywhere along it, and the map prints
names beside lines, in gaps of lines, on label boxes, on numbered keys, or not at all. Fully automatic name-to-line
assignment got about 40% right on Killington. What works:
- Take the most the source gives you. A resort's PDF often has the trail lines as vector strokes and the names
as text or outlined glyphs; then the geometry and the names are exact (
extract_pdf_vectors.py,
pdf_labels.py, pdf_glyphs.py). With only images, lines are detected from the paint (raster_lines.py).
- Match each name to the line it is printed along or at the end of, automatically where the map is
unambiguous (
pdf_resort.py), and settle the rest by looking at zoomed crops, recording every decision as a
point on the map with the crop that settled it. On maps where names can't be extracted, parallel AI readers
name numbered pieces on tiles.
- Check against the resort's own data: its trail report (names, ratings, areas), its GIS where published, and
OpenStreetMap's runs for which line a run follows.
- Audit every overlay on crops, one cell per trail, check every printed symbol against its trail, and hover
every trail in the real app. Pixel scores are never evidence; a test that hovers on the assigned path is
circular.
The playbook has the routes, the tools, the checks and every resort's specifics.
Repository layout
src/ the app (React 19 + TypeScript + Vite)
resorts.ts the resort registry: one entry per resort, each loaded on demand
data/resorts/<id>/ each resort's trail list and overlays (above)
components/ ImageMap (the map, overlays, tap sheet), TrailList, TripBar, StatsPanel, TripSummary,
ResortPicker, FilterBar, Account
trips/ trips on the device (store.ts), the merge rules (log.ts), the account sync (sync.ts)
account/ hooks/ sign-in and session; trips, filters, conditions
resortSearch.ts the picker's search; offline.ts: when to start the service worker
detection/ Killington's original line detector's ground truth (docs/killington.md)
public/ the map images (maps/), the service worker (sw.js), icons, manifest
api/ Vercel functions: conditions votes, season tallies, problem reports, link previews,
account deletion
middleware.ts sends link-preview bots to api/preview.ts
supabase/migrations/ the accounts table
tests/ npm test: trip merge and sync, resort search
scripts/ Node pipeline scripts: trails:apply, reviews:import, Killington's detector and OCR;
landingPages.ts, the resorts' search landing pages (written by the build)
tools/
trailmap/ the trail-map pipeline (Python and Node): extraction, matching, checks
resorts/<id>/ each resort's rebuild: regen.sh, its map's reading, its decisions
reports/ fetching resorts' trail reports (terrain feeds, Common Crawl)
prompts/ runs/ the AI readers' prompts and example arguments (.claude/workflows/ runs them)
review/ the Trail Check review page
archive/ every one-off script written along the way, indexed (not maintained)
*.cjs browser checks of the app and a Vercel-like static server
docs/ the playbook; Killington's history
marketing/google-ads/ the Google Ads campaign (generated)
work/ git-ignored: downloads and intermediate files of the pipelines
Scripts and checks
| command | what it does |
|---|
npm run dev / build / preview | the app: dev server, production build, a local preview of the build |
npm test | the trip merge and sync rules (random three-device histories), the resort search, recap links (every resort's trail ids hash apart), link previews, the search across resorts, the season tallies, problem reports and the landing pages |
npm run lint | ESLint (with npx tsc -b, the check before every commit) |
npm run trails:apply -- --resort <id> [--panel <p>] | proposals + reviews → trailPaths.json, the overlays the app draws |
npm run reviews:import -- <export dir> --resort <id> | the Trail Check page's export → trailReviews.json |
npm run ads:build | the Google Ads import files from marketing/google-ads/build.mjs |
npm run lines:eval / lines:overlay / lines:png | Killington's line detector: score, overlay, the app's line image |
tools/trailmap/resorts/<id>/regen.sh | rebuild one resort from its sources and decisions (IMAGES=1: its map image too; FORCE=1: on a source whose SHA-256 has changed, a new edition) |
tools/trailmap/regen_all.sh | rebuild every resort and list what git sees changed: after changing a shared tool |
tools/trailmap/hover_all.sh [<id>[:<panel>] ...] | build the app, serve it, hover every trail of every resort and panel |
python3 tools/trailmap/overlay_audit.py --resort <id> --out <dir> | one audit cell per trail: its overlay on the map, its labels boxed |
node tools/app_check.cjs <url> --resort <id> | a resort in the real app: found by the search, every panel loads with overlays, phone layout |
node tools/app_flows.cjs [dist] | phone flows (tap, mark, undo, summary, panels), two tabs, backups |
node tools/make_og_assets.cjs | redraw the link previews' fixed pictures (public/og/) |
|
Every pipeline tool (extraction, glyph reading, matching, audits, OpenStreetMap, trail reports, source finding) is
listed in the playbook's Part 2, with what it's for.
How the app works
- Resorts load on demand.
src/resorts.ts lists every resort with loaders for its trail list and overlays;
each resort's data is its own chunk, so a visit downloads the resorts it opens. A resort with several map panels
has one trail list and one overlay file per panel; a trail's area decides the panel it opens on.
- The map (
src/components/ImageMap/) draws the overlays as SVG over the map image, in percent of the image,
at a constant on-screen width. A tap or hover names the nearest trail to the pointer (the same test the hover
check uses), and the sheet lists every trail within a finger's width.
- Recaps (
src/recap/): link.ts packs a trip into a link (its name, first day, day count, new-run count,
and each run as five characters of a hash of its trail id, so a link survives a resort's trail list being
rebuilt); card.ts draws the card on a canvas from the map the app already shows (same origin, cached
offline), so it works without signal. RecapView is the read-only page a link opens.
- Trips are a log of events merged, never overwritten (
src/trips/log.ts): a trip's name and date come from the
copy edited last, a run is kept if it was marked after it was last unmarked, and deletions stay as markers so they
reach other devices. The merge is commutative, associative and idempotent; npm test checks it on random
histories of three devices.
- Offline:
public/sw.js precaches the app and every resort's data (the list is filled in by vite build,
vite.config.ts), caches each map when it's shown (every panel of a resort once it's opened), and on a deploy
swaps its cache while keeping the maps a phone already has. It installs only once the first map is on screen, so
its background downloads don't compete with that map.
- Accounts (optional) sync the trip log to one Supabase row per person with a version check
(
src/trips/sync.ts); conditions votes go to api/conditions.ts (Redis). Locally, vite and vite preview
serve both APIs: conditions from memory, accounts against the Supabase project in .env.local.
Accounts
Accounts are optional: with no Supabase variables set, the app works as before, with no sign-in shown. Sign-in is
Supabase Auth's one-time email code (no passwords), sent through Resend; the trips live in one Supabase table.
-
A Supabase project. A new one keeps this app's users and email wording separate. Free projects pause after
a week without use (the app keeps working on the device; sign-in and sync resume once the project is restored).
-
The table. In the SQL editor, run supabase/migrations/001_trip_logs.sql:
one row per person (trips, a JSON list, and a version), readable and writable only by that person (row-level
security), deleted with them.
-
Email codes. Authentication → Sign In / Providers → Email: on, new sign-ups allowed. Authentication → Emails →
Templates: put the code, {{ .Token }}, in both "Magic Link" and "Confirm signup" (a first sign-in uses the
second), e.g. Your My Ski Runs sign-in code is {{ .Token }}. The code is what works in the home-screen app; a
link, if a template keeps one, signs in the browser it opens in (Authentication → URL Configuration → Site URL:
the app's address).
-
Resend as the mail server. Authentication → Emails → SMTP settings: host smtp.resend.com, port 465, user
resend, password a Resend API key, sender an address on a domain verified in Resend (Supabase's own mailer
allows only a couple of emails an hour).
-
Vercel environment variables (Project Settings → Environment Variables; from Supabase → Project Settings →
API keys), then redeploy. VITE_ values are built into the app; the service key stays on the server:
| variable | value |
|---|
VITE_SUPABASE_URL | the project URL |
VITE_SUPABASE_ANON_KEY | the anon (publishable) key |
SUPABASE_SERVICE_ROLE_KEY | the service_role (secret) key, used only by api/account.ts to delete an account |
Vercel's Supabase integration sets NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY /
SUPABASE_SERVICE_ROLE_KEY instead, which work too. .env.example lists them for local runs (.env.local).
Without a Supabase project, scripts/mockSupabase.cjs stands in for one, and tools/accounts_check.cjs drives
two devices through sign-in, sync, offline edits and deletion (its header has the commands).
Trail conditions
The 👍/👎 votes and tags are shared through api/conditions.ts, a Vercel function backed by a Redis hash per
resort: add Upstash Redis from the Vercel Marketplace and connect it to the project (it sets KV_REST_API_URL and
KV_REST_API_TOKEN; UPSTASH_REDIS_REST_URL / _TOKEN work too). Until then the endpoint answers 503 and the app
keeps votes on the device (the sheet says so). Votes count for 24 hours; each device sends a random id.
Season tallies
For rankings of the season's runs across every resort, two anonymous tallies are kept in the same Redis as the trail
conditions (api/_lib/season.ts), each counting once per device, per trail, per day (Mountain time):
- votes: every 👍 / 👎 also counts toward the season (
api/conditions.ts), where today's conditions keep them
only 24 hours; a change of mind the same day moves the count instead of adding one;
- run marks: marking a run skied sends
{resort, trail, device} to /api/marks (undo takes it back the same
day), counted by season and by day (the last 15 days kept).
No trip, account or name is stored, and a device's id is kept only two days (to count it once a day). A season runs
July 1 to June 30. GET /api/season[?season=2026-27] ranks them: most loved (at least 5 votes, by the lower bound of
the Wilson score, so 40 up and 2 down beats 5 up and 0 down), most skied this season, and most skied in the last 7
days, with ids that the app's trail index names. Nothing in the app shows it yet: a "Top runs" page comes once a
season's data is enough to fill it.
Problem reports
"Something wrong?" in a trail's sheet posts {resort, panel, trailId, kind, note, x, y} to /api/report
(api/_lib/reports.ts): kind is name, line, rating, missing (no trail) or other, x/y where the
skier tapped (percent of the map image; none when the sheet came from the list or a link). They are kept, newest
first, in a Redis list in the same store as the trail conditions (the newest 5,000). The device's id only caps a
device at 20 reports a day and is not kept with the report.
To read them, set REPORTS_TOKEN in Vercel (Settings → Environment Variables, 16 characters or more, e.g.
openssl rand -hex 24) and redeploy; until then /api/reports answers 404. Then:
curl -H "Authorization: Bearer $REPORTS_TOKEN" 'https://www.myskiruns.app/api/reports?resort=stowe&limit=100'
To act on one, open /?resort=<id>&panel=<panel>&trail=<trailId> and fix the resort's data the way the playbook
says (its section in Part 3); x/y place a missing trail on the map.
Search landing pages
The app itself is one page, so for search engines the build also writes a plain page per resort,
/<id>-trail-map/ ("Stowe Trail Map: All 125 Runs"): the runs by area with their ratings, each linking into the app
on that trail, the difficulty mix, a link to open the map, and the other resorts by state. /trail-maps/ lists every
resort, and sitemap.xml and robots.txt list them all (scripts/landingPages.ts, called from vite.config.ts).
Their picture is the resort's link-preview image (/api/og?resort=<id>). After the first deploy, submit
https://www.myskiruns.app/sitemap.xml in Google Search Console (Sitemaps). The service worker leaves these pages
to the network.
Link previews
A link to the app pasted into iMessage, WhatsApp, Slack, Discord and the like shows a title, a line of text and a
picture. The site's own (index.html, between <!-- link preview --> markers) uses public/og/default.jpg. A link
naming a resort (?resort=stowe) shows "Stowe Trail Map" and the resort's map; a shared day (&recap=...) shows
" at ", its runs, new runs and date, and the map cropped to its runs, drawn in their colours.
Link-preview bots don't run the app, so middleware.ts (Vercel Routing Middleware, which runs before the static
files are served) sends them (by user agent, only for links with ?resort=) to api/preview.ts, which returns the app's own page with those tags filled in; everyone else gets the static page as
before. api/og.ts draws the picture (1200 x 630, with sharp) from the resort's map and the preview data the build
writes to dist/preview/<id>.json (vite.config.ts). tools/make_og_assets.cjs redraws public/og/ (the default
picture and the badge in a map picture's corner). To see what a service will show, paste a link into
opengraph.xyz or Facebook's Sharing Debugger.
Analytics
Vercel Web Analytics counts page views, with no cookies (so no consent banner), and a few events (src/analytics.ts):
Resort opened, Run marked, Trail found, Problem reported, Trip created, Summary opened, Share dialog opened, Recap shared (how: image, image saved,
link, link copied, text), Recap viewed, Recap: track your own. A shared day's link has its trip taken out of the URL
before it is counted. Only builds made on Vercel send anything, so local builds and the browser checks don't.
To turn it on: Vercel dashboard → the project → Analytics → Enable (Web Analytics), then redeploy. Page views
show on every plan; the custom events show on Vercel's Pro plan (on Hobby they are dropped).
Deploying
Vercel builds the app (npm run build) on every push and serves dist/ with api/*.ts as functions; pushes to
main go to production (www.myskiruns.app). api/ runs as Node ESM ("type": "module"), so its relative imports
need explicit .js extensions, or every request fails with a 500; npx vercel build reports a missing one as
TS2835. Environment variables: the accounts', the conditions store's and REPORTS_TOKEN, above. Nothing else to do for a new
resort: the service worker's precache list and version are filled in by the build. Push main on its own and check
that a Production deployment came for the commit (gh api 'repos/nighthawk6389/myskiruns/deployments?sha=<sha>'
lists each with its environment) before pushing the same commit to other branches: Arapahoe Basin's commit, pushed
to main and two branches within a second, got two Preview deployments and no Production one, so the site stayed
on the commit before until the next push to main.
Marketing
marketing/google-ads/ is a Google Search campaign ready to import into Google Ads Editor (everything imports
paused): an ad group per resort plus general run-tracker searches. npm run ads:build generates it from
build.mjs, where each resort has an entry (names, keywords, negatives); its README has the plan.
History
The app started on Killington's flattened raster map, where a line detector (97% F1 on line pixels) and OCR
still put only about 40% of trails on the right line. Reading numbered tiles with AI readers and a person's review
fixed that, and from the fourth resort on, the resorts' own PDFs gave exact lines and names. The detector, the OCR,
the audits and the attempts on other branches are in docs/killington.md; the lessons are in
the playbook's Part 5.
What's left
- A person's confirmation for the forty-seven resorts Claude checked on crops instead of the review page
(Sugarbush through Sun Valley), if wanted: the Trail Check page can be published for any resort (playbook,
Part 4).
- Shared tooling: a legend file per map feeding the extraction and the readers' prompts; readers run from a
script instead of an interactive session (playbook, Part 5).
- The hover check passes 18,515 of 18,524 points (2026-10-11). The nine misses are points where two trails'
overlays meet or share a stretch, where either name is right: Killington 2, Stowe 2, Beaver Creek 2, Big Sky's
Bowl 1, Heavenly 1, Mt. Bachelor 1 (each resort's numbers are in the playbook's Part 3).
- Whiteface, Winter Park, Breckenridge, Copper Mountain and Keystone key their decisions by piece id, valid for
their exact source file (the rebuild checks its SHA-256); Winter Park's and Copper Mountain's sites now serve
re-exports with other ids. Moving them onto
pdf_resort.py would key them by points.
- Areas: Keystone, Breckenridge, Copper Mountain, Winter Park and Sunday River list every trail under one area
for the whole map. Where a trail report groups the runs by mountain or peak (Keystone's
report.json: Bergman,
Dercum Mountain, North Peak, Outback), those groups would make the areas, as they do for the later resorts.
- Vail: Cookshack's second diamond has no line of its own (its overlay is the line it is printed beside).
- Killington's detector edge cases (3 false negatives, 1 false positive) are in
src/detection/LINES.md.