Skip to main content
About the project · 2025 — present

A logbook
for precision
pilots.

We-fly is a community-built record of every flight — the wind that day, the wing you flew, the line you took. Built by pilots, for pilots, in alpha.

Ready to log
Start tracking every thermal, ridge, and glide.
Start the logbook

Release history

An honest record of what shipped, what broke, and what we fixed. Newest at the top.

34 releases · oldest 16 Nov 2025
Added
xc-plannerthe planner is now **one four-step planning workflow** — 1 Design (route/task + flight date, collections always at hand) → 2 Airspaces → 3 NOTAMs → 4 Briefing — with a stepper in the top bar, Back/Continue navigation, and URL-shareable state (`?step=`, `?date=`); steps 2–4 unlock once the flight has ≥2 points and a date
xc-plannerstep 2 lists the controlled/restricted airspaces within a ±10 km corridor of the designed flight (same tiles and flagging rules as the post-flight check) for one-tap selection, fits the map to the flight, and refuses map-picks that are not along the planned line; drawing your own zones and loading an OpenAir file stay available under "Advanced"
xc-plannerstep 3 offers only the NOTAMs **along the flight and active on the chosen date** (corridor geometry + UTC-day overlap, validity window derived from the date), with a "more than 7 days out" notice beyond the feed's horizon; picked NOTAMs render solid on the map
xc-plannerstep 4 briefing — download the task (`.xctsk` + XCTrack QR) or route (GPX/WPT/GeoJSON + a QR opening the exact plan on another device), and one OpenAir v2.1 file bundling the picked airspaces **and the picked NOTAMs** (each NOTAM becomes an `R`/`Q` block with its band, geometry, `AI` id and ISO-8601 `AA` activation window)
xc-plannerflight-day weather in the briefing from the region's best model, merged finest-first per hour so short-horizon models degrade gracefully — UK & Ireland from DWD ICON-D2 (2.2 km) + ICON-EU (6.5 km), Alps from MeteoSwiss ICON-CH1 (1 km), rest of Europe from Météo-France AROME HD (1.3 km) + ICON-EU, worldwide from NOAA GFS — via the new public, rate-limited `/api/xc-planner/weather` endpoint (Open-Meteo), with sunrise/sunset and hourly temp/wind/gusts/cloud/rain plus model attribution
connectivitythe one-time device-password reveal (Profile → Connected services) now shows the full `wf_key_…` key on a wrapping, tap-to-select line and adds a **Scan onto phone** QR code, so a key generated on a laptop can be moved onto a phone (e.g. Flyskyhy) without typing 70 characters; the Device name field now explains it is only a label (not sent anywhere), and the Flyskyhy note links to the step-by-step upload guide
connectivitythe **Scan onto phone** QR now opens a lightweight `/connect` page with a one-tap **Copy password** button instead of encoding the bare key — the phone camera opens a real page (rather than treating the raw key as a web search) and drops the device password straight onto the clipboard. The password travels in the URL fragment (never sent to the server, so it stays out of logs and referrers), is strictly validated against the `wf_key_…` shape before anything is shown (so the page can't be abused to display attacker-controlled text on the We-Fly origin), and the fragment is cleared from the address bar on load
apinew **[Automated upload from a native app](/doc/api-native-upload)** developer page comparing the Leonardo protocol, personal API keys, and the OAuth device grant side by side, with a complete request for each — the previously undocumented native-upload paths partners kept asking for
xc-plannerairspace-design exports now target the OpenAir **v2.1** dialect — files carry the `*VERSION: 2.1`/`*WRITTEN_BY` header and an `AY` structural-type record (CTR, TMZ, RMZ, R, P, Q, TMA…) alongside the legacy `AC` class, both ignored by v1-only parsers so the output stays fully retro-compatible (openAIP, SeeYou, XCSoar, LK8000)
xc-plannerthe OpenAir export now enriches openAIP selections with the rest of the v2.1 metadata openAIP and autorouter know about the zone — the OpenAIP record id (`AI`), radio frequencies (`AF`/`AG`), and, for zones activated by autorouter NOTAMs, the concrete activation windows as ISO-8601 `AA` intervals (open-ended windows write `AA <start>/UFN`). Resolved best-effort at export time from the existing `/api/openaip/airspace` and `/api/notams` endpoints; enrichment failures never block the download
connectivityWebDAV instrument & cloud sync — point an Air³, vario, phone, or a mapped network drive at `/api/webdav` and new IGC files upload themselves; HTTP Basic auth with a per-device password generated in Profile → Connected services, the same ingest pipeline as the website upload, content-based dedup (safe to re-sync forever), and upload-only by design (deletions stay in the app)
surveycommunity survey — native `/survey` multi-step form (NPS, feature usage, roadmap wishlist) with per-response anonymity choice, once-only in-app invitation card, segmented re-engagement email campaign (active vs dormant copy, relaunch-safe send ledger), and `/admin/surveys` results dashboard with campaign controls
emaildev email safety net — every outgoing email goes through `safeSendEmail`; non-production environments can only deliver to `EMAIL_DEV_ALLOWLIST` addresses
xc-plannerNOTAM mode — free-flight NOTAMs on the map (restrictions red, warnings orange) with decoded popups, filters, click-to-probe lookup (#826)
connectivityconnect your autorouter.aero account (OAuth 2.0 Authorization Code) from Profile → Connected services — per-user token stored encrypted
xc-plannerlive, FIR-aware European NOTAMs — the map bounds decide which FIRs are queried, fetched per-pilot from their own autorouter account (Eurocontrol EAD) so the feed is never redistributed under a shared account
surveywiden dormant segment to 60 days or <=5 flights (#833)
surveyinclude incubating users in campaign audience (#835)
adminshow respondent name on survey text answers (#839)
unitsadd per-user measurement unit preferences (#844)
connectivityaccept Leonardo/Flyskyhy flight uploads (#854)
docFlySkyHy connectivity throught leonardo protocol
notamsreal airspace outlines instead of circle-only geometry (#863)
flightssurface flight actions as buttons with mobile labels (#901)
navsurface profile in the top bar and unify header icons (#902)
xc-plannerpropose a 3D flight review at the end of the workflow (#927)
xc-plannerlabel airspace volumes by name in the 3D review (#928)
xc-plannerlet pilots read an airspace's full record before selecting it (#929)
xc-plannertime the 3D review with the planned start and task shapes (#930)
mapsconsolidate map colors into a unified per-type standard (#931)
mapstoggle airspace groups from the legend (#933)
Changed
xc-plannerthe standalone Airspace and NOTAM modes are folded into workflow steps 2–3 (legacy `?mode=airspace` / `?mode=notam` links fall back to route design); the OpenAir export moved from the airspace sidebar to the briefing step
emailevery transactional and campaign email is now assembled from a shared design-system toolbox (`src/lib/email-templates/toolbox.ts`) instead of a per-file copy of the same blue-gradient styles — warm cream palette, Geist/EB Garamond type, dark masthead for campaigns and light for transactional, inbox preheaders throughout; the GDPR Article 33/34 and internal security alerts gain HTML bodies alongside their plain-text record, and `npm run email:preview` renders the whole programme for review
xc-plannerNOTAMs are now fetched live per pilot instead of a central cron/store — retires the shared-account ingestion, the `notams` collection, and `cron:refresh-notams`
xc-plannerNOTAM mode is now open to every pilot — autorouter issued OAuth client credentials, so the feature is no longer gated to admins (it falls back to admin-only only where no credentials are configured, e.g. dev/staging)
observabilityforward production errors to Sentry (EU region, DSN-gated) — covers server request errors, `logger.error`, client render errors, and uncaught client errors/unhandled promise rejections; source maps uploaded on `next build` when `SENTRY_AUTH_TOKEN` is set (deleted after upload, never served publicly)
connectcompose key-shape regex from a named prefix constant (#906)
depsresolve Dependabot security alerts (#916)
Fixed
observabilitya client crash reported from Firefox or Safari no longer reaches Sentry with zero frames. Client errors are beaconed to the server and re-thrown there, so they are read by the Node Sentry SDK, whose only stack parser understands V8's `at fn (url:line:col)` form — Firefox and Safari write `fn@url:line:col`, so a perfectly good browser stack was thrown away frame by frame and the issue arrived with a type, a message and no location at all (a Firefox `TypeError: t is undefined` on a takeoff page was untriageable for exactly this reason). Those stacks are now translated into the form the parser reads, led by the header line it requires — it skips the first line as the message, which would otherwise swallow the frame that actually crashed. The translation happens server-side, so it fixes reports from all four paths at once — uncaught errors, unhandled promise rejections and both React error boundaries — including browsers still running a cached bundle
redisthe app losing the boot race against the local Redis service no longer raises a high-priority Sentry issue. ioredis runs its own reconnection loop and emits an error for *every* failed attempt, and each one was reported — so a gap of a few seconds at startup, which the client closes by itself with nothing lost (commands wait in the offline queue), filed an `AggregateError: connect ECONNREFUSED ::1:6379` once per PM2 worker. Connection failures now stay in the server log as warnings for the first 30 seconds and reach Sentry only once the client has kept failing past that, then are suppressed until it recovers — so a Redis that is genuinely gone is one issue instead of one per retry, and its return is logged with how long it was down
routinga page that does not exist now actually says so. Asking for an unknown takeoff, doc page, doc category or shared recap returned "200 OK" with the not-found page painted on top — the page looked right to a person but told search engines, link checkers and monitoring that the URL was fine. The cause was a single app-wide loading screen: it commits the response before the page has had a chance to decide anything, which also downgraded every genuine redirect into a one-second "refresh this page" stall. The loading screen now sits only on the sections that have a real wait to cover (cockpit, logbook, profile, admin, the community feeds), so the rest of the app can answer with the right status again. The last legacy `/takeoff-finder` link is now an instant permanent redirect instead of a stall
takeoffsopening a takeoff page is no longer a one-second stall followed by the page loading twice. Every link into a takeoff detail page went through up to two server-side redirects (`/takeoff-finder/<id>` → `/takeoffs/<id>` → `/takeoffs/<id>/<slug>`), and because the app's root loading state flushes the page shell before the redirect is decided, the browser could not be sent on with a normal redirect — it got a one-second "refresh this page" instruction instead, each time. Links now point straight at the full URL, the trailing name in the address bar is decorative (any form of it renders, with search engines pointed at the canonical one), and the single legacy URL still in the wild is answered with a real, instant redirect
replaythe Fullscreen button no longer crashes the 3D replay and the flight comparison on Safari. Safari never shipped the unprefixed Fullscreen API — the desktop browser only has the `webkit*` aliases, and Safari on iPhone has no element-level fullscreen at all — so `element.requestFullscreen()` threw "requestFullscreen is not a function" and took the whole view down with it. Every fullscreen call now goes through one feature-detecting helper: it uses the vendor alias where there is one, hides the button entirely where fullscreen cannot work (iPhone) rather than offering a control that throws, and treats the browser's own refusal (no user gesture, iframe without the permission) as a refusal instead of a crash report. Menus opened from inside fullscreen keep working on Safari too, now that the portal target reads the prefixed fullscreen element (#922)
observabilitya `logger.error()` call that reports a *condition* rather than a caught exception no longer invents one. The absent third argument was still run through `toError()`, which fabricated an `UnknownError: An unknown error occurred` whose only stack frame sat in `error-utils.ts` — so all ~55 message-only call sites in the codebase reported as the same synthetic exception and collapsed into a single un-triageable Sentry issue, while the `captureMessage` path that groups on the real log message was left unreachable. Nothing thrown now means no exception to report (#920)
logbookdeleting a flight that is already gone, or one owned by someone else, is logged at `warn` instead of `error` — a stale flight list, a double-submitted delete, or a concurrent delete in another tab all land there, and none of them is a server fault. The line stays in the PM2 log (it is also the signal for an ownership-probing attempt) but no longer raises a Sentry error (#920)
observabilitya client crash that the browser reports without an `Error` object no longer reaches Sentry with zero frames. WebKit — Safari, and so every iOS browser including Chrome iOS — leaves `ErrorEvent.error` null for some crashes (stack exhaustion among them), and the reporter read only `event.error?.stack`, so the report arrived with a type, a message and no location at all. The `filename`/`lineno`/`colno` the event *does* carry are now turned into a parseable frame (and beaconed as a plain `source` field), which the source maps uploaded on every build resolve to a named function. The server-side reconstruction also stops leaving the API route's own stack on the error, which had been attributing these crashes to `/api/public/client-error` and collapsing unrelated client crashes into a single group (#873)
observabilitya failed network request no longer lands in Sentry as a "ClientRuntimeError: Load failed" crash — the browser refuses to say which request failed or why, so every report collapsed into one un-triageable group while the causes (lost signal, tab closed mid-request, content blocker) all sit outside the app. Every browser's phrasing of a transport-level `fetch` failure is now filtered client-side before the beacon and server-side as a backstop, from a single shared list (`src/lib/observability/client-error-noise.ts`) so the two can't drift; a real API failure returns a status code and is still reported (#917)
redisa `NOSCRIPT` reply from local Redis (its Lua script cache is emptied on restart/failover/`SCRIPT FLUSH`) is no longer reported to Sentry as an error — it is an expected, self-healing condition that `@upstash/ratelimit` recovers from by re-sending the script via `EVAL`, so the ioredis adapter now logs it at `warn` and re-throws for the fallback instead of `logger.error` (#912)
leonardothe Leonardo upload auth-failure response now names the actual mistake instead of a bare "Invalid User account" — it keeps that literal prefix (which Flyskyhy matches on) and appends a hint distinguishing "you sent your account login password; use a `wf_key_` device password" from "your device password was rejected (revoked/expired/unscoped)". The most common integration snag was pilots pasting their login password, which is rejected by design
apithe device-authorization guide is corrected to allow **public clients** (the code always did; native apps should register as public, no embedded secret), documents the access-token **refresh/rotation lifecycle** and 401/403 handling, and replaces the inaccurate `slow_down` claim with the real 429 rate-limit behaviour; the API-keys guide now states a key is the whole credential (no `client_id`) and shows an IGC upload; the internal Leonardo doc's throttle row is fixed from `429` to `200`
mapstop the "Cannot read properties of undefined (reading '_leaflet_pos')" crash when a map is unmounted mid zoom-animation — Leaflet's fallback timer fired `_onZoomTransitionEnd` on a removed map (its panes already deleted); a global guard makes the handler a no-op once the panes are gone (#865)
xc-planner`publicAppOrigin` now derives the public origin from the pinned `AUTOROUTER_REDIRECT_URI` (a runtime var) before `NEXT_PUBLIC_APP_URL` — the latter is inlined at build time, so a production build that lacked it still bounced the pilot to `http://localhost:3010/...` after a successful connect; the runtime pin makes the redirect correct without a rebuild that carries the public URL
xc-plannerthe autorouter OAuth callback and authorize routes now build their browser redirects from the public origin (`NEXT_PUBLIC_APP_URL`) via a shared `publicAppOrigin` helper — behind the reverse proxy they were built from the internal request origin, so after authorising, the pilot was bounced to `http://localhost:3010/...` (connection refused); the same helper now backs the `redirect_uri` derivation
xc-plannerthe autorouter OAuth `redirect_uri` now derives from `NEXT_PUBLIC_APP_URL` instead of the request origin — behind the reverse proxy the origin was the internal bind address (`http://localhost:3010`), so the authorize URL and token exchange used a localhost callback autorouter rejects; production now sends `https://we-fly.cloud/api/connect/autorouter/callback` with no `AUTOROUTER_REDIRECT_URI` pin needed
surveythe campaign ledger row is now released when a send fails, and the run aborts on a Resend quota/rate-limit error — previously a failed send (e.g. hitting the free-plan 100/day cap) left a permanent "contacted" tombstone, so those pilots were silently skipped on relaunch; adds `campaign:reconcile-survey-sends` to reconcile the ledger against Resend's delivery status and re-target pilots who never received the invite
emailescape the user's display name in the account-deletion and inactivity-warning emails — it was interpolated into the HTML unescaped
emailsend the inactivity-warning email to pilots with no display name — the greeting rendered "Hi undefined,"
cockpitsource lowest-low-save tile from detected low saves
adminmake survey text-answer panel scrollable (#837)
observabilityreport client errors as real Sentry exceptions (#838)
buildguard nullable useSearchParams/usePathname derefs
observabilityfilter Next.js deploy-skew Server Action errors from Sentry (#841)
flightsguard null glideRatio in phases tab glide extremes (#842)
observabilitysurvive third-party DOM tampering (removeChild NotFoundError) (#849)
profilestop overflow container from clipping card borders in connected-services dialog (#855)
connectivityanswer 200 on GET/HEAD for Leonardo submit endpoint (#856)
connectivityparse multipart + IGCigcIGC for Leonardo uploads (#857)
depsrestore pruned @emnapi lockfile entries to unbreak npm ci
xc-plannerdon't zoom-out and drop NOTAMs when clicking a shape (#867)
notamsresolve radius-less named-area NOTAMs to their real outline (#868)
notamsresolve glider-sector NOTAMs to their real outline (#869)
notamsresolve leading-token named-area NOTAMs to their real outline (#870)
notamsresolve every viewport candidate, not just the first 20 (#871)
observabilityrecover the real client error type from beacons (#874)
depssupport sharp 0.35 and bump js-yaml to 5.2.1 (#880)
securitypatch NextAuth/@auth/core auth advisories and nested sharp CVEs (#892)
connected-servicesallow re-copying a just-generated device password (#893)
observabilityignore benign web-streams transformAlgorithm rejection (#895)
fix: date range picker off-by-one (#899) and xc-planner mobile topbar overlap (#900) (#903)
flightsfilter GPS glitch speed spikes below the 150 km/h gate (#907)
xc-plannerkeep single-click add working while airspaces are shown (#932)
xc-plannerlet the 3D review orbit the whole plan before any scrub (#934)
docs: add interactive IGC & OpenAir format specifications (#887)