Skip to content

Commit 5eb78ba

Browse files
bgrozevclaude
andauthored
Next-generation architecture: TypeScript, Vite, modes, and the redesigned UI (#14)
* NOTES: mark Phase 0 complete Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * CI: pin node 24 (active LTS, supported to Apr 2028) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Tests: pin current behavior of the derive pipeline Golden-value tests for reposition, addWind, straightenLegs, averageWind and Winds.getWindAt with realistic inputs (parameter manoeuvre + 3-leg pattern + multi-row winds). Safety net for the Phase 1 refactors (interpolation fix, memoization, Winds -> plain data, core/ extraction). The two known bugs are deliberately not pinned: interpolation is only pinned without interpolation or with uniform-direction rows (so the pins survive the step 2a vector-interpolation fix), and the manoeuvre uses offsetXFt=300, far from the step 2b offset clamp. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix wind direction interpolation wrapping the wrong way across north Winds.getWindAt() interpolated direction linearly, so 350->10 degrees passed through 180 instead of 0. Interpolate the wind vector (u/v components) between rows instead: direction takes the shortest arc and speed is blended physically — opposing winds partially cancel, giving a lower speed than the linear average, which is correct and desired. Existing interpolation expectations updated accordingly (vector values instead of independent linear direction/speed); the exact-row pin in pipeline.test.ts is relaxed to 9 decimals because an exact altitude match now round-trips through u/v at p=0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix manoeuvre offsetXFt clamp; support zero and negative offsets createManoeuvrePath() clamped offsetXFt with Math.max(offsetXFt, 3), silently forcing a 3 ft minimum, because setFinalHeading() derives the final approach direction from the bearing between the last two points and breaks down when they coincide (offsetXFt=0 put p2 exactly on p1). Instead: translate by |offsetXFt| (flipping the bearing for negative values, i.e. offset to the opposite side of the final approach line), and for exactly 0 use a 0.01 ft epsilon segment — visually indistinguishable from zero but keeping the final heading defined for the downstream rotation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add input validation limits; clamp out-of-range values in the panels Absurd inputs (e.g. a 1,000,000 ft pattern leg) effectively broke the app: most numeric fields had no max, the wind table had no validation at all, and the target heading normalization went negative below -360. - validation.ts: LIMITS table (internal units) for pattern, manoeuvre, wind and direction inputs, plus clampNumber() and normalizeDirection() helpers that never let non-finite values through. - NumberInput: out-of-range entries show an error helperText and are clamped into range on blur; invalid values are never propagated raw. - Pattern/Manoeuvre panels: min/max wired from LIMITS (converted to the display unit). The manoeuvre "Back" field now accepts 0 and negative values (matches the offsetXFt fix). - Wind table: altitude/speed clamped, direction normalized on entry. - Target heading: proper [0, 360 normalization. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> EOF ) * Memoize the derive pipeline; remove reposition mutation leaks App.tsx recomputed reposition -> addWind -> straightenLegs -> averageWind inline on every render (every hover reran the full turf pipeline). Extract into hooks/useFlightPaths.ts, a useMemo keyed on the actual inputs (manoeuvre, pattern, target, effective winds, and the three relevant settings). reposition() now produces new point objects instead of setting phase and time/alt by mutating its (translated) points, so memoized inputs can never be aliased. The c2[i].properties.phase copy loop in App.tsx is dropped: addWind clones its input points, properties included, which the step-1 pinning test 'preserves point properties' proves. Behavior is identical per the pipeline golden tests; a new test pins that reposition leaves its inputs untouched. Lint warnings 60 -> 57. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Replace Winds/WindRow classes with plain data + pure functions Winds was a class instance stored in React state, with bound methods and ad hoc serialization. It becomes a plain WindProfile interface (WindRow is now just the IWindRow shape) plus pure functions: createWindRow, createWindProfile, copyProfile, getWindAt, setGroundWind, prepWind (moved from geo.ts) and interpolateWindRows. The effectiveWinds clone-and-patch in App.tsx becomes composeWinds(): forecast profile + observed ground row -> effective profile with the ground source marked as dropzone-observed. The wind summary also no longer sets 'observed' by mutating the profile's ground row. All call sites updated (useFetchForecast, useFlightPaths, App, WindsComponent, OpenMeteo/CSC/Spaceland providers, tests). No behavior change - pipeline golden tests unchanged and green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extract pure logic into src/core/ Create src/core/ per ARCHITECTURE.md and move the pure modules out of util/: geometry.ts (former geo.ts + reposition/averageWind/straightenLegs from util.ts), pattern.ts, manoeuvre.ts (+ setManoeuvreAltitude), wind.ts, units.ts (+ metersToFeet/ktsToFps/mphToFps conversion constants, formerly in geo.ts) and coords.ts (needed by reposition; moving it keeps core self-contained). Tests moved alongside; util.test.ts redistributed (reposition/averageWind -> geometry.test.ts, setManoeuvreAltitude -> manoeuvre.test.ts, CODEC_JSON -> new storage.test.ts against the copy in util/storage.ts, which App code already used). Dependency rule (documented in src/core/README.md): nothing in core/ may import from react, components, hooks, forecast (I/O) or map code. The wind source identifiers (SOURCE_MANUAL/DZ/OPEN_METEO, ForecastSource) move into core/wind.ts because WindProfile carries them; forecast/sources.ts re-exports them, keeping forecastSourceLabel (UI text) in the forecast layer. All imports updated directly; no re-export shims. util/ keeps the impure/app-specific modules (courses, csv, exports, dropzones, storage, migration, pathStats, validation). No behavior change; pipeline golden tests unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add versioned storage schemas with validating loaders Persisted state (target, pattern params, manoeuvre config, settings, presets, custom courses) is now written as a { schemaVersion, doc } envelope and read through validating migrate functions. Old or corrupt localStorage data can no longer crash the app: every migrate*() accepts unknown JSON and returns a valid document, defaulting missing/invalid fields, clamping numbers into LIMITS, and dropping garbage array entries. Legacy (pre-envelope) bare documents are handled as version 0, including the old {lat, lng} flight-point format for stored manoeuvre tracks. New pieces: - src/core/model.ts: canonical defaults (moved from useAppState / ManoeuvreParametersComponent, which re-export them) + migrate functions per document, unit-tested against garbage and legacy shapes. - util/storage.ts createVersionedCodec(): envelope parse/stringify with a never-throw fallback to migrate(undefined). - validation.ts and migration.ts move from util/ to core/ (model depends on both; keeps the core dependency rule intact). Decisions on ambiguous cases (documented in model.test.ts): presets without an id get a generated one instead of being dropped (their content is still recoverable); custom courses without a finite lat/lng ARE dropped (a course without a location is meaningless); pattern legs are always padded/truncated to exactly 3 because the UI addresses legs[0..2] directly. Note: flip.locations.custom (CustomLocationsComponent), stored manoeuvre tracks (ManoeuvreTrackComponent) and simple string keys remain unversioned - candidates for a follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Docs: Phase 1 complete; record implementation follow-ups in backlog Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Move pure map helpers into core (geometry, units, wind) destinationPoint/bearingBetween join core/geometry; formatDegrees, formatDistanceFeet and speedGustLabel join core/units; beaufortColor joins core/wind. MapComponent's local haversineDistanceFt is replaced by the existing core distanceFeet (same haversine, turf earth radius — sub-ppm difference, invisible after rounding). Prep for the Phase 2 map layerization. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Introduce src/map: provider-neutral adapter + Google implementation MapAdapter.tsx defines the primitive contracts layers are written against: MapContainer, MapPolyline, MapCircle, MapOverlay (DOM at a geo position), MapDragHandle, plus useMapClick (priority dispatch), useMapCursor (override stack), useMapZoom and MapControl (screen-anchored controls). src/map/google/ is now the only place importing @react-google-maps/api or referencing the google.maps namespace; src/map/index.ts binds the provider. MapComponent consumes only adapter primitives (no structural split yet): map plumbing (API loading, camera, click dispatch, cursor, zoom state) moved into GoogleMapContainer. Google-specific constants moved from src/constants to src/map/google/mapConfig.ts; POM/path styles re-expressed as neutral primitive props. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extract FlightPathsLayer into src/map/layers Pre-wind + corrected polylines, POM markers, altitude labels, hover tooltips (leg/manoeuvre/point stats), cross-path highlighting and crab arrows move into layers/FlightPathsLayer.tsx with their hover state. Shared tooltip styles/helpers land in layers/tooltip.tsx. Path style constants move with the layer; the now-empty src/constants/ is removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extract CourseLayer, TargetEditLayer and CourseEditLayer Course buoys/lines/markers (with the zoom>=20 marker gate) move to layers/CourseLayer. Target and course edit handles move to their own layers, each owning its live-drag preview state; TargetEditLayer registers its own crosshair cursor and background-click handler (priority 0), while the measure tool's click handler now registers at priority 10 — preserving the measure-beats-target-edit behavior. The CourseEditTarget and TargetEditTarget types now live with their layers (MapComponent re-exports them for App). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extract StationsLayer Observed-station wind arrows, the target-anchored ground-wind arrow (observed or forecast) and their tooltips move to layers/StationsLayer, which owns the shared hover state + leave timer. The target-anchored arrow lives here rather than a separate wind-arrows layer because it shares the single-tooltip hover state with the station markers; the arrow SVG is deduplicated into a WindArrowGlyph. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Move Places autocomplete into the google adapter LocationComponent reached into window.google.maps.places directly, with its own ad-hoc type declarations for the Google namespace. Per the src/map import rule (only src/map/google may reference google.maps), the autocomplete wiring moves to src/map/google/places.ts as attachPlaceAutocomplete(input, onPlace), exposed through the src/map index. Pure refactor: same no-op + console.log when the API is not yet loaded, same place_changed handling; the component now receives plain LatLng coordinates. Browser-verified: Target panel > Search shows Google autocomplete suggestions; selecting one moves the target and pans the map. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extract MeasureLayer; MapComponent becomes a thin composition The measure tool (ruler toggle, click-to-add points, cumulative distance labels, click-to-remove, clear button) moves from MapComponent into src/map/layers/MeasureLayer.tsx, which owns all measure state. MapComponent is now a ~100-line composition of MapContainer + feature layers; the CourseEditTarget/TargetEditTarget re-exports are gone, so App imports them from src/map/layers directly. Adds src/map/README.md documenting the adapter surface and the import rule. Pure refactor: identical styles, z-indexes, click priority. Browser-verified: enable "Show measure distance tool"; ruler toggles measure mode with crosshair cursor; clicks add points with cumulative labels (707 ft / 1108 ft); clicking a point removes it and labels recalculate; the X button clears all points; exiting resets the tool. No console errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Docs: Phase 2 complete; record map-layer follow-ups in backlog Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Backlog: MapLibre adapter as future idea Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Replace the fake demo router with react-router (real URLs) Panels now live at real paths (/pattern, /manoeuvre, /target, /wind, /courses, /settings, /about) with the map at the root; /map remains as a redirecting legacy alias. Browser back/forward and deep links work. Choices and rationale: - react-router-dom@7 (current major), used via a small adapter that satisfies the router interface of Toolpad's AppProvider. Toolpad integrates cleanly this way, so DashboardLayout stays (visuals unchanged). - History routing (clean URLs) rather than hash routing, because share links are owner-prioritized and clean paths are the target scheme (ARCHITECTURE "App plumbing"). Static-hosting reload safety comes from a build-time 404.html fallback: GitHub Pages serves 404.html for unknown paths, and the build now ships it as a copy of index.html (spa404Fallback plugin in vite.config.ts; site uses a custom domain at base '/', so no subpath complications). - The deliberate UX quirk is preserved cleanly in the adapter: navigating to the already-open panel routes to the map, acting as the panel-close toggle mobile relies on. - Route guard redirects unknown paths to the map. Pure URL-scheme helpers live in src/app/routing.ts with unit tests (guard logic included, ready for mode gating). Browser-verified (dev server): deep link to /wind opens the Wind panel; clicking the active Wind nav item closes it (routes to /); back button returns to /wind; /map redirects to /; no console errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add src/modes: declarative mode profiles (data, not code) Mode = {id, label, description, enabled, nav, mapLayers, defaults, features} per ARCHITECTURE "Modes". Ships two real modes — pattern (no Manoeuvre/Courses; student-friendly wind-arrow default) and swoop (today's full UI) — plus flocking and explore as disabled stubs that prove the shape. applyModeDefaults() resolves effective settings: a mode default applies only while the user's stored value equals the global default, so user config survives mode switches untouched. The accepted limitation (a mode-defaulted setting can't be stored back at the global-default value) is documented at the function. migrateModeId() validates stored/URL mode ids; anything unknown or disabled becomes null (= first-run picker). Unit tests cover definition integrity (nav/layers/features/defaults reference real ids), codec garbage-safety, and defaults resolution. Not yet wired into the UI — next commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Wire modes into the app: picker, switcher, gating, ?mode= links - First-run picker ("What are you planning?") shows until a mode is chosen; enabled modes are cards, stubs are greyed with a coming-soon chip. Behind the picker the app runs the fallback (swoop = full UI), so pre-mode users see nothing disappear. - Mode persisted at flip.mode via the Phase-1 versioned codec (schemaVersion envelope + migrateModeId; garbage or disabled ids degrade to "not chosen" = picker). - Toolbar gets a compact ModeSwitcher menu (per-mode icons, stubs disabled) ahead of the existing actions. - Gates driven by the mode definition: - sidebar nav + mobile bottom nav built from mode.nav (bottom nav = nav minus settings/about) - map layers via a new MapComponent `layers` prop (mode.mapLayers); e.g. CourseLayer/CourseEditLayer only exist in swoop - features: manoeuvre excluded from path derivation and course selection ignored outside swoop; presets toolbar gated - route guard: panels outside mode.nav redirect to the map - Mode in URL: ?mode=swoop|pattern on any path applies immediately (confirmation-free, per phase scope), persists, then the param is stripped to keep URLs canonical. Invalid values are ignored. - Effective settings via applyModeDefaults(): mode defaults apply only where the user's stored value equals the global default, so stored config survives mode switches untouched. The Settings panel edits stored values; known limitation documented at applyModeDefaults. Browser-verified (dev server + production preview): 1. Cleared site data -> picker appears; chose Pattern -> sidebar and bottom nav lose Manoeuvre/Courses; map fine; wind-arrow mode default active. 2. Toolbar switch to Swoop -> tabs reappear; selected a course and created a custom one -> renders on map; switching to Pattern hides the course layer and redirects /courses to the map while flip.courses.* data stays intact. 3. Deep link /wind opens the Wind panel; back button walks history; reload keeps state. 4. Active-item toggle closes the panel on desktop and via bottom nav at 375px viewport. 5. Mode survives reload; pattern leg altitude changed in swoop (300 -> 400) survives switching to pattern mode and back; ?mode= links apply + strip correctly. 6. npm run build && npm run preview: deep-link /wind reloads correctly on the static preview; 404.html ships identical to index.html for GitHub Pages. 7. No console errors in any of the above. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Docs: Phase 3 complete; record router/modes follow-ups in backlog Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Introduce src/data/wind: WindSource plugin layer replaces src/forecast Phase 4 slice A. The wind data layer is now a set of source plugins implementing the WindSource interface from ARCHITECTURE §2 (id, label, kind: model-forecast | sounding | observed-station, capabilities, fetch(location, opts)): - src/data/wind/source.ts: the interface, split into AloftWindSource (resolves to a WindProfile) and ObservedStationSource (resolves to stations near a location) for type-safe returns. - openmeteo.ts, stations/{nws,csc,spaceland}.ts: existing providers ported onto the interface (git mv from src/forecast, logic intact). Site-specific feeds (CSC, Spaceland) gate themselves by range inside fetch(); NWS declares capabilities.discovery. - stations/index.ts: location-based aggregation (was observedWind.ts), now iterating OBSERVED_STATION_SOURCES; dropzones only contribute supplemental AWOS station ids. - compose.ts: composeWithObservedGround() — the observed station wind becomes the ground row, tagged with station id + observation time. - index.ts: WIND_SOURCES registry + fetchForecast(center, opts). core/wind.ts types extended compatibly (winds are not persisted, so no codec changes): WindRow gains optional tempC/source/validTime, profiles gain meta { model, fetchedAt, location, elevationFt, station... }, and SOURCE_SOUNDING joins the source ids. OpenMeteo now tags every row with source + validTime. forecastSourceLabel moved into core/wind. Deleted dead legacy providers src/forecast/{csc,spaceland}.ts (unused since the station providers replaced them). New tests: registry/source conformance, OpenMeteo profile building (mocked recorded-shape responses, hour offset, below-ground level filtering, row tagging), NWS gridpoint discovery parsing (range filter, partial-observation fallback, by-id supplement), station aggregation (dedupe, sorting, failure isolation). openmeteo.ts uses bare fetch (was window.fetch) so node-env tests can stub it. Browser-verified (flip-dev, ZHills): forecast fetch populates the wind table, Zephyrhills NWS station discovered and injected as ground wind, paths redraw; no console errors. Tests 289 passing; lint 0 errors / 50 warnings (was 52); build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Cache ground elevation per location (it never changes) Phase 4 slice B; backlog "Cache elevation". fetchElevation ran on every forecast fetch even though elevation is immutable. New src/data/wind/elevation.ts owns the lookup: locations round to 3 decimal places (~110 m, so target nudges keep hitting), results persist in localStorage under 'flip.elevationCache' through the versioned-codec mechanism (schemaVersion 1, validating migrate drops garbage entries), bounded to 500 entries by evicting the oldest. No TTL — permanent by design. Storage-less environments (node tests) just always fetch. Tests: key rounding, hit/miss/nearby-vs-distant, envelope shape, corrupt-storage recovery, eviction, no-localStorage fallback. Browser-verified (flip-dev, ZHills) with an instrumented fetch counter: clicking Fetch forecast issues 1 GFS request and 0 elevation requests once the cache entry exists; localStorage holds {"schemaVersion":1,"doc":{"28.219,-82.151":75.45932}}. Tests 303 passing; lint 0 errors / 50 warnings; build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Prefetch a window of forecast hours; switch hours locally Phase 4 slice C; backlog "Prefetch forecast for next few hours". One OpenMeteo request already returns hourly arrays, so fetchOpenMeteo now requests a window (24h minimum, whole days up to the requested hour, capped at 168h to match the 7-day picker) and keeps it in a module-level cache. The forecast-time picker's +/- hour buttons and time edits are then served locally — no network — as long as the window is fresh (30 min TTL), covers the hour, and the target hasn't moved beyond hasTargetMovedTooFar. Indices are computed from timestamps, so a window fetched before an hour boundary stays valid after it (just shifted). Explicit refreshes bypass the window: the Fetch forecast button and the toolbar refresh action pass force → WindFetchOpts.forceRefresh. Hour switching (+/-, date/time edits, "now") does not. Tests: window sizing (24/48/168), local hour switch without a second request, hour-boundary re-alignment, refetch beyond window / after TTL / on target move, nudge tolerance, forceRefresh bypass (fake timers + resetOpenMeteoPrefetch between tests). Browser-verified (flip-dev, ZHills) with an instrumented fetch counter: Fetch forecast → 1 GFS request; three "+1 hour" clicks → 0 further requests, console shows "OpenMeteo prefetch hit: hour offset 1/2/3", wind table updates each time; no console errors. Tests 312 passing; lint 0 errors / 49 warnings; build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Show and select the OpenMeteo forecast model Phase 4 slice D; backlog "OpenMeteo model info". The Wind panel now badges the aloft source ("OpenMeteo - <model> - valid <time>"), and a Forecast model dropdown in Settings lets the user pick the model. The selected model (settings.windModel, persisted via the versioned codec) flows through useFetchForecast -> WindFetchOpts.model and is part of the prefetch cache key, so switching models refetches while switching hours still serves locally. OpenMeteo access moves from the model-specific /v1/gfs endpoint to the generic /v1/forecast?models=<id>. buildProfile now tolerates missing variables (null values), so models that don't cover every pressure level or the 80 m wind still produce a valid profile from the levels they do provide. Models offered (all verified 2026-07 against real /v1/forecast at 28.22,-82.15 to return usable wind at the pressure levels FliP uses): best_match - full 17 levels + 80 m (default) gfs_seamless - full 17 levels + 80 m icon_seamless - 9 levels + 80 m ecmwf_ifs025 - 5 levels, no 80 m (usable but coarse; null rows skipped) None dropped - all four return data; the null-tolerant builder covers the sparse ones. OPEN_METEO_MODELS + windModelLabel live in core/wind; migrateSettings validates windModel against the known ids. Browser-verified (flip-dev, ZHills, instrumented fetch): initial fetch hit models=best_match, badge "OpenMeteo - Best match"; selecting ICON in Settings persisted windModel=icon_seamless, and refetching hit models=icon_seamless with badge "OpenMeteo - ICON"; no console errors. Tests 318 passing (model-in-cache-key, meta.model, ECMWF null-row skipping, migrateSettings model validation); lint 0 errors / 49 warnings; build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add radiosonde soundings as an alternative winds-aloft source Phase 4 slice F; backlog "Soundings as wind source". Real radiosonde soundings from the Iowa Environmental Mesonet (IEM), implemented as a WindSource of kind 'sounding' (src/data/wind/soundings.ts). CORS verified with real fetches before building (both endpoints send Access-Control-Allow-Origin: *, so they are callable directly from the static client - no proxy): network: mesonet.agron.iastate.edu/geojson/network/RAOB.geojson sounding: mesonet.agron.iastate.edu/json/raob.py?ts=<UTC>&station=<id> The source discovers the nearest online RAOB station from the location (module-cached network GeoJSON), then fetches the most recent synoptic launch (00Z/12Z, newest-first with fallback since the latest may not be uploaded yet), converting each valid-wind level to an AGL WindRow (direction, knots, temperature) referenced to the surface geopotential height. Rows carry source=sounding + validTime; meta records the station id/name/distance and launch time. Wiring: settings.windAloftSource ('forecast' | 'sounding') selects the aloft source in Settings (the Forecast-model dropdown shows only for 'forecast'); useFetchForecast dispatches through fetchForecast's aloftSource option; the Wind panel badges the sounding station, distance and launch time. Observed ground wind still composes over the sounding surface row when enabled. Verdict: BUILT. IEM RAOB is CORS-viable and returns good data. Browser-verified (flip-dev, ZHills, instrumented fetch): selecting Radiosonde sounding fetched RAOB.geojson, tried 00Z (not yet uploaded) then fell back to 12Z, discovered station _TBW (Tampa, 39 mi), badged "Sounding - _TBW (39 mi) - launched Jul 14, 07:00 AM", and populated the table with the real AGL profile; no console errors. Tests 331 passing (network parsing, nearest-station, synoptic-time selection, profile building + null-wind skipping, discovery + latest/ fallback fetch, network caching, settings migration); lint 0 errors / 49 warnings; build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Docs: Phase 4 complete; record wind-subsystem follow-ups in backlog Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Docs: retract phantom past-forecast-time bug (clamps already handle it) Verified in-browser that fetch, model badge and NWS station discovery work; the empty table seen earlier was an automation click miss, not app behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Phase 5: installable PWA with offline app shell + cached forecasts vite-plugin-pwa (generateSW, autoUpdate). Manifest + icons (192/512, any + maskable, upscaled nearest-neighbor from the existing pixel-art logo — swap for higher-res art later). Service worker precaches the app shell and uses navigateFallback so the installed app opens any route offline. Runtime NetworkFirst caching for the weather APIs (OpenMeteo, NWS, IEM soundings) so the last-fetched forecast survives offline. Google Maps tiles are deliberately NOT cached (their terms restrict it — offline tiles are the MapLibre backlog item). The 404.html SPA fallback now runs enforce:'post' so it copies the final index.html after PWA injects the manifest link + SW registration; verified both index.html and 404.html register the SW (deep-link installs work). Verified in a production preview: SW active + controlling, manifest + 4 icons load, app-shell precache (10 entries), navigateFallback serves the shell for unknown deep routes, and the openmeteo runtime cache populates and reads back from cache. 331 tests, 0 lint errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Docs: mark Phase 5 (PWA) complete Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add MapLibre scaffolding + mapProvider setting (google default) Foundation for a runtime-switchable MapLibre map provider, alongside Google Maps. Nothing is wired yet — the default provider stays 'google' and the app renders exactly as before; the MapLibre modules compile but are not imported by the app. - Add maplibre-gl dependency (^5.24.0). - Settings.mapProvider: 'google' | 'maplibre' (MAP_PROVIDERS in types), default 'google' in DEFAULT_SETTINGS; versioned settings migration defaults a missing/invalid mapProvider to 'google' (never throws), with a unit test. - MapProviderContext + useMapProvider + MapDispatchContainerProps in the adapter, for the coming primitive dispatchers. - src/map/maplibre/: MapLibre container (satellite ESRI World Imagery raster style, own map-instance context, shared adapter contexts, click/cursor/zoom/center parity), a meter-radius circle-polygon helper with tests, and the map config. maplibre-gl confined to src/map/maplibre. Verified: npm test (335), npm run lint (0 errors), npm run build all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Make map primitives provider-polymorphic (Google/MapLibre dispatchers) Turn the six adapter primitives into runtime dispatchers that render the Google or MapLibre implementation based on the active provider: - MapContainer takes a `provider` prop (from settings via MapComponent), mounts the matching provider container, and publishes the provider on MapProviderContext. MapPolyline/MapCircle/MapOverlay/MapDragHandle read that context and delegate. attachPlaceAutocomplete dispatches by an explicit provider argument (it is not a component). - src/map/index.ts now binds the primitives to ./dispatch instead of ./google. Layers/components still import the same names from src/map and are unchanged. - Add the MapLibre primitives (polyline, circle, overlay, drag handle), the geocoder, the z-order helper, and the MapLibre index; maplibre-gl stays confined to src/map/maplibre. - Code-split: the whole MapLibre subtree loads via dynamic import, so the default Google build's main chunk is unchanged (maplibre-gl ships in a separate async chunk only fetched when MapLibre is selected). This keeps the PWA precache under its size limit. Default provider stays 'google'; there is no UI to switch yet, so the app renders exactly as before. Browser-verified (Google, default): satellite tiles, green pattern with POM altitude labels, average-wind arrow all render; zero console errors. npm test (335), npm run lint (0 errors), npm run build all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add map-provider Settings control + MapLibre-aware place search Make the MapLibre provider user-selectable and keep Target search working in both providers: - SettingsComponent: a "Map provider" select in the Map group (Google Maps / MapLibre satellite), styled like the existing selects. Switching it re-renders the map with the chosen provider without losing layer state (paths/POMs/target/course/stations/winds all derive from app state). - LocationComponent place search now passes the active provider to attachPlaceAutocomplete, so under MapLibre it uses the key-free Photon geocoder instead of Google Places (which needs the Google JS API). - MapLibreMapContainer: resize() on 'load' so the map paints even when the flex dashboard layout sizes the container a frame after creation. Browser-verified on a clean dev server: switching Google->MapLibre in Settings (no reload) renders ESRI satellite tiles with attribution and the average-wind arrow; zero console errors. Google remains the default and is unchanged. npm test (335), lint (0 errors), build all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix MapControl clickability + MapLibre container robustness - MapControl: wrap portalled content in a `pointer-events: auto` element. The MapLibre control host is transparent to pointer events (so the map stays draggable underneath), which left buttons rendered through MapControl — notably the measure-tool ruler — unclickable. The wrapper generates no box of its own, and controls that must not block the map (the wind arrow) still opt out with their own `pointerEvents: 'none'`. Google is unaffected (its host was already pointer-interactive). - MapLibreMapContainer: defer map creation until the container has a non-zero size (a MapLibre map created at 0x0 never renders and does not recover on its own), and reflow via a ResizeObserver on later size changes (e.g. the side panel opening/closing), matching the Google adapter's automatic reflow. Browser-verified under MapLibre: measure tool now works end to end — ruler toggles, click adds points (circles), the connecting line and the "308 ft" distance label render; target drag/click, wind arrows, and satellite tiles all render; zero console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix crash when unmounting the MapLibre map (provider switch) Switching the map provider tears down the MapLibre map via map.remove(), after which its layers and sources are gone and getLayer/removeSource/off throw. The layer/source cleanup effects ran unconditionally on unmount, so switching Google<->MapLibre threw inside a cleanup and blanked the React tree. Guard cleanup with isMapRemoved() (MapLibre's internal _removed flag, no public equivalent) and skip teardown on an already-removed map. Completes an in-progress fix the implementing agent had browser-verified but could not commit (tooling outage). Green: 335 tests, 0 lint errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Docs: MapLibre provider added + verified; record follow-ups Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Docs: MapLibre interaction spot-check passed Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Wind table: Beaufort color dot next to each speed (read-only view) Reuses core beaufortColor (already used for map wind arrows) so the table and map share one scale. Shown in the locked/read-only table — the common case; editing rows is unchanged. Browser-verified: green at 7 kts through orange at 17+. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Rename crab angle -> drift angle in the UI (owner's term) User-facing strings only: Settings 'Show drift angle arrows' + tooltip, and the map point tooltip 'Drift angle:'. To avoid two 'Drift' meanings in one tooltip, the existing wind-drift line is relabeled 'Wind drift:'. Internal names (showCrabArrow, crabAngle) kept to skip a settings migration. Backlog updated (rename done; NWS attribution already present; ground speed deferred). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Backlog hygiene: mark items delivered in Phases 1-5 The list had gone stale — offset bug, input limits, interpolation wrap, elevation cache, prefetch, model info, soundings, station discovery, ground-wind de-coupling, modes and PWA all shipped but were still open. Marked with commit refs and what actually landed. 'Initiation altitude not saved' could not be reproduced (feature is live and persisted) — flagged as needing a concrete repro or closure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix place-autocomplete listener/DOM leak on re-attach attachPlaceAutocomplete returned void and registered listeners with no way to detach: Google left a live place_changed listener behind, and the MapLibre/Photon geocoder appended a fresh dropdown <ul> plus input/blur listeners on every call. MapSearchBox's effect had no cleanup and depended on onPlaceSelected, which callers do not memoize (selectLocation is recreated each render in useTargetContext) — so both stacked up on every render. Both implementations and the dispatcher now return a disposer (the lazy MapLibre branch is cancel-safe if disposed before its chunk resolves), and MapSearchBox ref-stabilizes the callback so it re-attaches only when the provider changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Keep the heading handle a constant screen distance from the target The heading handle sat at a fixed 15m from the target, which is only ~14px at zoom 17 near 28N — smaller than the two handles' combined radii, and it shrinks further as you zoom out. With the heading handle also above the target in z-order, it swallowed the target's drags. Add core/geometry metersPerPixel(lat, zoom) (Web Mercator, 256px tiles) and place the handle at a fixed 40px offset converted to meters for the current zoom, so the gap no longer depends on zoom. Swapping z-order alone would only have inverted the problem. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Wind table: stop clipping typed values in the editable rows Three number fields with spinners plus a remove button share the panel width; MUI's default cell and input padding left too little room, so e.g. a five-digit altitude was clipped. Trim cell/input padding (edit state only) and give the fields a minWidth. Browser-verified: 13500 now fits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Drop the redundant KZPH supplement from ZHills Verified against the live NWS API: gridpoints/TBW/82,110/stations returns KZPH as the nearest of 51 stations, so listing it under the dropzone's nearbyStations only forced a duplicate by-id fetch that discovery already covered. Also confirmed KM08 (Bolivar) is still absent from its gridpoint list, so the supplement mechanism itself stays. The supplement tests used ZHills as their example; repointed them at Bolivar/KM08 (which genuinely needs a supplement) and added a case pinning that a dropzone without supplements does no by-id lookup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Wind panel: name the sounding station, badge manual winds The sounding badge showed meta.station (the raw id, e.g. '_TBW') even though the source already captures meta.stationName — prefer the name and fall back to the id. Browser-verified: now reads 'Sounding · Tampa Bay Area -- KTPA KTBW (39 mi) · launched Jul 16, 07:00 AM'. Manually entered winds had no badge at all (only the OpenMeteo and sounding branches rendered one), so the source line silently vanished; add a 'Manually entered' caption for consistency. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Hide the forecast-time picker when soundings are selected A radiosonde profile is whatever was last launched, so the sounding source ignores hourOffset — the time picker sat above it doing nothing. Key the picker off the selected aloft source (not the fetched profile's) so it disappears as soon as soundings are chosen, before any fetch. The launch time is already shown in the sounding badge. Browser-verified: hidden for soundings, returns for model forecast. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Mode picker: give the cards accessible names The card text is laid out as separate nodes, so each CardActionArea was an unnamed button to a screen reader. Add aria-label from the mode's label + description, marking the stub modes as coming soon. Browser-verified: all four cards now expose names, disabled state included. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Wind panel: link out to Windy for the current target A second opinion on the same spot, using Windy's ?lat,lng,zoom deep link (format verified to resolve). Opens in a new tab with noopener/noreferrer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Select numeric field contents on focus These fields are retyped wholesale rather than edited in place, so landing in one with the caret appended to the old value meant clearing it first. Select on focus in NumberInput (pattern/manoeuvre/settings) and in the three editable wind-row fields. Verified by spying on select(): focusing a field invokes it. (Number inputs do not expose selectionStart, so the selection itself is not readable.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Link the sounding station to its IEM page Soundings were the only fetched wind source with no way to see more about where the data came from (observed stations already link out). Link the station name to IEM's RAOB station page, keyed on the station id the source already carries. URL verified before wiring: networks.php?station=<id>&network=RAOB returns 200 for both the '_TBW' and 'KTBW' id forms. (Earlier candidates — IEM /raob/ and the University of Wyoming cgi-bin sounding endpoint — 404.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Move initiation-altitude scaling into core; drop dead setManoeuvreAltitude setManoeuvreAltitude had no production callers: the feature it once served (ManoeuvreAltitudeControl -> initiationAltitudeOffset) is implemented by different code that supersedes it — offset-based, clamped to +/-15%, and immutable, where the old function mutated in place with no clamp. That live logic sat inline in useAppState's computeManoeuvre, i.e. pure domain maths inside a React hook, against the core/ dependency rule. Extract it as core/manoeuvre applyInitiationAltitudeOffset (+ MAX_INITIATION_OFFSET_ FRACTION), have the hook call it, and delete the dead function. Also guard the originalInitAlt === 0 divide-by-zero the inline version could hit. Replaces the dead function's tests with six covering the real path: scaling up/down, clamping, immutability, no-ops, zero-altitude. Verified in the running app: +100 -> 1100, +9999 clamps to 1150, input unmutated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Backlog: record solo-batch progress and the codec coupling Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Versioned codecs for custom locations and stored tracks Moves the last two unversioned localStorage documents onto createVersionedCodec: `flip.custom_locations` (CustomLocationsComponent) and `flip.manoeuvre.track.tracks` (ManoeuvreTrackComponent). Their element types move to types/index.ts (CustomLocation, StoredTrack) so core/model.ts can own the loaders without depending on components. Loader decisions (mirroring migrateCustomCourses): - Both key on `name`, which is the UI's identity for an entry, so entries without a non-empty name are dropped — they could never be selected. - Locations without finite lat/lng are dropped; coordinates are clamped and `direction` defaults to 0 / is normalized. - Tracks whose points migrate to an empty path are dropped rather than kept as a selectable entry that draws nothing; `track` accepts the legacy {lat,lng,...} point format via migrateToFlightPath, and `description` defaults to ''. Legacy bare arrays (what is in users' browsers today) still load: the codec passes an envelope-less document to migrate() as version 0. Verified: 369 tests pass (was 343), lint 0 errors / 50 warnings (unchanged), build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Validate the stored Locations tab; audit the simple string keys Audit of every remaining non-versioned useLocalStorageState key. Toolpad's default codec for these is CODEC_STRING (identity), so the stored value is a bare string and parsing can never throw — the only failure mode is a value the UI doesn't expect. - `flip.location.tab` — left a plain string, given a validating fallback. Versioning it buys nothing (a 3-value enum of UI ephemera), and would actively cost: the stored value is a bare `dropzones`, which JSON.parse rejects, so wrapping it would reset every existing user's tab. The real bug is that an unrecognized value matched no `selectedTab === ...` branch and rendered an empty panel with every tab unselected; it now falls back to Dropzones. - `flip.courses.selected`, `flip.presets.active` — left alone. They are ids whose validity is relative to a separate, user-mutable list, so no static loader can judge them; a stale id already degrades to "nothing selected" (consumers `.find()` and fall back to undefined/null), which is graceful and self-correcting. - `flip.mode` is already versioned (migrateModeId). Verified: 369 tests pass, lint 0 errors / 50 warnings (unchanged), build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Remove the dead createSafeCodec and createSimpleCodec Both are now callerless: createSimpleCodec's only two callers moved to createVersionedCodec in 7f2de97, and createSafeCodec was already dead (only its own doc comment named it). deepMerge goes with them — it was createSafeCodec's private helper and has no other user. createVersionedCodec and CODEC_JSON stay. Note that CODEC_JSON has no production callers either, only its own tests; leaving it as instructed rather than widening the cleanup. Verified: 369 tests pass, lint 0 errors / 50 warnings (unchanged), build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Backlog: item 7 done (versioned codecs + codec cleanup); record follow-ups Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Docs: add HANDOFF.md as the redesign entry point A new session needs one place that says where things stand, what is next, which rules are non-negotiable, and which traps to avoid. NOTES.md had grown into a per-phase log that answers 'why', not 'what now', so add HANDOFF.md and point CLAUDE.md and NOTES.md at it. Records the hard rules (no push / deploy / flip-next), the working agreements that earned their keep (commit every green slice; verify before building; do not fix phantoms), the environment gotchas (VITE_ env prefix, SW only in prod builds, flaky browser automation, one driver at a time) and the settled decisions not to re-litigate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Backlog: record 2026-07-16 architecture review findings and accepted features Review verdict: architecture sound; weak spots recorded as a new 'Architecture-review follow-ups' section (error surface, useWinds extraction, core/ layering leftovers, component tests, settings layering, track-scale rendering). Owner-accepted feature additions: wind time scrubber, persist winds + staleness banner, model/sounding comparison view (extended per owner), replay animation, side profile view (needs concept demo), live GPS mode, DZ wind climatology, METAR/TAF (scope vs stations open). Notes added to existing items: share-links encoding proposal, reach-before- flocking ordering, flocking parked pending owner input. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Surface wind-fetch errors; keep previous profile on failure A failed forecast fetch used to reset the wind table to an empty profile with only a console.log. Now the previous profile is kept, the hook exposes an error state, and a new app-wide NotificationsProvider (MUI Snackbar + Alert) shows the failure to the user. Verified via tests, lint, build (no browser in this environment). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Backlog: error-surface item done (cf21e1e) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Extract the useWinds facade from App.tsx Move the wind orchestration out of App.tsx into hooks/useWinds: forecast fetching, observed stations, their composition into the effective profile, the forecastTime/observed-reset coupling, the fetch-error notification, and the wind summary construction (now memoized via makeWindSummary). Pure refactor, no behavior change. Also render CustomAppTitle as an element instead of calling the component as a plain function in the appTitle slot. npm test (378), lint (0 errors, 50 known warnings) and build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Pin pathStats behavior with tests before moving it into core/ Covers per-leg stats (alt/time/heading/bearing/distance/glide), the point-to-segment map, wind drift from pre/post-wind shifts, manoeuvre stats (bearings, depth/offset projections), and getPointSegmentStats lookups. Tests lead the refactor per the working agreement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Move pathStats and courses from util/ into core/ Both are pure logic (turf + shared types only) that predated the core extraction; this completes the layering for them. Mechanical move plus import-path updates — no code changes. Tests, lint and build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Dedupe the drift-angle formula and share PointData from core The heading-vs-bearing fold was written out twice in FlightPathsLayer (tooltip row and arrow gate); both now use core/pathStats.driftAngle, with tests covering the wrap across north. FlightPathsLayer's local PointData copy is replaced by the one core/pathStats now exports. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Backlog: useWinds extraction and core layering done Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add React Testing Library smoke tests for hooks and panels First component/hook-level tests (everything so far was pure logic): usePresets round-trip against localStorage (create/load/update/rename/ delete, persistence across a remount) and PatternComponent rendering + input clamping (out-of-range values never propagate raw; clamped on blur). Tests opt into jsdom per-file so the pure suites keep the node environment. New devDependencies (pre-approved for this item): @testing-library/react, @testing-library/jest-dom, jsdom. The route guard was already covered in app/routing.test.ts including nav-list filtering, so no extension was needed there. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Backlog: component/hook tests done (19bf4f5) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Settings layering: explicit touch tracking replaces the defaults heuristic applyModeDefaults previously applied a mode default wherever the stored value equaled the global default, which made it impossible to force a mode-overridden setting back to the global default (documented trap). Settings keys the user has explicitly changed are now tracked in a persisted, versioned 'flip.settings.touched' document: setSettings marks every key whose value changed (the Settings panel is the only writer), resolution becomes touched-always-wins / untouched-takes-mode-default, and resetAll clears the list. Pre-tracking users are seeded with every key whose stored value differs from the global default, reproducing the old behavior exactly until their next explicit change. Covers: core migrate/seed tests, applyModeDefaults tests including the force-back-to-default case, and hook-level tests for marking, seeding, persistence across remount and resetAll (396 tests total). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Remove the dead CODEC_JSON Its only references were its own tests (kept alive earlier only by task scope). The versioned codec is the sole codec in use. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Backlog: settings layering + minors done; review section complete All architecture-review follow-ups are now closed except the two deliberately deferred: track-scale path rendering (Phase 7) and the accepted openmeteo prefetch singleton. Cross-referenced entries updated (Phase-1 CODEC_JSON, Phase-3 mode-defaults heuristic, Phase-4 KZPH). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Jump to course: pan map to a course when selected Built-in canopy-piloting courses are geographically anchored (e.g. Skydive Arizona); selecting one far from the target used to render nothing visible. The map camera now pans to the course center on selection and back to the target on deselection. Mechanism: App derives a mapCenter state (follows the target; jumps on course-selection *changes*, so a persisted selection does not hijack the initial view). MapComponent gained a separate cameraCenter prop because its center prop also anchors the stations/ground-wind layer, which must stay at the target. Both map providers already pan only when the camera center changes, so free drag is unaffected. Browser-verified (Google provider): selecting "Skydive Arizona: Distance" from the default Florida target pans to Eloy, AZ (32.808,-111.582); deselecting pans back to ZHills; drag unaffected. Closes the Phase-2 "jump to course" and Phase-3 "selecting a course doesn't pan" follow-ups. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Improve map tooltip contrast over satellite imagery The shared tooltip style was 11px white on 85%-alpha black, which reads poorly over dark satellite imagery (and the box itself melts into dark terrain). Now: near-opaque background (rgba(10,10,10,0.92)), a hairline white border so the box separates from dark imagery, 12px body text, stronger shadow, and a brighter secondary style for the coordinate line (new TOOLTIP_SECONDARY_STYLE, replacing the ad-hoc 10px #aaa). Applies to all consumers of TOOLTIP_STYLE: point/leg/manoeuvre tooltips in FlightPathsLayer and station tooltips in StationsLayer. Inline styles render inside the map overlay portal, so both app UI themes see the same (readable) result. Browser-verified on the point tooltip (Google provider, satellite); leg-stat tooltips share the same container/row styles but automation hovers could not land precisely on a POM (known flakiness). Closes the Phase-2 "leg tooltip body rows low-contrast" follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Wind table: per-row source indicators Rows have carried source metadata since Phase 4 (open-meteo / sounding / station id / none-for-manual) but the table never showed it — the ground row injected from an observed station was indistinguishable from forecast rows. The read-only (locked) table now shows a subtle leading icon per row: - green sensors icon for an observed-station row, tooltip with the station id and observation age; - muted cloud icon for forecast rows (OpenMeteo or sounding, per tooltip); - muted pencil icon for manually entered rows (possible in a locked table when a manual aloft profile is composed with observed ground). The edit-mode table is unchanged (everything there is editable/manual, and the row is already cramped). Classification is a new pure helper, core/wind.windRowSourceKind, covered by tests (+4). Browser-verified: after a fetch at ZHills, the injected NWS ground row shows the green sensors icon; the OpenMeteo rows show cloud icons. Closes the ARCHITECTURE §2 per-row provenance ask (owner item 19). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Show ground speed in the map point tooltip New core/pathStats.groundSpeedKts (tested): ground speed at a path point from a centered difference over its two adjacent points (the prev-to-next segment), falling back to the single available neighbor at the path ends; null for degenerate inputs. Centered differencing was chosen over per-segment speed so the value at a point is not biased toward whichever side of it the segment boundary falls on. FlightPathsLayer threads the full path plus the user's wind-speed unit formatter (useUnits.formatWindSpeed) into the point tooltip, which now shows "Gnd speed" between Time and the coordinates. Works on both the wind-corrected and pre-wind paths. Browser-verified: hovering a base-leg point showed 23.2 kts; switching the wind-speed unit setting to m/s changed it to 11.9 m/s (same speed, converted), confirming the unit preference is respected. This was deferred twice because the tooltip only received an altitude formatter; closes the "ground speed in point hover popup" backlog item (owner item 14). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Show cumulative turn in the manoeuvre point tooltip New core helpers (tested): - headingDeltaDeg: signed shortest-arc heading change in [-180, 180), positive = clockwise, 350->10 is +20; - cumulativeTurnDeg: per-point cumulative signed turn along a path, summing per-segment shortest-arc deltas so a full 270 turn reads ~270 at the end instead of its 360-wrapped remainder; duplicate points (e.g. epsilon segments) carry the total forward instead of producing garbage bearings. FlightPathsLayer builds an original-index -> turn map over the manoeuvre-phase points (ordered by time, i.e. from initiation) for both paths, and the point tooltip shows "Rotated: N° left/right" on manoeuvre points (hidden below 1°). Browser-verified with the "Sample 90" manoeuvre: a point 2 s after initiation showed "Rotated: 4° right" (initial GPS wobble), the end of the turn "Rotated: 87° left" — consistent with a gentle 90° left turn. Closes the "degrees rotated in map hover" backlog item (owner item 15), the last of the five owner-agreed quick items. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Persist winds across reloads + staleness indicator Winds lived in useState only, so a reload lost both manual edits and the fetched forecast. The current WindProfile now persists under the versioned localStorage key flip.winds: - core/model.migrateStoredWinds (tested): validating loader that revives the profile's Dates (validTime, per-row validTime, meta.fetchedAt) from their JSON ISO-string form, clamps/defaults invalid fields, drops unusable rows, never throws, and returns null for garbage so "nothing stored" is distinguishable from a profile. - useFetchForecast stores the profile via useLocalStorageState + createVersionedCodec (same pattern as the other persisted docs); resetWinds clears the key. Manual profiles persist too. Staleness: the wind panel's source badge (OpenMeteo and sounding) gains "· fetched N min ago", ticking once a minute; past 30 minutes it turns warning-colored and shows an inline "refresh" link that forces a fetch (bypassing the prefetch cache). Manual profiles show no age. fetchedAt is the profile's meta.fetchedAt, i.e. when the data was actually retrieved (the OpenMeteo prefetch cache can serve an already-minutes-old window, which the age honestly reflects). Browser-verified: fetch -> reload restores the full table with the badge and age; unlock -> edit a row -> reload restores the manual profile including the edit. The >30 min warning/refresh path is logic-only verified (cannot wait 30 min in a session). Observed-station ground injection is runtime composition and is deliberately not persisted: after a reload the raw forecast ground row shows until the next fetch discovers stations again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Wind time scrubber over the prefetched forecast window A horizontal hour slider under the forecast-time picker: dragging it steps through the hours already sitting in the OpenMeteo prefetch cache, so the wind table and the map paths morph live with no network traffic. - data: new openmeteo.prefetchedWindowHours(point, model) reports how many hours from "now" the cached window still covers, applying exactly the freshness rules the fetch itself uses (TTL, model, location) so the scrubber never promises a local hour that a real fetch would re-request. Tested (null before fetch/after reset/on TTL expiry/for other model or distant location; shrinks as the clock advances). - hooks: useWinds exposes scrubHours, re-derived after every fetch (the cache is module state; the profile is the reactive proxy). - UI: MUI Slider (0 .. scrubHours-1, "now"/"+Nh" value labels), shown only when a fetch has filled the cache and the aloft source is an hourly model. It composes with the picker as the fast path: each step calls the same onForecastTimeChange + fetch pair the picker's +/-1h buttons use, so a non-now hour clears observed-station injection identically. Hidden for soundings together with the picker (the Phase-4 "picker inert for soundings" follow-up was already handled by hiding the picker; marked done). Browser-verified with window.fetch instrumented: scrubbing 0 -> +12h replaced the whole profile (valid time +12h, directions 280->010) with zero fetch() calls; scrubbing back to "now" kept the wind fetch local and re-fired only NWS station discovery, exactly like pressing "now". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Model/sounding comparison view — first pass core/windCompare: sample every profile on a common 500 ft ladder using the app's own vector interpolation (prepWind + getWindAt); below a profile's lowest row the lowest row applies, above its highest row the cell is null — sparse sources (ECMWF's 5 levels) compare on honest terms. Disagreement thresholds are named, exported constants (>15° / >5 kts; directions of <3 kt winds are noise), all tested. Fetch side: fetchOpenMeteoComparison serves from the prefetch window when it matches but never stores, so a comparison sweep cannot evict the cached window that hour-switching and the scrubber rely on. Soundings included when a station is available. UI: a compare toggle in the wind panel opens a read-only side-by-side (per-altitude arrows + speeds per source, disagreeing bands highlighted); the active profile driving the flight path is untouched. First pass — visualization design open for owner iteration (noted in BACKLOG). Committed on the agent's behalf after verifying: 440 tests, lint 0 errors/50 known warnings, build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Flocking core math: path, into-wind, drift vectors, FWC spot description Port of the Flocking Wind Calculator's Drift.kt math into src/core/flocking.ts, pure and tested (20 tests; 440 -> 460): - makeFlockingPath: no-wind descent path at the origin, point[0] = end of jump (windowBottomFt, time 0), later points backward in time up to the exit at windowTopFt, 1 s steps like makePattern. Deviation from FWC (which has no path concept): POM points are inserted exactly at round altitude multiples so map labels read 5000/6000/... cleanly; the interval is a parameter (1000 ft default, ~250 m for metric users). - intoWindDirection: wind applied to a zero-horizontal-speed descent; the bearing end -> corrected exit is the into-wind jumprun. Calm winds return 0 (stable, arbitrary). - flockingVectors: the FWC "Wind drift / Canopy flight / Combined" block from the positioned ideal+corrected path endpoints. - spotDescription: FWC's projection math line-for-line, including its left/right convention (which inverts the geometric side for PAST exits - kept for parity, noted in a doc comment). Deviation (owner-approved): wind application reuses FliP's addWind (vector-interpolating) rather than FWC's per-forecast-level stepwise sum; the closed-form parity test asserts agreement within a few percent on the FWC default window (12k -> 4k ft, 21 mph descent, 50 mph horizontal, uniform 20 kt wind). npm test 460 green, lint 0 errors / 50 known warnings, build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Flocking params document: FlockingParams + migration and limits FlockingParams lives in core/flocking (like MakePatternParams in core/pattern): altitude window top/bottom in plain feet (FWC uses kft), descent + horizontal speed in mph, direction as cardinal degrees or 'into-wind' (replacing FWC's -1 sentinel), distance unit mi/nm/km (FWC has mi/nm; km added), and an optional pinned reference point C. - DEFAULT_FLOCKING_PARAMS in core/model: FWC's defaults (12000/4000 ft, Flow 21/50 mph, into-wind, mi, no reference point). - migrateFlockingParams: validating loader, never throws; clamps via new LIMITS entries (flockingAltitudeFt 0..30000, flockingDescentRateMph 1..100, flockingHorizontalSpeedMph 0..150 - XRW's 40/70 fit), drops invalid reference points, normalizes numeric directions. - 13 new tests in core/model.test.ts (garbage table + field cases). npm test 473 green, lint 0 errors / 50 known warnings, build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Flocking mode live: mode wiring, derivation hook, panel - modes: 'flocking' enabled (picker card, toolbar switcher and ?mode= links all work off enabled flags); nav [flocking, target, wind, settings, about]; map layers [flocking, stations, targetEdit, windArrow] (the flocking layer itself lands in the next commit). 'flocking' added to PANEL_IDS and MAP_LAYER_IDS; swoop deliberately does not gain the flocking panel/layer (tests updated). - state: flockingParams persisted under versioned key flip.flocking.params in useAppState; included in resetAll. - hooks/useFlockingPath: memoized derive pipeline - resolve into-wind from the wind profile, makeFlockingPath (POM interval 82…
1 parent 860302c commit 5eb78ba

244 files changed

Lines changed: 50482 additions & 21023 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
name: add-persisted-field
3+
description: Add a field to a persisted FliP document (Settings, FlockingParams, PatternParams, a new store, ...) through the versioned-codec pipeline. Use when adding any user-facing option that must survive a reload.
4+
---
5+
6+
# Add a persisted field to FliP
7+
8+
Every persisted document goes through a **versioned codec**
9+
(`util/storage.ts createVersionedCodec`) whose `migrate*` loader lives in
10+
`core/model.ts` and **must never throw** — old/corrupt localStorage has to
11+
degrade gracefully. Skipping any step below breaks a test; do them all.
12+
13+
## Adding a field to an EXISTING document (the common case)
14+
15+
1. **Type** — add the field to the interface in `core/` (e.g.
16+
`FlockingParams` in `core/flocking.ts`, `Settings`/`UnitPreferences`,
17+
`PatternParams`).
18+
2. **Default** — add it to the `DEFAULT_*` constant in `core/model.ts`.
19+
Distances persist in **statute miles**; express nm/km defaults via
20+
`displayToMiles(x, 'nm')`, not a magic number.
21+
3. **Migration** — add the field to the matching `migrate*` in
22+
`core/model.ts`, using the existing helpers so garbage never throws:
23+
- number in a range → `limitedNumber(r.x, d.x, LIMITS.xxx)`
24+
- free number → `finiteNumber` / `normalizeDirection`
25+
- enum/string set → `oneOf(r.x, ALLOWED, d.x)`
26+
- boolean → `booleanOr(r.x, d.x)`
27+
- string → `stringOr(r.x, '')`
28+
- nested/array → a dedicated `migrateX` that filters bad entries.
29+
4. **Limits** — if it needs bounds, add a `LIMITS.xxx` entry in
30+
`core/validation.ts`.
31+
5. **Tests** — in `core/model.test.ts`:
32+
- the garbage cases already assert `migrateX(g).toEqual(DEFAULT_X)`;
33+
- the **"keeps valid params"** test does `.toEqual(fullObject)` — you
34+
MUST add your field to that fixture or it fails;
35+
- add a case: valid value kept, out-of-range clamped, missing → default.
36+
6. **Wire the UI** — thread it: `App.tsx` → component props → the control
37+
(usually `NumberInput`/`Switch`/`ToggleButton`). Direction fields use
38+
`NumberInput`'s `wrap={360}`; ring/offset fields round with
39+
`roundDist(.., unit, 2)`.
40+
41+
## Adding a WHOLE NEW persisted store
42+
43+
Also do, in `hooks/useAppState.tsx`:
44+
45+
7. `const [storedX, setStoredX] = useLocalStorageState<X>('flip.x', DEFAULT_X,
46+
{ codec: createVersionedCodec(SCHEMA_VERSION, migrateX) });`
47+
then `const x = storedX ?? DEFAULT_X;` (wrap in `useMemo` if it's an
48+
object/array read by a `useCallback`, or lint flags the dep).
49+
8. Add `x` + its setter to the context **interface**, the value **memo**
50+
(both the object and its dependency array), and clear it in `resetAll`.
51+
9. If the codec's stored type includes `null` (a "never set yet" state),
52+
widen the codec generic — and if a parenthesized `keyof` generic trips
53+
the `indent` lint rule, hoist it to a `type` alias first.
54+
55+
## Gotchas seen repeatedly
56+
57+
- Adding to the type but not the default/migration → a `tsc` "missing
58+
property" error or a failing `.toEqual` test. Add all three together.
59+
- `SCHEMA_VERSION` does not change for an additive field — the migration
60+
fills the default for old envelopes.
61+
- A field only relevant to one mode still persists in the one shared
62+
document; gate its *use*, not its storage (e.g. flocking corridors).
63+
64+
## Verify
65+
66+
`npm test`, `npm run lint` (0 errors, ≤ 50 warnings), `npm run build` all
67+
green before committing. Commit the slice immediately (per the branch's
68+
working agreement).
69+
70+
### Regenerating golden values
71+
72+
If a core-math change shifts pinned test numbers (as the `addWind` fix
73+
did), don't hand-edit them: write a throwaway `*.test.ts` that runs the
74+
pipeline and `console.log`s the new values, paste them in, delete the
75+
scratch test. Same trick works for any large pinned fixture.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
name: verify-flip
3+
description: Verify a FliP change actually works, given this repo's real browser-automation limits. Use before committing anything with a runtime surface, especially map/flocking UI.
4+
---
5+
6+
# Verify a FliP change
7+
8+
The always-run gate: `npm test`, `npm run lint` (0 errors, ≤ 50 known
9+
warnings), `npm run build` — green before every commit.
10+
11+
Beyond that, **prefer a unit or hook test of the actual contract over a
12+
browser check.** The pure core (`core/`) and the derive hooks
13+
(`useFlockingPath`, `usePresets`, `useAppState`) are directly testable
14+
with Vitest + RTL `renderHook`; a targeted test proves more, faster, than
15+
driving the UI.
16+
17+
## The falsifiability rule (the important one)
18+
19+
**When an automated check passes, ask: would it have FAILED before the
20+
change?** If not, it proved nothing.
21+
22+
To find out: stash the change, re-run the exact same check on the
23+
pre-change code. Identical behavior ⇒ the check is worthless, and a
24+
"pass" is a false positive. This session, a browser check appeared to
25+
confirm a click-to-move fix; the old code behaved identically, because
26+
automated map clicks never reach the handler at all. It was re-verified
27+
with a unit test of the contract (`map/layers/TargetEditLayer.test.tsx`
28+
note the `vi.mock('..')` that stubs the provider-bound primitives).
29+
30+
## Browser automation limits here (measured, not guessed)
31+
32+
Reliable: DOM queries, `javascript_tool`, screenshots, reading
33+
`localStorage` to confirm a state write, `read_page` refs for clicking
34+
real DOM buttons.
35+
36+
Unreliable / broken:
37+
- Coordinate clicks frequently do **not** reach the Google Maps click
38+
handler (so "clicked the map, nothing moved" proves nothing).
39+
- Synthetic drags do **not** drive the map drag handles.
40+
- Wheel-zoom can hang the tooling.
41+
- `read_page` sometimes reports a 0x0 viewport on panel routes.
42+
- Reading an input's value synchronously after dispatching an `input`
43+
event shows the pre-React value — `await` a tick first.
44+
45+
Testing UI state via localStorage: seed `flip.mode`, `flip.winds`,
46+
`flip.flocking.params` etc. as `{schemaVersion:1, doc:{...}}` envelopes,
47+
`location.href='/flocking'`, then read the panel via `data-testid`
48+
(`flocking-spot`, `flocking-miss`, `flocking-deviation`) or
49+
`input[aria-label=...]`, and read the params back out of localStorage to
50+
confirm writes.
51+
52+
## Never yet exercised by a real pointer
53+
54+
These pass by unit test + DOM inspection but no automated drag can drive
55+
them — flag for the owner rather than claim verified:
56+
57+
- Flocking free mode: the jumprun 2-D **move** handle, the **rotate**
58+
handle, the canopy-rotate handle at the flight's end.
59+
- The **Spot Reference** drag (dragging pins it).
60+
- The flocking target drag (click-to-move is deliberately off there).
61+
62+
## PWA
63+
64+
The service worker only exists in a production build — verify offline/PWA
65+
behavior with `npm run build && npm run preview`, not the dev server.

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
REACT_APP_GOOGLE_MAPS_API_KEY=your_google_maps_api_key_here
1+
VITE_GOOGLE_MAPS_API_KEY=your_google_maps_api_key_here

.eslintrc.js

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
module.exports = {
2+
"root": true,
23
"env": {
34
"browser": true,
45
"es2021": true
@@ -8,28 +9,89 @@ module.exports = {
89
"plugin:react/recommended",
910
"@jitsi/eslint-config"
1011
],
11-
"overrides": [
12-
],
13-
"parser": "@babel/eslint-parser",
12+
"parser": "@typescript-eslint/parser",
1413
"parserOptions": {
1514
"ecmaVersion": "latest",
1615
"sourceType": "module",
17-
"requireConfigFile": false,
1816
"ecmaFeatures": {
1917
"jsx": true
20-
},
21-
"babelOptions": {
22-
"presets": ["@babel/preset-react"]
2318
}
2419
},
2520
"plugins": [
26-
"react"
21+
"react",
22+
"react-hooks",
23+
"@typescript-eslint"
24+
],
25+
"overrides": [
26+
{
27+
"files": ["*.ts", "*.tsx"],
28+
"extends": [
29+
"plugin:@typescript-eslint/eslint-recommended",
30+
"plugin:@typescript-eslint/recommended"
31+
],
32+
"rules": {
33+
// Warn-only for now; tighten during Phase 1 refactors
34+
"@typescript-eslint/no-explicit-any": 1,
35+
"prefer-const": 1,
36+
"react-hooks/exhaustive-deps": 1
37+
}
38+
},
39+
{
40+
"files": ["*.test.ts", "*.test.tsx"],
41+
"env": {
42+
"node": true
43+
}
44+
}
2745
],
2846
"rules": {
2947
"require-jsdoc": 0,
3048
"max-params": 0,
3149
"react/prop-types": 0,
32-
"object-property-newline": 0
50+
"object-property-newline": 0,
51+
52+
// Match the existing 2-space code style instead of reformatting the
53+
// whole codebase (@jitsi config assumes 4-space).
54+
"indent": ["error", 2, { "SwitchCase": 1 }],
55+
56+
// Stylistic rules from @jitsi/eslint-config that the existing code
57+
// doesn't follow; disabled to keep lint useful without a mass reformat.
58+
"array-bracket-spacing": 0,
59+
"padding-line-between-statements": 0,
60+
"no-multi-spaces": 0,
61+
"curly": 0,
62+
"max-len": 0,
63+
"lines-around-comment": 0,
64+
"no-extra-parens": 0,
65+
"import/order": 0,
66+
"operator-linebreak": 0,
67+
"brace-style": 0,
68+
"no-negated-condition": 0,
69+
"arrow-body-style": 0,
70+
"max-statements-per-line": 0,
71+
"sort-imports": 0,
72+
"key-spacing": 0,
73+
"comma-dangle": 0,
74+
"quotes": 0,
75+
"no-continue": 0,
76+
"no-confusing-arrow": 0,
77+
"newline-per-chained-call": 0,
78+
"no-mixed-operators": 0,
79+
"no-bitwise": 0,
80+
81+
// Substantive rules the current code violates: warn for now, clean up
82+
// in Phase 1 refactors (auto-fixing e.g. eqeqeq can change behavior).
83+
"eqeqeq": 1,
84+
"no-eq-null": 1,
85+
"no-shadow": 1,
86+
"no-implicit-coercion": 1,
87+
"dot-notation": 1,
88+
"prefer-const": 1,
89+
"prefer-template": 1,
90+
"no-extra-boolean-cast": 1,
91+
"import/no-duplicates": 1,
92+
"@typescript-eslint/no-explicit-any": 1,
93+
"react-hooks/rules-of-hooks": 2,
94+
"react-hooks/exhaustive-deps": 1
3395
},
3496
"settings": {
3597
"react": {

.github/workflows/static.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,20 @@ jobs:
3333
uses: actions/checkout@v4
3434
- name: Setup Pages
3535
uses: actions/configure-pages@v5
36-
- name: Use Node.js
37-
uses: actions/setup-node@v3
36+
- name: Use Node.js
37+
uses: actions/setup-node@v4
3838
with:
39+
node-version: 24
3940
cache: 'npm'
4041
- run: npm install
41-
- run: npm test -- --watchAll=false
42+
- run: npm run lint
43+
- run: npm test
4244
- run: node scripts/inject-ga.js
4345
env:
4446
GA_ID: ${{ secrets.GA_ID }}
4547
- run: npm run build --if-present
4648
env:
47-
REACT_APP_GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
49+
VITE_GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
4850
- name: Upload artifact
4951
uses: actions/upload-pages-artifact@v3
5052
with:

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,6 @@ yarn-error.log*
2828
*iml
2929
.idea
3030
*.swp
31+
32+
# vite-plugin-pwa dev output
33+
dev-dist

0 commit comments

Comments
 (0)