From 34c6ac90ae2d3e770d31acf6b5159738735d912c Mon Sep 17 00:00:00 2001 From: handfly Date: Sun, 3 May 2026 08:46:16 -0400 Subject: [PATCH] Initial Commit --- .directory | 2 + COMPLETED.md | 68 + DOCUMENTATION.md | 333 ++++ ROADMAP.md | 104 + index.html | 5 + package.json | 19 + src/data/aircraft/G450.json | 498 +++++ src/data/aircraft/G500.json | 18 + src/data/aircraft/G650.json | 27 + src/data/aircraft/G700.json | 109 ++ src/data/reference.json | 680 +++++++ src/main.jsx | 4 + src/toolkit.jsx | 3666 +++++++++++++++++++++++++++++++++++ vite.config.js | 3 + 14 files changed, 5536 insertions(+) create mode 100644 .directory create mode 100644 COMPLETED.md create mode 100644 DOCUMENTATION.md create mode 100644 ROADMAP.md create mode 100644 index.html create mode 100644 package.json create mode 100644 src/data/aircraft/G450.json create mode 100644 src/data/aircraft/G500.json create mode 100644 src/data/aircraft/G650.json create mode 100644 src/data/aircraft/G700.json create mode 100644 src/data/reference.json create mode 100644 src/main.jsx create mode 100644 src/toolkit.jsx create mode 100644 vite.config.js diff --git a/.directory b/.directory new file mode 100644 index 0000000..9dd830b --- /dev/null +++ b/.directory @@ -0,0 +1,2 @@ +[Desktop Entry] +Icon=orange-folder-git diff --git a/COMPLETED.md b/COMPLETED.md new file mode 100644 index 0000000..85d0b58 --- /dev/null +++ b/COMPLETED.md @@ -0,0 +1,68 @@ +# Pilot's Toolkit โ€” Completed Milestones + +*Last updated: February 28, 2026* + +--- + +## ๐Ÿ Release Candidate 1 (RC1) โ€” February 28, 2026 + +Phase I feature set locked. Seven modules, both platforms at parity, all data externalized to versioned JSON (v3 split-file structure with per-aircraft isolation and graceful error handling). No new features planned before Docker deployment (Phase II). + +**Modules:** Fuel Order, Pavement Strength, Crosswind, Fuel Buckets, HF Frequencies, Passdown, Flight Level โ†” Meters +**Platforms:** Web (React/Vite), Android (Kotlin/Jetpack Compose) +**Data:** `reference.json` + `aircraft/{G450,G500,G650,G700}.json` + +--- + +## Aircraft Data Status + +| Aircraft | Pavement (ACN/PCN) | Pavement (ACR/PCR) | ESWL | Weight Limits | Fuel Buckets | +|----------|-------------------|-------------------|------|---------------|-------------| +| G450 | โš  Updated data pending | โš  Updated data pending | โœ… Formula | โœ… 42,000 / 74,600 / 75,000 | โœ… Built-in | +| G500 | โŒ Not started | โŒ Not started | โŒ Not started | โŒ Not started | โŒ Not started | +| G650 | โŒ Not started | โŒ Not started | โœ… Formula | โœ… 52,600 / 99,600 / 100,000 | โŒ Not started | +| G700 | โœ… Complete (ยฑ2%) | โœ… Complete (ยฑ2%) | โœ… Thresholds (S-85 / D-108) | โœ… 56,000 / 107,600 / 108,000 | โŒ Not started | + +*Weight limits shown as: BEW / MTOW / Max Ramp* + +--- + +## Phase I โ€” Web & Android Apps + +### Web (React/Vite) + +- **Fuel Order Calculator** โ€” ASTM D1250 Table 6B density correction replaces previous linear interpolation. Fuel density input at 15ยฐC reference temperature with three-way unit toggle: specific gravity (SG), kg/L, or lb/US gallon (default) โ€” values auto-convert when switching units. Default 0.810 SG / 6.76 lb/gal (typical Jet-A) so calculator works without a ticket in hand. Ambient temperature corrects the reference density using the ASTM thermal expansion coefficient and volume correction factor (Kโ‚€=346.4228, Kโ‚=0.4388 for refined products). Results show both reference density (15ยฐC) and corrected density at ambient temp in lb/gal. Jet-A spec range validation (0.775โ€“0.840 SG). Outputs in US gallons, imperial gallons, and liters, all rounded up to nearest 10. Primary volume unit configurable in hamburger menu with locale-based auto-detection (US โ†’ US gal, UK/Caribbean Commonwealth โ†’ imp gal, everyone else โ†’ liters). ยฐC/ยฐF toggle with live value conversion on switch (integer rounding). Temperature defaults to ยฐC (aviation standard). Auto-calc from Total Required minus On Board. Density display. Temperature warnings adjusted: danger below โˆ’40ยฐC (freeze point), warning above +55ยฐC. Fully supersedes the original `fuelorder.cpp` standalone calculator (retired). +- **Pavement Strength Module** โ€” Full PCN/PCR system selector (PCR default) with ACN/ACR output, rigid and flexible pavement types, four subgrade categories (Aโ€“D). Dynamic "Airport PCN/PCR" label. Airport comparison banner (green pass / red exceed with percentage). Partial availability support for aircraft with ESWL-only or threshold-only data. +- **Crosswind Calculator** โ€” Combined wind direction/speed input (xxxxx format, auto-slash on blur), separate optional gust field, runway input accepting both runway number (01โ€“36) and heading (010โ€“360). Headwind/tailwind and crosswind components for sustained and gust winds. Tailwind values in red. +- **Fuel Buckets** โ€” Slider-based stage length lookup with interpolated fuel burn. CSV import for operator-specific data via hamburger menu. Per-aircraft localStorage persistence. Named fuel profiles (see below). +- **Named Fuel Profiles** โ€” Fuel bucket schedules are now named per operator/aircraft (e.g., "FLEXJET G450"). Both web and Android. CSV import prompts for a profile name (web: browser prompt, Android: defaults to filename). Profiles exportable as JSON files (`FUEL_PROFILE_name_aircraft.json`) with format identifier, aircraft ID, name, and bucket data โ€” designed for sharing between crew members and devices. JSON import reads the embedded aircraft ID and name, targeting the correct aircraft automatically. Hamburger menu shows loaded profiles with name, aircraft, entry count, export/share button, and clear button. The FuelBucketsModule displays the active profile name. Backward-compatible: old anonymous bucket arrays auto-migrate to `{ name: "Custom", buckets: [...] }` on load. Android uses SharedPreferences storage (fuel_profiles), web uses localStorage. +- **HF Frequencies** โ€” ARINC Atlantic/Pacific page age check via "Valid from" HTML parsing with fallback URL chain (tries each URL in order, stops at first valid response). Atlantic uses end-of-window time for staleness ("Frequencies Valid Until"). Pacific uses start time ("Frequencies Valid From"). 4-hour staleness warning. Date mismatch detection (accepts today/yesterday UTC to handle timezone boundary). Direct link to open ARINC page in browser (points to whichever URL succeeded). +- **Flight Level โ†” Meters** โ€” Two sub-modes: Meters โ†” Feet lookup (OEM Table 15, 107 entries from 300 m / 1,000 ft through 15,100 m / 49,500 ft) with type-ahead search in either direction and exact match highlighting; and China RVSM FLAS (Figure 5, 50 entries) with dual eastbound/westbound toggle filter (both on = all levels, one off = filtered), RVSM level color coding (blue = eastbound 0ยฐโ€“179ยฐ, amber = westbound 186ยฐโ€“359ยฐ), and operational notes. Both platforms. Source: GVIII-G700 Operating Manual 06-10-00, 2024-03-29. +- **JSON Data Extraction** โ€” All data externalized from inline code into JSON files shared across platforms. Version 3 split-file structure: `reference.json` (airframe-agnostic: meters-to-feet table, China FLAS table, pavement subgrade labels) and per-aircraft files (`aircraft/G450.json`, `G500.json`, `G650.json`, `G700.json`) each containing weight limits, ESWL config, PCN/PCR coefficients, fuel buckets, and wind limitations placeholder. Graceful error handling: each aircraft file loads independently โ€” a malformed file is skipped with a log warning rather than crashing the app. Android uses `DataLoader.init(context)` parsing from `assets/`; web uses Vite static JSON imports with per-aircraft `loadAircraft()` wrapper. ESWL uses `type` discriminator (`formula` vs `thresholds`). Both platforms refactored and build-tested. +- **Passdown Module (Round 1)** โ€” Crew passdown form: crew emails (PIC, SIC, Lead CA) at top, registration, date, airport (ICAO, auto-uppercase), FBO, FBO phone (auto-format 10-digit US on blur, clickable tel: link in detail view), maintenance status, narrative, oncoming crew emails (PIC, SIC) at bottom. IndexedDB storage with autocomplete dropdowns (sorted alphabetically) that learn from entries. History view with detail/edit/delete (two-tap confirm). Plain text export (copy to clipboard + email with all crew addresses auto-populated in TO field). Passdown tab in first position, Fuel tab retains default focus. Content card widens for Passdown. +- **Passdown Round 2** โ€” .ptz export/import: gzipped JSON array format (batch-capable by design, single-file workflow for now). Email attachment options: plain text body only, .ptz attachment only, or both. Import: decompress โ†’ parse โ†’ deduplicate by ID โ†’ merge into local DB. Android file association for .ptz. Stale date prompt: if passdown date โ‰  today when sending, user chooses "Send Anyway", "Update to Today & Send", or "Cancel". Send tracking: `lastSentAt` timestamp stamped on each send, shown in detail view and as โœ‰ indicator in history list (not delivery confirmation โ€” tracks intent only). History age styling: today's passdowns get full accent color + left border, yesterday's are muted, older entries are dimmed at 60% opacity โ€” instant visual triage. History search/filter: filter bar at top of history view with registration autocomplete dropdown and From/To date range fields; result count shown when filters active; "Clear" resets all filters. Batch export: "Export (N)" button appears when filters are active with results โ€” exports all matching passdowns as a single .ptz file, sorted by date. Filename convention: `PASSDOWN_BATCH_REG_DATEFROM_DATETO.ptz` (single tail) or `PASSDOWN_BATCH_Nrecords_DATEFROM_DATETO.ptz` (mixed). Maintenance log view eliminated โ€” registration + date range filtering covers the real use case. Narrative search deferred to TBD. +- **Passdown Batch Import Preview** โ€” Import flow now shows a preview screen before committing. Each passdown in the .ptz file is listed with registration, date, location, and PIC. Duplicates (matching ID already in local DB) flagged with "DUPLICATE" badge and unchecked by default. User can select/deselect individual entries, select all, or select none. "Import (N)" commits only checked entries and learns lookups from imported data. Works for both file picker imports and Android file association (tapping .ptz in file manager). Replaces the previous all-or-nothing auto-import flow. +- **Global Aircraft Selector** โ€” Compact chip in top bar, color-coded (teal bold for default, amber semibold for non-default). Full selector in hamburger menu with "Set Default" feature. Persistent via localStorage. +- **Dark Mode** โ€” Toggle in hamburger menu, persisted in localStorage. +- **Hamburger Menu** โ€” Aircraft selector, dark mode toggle, CSV import per aircraft, passdown retention settings (3/6/12/24/36 months or never, auto-purge on app open), About section. +- **UI Polish** โ€” Leading zero stripping on heading inputs, side-by-side wind/gust fields, "Flight Time" label on buckets results, temperature field proportions (2/5 input, 3/5 toggle), disclaimer footer on all modules. +- **User Documentation** โ€” `DOCUMENTATION.md` covering all seven modules, data file structure for editors (reference.json and per-aircraft JSON schema with field-level documentation), adding new airframes, .ptz file format (field table, filename conventions, manual inspection command), fuel profile JSON format, fuel bucket CSV format, and app settings. + +--- + +### Android (Kotlin / Jetpack Compose) + +- **Status:** Feature parity with web โ€” RC1. All seven modules implemented including Passdown with SharedPreferences/JSON storage, autocomplete, history, detail view with tappable phone (ACTION_DIAL), copy to clipboard, email intent with crew addresses, two-tap delete confirmation. Passdown retention settings in hamburger menu. Fuel profiles: CSV import, JSON import/export, profile management in hamburger menu. Flight Level โ†” Meters with lookup and China FLAS. Scrollable menu. Crosswind input uses focus-loss formatting (no cursor jumping). Data loaded from `assets/` JSON files via `DataLoader.init(context)` with per-aircraft error isolation. +- **Package:** `com.harshmallow.pilottoolkit` + +--- + +## Architecture Notes + +- **Web stack:** Single-file React component (toolkit.jsx, ~3400 lines), Vite dev server, no backend, no accounts. +- **Data files:** Split-file JSON structure (v3): `reference.json` (flight levels, subgrade labels) + per-aircraft files in `aircraft/` directory. Web imports via Vite; Android reads from `assets/` via `DataLoader`. Each aircraft file loads independently with graceful error handling. +- **Data storage (web):** localStorage for preferences, dark mode, fuel buckets CSV, retention settings. IndexedDB for passdown records and autocomplete lookups. +- **Data storage (Android):** SharedPreferences for all settings, aircraft defaults, passdown data (JSON serialization), and fuel profiles. Room/SQLite migration planned. +- **Data portability:** CSV import/export for fuel bucket data. Plain text / email export for passdowns. .ptz (gzipped JSON array) for structured import/export, batch-capable. +- **Formulas:** Linear regression fits derived from official aircraft manual charts. Rยฒ > 0.99 on all curves. Error estimates noted per aircraft. +- **Disclaimer:** "Not for operational use ยท Verify all calculations independently" on every module. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 0000000..bcf37b2 --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,333 @@ +# Pilot's Toolkit โ€” Documentation + +*RC1 โ€” February 28, 2026* + +--- + +## Overview + +Pilot's Toolkit is a collection of aviation utilities for flight crews. It runs as a web app (React/Vite) and an Android app (Kotlin/Jetpack Compose). Both platforms share the same data files and feature set. + +All calculations carry the disclaimer: **Not for operational use โ€” verify all calculations independently.** + +--- + +## Modules + +### Fuel Order Calculator + +Enter total fuel required, fuel on board, and fuel density to get the order quantity in US gallons, imperial gallons, and liters, all rounded up to the nearest 10 units. + +Fuel density is entered at the 15ยฐC reference temperature. The three-way toggle switches between specific gravity, kg/L, and lb/US gallon โ€” values auto-convert when you switch units. If you don't have a fuel ticket, the default (0.810 SG / 6.76 lb/gal) is typical Jet-A. + +The ambient temperature field corrects density using the ASTM D1250 Table 6B thermal expansion model. Temperature defaults to ยฐC; tap the toggle to switch to ยฐF (the value converts live). The primary volume unit (US gal, imperial gal, or liters) is set in the hamburger menu and defaults based on locale. + +### Pavement Strength + +Calculates ACN/ACR values for the selected aircraft at a given weight, using linear regression fits from official aircraft manual charts. Supports PCN (legacy) and PCR (ICAO 2024+ transition), rigid and flexible pavement types, and four subgrade categories (A through D). + +Enter the airport's published PCN or PCR value in the comparison field to see a pass/exceed result with percentage. Airport pavement values can be found in the Jeppesen airport pages, the national AIP, or airport operator publications. + +Some aircraft (G450, G650) show a computed ESWL using a weight-based formula. Others (G700) show fixed ESWL thresholds (single wheel and dual wheel) because the manufacturer publishes specific values rather than a formula. + +### Crosswind Calculator + +Enter wind as a five-digit string โ€” for example, typing `31015` auto-formats to `310/15` when the field loses focus. Gust goes in the separate field. Runway accepts either a runway number (01โ€“36) or a heading (010โ€“360). + +The output shows headwind/tailwind and crosswind components for both sustained and gust winds. Tailwind values display in red. + +### Fuel Buckets + +Drag the slider to a stage length and see the interpolated fuel burn rate. Data comes from either the aircraft's built-in bucket schedule (G450 only at RC1) or an operator-specific profile loaded via CSV or JSON import in the hamburger menu. + +**CSV format** for import: two columns โ€” stage length (hours, decimal) and fuel burn (lbs/hr). A header row is optional and auto-detected. + +``` +stage_length,fuel_burn +0.1,650 +1.0,548.5 +3.0,449 +``` + +Profiles are named and stored per aircraft. See the "Fuel Profile JSON Format" section below for the JSON import/export schema. + +### HF Frequencies + +Fetches current ARINC HF frequency schedules for Atlantic and Pacific. Shows a staleness indicator โ€” frequencies change on propagation-based windows (typically every few hours), so the 4-hour threshold flags when you should reload. The link opens the ARINC page directly in your browser. + +### Passdown + +A crew changeover form for sharing operational information: crew emails, registration, date, airport, FBO, FBO phone, maintenance status, and narrative. Records are stored locally (IndexedDB on web, SharedPreferences on Android). + +The history view shows all passdowns with age-based color coding: today's entries are highlighted, yesterday's are muted, older entries are dimmed. Filter by registration and/or date range. Batch export produces a `.ptz` file of all matching results. See ".ptz File Format" below for the schema. + +### Flight Level โ†” Meters + +Two modes. **Meters โ†” Feet Lookup** searches the OEM conversion table (ICAO Table 15, 107 entries). Type in either direction and results filter live. **China FLAS** shows the China RVSM Flight Level Allocation Scheme with dual toggle buttons for eastbound (0ยฐโ€“179ยฐ) and westbound (186ยฐโ€“359ยฐ) filtering. RVSM levels are color-coded blue (east) and amber (west). When both buttons are active, all levels display. + +ATC clears flight levels in meters; the pilot sets the altimeter to the corresponding feet value from the table. Due to rounding, the onboard metric readout may differ from the cleared value by up to 30 meters. + +**Do not use for approach minima.** Chart values are rounded to the nearest 100 feet. + +--- + +## Data File Structure + +All aircraft-specific and reference data lives in JSON files shared by both platforms. This is the section to read if you need to edit existing data or add a new airframe. + +### Directory Layout + +**Web** (Vite `src/` directory): +``` +src/ + data/ + reference.json + aircraft/ + G450.json + G500.json + G650.json + G700.json +``` + +**Android** (`app/src/main/`): +``` +assets/ + reference.json + aircraft/ + G450.json + G500.json + G650.json + G700.json +``` + +Both platforms read the same JSON โ€” just placed in the appropriate directory. The web app imports via Vite at build time; Android reads from `assets/` at runtime via `DataLoader.init(context)`. + +### Error Handling + +Each aircraft file loads independently. If `G700.json` has a syntax error, the other three airframes still load normally. The app logs a warning rather than crashing. On Android, `DataLoader.loadErrors` contains a map of failed aircraft IDs and error messages. + +### reference.json + +Airframe-agnostic data. Not included in per-aircraft export/import. + +```json +{ + "_meta": { + "format": "pilot-toolkit-data", + "version": 3 + }, + "pavementSubgrades": { + "A": "A โ€“ High", + "B": "B โ€“ Medium", + "C": "C โ€“ Low", + "D": "D โ€“ Ultra Low" + }, + "metersToFeet": { + "source": "...", + "caution": "Do not use for approach minima.", + "entries": [ + [15100, 49500], + [300, 1000] + ] + }, + "chinaFLAS": { + "source": "...", + "entries": [ + [15500, 50900], + [100, 300] + ], + "rvsmEastbound": [29100, 31100, 33100, 35100, 37100, 39100, 41100], + "rvsmWestbound": [30100, 32100, 34100, 36100, 38100, 40100] + } +} +``` + +The `entries` arrays are `[meters, feet]` pairs, sorted descending by meters. RVSM arrays list feet values assigned to each direction. + +### Aircraft JSON Files + +Each file represents one airframe. The filename (minus `.json`) is the aircraft ID used throughout the app. + +#### Required Fields + +| Field | Type | Description | +|-------|------|-------------| +| `_meta.format` | string | Must be `"pilot-toolkit-aircraft"` | +| `_meta.version` | number | Schema version (currently `3`) | +| `_meta.aircraftId` | string | Must match the filename | +| `label` | string | Display name (e.g., `"G700"`) | +| `available` | boolean | `true` if the aircraft has enough data for calculations; `false` shows it as a placeholder | + +#### Optional Fields (null if not available) + +| Field | Type | Description | +|-------|------|-------------| +| `weightLimits` | object | `minBew`, `mtow`, `maxRamp` (all integers, lbs) | +| `eswl` | object | ESWL configuration โ€” see below | +| `pcn` | object | PCN regression coefficients โ€” see below | +| `pcr` | object | PCR regression coefficients โ€” same structure as `pcn` | +| `pcnError` | string | Error margin (e.g., `"ยฑ2%"`) | +| `pcrError` | string | Error margin | +| `windLimitations` | object | Reserved for future use, currently `null` | +| `fuelBuckets` | array | Array of `[stageLength, fuelBurn]` pairs | + +#### ESWL โ€” Two Types + +The `eswl` field uses a `type` discriminator. + +**Formula type** (G450, G650): ESWL is calculated as `weight ร— weightFactor รท wheelFactor`. + +```json +"eswl": { + "type": "formula", + "weightFactor": 0.45, + "wheelFactor": 1.23 +} +``` + +**Thresholds type** (G700): Manufacturer publishes fixed values. + +```json +"eswl": { + "type": "thresholds", + "singleWheel": { "label": "S-85", "value": 85000, "unit": "lbs" }, + "dualWheel": { "label": "D-108", "value": 108000, "unit": "lbs" }, + "note": "Unrestricted operations at or above these values." +} +``` + +#### Pavement Coefficients (PCN/PCR) + +Linear regression: `result = slope ร— weight + intercept`. The structure is the same for both `pcn` and `pcr`: + +```json +"pcn": { + "rigid": { + "A": { "slope": 0.0003323, "intercept": -2.4247 }, + "B": { "slope": 0.0003268, "intercept": -1.2011 }, + "C": { "slope": 0.0003521, "intercept": -2.1385 }, + "D": { "slope": 0.0003547, "intercept": -1.5244 } + }, + "flexible": { + "A": { "slope": 0.000286, "intercept": -2.4517 }, + "...": "..." + } +} +``` + +Each combination of pavement type (rigid/flexible) and subgrade (A/B/C/D) has its own slope and intercept. These are derived from linear regression fits to official aircraft manual charts. + +#### Fuel Buckets + +Array of `[stageLength, fuelBurn]` pairs sorted by stage length. Stage length is in decimal hours; fuel burn is in lbs/hr. + +```json +"fuelBuckets": [ + [0.1, 650.0], + [1.0, 548.5], + [3.0, 449.0], + [9.9, 456.6] +] +``` + +Set to `null` if no built-in data is available for the airframe. Users can still load operator-specific data via CSV or JSON import. + +### Adding a New Airframe + +1. Create a new JSON file in `aircraft/` (e.g., `G280.json`). +2. Set `_meta.aircraftId` and `label` to match. +3. Set `available` to `false` if the data is incomplete โ€” the app shows the aircraft in the selector but disables calculations. +4. Populate whichever fields you have. Everything except `_meta`, `label`, and `available` is nullable. +5. On Android, the file is auto-discovered from the `aircraft/` directory. On web, add an import line and a `loadAircraft()` call in `toolkit.jsx`. + +--- + +## Exchange Formats + +### .ptz File Format + +The `.ptz` format is a **gzipped JSON array** of passdown objects. The format is open โ€” third-party tools and operators are free to generate or consume `.ptz` files. + +To inspect a `.ptz` file manually: `gunzip < PASSDOWN_N12345_2026-02-28.ptz | python3 -m json.tool` + +Each object in the array has the following fields: + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Unique identifier (UUID-style) | +| `date` | string | Passdown date (`YYYY-MM-DD`) | +| `registration` | string | Aircraft registration (e.g., `N12345`) | +| `airport` | string | ICAO code (e.g., `KPTK`) | +| `fbo` | string | FBO name | +| `fboPhone` | string | FBO phone number | +| `pic` | string | PIC email | +| `sic` | string | SIC email | +| `ca1` | string | Lead cabin attendant email | +| `oncomingPic` | string | Oncoming PIC email | +| `oncomingSic` | string | Oncoming SIC email | +| `maintenance` | string | Maintenance status / squawks | +| `narrative` | string | Free-text operational notes | +| `createdAt` | string | ISO 8601 timestamp | +| `updatedAt` | string | ISO 8601 timestamp | +| `lastSentAt` | string | ISO 8601 timestamp (empty string if never sent) | + +Filename conventions: +- Single passdown: `PASSDOWN_REG_DATE.ptz` +- Batch (single tail): `PASSDOWN_BATCH_REG_FROMDATE_TODATE.ptz` +- Batch (mixed tails): `PASSDOWN_BATCH_Nrecords_FROMDATE_TODATE.ptz` + +On import, duplicates are detected by `id` and flagged in the preview screen. + +### Fuel Profile JSON Format + +Exported as `FUEL_PROFILE_name_aircraft.json`. The app validates the `format` field on import. + +```json +{ + "format": "pilot-toolkit-fuel-profile", + "version": 1, + "aircraft": "G450", + "aircraftLabel": "G450", + "name": "FLEXJET G450", + "entries": 99, + "buckets": [ + [0.1, 650.0], + [1.0, 548.5], + [9.9, 456.6] + ] +} +``` + +| Field | Required on Import | Description | +|-------|-------------------|-------------| +| `format` | Yes | Must be `"pilot-toolkit-fuel-profile"` | +| `aircraft` | Yes | Target aircraft ID (e.g., `"G450"`) | +| `name` | No | Profile name (defaults to `"Imported"`) | +| `buckets` | Yes | Array of `[stageLength, fuelBurn]` pairs | +| `version`, `aircraftLabel`, `entries` | No | Informational; ignored on import | + +### Fuel Bucket CSV Format + +Two columns: stage length (decimal hours) and fuel burn (lbs/hr). Header row is optional and auto-detected. + +``` +stage_length,fuel_burn +0.1,650 +1.0,548.5 +3.0,449 +9.9,456.6 +``` + +On import, the app prompts for a profile name. The data replaces any existing profile for the selected aircraft. + +--- + +## Settings + +Available in the hamburger menu (โ˜ฐ): + +- **Aircraft selector** โ€” Sets the active airframe for pavement, fuel order, and fuel bucket modules. "Set Default" persists the choice across sessions. +- **Dark mode** โ€” Toggles light/dark theme. +- **Primary volume unit** โ€” US gallons, imperial gallons, or liters. Affects fuel order output. Auto-detected from locale on first use. +- **Passdown retention** โ€” How long to keep passdown history: 3, 6, 12, 24, or 36 months, or never delete. Auto-purge runs on app open. +- **Fuel profiles** โ€” View, export, and clear loaded profiles per aircraft. CSV and JSON import. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..c202626 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,104 @@ +# Pilot's Toolkit โ€” Working Roadmap + +*Last updated: March 4, 2026* +*For completed milestones, see COMPLETED.md* + +--- + +> **RC1 locked โ€” February 28, 2026.** Seven modules, both platforms, data externalized. +> **RC2 planning โ€” March 2026.** FBO Contact and Passdown Audit Trail targeted for next release. + +--- + +## Phase I โ€” Web & Android Apps + +### RC2 Targets + +- **FBO Contact** โ€” New module: outbound communication tab for contacting FBOs ahead of arrival. FBO autocomplete (learned from usage, auto-populates email), request details field with placeholder prompts (purpose of visit, transportation, services, ETA/ETD, catering), saveable/editable signature block (pilot name, registration, contact info, FBO loyalty program numbers). Email compose on send. Rewards numbers excluded from bulk exports. + +- **Passdown Audit Trail** โ€” Diffs stored as a `history` array inline in the passdown JSON object. Each save captures timestamp, changed fields, and previous values. Compresses naturally inside existing `.ptz` gzip. Accessible from the passdown detail view only, via a disclosure element โ€” main form and history list show only the final version. Retention policy on snapshots to keep record size bounded. + +### Future Revisions + +- **Crew Rest Calculator** โ€” Simplified rest period calculator for augmented crews (3โ€“4 pilots). Equal division of en route rest after 30-minute all-hands blocks at departure and arrival. Potential paid add-on. Timer/alarm deferred (Android foreground services, notification channels). Full Part 117 compliance may not be attainable โ€” regulatory liability is a fundamentally different risk profile than the rest of the toolkit. +- **Crosswind Limits Overlay** โ€” Per-aircraft wind limitations. Placeholder field exists in aircraft JSON. Blocked by data sourcing complexity (OEM vs operator limits, angle-dependent calculations). +- **Room/SQLite Migration (Android)** โ€” Migrate passdown storage from SharedPreferences to Room for better query/filter scalability. + +### Removed from Consideration + +- ~~Unit Converter~~ โ€” Duplicates existing EFB tools and web searches. +- ~~Pressure Conversion~~ โ€” Marginal standalone value. +- ~~Density Altitude Calculator~~ โ€” Covered by existing fleet performance tools. + +--- + +## Phase II โ€” Docker Deployment + +Single Docker container hosting the full stack: nginx (static Vite frontend + API proxy) + Node/Express (thin REST API, session-based auth) + SQLite (single-file database). Designed for homelab or small-group hosting. Doubles as an iOS demonstrator โ€” iOS users access the web app through a browser before native iOS is built. + +- **Multi-user requirement:** User accounts with data partitioning. Each user's passdowns, lookups, and settings are isolated โ€” no cross-visibility. Initial auth model: email + provided password, user changes password at first login. Managed by a small trusted group; more sophisticated auth layered on later if the user base grows. +- **Frontend migration:** Storage calls swap from IndexedDB/localStorage to `fetch('/api/...')`. Data model, UI, and module logic stay untouched. +- **API routes:** Auth (login/logout/session), passdowns CRUD, lookups CRUD, settings, .ptz import/export. +- **Deployment:** Single `docker-compose.yml`, one volume for the SQLite database file. Exposed on port 38911 (no registered IANA service, low bot visibility). Runs on homelab HPC server. +- **Security posture (proof of concept):** + - nginx rate limiting on login endpoint (e.g., 5 attempts/min/IP โ†’ 429). + - fail2ban monitoring nginx logs for 429s/401s โ€” auto-bans offending IPs via iptables (e.g., 5 failures in 10 min โ†’ 1 hour ban, escalating for repeat offenders). + - Session-based auth: user logs in once, stays logged in until explicit logout or token expiry (controlled server-side). No per-login OTP friction. + - High port number provides minor obscurity benefit (not a security measure, but reduces noise from common-port scanners). +- **Security upgrades (post-approval):** Cloudflare Tunnel (no open ports, email OTP gatekeeper, free tier, nothing to install on user devices โ€” runs on server only) or VPN (Tailscale/WireGuard) if users can install software on their devices. Both are future options, not proof-of-concept requirements. Cloudflare session windows are configurable (24h to 30 days) but still require periodic re-auth, which adds friction for frequent daily use. +- **Data security (future โ€” managed device deployment):** Current .ptz files are plain-text gzipped JSON โ€” readable by anyone with `gunzip`. Acceptable for a hand-selected beta group, but operationally sensitive data (maintenance squawks, crew schedules, tail numbers) must be protected on managed devices. Planned approach: when the Docker backend is live, passdown sync goes through the authenticated API โ€” no .ptz files over email as the primary flow. The server controls who sees what; unmanaged/unauthorized devices simply can't authenticate. For offline/disconnected scenarios where .ptz file transfer is still needed, options include symmetric encryption (AES with operator-level shared passphrase) or per-user keys issued by the server. Key management complexity scales with the auth model โ€” keep it simple until the user base demands otherwise. The goal: make it difficult for an individual to exfiltrate company operational data from a managed device. +- **Mobile apps:** Stay local-only unless API sync is added later. Manual .ptz import/export provides a sync path for small groups in the meantime. + +--- + +## Phase III โ€” iOS (SwiftUI) + +- **Status:** Planning only. Architecture mapped out (SwiftUI + UserDefaults for persistence). +- **Prerequisite:** Apple Developer Program ($99/year). Docker deployment (Phase II) provides a web-based stopgap for iOS users in the meantime. +- **Approach:** Incremental migration in small, manageable chunks mirroring the Android module structure. Each module ported and tested independently before moving to the next. Allows troubleshooting at each step without large-scale rework. +- **Migration order (tentative):** + 1. App shell โ€” navigation, tab bar, hamburger menu, aircraft selector, dark mode, theme + 2. Fuel Order Calculator โ€” simplest module, validates the SwiftUI patterns and data flow + 3. Crosswind Calculator โ€” standalone, no data dependencies + 4. Pavement Strength โ€” aircraft-dependent, tests data model integration + 5. Fuel Buckets โ€” slider, interpolation, CSV import, named profiles + 6. Flight Level โ†” Meters โ€” static data tables, lookup search, FLAS direction filter + 7. HF Frequencies โ€” network fetch, HTML parsing + 8. Passdown Module โ€” most complex, all CRUD + storage + export +- **Storage:** Core Data or SwiftData for structured records, UserDefaults for preferences. +- **Notable differences:** iOS will need its own .ptz export mechanism (likely via share sheet using UIActivityViewController), and HF fetch should work natively (no CORS restriction). + +--- + +## Phase IV โ€” Future Enhancements + +### Ride Report / Turbulence Sensing + +Use phone accelerometer hardware to detect and classify turbulence intensity in real time. + +- **v1 (near-term, once core stable):** Simple vertical g-meter with real-time display, min/max peak hold, color-coded intensity gauge (light/moderate/severe/extreme bands). +- **v2 (requires research):** Filtered intensity classification with time history graph. Key challenge is signal processing โ€” low-pass/high-pass filter design, possibly Kalman filter for orientation fusion (accelerometer + gyroscope), windowing functions to separate turbulence from handling noise. This is a signal processing problem, not just linear math. +- **v3 (ambitious, far future):** Crowd-sourced ride reports with GPS location, altitude, and intensity. Essentially a mini Turbulence Aware / PIREP system. +- **APIs:** Web โ€” DeviceMotion API. Android โ€” SensorManager. iOS โ€” CoreMotion. +- **Status:** Wish list. Filtering approach needs dedicated research before implementation. + +### App Icon + +Ideas discussed: compass rose, PT monogram, stylized wing/chevron, wrench+propeller hybrid, flight instrument silhouette. Compass rose and PT monogram scale best to small sizes (favicon, 48px Android icon). + +--- + +## Aircraft Data (Suspended) + +Resumed when source data becomes available. + +- **G650** โ€” ESWL formula working, max ramp weight needs confirmation (100,000 vs 104,000 lbs). ACN/ACR formulas not yet started (charts need deciphering). +- **G700 fuel buckets** โ€” Data not available for an indefinite period. +- **G500** โ€” Placeholder only. No pavement, weight, or bucket data yet. +- **G450** โ€” Updated pavement calculations pending integration of corrected data. + +--- + +## Known Issues + +- **BlueMail (Android):** Send button uses `ACTION_SEND` with `message/rfc822` (.ptz attachment + plain text body + TO/CC). BlueMail receives the attachment but may not populate recipient fields or body text. Works correctly with Gmail and other standard email clients. BlueMail not presently supported; investigating. diff --git a/index.html b/index.html new file mode 100644 index 0000000..83dcbd1 --- /dev/null +++ b/index.html @@ -0,0 +1,5 @@ + + +Pilot's Toolkit +
+ diff --git a/package.json b/package.json new file mode 100644 index 0000000..603ea2e --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "pilot-toolkit-web", + "version": "1.0.0-rc1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "vite": "^7", + "@vitejs/plugin-react": "^4" + } +} diff --git a/src/data/aircraft/G450.json b/src/data/aircraft/G450.json new file mode 100644 index 0000000..072a47b --- /dev/null +++ b/src/data/aircraft/G450.json @@ -0,0 +1,498 @@ +{ + "_meta": { + "format": "pilot-toolkit-aircraft", + "version": 3, + "aircraftId": "G450", + "generated": "2026-02-28" + }, + "label": "G450", + "available": true, + "weightLimits": { + "minBew": 42000, + "mtow": 74600, + "maxRamp": 75000 + }, + "eswl": { + "type": "formula", + "weightFactor": 0.45, + "wheelFactor": 1.23, + "formula": "weight ร— weightFactor รท wheelFactor" + }, + "pcn": { + "rigid": { + "A": { + "slope": 0.0003669, + "intercept": -3.0215 + }, + "B": { + "slope": 0.0003742, + "intercept": -2.777 + }, + "C": { + "slope": 0.000378, + "intercept": -2.58 + }, + "D": { + "slope": 0.0003813, + "intercept": -2.3455 + } + }, + "flexible": { + "A": { + "slope": 0.0003187, + "intercept": -2.9945 + }, + "B": { + "slope": 0.0003296, + "intercept": -3.036 + }, + "C": { + "slope": 0.0003467, + "intercept": -3.0745 + }, + "D": { + "slope": 0.0003416, + "intercept": -1.796 + } + } + }, + "pcr": { + "rigid": { + "A": { + "slope": 0.003596, + "intercept": -32.304 + }, + "B": { + "slope": 0.003658, + "intercept": -29.142 + }, + "C": { + "slope": 0.003692, + "intercept": -26.508 + }, + "D": { + "slope": 0.003731, + "intercept": -25.019 + } + }, + "flexible": { + "A": { + "slope": 0.002592, + "intercept": -21.282 + }, + "B": { + "slope": 0.003723, + "intercept": -65.027 + }, + "C": { + "slope": 0.004177, + "intercept": -68.073 + }, + "D": { + "slope": 0.0041, + "intercept": -41 + } + } + }, + "pcnError": "ยฑ3%", + "pcrError": "not calculated", + "windLimitations": null, + "fuelBuckets": [ + [ + 0.1, + 650 + ], + [ + 0.2, + 650 + ], + [ + 0.3, + 650 + ], + [ + 0.4, + 650 + ], + [ + 0.5, + 650 + ], + [ + 0.6, + 650 + ], + [ + 0.7, + 650 + ], + [ + 0.8, + 650 + ], + [ + 0.9, + 650 + ], + [ + 1, + 548.5 + ], + [ + 1.1, + 534 + ], + [ + 1.2, + 523 + ], + [ + 1.3, + 513.5 + ], + [ + 1.4, + 504.5 + ], + [ + 1.5, + 496.5 + ], + [ + 1.6, + 489.5 + ], + [ + 1.7, + 483.5 + ], + [ + 1.8, + 478 + ], + [ + 1.9, + 474 + ], + [ + 2, + 470.5 + ], + [ + 2.1, + 467.5 + ], + [ + 2.2, + 464.5 + ], + [ + 2.3, + 462 + ], + [ + 2.4, + 459.5 + ], + [ + 2.5, + 457 + ], + [ + 2.6, + 455 + ], + [ + 2.7, + 453 + ], + [ + 2.8, + 451.5 + ], + [ + 2.9, + 450 + ], + [ + 3, + 449 + ], + [ + 3.1, + 448.3 + ], + [ + 3.2, + 448.5 + ], + [ + 3.3, + 448.7 + ], + [ + 3.4, + 448.9 + ], + [ + 3.5, + 449.1 + ], + [ + 3.6, + 449.3 + ], + [ + 3.7, + 449.5 + ], + [ + 3.8, + 449.7 + ], + [ + 3.9, + 449.9 + ], + [ + 4, + 450.1 + ], + [ + 4.1, + 450.3 + ], + [ + 4.2, + 450.5 + ], + [ + 4.3, + 450.7 + ], + [ + 4.4, + 450.9 + ], + [ + 4.5, + 451.1 + ], + [ + 4.6, + 451.3 + ], + [ + 4.7, + 451.5 + ], + [ + 4.8, + 451.7 + ], + [ + 4.9, + 451.9 + ], + [ + 5, + 452.1 + ], + [ + 5.1, + 452.3 + ], + [ + 5.2, + 452.5 + ], + [ + 5.3, + 452.7 + ], + [ + 5.4, + 452.9 + ], + [ + 5.5, + 453.1 + ], + [ + 5.6, + 453.3 + ], + [ + 5.7, + 453.5 + ], + [ + 5.8, + 453.7 + ], + [ + 5.9, + 453.9 + ], + [ + 6, + 454.2 + ], + [ + 6.1, + 454.5 + ], + [ + 6.2, + 454.8 + ], + [ + 6.3, + 455.1 + ], + [ + 6.4, + 455.4 + ], + [ + 6.5, + 455.7 + ], + [ + 6.6, + 456 + ], + [ + 6.7, + 456.3 + ], + [ + 6.8, + 456.6 + ], + [ + 6.9, + 456.6 + ], + [ + 7, + 456.6 + ], + [ + 7.1, + 456.6 + ], + [ + 7.2, + 456.6 + ], + [ + 7.3, + 456.6 + ], + [ + 7.4, + 456.6 + ], + [ + 7.5, + 456.6 + ], + [ + 7.6, + 456.6 + ], + [ + 7.7, + 456.6 + ], + [ + 7.8, + 456.6 + ], + [ + 7.9, + 456.6 + ], + [ + 8, + 456.6 + ], + [ + 8.1, + 456.6 + ], + [ + 8.2, + 456.6 + ], + [ + 8.3, + 456.6 + ], + [ + 8.4, + 456.6 + ], + [ + 8.5, + 456.6 + ], + [ + 8.6, + 456.6 + ], + [ + 8.7, + 456.6 + ], + [ + 8.8, + 456.6 + ], + [ + 8.9, + 456.6 + ], + [ + 9, + 456.6 + ], + [ + 9.1, + 456.6 + ], + [ + 9.2, + 456.6 + ], + [ + 9.3, + 456.6 + ], + [ + 9.4, + 456.6 + ], + [ + 9.5, + 456.6 + ], + [ + 9.6, + 456.6 + ], + [ + 9.7, + 456.6 + ], + [ + 9.8, + 456.6 + ], + [ + 9.9, + 456.6 + ] + ] +} \ No newline at end of file diff --git a/src/data/aircraft/G500.json b/src/data/aircraft/G500.json new file mode 100644 index 0000000..798fa12 --- /dev/null +++ b/src/data/aircraft/G500.json @@ -0,0 +1,18 @@ +{ + "_meta": { + "format": "pilot-toolkit-aircraft", + "version": 3, + "aircraftId": "G500", + "generated": "2026-02-28" + }, + "label": "G500", + "available": false, + "weightLimits": null, + "eswl": null, + "pcn": null, + "pcr": null, + "pcnError": null, + "pcrError": null, + "windLimitations": null, + "fuelBuckets": null +} \ No newline at end of file diff --git a/src/data/aircraft/G650.json b/src/data/aircraft/G650.json new file mode 100644 index 0000000..9752e73 --- /dev/null +++ b/src/data/aircraft/G650.json @@ -0,0 +1,27 @@ +{ + "_meta": { + "format": "pilot-toolkit-aircraft", + "version": 3, + "aircraftId": "G650", + "generated": "2026-02-28" + }, + "label": "G650", + "available": true, + "weightLimits": { + "minBew": 52600, + "mtow": 99600, + "maxRamp": 100000 + }, + "eswl": { + "type": "formula", + "weightFactor": 0.45, + "wheelFactor": 1.28, + "formula": "weight ร— weightFactor รท wheelFactor" + }, + "pcn": null, + "pcr": null, + "pcnError": null, + "pcrError": null, + "windLimitations": null, + "fuelBuckets": null +} \ No newline at end of file diff --git a/src/data/aircraft/G700.json b/src/data/aircraft/G700.json new file mode 100644 index 0000000..33afe59 --- /dev/null +++ b/src/data/aircraft/G700.json @@ -0,0 +1,109 @@ +{ + "_meta": { + "format": "pilot-toolkit-aircraft", + "version": 3, + "aircraftId": "G700", + "generated": "2026-02-28" + }, + "label": "G700", + "available": true, + "weightLimits": { + "minBew": 56000, + "mtow": 107600, + "maxRamp": 108000 + }, + "eswl": { + "type": "thresholds", + "singleWheel": { + "label": "S-85", + "value": 85000, + "unit": "lbs" + }, + "dualWheel": { + "label": "D-108", + "value": 108000, + "unit": "lbs" + }, + "note": "Unrestricted operations at or above these values." + }, + "pcn": { + "rigid": { + "A": { + "slope": 0.0003323, + "intercept": -2.4247 + }, + "B": { + "slope": 0.0003268, + "intercept": -1.2011 + }, + "C": { + "slope": 0.0003521, + "intercept": -2.1385 + }, + "D": { + "slope": 0.0003547, + "intercept": -1.5244 + } + }, + "flexible": { + "A": { + "slope": 0.000286, + "intercept": -2.4517 + }, + "B": { + "slope": 0.0003167, + "intercept": -3.794 + }, + "C": { + "slope": 0.0003145, + "intercept": -1.7641 + }, + "D": { + "slope": 0.0003221, + "intercept": -0.2135 + } + } + }, + "pcr": { + "rigid": { + "A": { + "slope": 0.0033054, + "intercept": -25.3477 + }, + "B": { + "slope": 0.0033973, + "intercept": -24.1697 + }, + "C": { + "slope": 0.0034952, + "intercept": -24.4997 + }, + "D": { + "slope": 0.0035379, + "intercept": -21.5928 + } + }, + "flexible": { + "A": { + "slope": 0.0022591, + "intercept": -3.1378 + }, + "B": { + "slope": 0.0027682, + "intercept": -29.7287 + }, + "C": { + "slope": 0.0034418, + "intercept": -62.6803 + }, + "D": { + "slope": 0.0038994, + "intercept": -66.621 + } + } + }, + "pcnError": "ยฑ2%", + "pcrError": "ยฑ2%", + "windLimitations": null, + "fuelBuckets": null +} \ No newline at end of file diff --git a/src/data/reference.json b/src/data/reference.json new file mode 100644 index 0000000..a64fd4f --- /dev/null +++ b/src/data/reference.json @@ -0,0 +1,680 @@ +{ + "_meta": { + "format": "pilot-toolkit-data", + "version": 3, + "generated": "2026-02-28", + "notes": "Reference data is airframe-agnostic. Not included in per-aircraft export/import." + }, + "pavementSubgrades": { + "A": "A โ€“ High", + "B": "B โ€“ Medium", + "C": "C โ€“ Low", + "D": "D โ€“ Ultra Low" + }, + "metersToFeet": { + "source": "GVIII-G700 Operating Manual Table 15, 06-10-00, 2024-03-29", + "caution": "Do not use for approach minima. Values rounded to nearest 100 ft.", + "note": "Because of rounding differences, most metric flight levels can be satisfied by two equivalent feet values. Of the two, the closest value in feet is used in this table.", + "entries": [ + [ + 15100, + 49500 + ], + [ + 14100, + 46300 + ], + [ + 13100, + 43000 + ], + [ + 12800, + 42000 + ], + [ + 12500, + 41000 + ], + [ + 12200, + 40000 + ], + [ + 12100, + 39700 + ], + [ + 11900, + 39000 + ], + [ + 11600, + 38100 + ], + [ + 11300, + 37100 + ], + [ + 11100, + 36400 + ], + [ + 10900, + 35800 + ], + [ + 10600, + 34800 + ], + [ + 10300, + 33800 + ], + [ + 10100, + 33100 + ], + [ + 9900, + 32500 + ], + [ + 9600, + 31500 + ], + [ + 9300, + 30500 + ], + [ + 9100, + 29900 + ], + [ + 8900, + 29200 + ], + [ + 8600, + 28200 + ], + [ + 8300, + 27200 + ], + [ + 8100, + 26600 + ], + [ + 8000, + 26200 + ], + [ + 7900, + 25900 + ], + [ + 7800, + 25600 + ], + [ + 7700, + 25300 + ], + [ + 7600, + 24900 + ], + [ + 7500, + 24600 + ], + [ + 7400, + 24300 + ], + [ + 7300, + 24000 + ], + [ + 7200, + 23600 + ], + [ + 7100, + 23300 + ], + [ + 7000, + 23000 + ], + [ + 6900, + 22600 + ], + [ + 6800, + 22300 + ], + [ + 6700, + 22000 + ], + [ + 6600, + 21700 + ], + [ + 6500, + 21300 + ], + [ + 6400, + 21000 + ], + [ + 6300, + 20700 + ], + [ + 6200, + 20300 + ], + [ + 6100, + 20000 + ], + [ + 6000, + 19700 + ], + [ + 5900, + 19400 + ], + [ + 5800, + 19000 + ], + [ + 5700, + 18700 + ], + [ + 5600, + 18400 + ], + [ + 5500, + 18000 + ], + [ + 5400, + 17700 + ], + [ + 5300, + 17400 + ], + [ + 5200, + 17100 + ], + [ + 5100, + 16700 + ], + [ + 5000, + 16400 + ], + [ + 4900, + 16100 + ], + [ + 4800, + 15700 + ], + [ + 4700, + 15400 + ], + [ + 4600, + 15100 + ], + [ + 4500, + 14800 + ], + [ + 4400, + 14400 + ], + [ + 4300, + 14100 + ], + [ + 4200, + 13800 + ], + [ + 4100, + 13500 + ], + [ + 4000, + 13100 + ], + [ + 3900, + 12800 + ], + [ + 3800, + 12500 + ], + [ + 3700, + 12100 + ], + [ + 3600, + 11800 + ], + [ + 3500, + 11500 + ], + [ + 3400, + 11200 + ], + [ + 3300, + 10800 + ], + [ + 3200, + 10500 + ], + [ + 3100, + 10200 + ], + [ + 3000, + 9800 + ], + [ + 2900, + 9500 + ], + [ + 2800, + 9200 + ], + [ + 2700, + 8900 + ], + [ + 2600, + 8500 + ], + [ + 2500, + 8200 + ], + [ + 2400, + 7900 + ], + [ + 2300, + 7500 + ], + [ + 2200, + 7200 + ], + [ + 2100, + 6900 + ], + [ + 2000, + 6600 + ], + [ + 1900, + 6200 + ], + [ + 1800, + 5900 + ], + [ + 1700, + 5600 + ], + [ + 1600, + 5200 + ], + [ + 1500, + 4900 + ], + [ + 1400, + 4600 + ], + [ + 1300, + 4300 + ], + [ + 1200, + 3900 + ], + [ + 1100, + 3600 + ], + [ + 1000, + 3300 + ], + [ + 900, + 3000 + ], + [ + 850, + 2800 + ], + [ + 800, + 2600 + ], + [ + 750, + 2500 + ], + [ + 700, + 2300 + ], + [ + 650, + 2100 + ], + [ + 600, + 2000 + ], + [ + 550, + 1800 + ], + [ + 500, + 1600 + ], + [ + 450, + 1500 + ], + [ + 400, + 1300 + ], + [ + 350, + 1100 + ], + [ + 300, + 1000 + ] + ] + }, + "chinaFLAS": { + "source": "GVIII-G700 Operating Manual Figure 5, 06-10-00, 2024-03-29", + "caution": "Do not use for approach minima. Values rounded to nearest 100 ft.", + "notes": [ + "ATC will issue the Flight Level clearance in meters.", + "Pilots shall use the China RVSM FLAS table to determine the corresponding flight level in feet.", + "The aircraft shall be flown using the flight level in FEET.", + "Due to rounding differences, the metric readout of the onboard avionics will not necessarily correspond to the cleared Flight Level in meters however the difference will never be more than 30 meters." + ], + "entries": [ + [ + 15500, + 50900 + ], + [ + 14900, + 48900 + ], + [ + 14300, + 46900 + ], + [ + 13700, + 44900 + ], + [ + 13100, + 43000 + ], + [ + 12500, + 41100 + ], + [ + 12200, + 40100 + ], + [ + 11900, + 39100 + ], + [ + 11600, + 38100 + ], + [ + 11300, + 37100 + ], + [ + 11000, + 36100 + ], + [ + 10700, + 35100 + ], + [ + 10400, + 34100 + ], + [ + 10100, + 33100 + ], + [ + 9800, + 32100 + ], + [ + 9500, + 31100 + ], + [ + 9200, + 30100 + ], + [ + 8900, + 29100 + ], + [ + 8400, + 27600 + ], + [ + 8100, + 26600 + ], + [ + 7800, + 25600 + ], + [ + 7500, + 24600 + ], + [ + 7200, + 23600 + ], + [ + 6900, + 22600 + ], + [ + 6600, + 21700 + ], + [ + 6300, + 20700 + ], + [ + 6000, + 19700 + ], + [ + 5700, + 18700 + ], + [ + 5400, + 17700 + ], + [ + 5100, + 16700 + ], + [ + 4800, + 15700 + ], + [ + 4500, + 14800 + ], + [ + 4200, + 13800 + ], + [ + 3900, + 12800 + ], + [ + 3600, + 11800 + ], + [ + 3300, + 10800 + ], + [ + 3000, + 9800 + ], + [ + 2700, + 8900 + ], + [ + 2400, + 7900 + ], + [ + 2100, + 6900 + ], + [ + 1800, + 5900 + ], + [ + 1500, + 4900 + ], + [ + 1200, + 3900 + ], + [ + 900, + 3000 + ], + [ + 600, + 2000 + ], + [ + 500, + 1600 + ], + [ + 400, + 1300 + ], + [ + 300, + 1000 + ], + [ + 200, + 700 + ], + [ + 100, + 300 + ] + ], + "rvsmEastbound": [ + 29100, + 31100, + 33100, + 35100, + 37100, + 39100, + 41100 + ], + "rvsmWestbound": [ + 30100, + 32100, + 34100, + 36100, + 38100, + 40100 + ], + "eastboundHeading": "0ยฐ to 179ยฐ", + "westboundHeading": "186ยฐ to 359ยฐ" + } +} \ No newline at end of file diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..90807c8 --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,4 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import Toolkit from "./toolkit.jsx"; +createRoot(document.getElementById("root")).render(); diff --git a/src/toolkit.jsx b/src/toolkit.jsx new file mode 100644 index 0000000..ca745ac --- /dev/null +++ b/src/toolkit.jsx @@ -0,0 +1,3666 @@ +import { useState, useCallback, useEffect, useRef, useMemo } from "react"; +import referenceData from "./data/reference.json"; +import g450Raw from "./data/aircraft/G450.json"; +import g500Raw from "./data/aircraft/G500.json"; +import g650Raw from "./data/aircraft/G650.json"; +import g700Raw from "./data/aircraft/G700.json"; + +/* โ”€โ”€โ”€ Aircraft Data (derived from data/aircraft/*.json) โ”€โ”€โ”€ */ +const aircraftData = {}; +const dataLoadErrors = {}; + +function loadAircraft(id, ac) { + try { + const entry = { + label: ac.label, + available: ac.available, + weightLimits: ac.weightLimits, + pcn: ac.pcn, + pcr: ac.pcr, + pcnError: ac.pcnError, + pcrError: ac.pcrError, + fuelBuckets: ac.fuelBuckets, + }; + if (ac.eswl?.type === "formula") { + entry.eswl = { weightFactor: ac.eswl.weightFactor, wheelFactor: ac.eswl.wheelFactor }; + entry.eswlThresholds = null; + } else if (ac.eswl?.type === "thresholds") { + entry.eswl = null; + entry.eswlThresholds = { singleWheel: ac.eswl.singleWheel, dualWheel: ac.eswl.dualWheel, note: ac.eswl.note }; + } else { + entry.eswl = null; + entry.eswlThresholds = null; + } + aircraftData[id] = entry; + } catch (e) { + console.error(`Failed to load ${id}:`, e); + dataLoadErrors[id] = e.message; + } +} + +loadAircraft("G450", g450Raw); +loadAircraft("G500", g500Raw); +loadAircraft("G650", g650Raw); +loadAircraft("G700", g700Raw); + +const subgradeLabels = referenceData.pavementSubgrades; + +/* โ”€โ”€โ”€ Theme & Design Tokens โ”€โ”€โ”€ */ +const themes = { + light: { + bg: "#f4f7f9", + surface: "#ffffff", + surfaceAlt: "#eef3f6", + border: "#d0dbe3", + borderFocus: "#2ec4b6", + text: "#1a2b3c", + textSecondary: "#5a7080", + textMuted: "#8a9baa", + accent: "#2ec4b6", + accentHover: "#28b0a3", + accentSubtle: "rgba(46,196,182,0.08)", + accentGlow: "rgba(46,196,182,0.25)", + warning: "#e07c3e", + warningBg: "rgba(224,124,62,0.08)", + warningBorder: "rgba(224,124,62,0.25)", + danger: "#d94452", + dangerBg: "rgba(217,68,82,0.08)", + dangerBorder: "rgba(217,68,82,0.25)", + resultBg: "#f0faf9", + resultBorder: "rgba(46,196,182,0.2)", + shadow: "0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)", + shadowLg: "0 4px 16px rgba(0,0,0,0.08)", + navBg: "#1a2b3c", + navText: "#c8d8e4", + navActive: "#2ec4b6", + navHover: "rgba(46,196,182,0.1)", + toggleBg: "#d0dbe3", + toggleKnob: "#ffffff", + }, + dark: { + bg: "#0f1923", + surface: "#1a2b3c", + surfaceAlt: "#223444", + border: "#2a4050", + borderFocus: "#2ec4b6", + text: "#e0eaf0", + textSecondary: "#8aa0b0", + textMuted: "#5a7585", + accent: "#2ec4b6", + accentHover: "#36d4c6", + accentSubtle: "rgba(46,196,182,0.1)", + accentGlow: "rgba(46,196,182,0.3)", + warning: "#e8944e", + warningBg: "rgba(232,148,78,0.1)", + warningBorder: "rgba(232,148,78,0.3)", + danger: "#e5616e", + dangerBg: "rgba(229,97,110,0.1)", + dangerBorder: "rgba(229,97,110,0.3)", + resultBg: "rgba(46,196,182,0.06)", + resultBorder: "rgba(46,196,182,0.15)", + shadow: "0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2)", + shadowLg: "0 4px 16px rgba(0,0,0,0.4)", + navBg: "#0a1420", + navText: "#8aa0b0", + navActive: "#2ec4b6", + navHover: "rgba(46,196,182,0.12)", + toggleBg: "#2a4050", + toggleKnob: "#e0eaf0", + }, +}; + +/* โ”€โ”€โ”€ Utility Functions โ”€โ”€โ”€ */ +// ASTM D1250 Table 6B โ€” Generalized Products density correction +// sg15: specific gravity at 15ยฐC (dimensionless, numerically = kg/L) +// tempC: observed/ambient temperature in ยฐC +// Returns: density in lb/US gallon at observed temperature +const LB_PER_GAL_PER_KGL = 8.34540; // 1 kg/L = 8.34540 lb/US gal +function astmDensityLbGal(sg15, tempC) { + const rho15 = sg15 * 1000; // kg/mยณ + // Table 6B coefficients (refined products) + const K0 = 346.4228, K1 = 0.4388; + const alpha15 = K0 / (rho15 * rho15) + K1 / rho15; + const deltaT = tempC - 15; + const VCF = Math.exp(-alpha15 * deltaT * (1 + 0.8 * alpha15 * deltaT)); + const rhoT = rho15 * VCF; // kg/mยณ at observed temp + return (rhoT / 1000) * LB_PER_GAL_PER_KGL; +} + +function roundUpToTen(val) { + return Math.ceil(val / 10) * 10; +} + +function fahrenheitToCelsius(f) { + return (f - 32) / 1.8; +} + +function calculateESWL(weightLbs, eswlConfig) { + return (weightLbs * eswlConfig.weightFactor) / eswlConfig.wheelFactor; +} + +function evaluateFormula(formula, weightLbs) { + return formula.slope * weightLbs + formula.intercept; +} + +/* โ”€โ”€โ”€ localStorage helpers โ”€โ”€โ”€ */ +function getSavedDefault() { + try { + const saved = localStorage.getItem("pilotToolkit_defaultAircraft"); + if (saved && aircraftData[saved]) return saved; + } catch (e) { /* ignore */ } + return "G450"; +} + +function saveDefault(type) { + try { + localStorage.setItem("pilotToolkit_defaultAircraft", type); + } catch (e) { /* ignore */ } +} + +function getSavedDarkMode() { + try { + const saved = localStorage.getItem("pilotToolkit_darkMode"); + if (saved !== null) return saved === "true"; + } catch (e) { /* ignore */ } + return false; +} + +function saveDarkMode(val) { + try { + localStorage.setItem("pilotToolkit_darkMode", val.toString()); + } catch (e) { /* ignore */ } +} + +/* โ”€โ”€โ”€ Volume Unit Preference โ”€โ”€โ”€ */ +// "usgal" | "liters" | "impgal" +function detectDefaultVolumeUnit() { + try { + const lang = (navigator.language || "en-US").toLowerCase(); + // Imperial gallon countries: UK, Caribbean Commonwealth (St. Lucia, Antigua, etc.) + if (lang === "en-gb" || lang === "en-lc" || lang === "en-ag" || lang === "en-ky" + || lang === "en-bz" || lang === "en-ms" || lang === "en-vg" || lang === "en-ai") return "impgal"; + // US customary + if (lang.startsWith("en-us") || lang === "en-pr" || lang === "en-vi" || lang === "en-gu") return "usgal"; + // Everyone else (Canada, Europe, Asia, Oceania, Latin America, Africa) + return "liters"; + } catch (e) { return "usgal"; } +} + +function getSavedVolumeUnit() { + try { + const saved = localStorage.getItem("pilotToolkit_volumeUnit"); + if (saved && ["usgal", "liters", "impgal"].includes(saved)) return saved; + } catch (e) { /* ignore */ } + return detectDefaultVolumeUnit(); +} + +function saveVolumeUnit(unit) { + try { + localStorage.setItem("pilotToolkit_volumeUnit", unit); + } catch (e) { /* ignore */ } +} + +/* โ”€โ”€โ”€ Fuel Profiles (named bucket data, per aircraft, localStorage) โ”€โ”€โ”€ */ +function getFuelProfile(aircraftId) { + try { + const saved = localStorage.getItem(`pilotToolkit_buckets_${aircraftId}`); + if (!saved) return null; + const parsed = JSON.parse(saved); + // Migration: old format was bare array, new format is { name, buckets } + if (Array.isArray(parsed)) return { name: "Custom", buckets: parsed }; + return parsed; + } catch (e) { /* ignore */ } + return null; +} + +function saveFuelProfile(aircraftId, name, buckets) { + try { + localStorage.setItem(`pilotToolkit_buckets_${aircraftId}`, JSON.stringify({ name, buckets })); + } catch (e) { /* ignore */ } +} + +function clearFuelProfile(aircraftId) { + try { + localStorage.removeItem(`pilotToolkit_buckets_${aircraftId}`); + } catch (e) { /* ignore */ } +} + +function getFuelProfileAircraftIds() { + const ids = []; + try { + Object.keys(aircraftData).forEach((id) => { + if (localStorage.getItem(`pilotToolkit_buckets_${id}`)) ids.push(id); + }); + } catch (e) { /* ignore */ } + return ids; +} + +function exportFuelProfileJSON(aircraftId, profile) { + const payload = { + format: "pilot-toolkit-fuel-profile", + version: 1, + aircraft: aircraftId, + aircraftLabel: aircraftData[aircraftId]?.label || aircraftId, + name: profile.name, + entries: profile.buckets.length, + buckets: profile.buckets, + }; + const json = JSON.stringify(payload, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const safeName = profile.name.replace(/[^A-Za-z0-9_-]/g, "_"); + const filename = `FUEL_PROFILE_${safeName}_${aircraftId}.json`; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; a.download = filename; document.body.appendChild(a); + a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); +} + +function parseFuelProfileJSON(text) { + const obj = JSON.parse(text); + if (obj.format !== "pilot-toolkit-fuel-profile") throw new Error("Not a valid fuel profile file."); + if (!Array.isArray(obj.buckets) || obj.buckets.length === 0) throw new Error("No bucket data found."); + const name = obj.name || "Imported"; + const aircraft = obj.aircraft || null; + const buckets = obj.buckets.map((b) => { + if (!Array.isArray(b) || b.length < 2) throw new Error("Invalid bucket entry."); + return [parseFloat(b[0]), parseFloat(b[1])]; + }); + buckets.sort((a, b) => a[0] - b[0]); + return { name, aircraft, buckets }; +} + +/* โ”€โ”€โ”€ CSV Parser for fuel buckets โ”€โ”€โ”€ */ +// Expects two columns: stage_length, fuel_burn +// Header row is optional (detected and skipped if present) +function parseBucketCSV(text) { + const lines = text.trim().split(/\r?\n/).filter((l) => l.trim() !== ""); + if (lines.length === 0) return { error: "File is empty." }; + + // Detect header row + const firstFields = lines[0].split(",").map((f) => f.trim()); + const startsWithHeader = isNaN(parseFloat(firstFields[0])); + const dataLines = startsWithHeader ? lines.slice(1) : lines; + + if (dataLines.length === 0) return { error: "No data rows found." }; + + const buckets = []; + for (let i = 0; i < dataLines.length; i++) { + const fields = dataLines[i].split(",").map((f) => f.trim()); + if (fields.length < 2) return { error: `Row ${i + 1}: expected 2 columns, found ${fields.length}.` }; + const stage = parseFloat(fields[0]); + const burn = parseFloat(fields[1]); + if (isNaN(stage) || isNaN(burn)) return { error: `Row ${i + 1}: non-numeric value ("${fields[0]}", "${fields[1]}").` }; + if (stage < 0 || burn < 0) return { error: `Row ${i + 1}: negative values not allowed.` }; + buckets.push([stage, burn]); + } + + // Sort by stage length + buckets.sort((a, b) => a[0] - b[0]); + return { buckets }; +} + +/* โ”€โ”€โ”€ IndexedDB Helper (Passdown storage) โ”€โ”€โ”€ */ +const PASSDOWN_DB = "pilotToolkitPassdownDB"; +const PASSDOWN_DB_VERSION = 1; + +function openPassdownDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(PASSDOWN_DB, PASSDOWN_DB_VERSION); + req.onupgradeneeded = (e) => { + const db = e.target.result; + if (!db.objectStoreNames.contains("passdowns")) { + const store = db.createObjectStore("passdowns", { keyPath: "id" }); + store.createIndex("date", "date"); + store.createIndex("registration", "registration"); + store.createIndex("airport", "airport"); + store.createIndex("createdAt", "createdAt"); + } + if (!db.objectStoreNames.contains("lookups")) { + db.createObjectStore("lookups", { keyPath: ["type", "value"] }).createIndex("type", "type"); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function dbGetAll(storeName) { + const db = await openPassdownDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, "readonly"); + const req = tx.objectStore(storeName).getAll(); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function dbPut(storeName, record) { + const db = await openPassdownDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, "readwrite"); + tx.objectStore(storeName).put(record); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +async function dbDelete(storeName, key) { + const db = await openPassdownDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, "readwrite"); + tx.objectStore(storeName).delete(key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +async function getLookups(type) { + const all = await dbGetAll("lookups"); + return all.filter((l) => l.type === type).map((l) => l.value).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); +} + +async function addLookup(type, value) { + if (!value || !value.trim()) return; + await dbPut("lookups", { type, value: value.trim() }); +} + +async function deleteLookup(type, value) { + await dbDelete("lookups", [type, value]); +} + +async function clearAllLookups() { + const db = await openPassdownDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction("lookups", "readwrite"); + tx.objectStore("lookups").clear(); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +async function getAllPassdowns() { + const all = await dbGetAll("passdowns"); + return all.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || "")); +} + +async function savePassdown(record) { + await dbPut("passdowns", record); +} + +async function deletePassdown(id) { + await dbDelete("passdowns", id); +} + +/* โ”€โ”€โ”€ Retention Settings โ”€โ”€โ”€ */ +function getRetentionMonths() { + try { + const saved = localStorage.getItem("pilotToolkit_retentionMonths"); + if (saved !== null) return parseInt(saved); + } catch (e) { /* ignore */ } + return 12; // default 12 months +} + +function saveRetentionMonths(months) { + try { + localStorage.setItem("pilotToolkit_retentionMonths", months.toString()); + } catch (e) { /* ignore */ } +} + +async function purgeOldPassdowns(months) { + if (months <= 0) return 0; // 0 = never purge + const cutoff = new Date(); + cutoff.setMonth(cutoff.getMonth() - months); + const cutoffStr = cutoff.toISOString(); + const all = await getAllPassdowns(); + let purged = 0; + for (const p of all) { + if (p.createdAt && p.createdAt < cutoffStr) { + await deletePassdown(p.id); + purged++; + } + } + return purged; +} + +function generateId() { + return Date.now().toString(36) + Math.random().toString(36).substr(2, 6); +} + +function todayUTC() { + const d = new Date(); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`; +} + +function formatDatePilot(dateStr) { + if (!dateStr) return ""; + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const parts = dateStr.split("-"); + if (parts.length !== 3) return dateStr; + return `${parseInt(parts[2])} ${months[parseInt(parts[1]) - 1]} ${parts[0]}`; +} + +async function exportPassdownPtz(passdown) { + const json = JSON.stringify([passdown], null, 2); + const blob = new Blob([json]); + const cs = new CompressionStream("gzip"); + const compressedStream = blob.stream().pipeThrough(cs); + const compressedBlob = await new Response(compressedStream).blob(); + const reg = (passdown.registration || "NOREG").replace(/[^A-Za-z0-9]/g, ""); + const date = (passdown.date || "nodate").replace(/[^0-9-]/g, ""); + const filename = `PASSDOWN_${reg}_${date}.ptz`; + const url = URL.createObjectURL(compressedBlob); + const a = document.createElement("a"); + a.href = url; a.download = filename; document.body.appendChild(a); + a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); +} + +async function exportBatchPtz(passdownArray) { + const json = JSON.stringify(passdownArray, null, 2); + const blob = new Blob([json]); + const cs = new CompressionStream("gzip"); + const compressedStream = blob.stream().pipeThrough(cs); + const compressedBlob = await new Response(compressedStream).blob(); + const regs = [...new Set(passdownArray.map((p) => p.registration).filter(Boolean))]; + const regPart = regs.length === 1 ? regs[0].replace(/[^A-Za-z0-9]/g, "") : `${passdownArray.length}records`; + const dates = passdownArray.map((p) => p.date).filter(Boolean).sort(); + const datePart = dates.length >= 2 ? `${dates[0]}_${dates[dates.length - 1]}` : dates[0] || "nodate"; + const filename = `PASSDOWN_BATCH_${regPart}_${datePart}.ptz`; + const url = URL.createObjectURL(compressedBlob); + const a = document.createElement("a"); + a.href = url; a.download = filename; document.body.appendChild(a); + a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); +} + +async function parsePtzFile(file) { + const ds = new DecompressionStream("gzip"); + const decompressedStream = file.stream().pipeThrough(ds); + const text = await new Response(decompressedStream).text(); + const arr = JSON.parse(text); + if (!Array.isArray(arr)) throw new Error("Invalid .ptz file: expected JSON array"); + return arr.filter((p) => p && p.id); +} + +async function importSelectedPassdowns(selected) { + let imported = 0; + for (const p of selected) { + await savePassdown(p); + if (p.registration) await addLookup("registration", p.registration); + if (p.airport) await addLookup("airport", p.airport); + if (p.fbo) await addLookup("fbo", p.fbo); + for (const email of [p.pic, p.sic, p.ca1, p.oncomingPic, p.oncomingSic]) { + if (email) await addLookup("crew", email); + } + imported++; + } + return imported; +} + +async function importPassdownPtz(file) { + const arr = await parsePtzFile(file); + const existing = await getAllPassdowns(); + const existingIds = new Set(existing.map((p) => p.id)); + const toImport = arr.filter((p) => !existingIds.has(p.id)); + const imported = await importSelectedPassdowns(toImport); + return { imported, skipped: arr.length - imported, total: arr.length }; +} + +function formatPhone(raw) { + const digits = raw.replace(/\D/g, ""); + if (digits.length === 10) { + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; + } + return raw; // international or non-standard โ€” leave as entered +} + +function phoneToTel(formatted) { + const digits = formatted.replace(/\D/g, ""); + // If 10 digits (US), prepend +1 for tel: protocol + if (digits.length === 10) return `+1${digits}`; + // If starts with + in original, keep the digits as-is + if (formatted.trim().startsWith("+")) return `+${digits}`; + return digits; +} + +function passdownToPlainText(p) { + const lines = [ + `PASSDOWN โ€” ${formatDatePilot(p.date).toUpperCase()}`, + `REG: ${p.registration || "โ€”"} | APT: ${p.airport || "โ€”"} | FBO: ${p.fbo || "โ€”"}`, + ]; + if (p.fboPhone) lines.push(`FBO Phone: ${p.fboPhone}`); + lines.push(""); + lines.push("CREW:"); + lines.push(` PIC: ${p.pic || "โ€”"}`); + lines.push(` SIC: ${p.sic || "โ€”"}`); + if (p.ca1) lines.push(` Lead CA: ${p.ca1}`); + if (p.oncomingPic || p.oncomingSic) { + lines.push(""); + lines.push("ONCOMING CREW:"); + if (p.oncomingPic) lines.push(` PIC: ${p.oncomingPic}`); + if (p.oncomingSic) lines.push(` SIC: ${p.oncomingSic}`); + } + lines.push(""); + lines.push("MAINTENANCE STATUS:"); + lines.push(p.maintenance || "(none)"); + lines.push(""); + lines.push("NARRATIVE:"); + lines.push(p.narrative || "(none)"); + return lines.join("\n"); +} + +/* โ”€โ”€โ”€ Icons (inline SVG) โ”€โ”€โ”€ */ +const FuelIcon = ({ size = 20, color = "currentColor" }) => ( + + + + + + +); + +const RunwayIcon = ({ size = 20, color = "currentColor" }) => ( + + + + + + +); + +const WindIcon = ({ size = 20, color = "currentColor" }) => ( + + + + + +); + +const SunIcon = ({ size = 16 }) => ( + + + + +); + +const MoonIcon = ({ size = 16 }) => ( + + + +); + +const CheckIcon = ({ size = 14, color = "currentColor" }) => ( + + + +); + +const MenuIcon = ({ size = 20, color = "currentColor" }) => ( + + + + + +); + +const CloseIcon = ({ size = 20, color = "currentColor" }) => ( + + + + +); + +const UploadIcon = ({ size = 16, color = "currentColor" }) => ( + + + + + +); + +const DownloadIcon = ({ size = 16, color = "currentColor" }) => ( + + + + + +); + +const TrashIcon = ({ size = 16, color = "currentColor" }) => ( + + + + + + + +); + +/* โ”€โ”€โ”€ Shared UI Components โ”€โ”€โ”€ */ +const ToggleSwitch = ({ checked, onChange, labelLeft, labelRight, theme }) => ( +
+ {labelLeft} + + {labelRight} +
+); + +const SegmentedControl = ({ options, value, onChange, theme }) => ( +
+ {options.map((opt) => { + const isActive = value === opt.value; + return ( + + ); + })} +
+); + +const makeInputStyle = (theme) => ({ + width: "100%", padding: "12px 14px", fontSize: "16px", + fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', monospace", + border: `1.5px solid ${theme.border}`, borderRadius: "8px", + backgroundColor: theme.surfaceAlt, color: theme.text, outline: "none", + transition: "border-color 0.2s, box-shadow 0.2s", boxSizing: "border-box", +}); + +const makeLabelStyle = (theme) => ({ + display: "block", fontSize: "13px", fontWeight: 600, color: theme.textSecondary, + marginBottom: "6px", letterSpacing: "0.03em", textTransform: "uppercase", +}); + +const focusHandlers = (theme) => ({ + onFocus: (e) => { e.target.style.borderColor = theme.borderFocus; e.target.style.boxShadow = `0 0 0 3px ${theme.accentGlow}`; }, + onBlur: (e) => { e.target.style.borderColor = theme.border; e.target.style.boxShadow = "none"; }, +}); + +const ClearButton = ({ onClick, theme }) => ( + +); + +const ResultValue = ({ value, unit, danger, theme }) => ( + + {value} + {unit && {unit}} + +); + +/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + MODULE: Fuel Order + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ +const FuelOrderModule = ({ theme, volumeUnit }) => { + const [tempInput, setTempInput] = useState(""); + const [totalInput, setTotalInput] = useState(""); + const [onBoardInput, setOnBoardInput] = useState(""); + const [fuelInput, setFuelInput] = useState(""); + const [fuelManual, setFuelManual] = useState(false); + const [useFahrenheit, setUseFahrenheit] = useState(false); + const [densityInput, setDensityInput] = useState("6.76"); + const [densityUnit, setDensityUnit] = useState("lbgal"); // "sg" | "kgl" | "lbgal" + const [results, setResults] = useState(null); + const [warning, setWarning] = useState(null); + + const inputStyle = makeInputStyle(theme); + const labelStyle = makeLabelStyle(theme); + const focus = focusHandlers(theme); + + const handleTempUnitToggle = (toFahrenheit) => { + const val = parseFloat(tempInput); + if (!isNaN(val)) { + if (toFahrenheit) { + setTempInput(Math.round(val * 1.8 + 32).toString()); + } else { + setTempInput(Math.round((val - 32) / 1.8).toString()); + } + } + setUseFahrenheit(toFahrenheit); + }; + + // Auto-calculate Fuel Order from Total - OnBoard (unless user manually entered) + useEffect(() => { + if (fuelManual) return; + const total = parseFloat(totalInput); + const onBoard = parseFloat(onBoardInput); + if (!isNaN(total) && !isNaN(onBoard)) { + const diff = total - onBoard; + setFuelInput(diff > 0 ? diff.toString() : "0"); + } else if (totalInput === "" && onBoardInput === "") { + // Both cleared, don't touch fuelInput + } + }, [totalInput, onBoardInput, fuelManual]); + + const handleFuelInputChange = (e) => { + setFuelInput(e.target.value); + setFuelManual(true); + }; + + const handleTotalChange = (e) => { + setTotalInput(e.target.value); + setFuelManual(false); + }; + + const handleOnBoardChange = (e) => { + setOnBoardInput(e.target.value); + setFuelManual(false); + }; + + // Convert displayed density value when switching units + const handleDensityUnitChange = (newUnit) => { + const val = parseFloat(densityInput); + if (!isNaN(val)) { + const sg = densityUnit === "lbgal" ? val / LB_PER_GAL_PER_KGL : val; + const converted = newUnit === "lbgal" ? sg * LB_PER_GAL_PER_KGL : sg; + setDensityInput(converted.toFixed(newUnit === "lbgal" ? 2 : 3)); + } + setDensityUnit(newUnit); + }; + + const densityUnitOptions = [ + { value: "sg", label: "SG" }, + { value: "kgl", label: "kg/L" }, + { value: "lbgal", label: "lb/gal" }, + ]; + + const calculate = useCallback(() => { + const tempVal = parseFloat(tempInput); + const fuelVal = parseFloat(fuelInput); + const densityVal = parseFloat(densityInput); + if (isNaN(tempVal) || isNaN(fuelVal) || isNaN(densityVal)) { setResults(null); setWarning(null); return; } + if (fuelVal <= 0) { setResults(null); setWarning({ level: "warning", msg: "Fuel order must be positive." }); return; } + + const tempC = useFahrenheit ? fahrenheitToCelsius(tempVal) : tempVal; + const sg15 = densityUnit === "lbgal" ? densityVal / LB_PER_GAL_PER_KGL : densityVal; + + // Validate inputs + const warnings = []; + if (tempC < -40) { + warnings.push({ level: "danger", msg: "Temperature is below \u221240\u00b0C. Approaching Jet-A freeze point." }); + } else if (tempC > 55) { + warnings.push({ level: "warning", msg: "Temperature is above +55\u00b0C. Verify conditions." }); + } + if (sg15 < 0.775) { + warnings.push({ level: "warning", msg: `Density ${sg15.toFixed(3)} SG is below Jet-A minimum spec (0.775).` }); + } else if (sg15 > 0.840) { + warnings.push({ level: "warning", msg: `Density ${sg15.toFixed(3)} SG is above Jet-A maximum spec (0.840).` }); + } + setWarning(warnings.length > 0 ? warnings[0] : null); // show most severe + + const correctedLbGal = astmDensityLbGal(sg15, tempC); + const usGallons = fuelVal / correctedLbGal; + const liters = usGallons * 3.78541; + const imperialGallons = liters / 4.54609; + + setResults({ + usGallons: roundUpToTen(usGallons), + liters: roundUpToTen(liters), + imperialGallons: roundUpToTen(imperialGallons), + refDensityLbGal: (sg15 * LB_PER_GAL_PER_KGL).toFixed(3), + correctedLbGal: correctedLbGal.toFixed(3), + tempCDisplay: Math.round(tempC), + }); + }, [tempInput, fuelInput, densityInput, densityUnit, useFahrenheit]); + + useEffect(() => { calculate(); }, [calculate]); + + const clearInputs = () => { + setTempInput(""); setTotalInput(""); setOnBoardInput(""); + setFuelInput(""); setFuelManual(false); + setDensityInput(densityUnit === "lbgal" ? (0.810 * LB_PER_GAL_PER_KGL).toFixed(2) : "0.810"); + setResults(null); setWarning(null); + }; + + const isAutoCalc = !fuelManual && totalInput !== "" && onBoardInput !== ""; + + const segBtnStyle = (active) => ({ + flex: 1, padding: "6px 4px", fontSize: "11px", fontWeight: 600, border: "none", + backgroundColor: active ? theme.accent : "transparent", + color: active ? "#fff" : theme.textSecondary, + cursor: "pointer", transition: "all 0.15s", + letterSpacing: "0.02em", + }); + + return ( +
+
+
+ +
+ setTempInput(e.target.value)} placeholder={useFahrenheit ? "e.g. 59" : "e.g. 15"} style={{ ...inputStyle, flex: "2 1 0" }} {...focus} /> +
+ +
+
+
+ + {/* Density at 15ยฐC */} +
+ +
+ setDensityInput(e.target.value)} + placeholder={densityUnit === "lbgal" ? "e.g. 6.76" : "e.g. 0.810"} + step={densityUnit === "lbgal" ? "0.01" : "0.001"} + style={{ ...inputStyle, flex: "2 1 0" }} {...focus} /> +
+
+ {densityUnitOptions.map((opt) => ( + + ))} +
+
+
+
+ From fuel ticket. Jet-A spec: {densityUnit === "lbgal" ? "6.47 \u2013 7.01 lb/gal" : "0.775 \u2013 0.840"} +
+
+ + {/* Optional: Total Required and On Board */} +
+
+ Optional \u2014 calculate fuel order +
+
+
+ + +
+
+ + +
+
+
+ + {/* Fuel Order โ€” auto-calculated or manual */} +
+ + +
+
+ + + {warning && ( +
+ {warning.level === "danger" ? "\u26d4" : "\u26a0"} {warning.msg} +
+ )} + + {results && ( +
+
+ Order Quantities (rounded up to nearest 10) +
+
+ {(() => { + const all = [ + { key: "usgal", label: "US Gallons", value: results.usGallons }, + { key: "liters", label: "Liters", value: results.liters }, + { key: "impgal", label: "Imperial Gallons", value: results.imperialGallons }, + ]; + const primary = all.find((u) => u.key === volumeUnit) || all[0]; + const rest = all.filter((u) => u.key !== volumeUnit); + return [ + { ...primary, secondary: false }, + ...rest.map((u) => ({ ...u, secondary: true })), + ].map((item) => ( +
+ + {item.label} + + +
+ )); + })()} +
+
+
+ Reference density (15\u00b0C) + {results.refDensityLbGal} lb/gal +
+
+ Corrected density ({results.tempCDisplay}\u00b0C) + {results.correctedLbGal} lb/gal +
+
+
+ )} +
+ ); +}; + +/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + MODULE: Pavement Strength / ESWL + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ +const PavementModule = ({ theme, aircraft }) => { + const [weightInput, setWeightInput] = useState(""); + const [system, setSystem] = useState("pcr"); + const [pavementType, setPavementType] = useState("rigid"); + const [subgrade, setSubgrade] = useState("A"); + const [airportPavementInput, setAirportPavementInput] = useState(""); + const [results, setResults] = useState(null); + const [weightWarning, setWeightWarning] = useState(null); + + const acData = aircraftData[aircraft]; + const inputStyle = makeInputStyle(theme); + const labelStyle = makeLabelStyle(theme); + const focus = focusHandlers(theme); + + const calculate = useCallback(() => { + if (!acData.available) { setResults(null); setWeightWarning(null); return; } + const weight = parseFloat(weightInput); + if (isNaN(weight) || weight <= 0) { setResults(null); setWeightWarning(null); return; } + + // Weight sanity checks + const limits = acData.weightLimits; + if (limits) { + if (limits.maxRamp && weight > limits.maxRamp) { + setWeightWarning({ level: "danger", msg: `Exceeds ${acData.label} max ramp weight (${limits.maxRamp.toLocaleString()} lbs) \u2014 calculation blocked` }); + setResults(null); + return; + } else if (limits.mtow && weight > limits.mtow) { + setWeightWarning({ level: "warning", msg: `Exceeds ${acData.label} MTOW (${limits.mtow.toLocaleString()} lbs)` }); + } else if (limits.minBew && weight <= limits.minBew) { + setWeightWarning({ level: "warning", msg: `Weight is at or below typical BEW (${limits.minBew.toLocaleString()} lbs) \u2014 verify input` }); + } else { + setWeightWarning(null); + } + } else { + setWeightWarning(null); + } + + const hasEswl = !!acData.eswl; + const hasFormulas = !!(system === "pcn" ? acData.pcn : acData.pcr); + + const eswl = hasEswl ? calculateESWL(weight, acData.eswl) : null; + + if (hasFormulas) { + const formulas = system === "pcn" ? acData.pcn : acData.pcr; + const formula = formulas[pavementType][subgrade]; + const acnAcr = Math.ceil(evaluateFormula(formula, weight)); + const aircraftLabel = system === "pcn" ? "ACN" : "ACR"; + const pavementLabel = system.toUpperCase(); + + setResults({ + eswl: eswl !== null ? Math.round(eswl) : null, + value: acnAcr, + hasAcrAcn: true, + aircraftRatingLabel: aircraftLabel, + pavementRatingLabel: pavementLabel, + formulaLabel: subgradeLabels[subgrade], + pavementTypeLabel: pavementType.charAt(0).toUpperCase() + pavementType.slice(1), + aircraftLabel: acData.label, + eswlFormula: hasEswl ? `weight \u00d7 ${acData.eswl.weightFactor} \u00f7 ${acData.eswl.wheelFactor}` : null, + errorNote: system === "pcn" ? acData.pcnError : acData.pcrError, + }); + } else if (hasEswl) { + // ESWL only โ€” no ACR/ACN formulas yet + setResults({ + eswl: Math.round(eswl), + value: null, + hasAcrAcn: false, + aircraftLabel: acData.label, + eswlFormula: `weight \u00d7 ${acData.eswl.weightFactor} \u00f7 ${acData.eswl.wheelFactor}`, + }); + } else { + setResults(null); + } + }, [weightInput, system, pavementType, subgrade, acData]); + + useEffect(() => { calculate(); }, [calculate]); + useEffect(() => { setResults(null); setWeightWarning(null); setAirportPavementInput(""); }, [aircraft]); + + const clearInputs = () => { setWeightInput(""); setAirportPavementInput(""); setResults(null); setWeightWarning(null); }; + + const hasFormulas = !!(system === "pcn" ? acData.pcn : acData.pcr); + + // Comparison logic (only when ACR/ACN is available) + const airportVal = parseFloat(airportPavementInput); + const comparison = (results && results.hasAcrAcn && !isNaN(airportVal) && airportVal > 0) ? (() => { + if (results.value <= airportVal) { + return { level: "ok", msg: `${results.aircraftRatingLabel} ${results.value} \u2264 ${results.pavementRatingLabel} ${airportVal} \u2014 Pavement is suitable.` }; + } else { + const pct = (((results.value - airportVal) / airportVal) * 100).toFixed(0); + // Reverse calculation: solve for max weight at this pavement rating + const formulas = system === "pcn" ? acData.pcn : acData.pcr; + const formula = formulas[pavementType][subgrade]; + const maxWeightRaw = (airportVal - formula.intercept) / formula.slope; + const maxWeight = Math.floor(maxWeightRaw / 500) * 500; // round down to nearest 500 + const minBew = acData.weightLimits?.minBew || 0; + const weightMsg = maxWeight <= minBew + ? " Pavement cannot support this aircraft at any operational weight." + : ` Maximum weight for this pavement: ${maxWeight.toLocaleString()} lbs.`; + return { level: "danger", msg: `${results.aircraftRatingLabel} ${results.value} > ${results.pavementRatingLabel} ${airportVal} \u2014 Pavement strength exceeded by ${pct}%.${weightMsg}` }; + } + })() : null; + + return ( +
+
+ {!acData.available && !acData.eswlThresholds && ( +
+ {acData.label} data is not yet available. +
Data will be added in a future update. +
+ )} + + {/* ESWL Thresholds (e.g. G700 โ€” fixed values, no formula) */} + {!acData.available && acData.eswlThresholds && ( + <> +
+
+ ESWL Thresholds \u2014 {acData.label} +
+
+
+ + Single Wheel ({acData.eswlThresholds.singleWheel.label}) + + +
+
+ + Dual Wheel ({acData.eswlThresholds.dualWheel.label}) + + +
+
+
+ {acData.eswlThresholds.note} +
+
+ +
+ {acData.label} ACR/ACN formulas are not yet available. +
Data will be added in a future update. +
+ + )} + {acData.available && ( + <> +
+ + setWeightInput(e.target.value)} placeholder="e.g. 73600" style={inputStyle} {...focus} /> +
+ + {/* Weight warning */} + {weightWarning && ( +
+ {weightWarning.level === "danger" ? "\u26d4" : "\u26a0"} {weightWarning.msg} +
+ )} + + {hasFormulas ? ( + <> +
+ +
+
+ +
+
+ setAirportPavementInput(e.target.value)} placeholder={`Airport ${system.toUpperCase()}`} style={{ ...inputStyle, height: "100%", boxSizing: "border-box", fontSize: "13px", padding: "0 12px", margin: 0 }} {...focus} /> +
+
+
+
+ + +
+
+ + +
+ + ) : ( +
+ {acData.label} ACR/ACN formulas are not yet available. +
ESWL calculation is available below. +
+ )} + + + )} +
+ {acData.available && } + + {results && ( +
+
+ Results \u2014 {results.aircraftLabel} +
+
+ {results.hasAcrAcn && ( +
+ + {results.aircraftRatingLabel} ({results.pavementTypeLabel}, {results.formulaLabel}) + + +
+ )} + {results.eswl !== null && ( +
+ ESWL + +
+ )} + {results.eswl === null && acData.eswlThresholds && results.hasAcrAcn && ( + <> +
+ + ESWL Single ({acData.eswlThresholds.singleWheel.label}) + + +
+
+ + ESWL Dual ({acData.eswlThresholds.dualWheel.label}) + + +
+ + )} +
+ + {/* Airport comparison result */} + {comparison && ( +
+ {comparison.level === "ok" ? "\u2705" : "\u26d4"} {comparison.msg} +
+ )} + +
+ {results.eswlFormula &&
ESWL = {results.eswlFormula}
} + {!results.eswlFormula && acData.eswlThresholds && ( +
{acData.eswlThresholds.note}
+ )} + {results.hasAcrAcn &&
{results.aircraftRatingLabel} est. error: {results.errorNote}
} +
+
+ )} +
+ ); +}; + +/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + MODULE: Crosswind Calculator + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ +const CrosswindModule = ({ theme }) => { + const [runwayInput, setRunwayInput] = useState(""); + const [windInput, setWindInput] = useState(""); + const [gustInput, setGustInput] = useState(""); + const [results, setResults] = useState(null); + + const inputStyle = makeInputStyle(theme); + const labelStyle = makeLabelStyle(theme); + const focus = focusHandlers(theme); + + // Handle wind input: auto-insert "/" after 3 digits + const handleWindInput = (e) => { + const raw = e.target.value; + // Strip everything except digits and one slash + let digits = raw.replace(/[^0-9]/g, ""); + // Limit to 5 digits total (3 dir + 2 speed) + if (digits.length > 5) digits = digits.slice(0, 5); + // Auto-insert slash after 3 digits + if (digits.length > 3) { + setWindInput(digits.slice(0, 3) + "/" + digits.slice(3)); + } else { + setWindInput(digits); + } + }; + + // Parse direction and speed from "xxx/xx" format + const parseWind = () => { + const parts = windInput.split("/"); + if (parts.length === 2 && parts[0].length === 3 && parts[1].length >= 1) { + return { dir: parseFloat(parts[0]), spd: parseFloat(parts[1]) }; + } + return { dir: NaN, spd: NaN }; + }; + + const calculate = useCallback(() => { + const rwyRaw = parseFloat(runwayInput); + const { dir: windDir, spd: windSpd } = parseWind(); + const gustSpd = parseFloat(gustInput); + + if (isNaN(rwyRaw) || isNaN(windDir) || isNaN(windSpd)) { setResults(null); return; } + + // Accept runway as 1-36 (runway number) or 10-360 (heading) + const rwyHeading = rwyRaw <= 36 ? rwyRaw * 10 : rwyRaw; + + const angleDeg = windDir - rwyHeading; + const angleRad = (angleDeg * Math.PI) / 180; + + const crosswind = Math.abs(windSpd * Math.sin(angleRad)); + const headwind = windSpd * Math.cos(angleRad); + + let gustCrosswind = null; + let gustHeadwind = null; + if (!isNaN(gustSpd) && gustSpd > windSpd) { + gustCrosswind = Math.abs(gustSpd * Math.sin(angleRad)); + gustHeadwind = gustSpd * Math.cos(angleRad); + } + + setResults({ + crosswind: Math.round(crosswind), + headwind: Math.round(headwind), + isTailwind: headwind < 0, + gustCrosswind: gustCrosswind !== null ? Math.round(gustCrosswind) : null, + gustHeadwind: gustHeadwind !== null ? Math.round(gustHeadwind) : null, + gustIsTailwind: gustHeadwind !== null ? gustHeadwind < 0 : false, + }); + }, [runwayInput, windInput, gustInput]); + + useEffect(() => { calculate(); }, [calculate]); + + const clearInputs = () => { setRunwayInput(""); setWindInput(""); setGustInput(""); setResults(null); }; + + return ( +
+
+
+ + setRunwayInput(e.target.value.replace(/^0+(?=\d)/, ""))} placeholder="e.g. 27 or 270" style={inputStyle} {...focus} /> +
+
+
+ + +
+
+ + setGustInput(e.target.value)} placeholder="e.g. 25" style={inputStyle} {...focus} /> +
+
+
+ + + + {results && ( +
+
+ Wind Components (sustained) +
+
+
+ Crosswind + +
+
+ + {results.isTailwind ? "Tailwind" : "Headwind"} + + +
+
+ + {results.gustCrosswind !== null && ( + <> +
+ Wind Components (gust) +
+
+
+ Crosswind + +
+
+ + {results.gustIsTailwind ? "Tailwind" : "Headwind"} + + +
+
+ + )} + + {/* Tailwind warnings */} + {(() => { + const sustainedTail = results.isTailwind ? Math.abs(results.headwind) : 0; + const gustTail = results.gustIsTailwind ? Math.abs(results.gustHeadwind) : 0; + const maxTail = Math.max(sustainedTail, gustTail); + if (maxTail <= 0) return null; + return ( +
+ {maxTail > 10 && ( +
+ WARNING โ€” Tailwind exceeds 10 kts. Landing may not be approved. +
+ )} + {maxTail > 5 && ( +
+ Tailwind exceeds 5 kts โ€” runway must be UNCONTAMINATED. +
+ )} + {maxTail > 0 && maxTail <= 5 && ( +
+ Tailwind component present โ€” use caution. +
+ )} +
+ ); + })()} +
+ )} +
+ ); +}; + +/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + MODULE: Fuel Buckets (Stage Length vs Fuel Burn) + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ +const BucketIcon = ({ size = 20, color = "currentColor" }) => ( + + + + + +); + +const FuelBucketsModule = ({ theme, aircraft, fuelProfile }) => { + const acData = aircraftData[aircraft]; + const builtInBuckets = acData.fuelBuckets; + const buckets = fuelProfile ? fuelProfile.buckets : builtInBuckets; + const isCustom = !!fuelProfile; + const [selectedIndex, setSelectedIndex] = useState(0); + + // Reset selection when aircraft or data source changes + useEffect(() => { setSelectedIndex(0); }, [aircraft, isCustom]); + + if (!buckets) { + return ( +
+ {acData.label} fuel bucket data is not yet available. +
Import a CSV via the menu, or data will be added in a future update. +
+ ); + } + + const entry = buckets[selectedIndex]; + const stageLength = entry[0].toFixed(1); + const fuelBurn = entry[1].toFixed(1); + + return ( +
+ {/* Stage Length Selector */} +
+ + setSelectedIndex(parseInt(e.target.value))} + style={{ + width: "100%", + accentColor: theme.accent, + cursor: "pointer", + height: "6px", + }} + /> +
+ {buckets[0][0].toFixed(1)} + + {stageLength} + + {buckets[buckets.length - 1][0].toFixed(1)} +
+
+ + {/* Result */} +
+
+ Fuel Burn \u2014 {acData.label} +
+
+ + Flight Time {stageLength} hr + + +
+
+ + {/* Note about data */} +
+ {isCustom ? `Using "${fuelProfile.name}" profile.` : "Using built-in data."}{" "} + Verify against current company fuel schedules. +
+
+ ); +}; + +/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + MODULE: HF Frequencies (ARINC) + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ +const RadioIcon = ({ size = 20, color = "currentColor" }) => ( + + + + + + + + +); + +const RefreshIcon = ({ size = 14, color = "currentColor" }) => ( + + + + +); + +const ExternalLinkIcon = ({ size = 12, color = "currentColor" }) => ( + + + + + +); + +const ARINC_URLS = { + atlantic: [ + "https://www.radio.arinc.net/atlantic/", + "https://radio.arinc.net/atlantic/", + ], + pacific: [ + "https://www.radio.arinc.net/pacific/", + "https://radio.arinc.net/pacific/", + ], +}; + +const HFModule = ({ theme }) => { + const [region, setRegion] = useState("atlantic"); + const [pageDate, setPageDate] = useState(null); + const [dateMismatch, setDateMismatch] = useState(false); + const [fetchError, setFetchError] = useState(null); + const [isFetching, setIsFetching] = useState(false); + const [resolvedUrl, setResolvedUrl] = useState(ARINC_URLS.atlantic[0]); + const [now, setNow] = useState(Date.now()); + + // Tick every minute to update staleness + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 60000); + return () => clearInterval(timer); + }, []); + + // Parse "Valid from Feb. 22, 2026, 1200Z - 1900Z" (Atlantic) or "Valid from Feb. 22, 2026, 1615Z" (Pacific) + // Atlantic: use the END time (1900Z) for staleness โ€” that's when frequencies expire + // Pacific: use the single time + const parseValidFrom = (html, reg) => { + const months = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 }; + + // Try Atlantic format first: "Valid from Mon. DD, YYYY, HHmmZ - HHmmZ" + const atlMatch = html.match(/Valid from\s+(\w+)\.\s+(\d+),\s+(\d{4}),\s+(\d{4})Z\s*-\s*(\d{4})Z/i); + if (atlMatch) { + const mon = months[atlMatch[1]]; + if (mon === undefined) return null; + const day = parseInt(atlMatch[2]); + const year = parseInt(atlMatch[3]); + // Use end time for Atlantic + const hhmm = atlMatch[5]; + const hh = parseInt(hhmm.substring(0, 2)); + const mm = parseInt(hhmm.substring(2, 4)); + return { time: new Date(Date.UTC(year, mon, day, hh, mm)), year, mon, day }; + } + + // Pacific format: "Valid from Mon. DD, YYYY, HHmmZ" + const pacMatch = html.match(/Valid from\s+(\w+)\.\s+(\d+),\s+(\d{4}),\s+(\d{4})Z/i); + if (pacMatch) { + const mon = months[pacMatch[1]]; + if (mon === undefined) return null; + const day = parseInt(pacMatch[2]); + const year = parseInt(pacMatch[3]); + const hhmm = pacMatch[4]; + const hh = parseInt(hhmm.substring(0, 2)); + const mm = parseInt(hhmm.substring(2, 4)); + return { time: new Date(Date.UTC(year, mon, day, hh, mm)), year, mon, day }; + } + + return null; + }; + + const checkPageAge = async () => { + setIsFetching(true); + setFetchError(null); + setDateMismatch(false); + const urls = ARINC_URLS[region]; + let lastError = null; + for (const tryUrl of urls) { + try { + const resp = await fetch(tryUrl); + const html = await resp.text(); + const parsed = parseValidFrom(html, region); + if (parsed && !isNaN(parsed.time.getTime())) { + setPageDate(parsed.time); + setNow(Date.now()); + setResolvedUrl(tryUrl); + + // Date mismatch check: accept today or yesterday UTC (frequencies from yesterday can still be valid near day boundary) + const todayUtc = new Date(); + const yesterdayUtc = new Date(todayUtc.getTime() - 86400000); + const matchesToday = parsed.year === todayUtc.getUTCFullYear() && parsed.mon === todayUtc.getUTCMonth() && parsed.day === todayUtc.getUTCDate(); + const matchesYesterday = parsed.year === yesterdayUtc.getUTCFullYear() && parsed.mon === yesterdayUtc.getUTCMonth() && parsed.day === yesterdayUtc.getUTCDate(); + if (!matchesToday && !matchesYesterday) { + setDateMismatch(true); + } + setIsFetching(false); + return; // Success โ€” stop trying further URLs + } else { + lastError = "Could not find \"Valid from\" timestamp on ARINC page."; + } + } catch (e) { + lastError = "Unable to reach ARINC (likely blocked by browser CORS policy). Use the native app or open the page directly."; + } + } + // All URLs exhausted + setFetchError(lastError); + setResolvedUrl(urls[0]); // Default to first URL for the external link + setIsFetching(false); + }; + + // Staleness + const staleMins = pageDate ? Math.floor((now - pageDate.getTime()) / 60000) : null; + const isStale = staleMins !== null && staleMins >= 240; + const stalenessText = staleMins !== null + ? staleMins < 1 ? "Just now" + : staleMins < 60 ? `${staleMins}m ago` + : staleMins < 1440 ? `${Math.floor(staleMins / 60)}h ${staleMins % 60}m ago` + : `${Math.floor(staleMins / 1440)}d ${Math.floor((staleMins % 1440) / 60)}h ago` + : null; + + const url = resolvedUrl; + const regionLabel = region.charAt(0).toUpperCase() + region.slice(1); + + return ( +
+
+ + {/* Region selector */} + { setRegion(v); setPageDate(null); setFetchError(null); setDateMismatch(false); setResolvedUrl(ARINC_URLS[v][0]); }} + theme={theme} + /> + + {/* Check Page Age + staleness */} +
+ + + {stalenessText && ( + + {isStale && "\u26a0 "}{stalenessText} + + )} +
+ + {/* Stale warning */} + {isStale && ( +
+ {"\u26a0"} ARINC page is over 4 hours old. Frequencies may have changed. +
+ )} + + {/* Fetch error */} + {fetchError && ( +
+ {fetchError} +
+ )} + + {/* Date mismatch warning */} + {dateMismatch && ( +
+ {"\u26d4"} ARINC page date does not match today's date โ€” verify data is current. +
+ )} + + {/* Page date result */} + {pageDate && ( +
+
+ {region === "atlantic" ? "Frequencies Valid Until" : "Frequencies Valid From"} +
+
+ {pageDate.getUTCDate().toString().padStart(2, "0")} {["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][pageDate.getUTCMonth()]} {pageDate.getUTCFullYear()}, {pageDate.getUTCHours().toString().padStart(2, "0")}{pageDate.getUTCMinutes().toString().padStart(2, "0")}Z +
+
+ )} + + {/* Direct link */} + e.currentTarget.style.backgroundColor = `${theme.accent}10`} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = "transparent"} + > + Open ARINC {regionLabel} Page + + + {/* Disclaimer */} +
+ Frequency information sourced from ARINC (Collins Aerospace). Frequencies change based on propagation conditions. + Verify against the official source before use. This application is not affiliated with ARINC or Collins Aerospace. +
+
+
+ ); +}; + +/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + MODULE: Passdown + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ +const ClipboardIcon = ({ size = 20, color = "currentColor" }) => ( + + + + + +); + +/* Autocomplete input field */ +const AutocompleteField = ({ value, onChange, options, label, placeholder, theme, uppercase, inputStyle: overrideStyle }) => { + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState(""); + const wrapRef = useRef(null); + const timeoutRef = useRef(null); + + const filtered = options.filter((o) => + o.toLowerCase().includes((filter || value || "").toLowerCase()) + ); + + const handleFocus = (e) => { setFilter(""); setOpen(true); e.currentTarget.style.borderColor = theme.borderFocus; }; + const handleBlur = (e) => { timeoutRef.current = setTimeout(() => setOpen(false), 180); e.currentTarget.style.borderColor = theme.border; }; + const handleSelect = (v) => { + clearTimeout(timeoutRef.current); + onChange(v); + setOpen(false); + }; + const handleChange = (e) => { + let v = e.target.value; + if (uppercase) v = v.toUpperCase(); + onChange(v); + setFilter(v); + if (!open) setOpen(true); + }; + + const baseInput = { + width: "100%", padding: "10px 12px", fontSize: "14px", fontWeight: 500, + borderRadius: "8px", border: `1px solid ${theme.border}`, + backgroundColor: "transparent", color: theme.text, outline: "none", + boxSizing: "border-box", transition: "border-color 0.2s", + }; + + return ( +
+ + {open && filtered.length > 0 && ( +
+ {filtered.map((opt) => ( +
handleSelect(opt)} + style={{ + padding: "8px 12px", fontSize: "13px", color: theme.text, + cursor: "pointer", transition: "background-color 0.1s", + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = theme.surfaceAlt} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = "transparent"} + > + {opt} +
+ ))} +
+ )} +
+ ); +}; + +const PassdownModule = ({ theme }) => { + const [view, setView] = useState("form"); // "form" | "history" | "detail" | "manage" | "import" + const [passdowns, setPassdowns] = useState([]); + const [lookupCache, setLookupCache] = useState({}); + const [selectedPassdown, setSelectedPassdown] = useState(null); + const [editingId, setEditingId] = useState(null); + const [saveStatus, setSaveStatus] = useState(null); + const [copyStatus, setCopyStatus] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [confirmStale, setConfirmStale] = useState(false); + const [importStatus, setImportStatus] = useState(null); + const [importPreview, setImportPreview] = useState(null); // { entries: [...], existingIds: Set } + const [importSelected, setImportSelected] = useState(new Set()); + const [confirmClearAll, setConfirmClearAll] = useState(false); + const [filterReg, setFilterReg] = useState(""); + const [filterDateFrom, setFilterDateFrom] = useState(""); + const [filterDateTo, setFilterDateTo] = useState(""); + const fileInputRef = React.useRef(null); + + // Form fields + const [reg, setReg] = useState(""); + const [date, setDate] = useState(todayUTC); + const [airport, setAirport] = useState(""); + const [fbo, setFbo] = useState(""); + const [fboPhone, setFboPhone] = useState(""); + const [pic, setPic] = useState(""); + const [sic, setSic] = useState(""); + const [ca1, setCa1] = useState(""); + const [oncomingPic, setOncomingPic] = useState(""); + const [oncomingSic, setOncomingSic] = useState(""); + const [maintenance, setMaintenance] = useState(""); + const [narrative, setNarrative] = useState(""); + + const lookupTypes = ["registration", "airport", "fbo", "crew"]; + + const refreshData = async () => { + const [all, ...lookups] = await Promise.all([ + getAllPassdowns(), + ...lookupTypes.map((t) => getLookups(t)), + ]); + setPassdowns(all); + const cache = {}; + lookupTypes.forEach((t, i) => { cache[t] = lookups[i]; }); + setLookupCache(cache); + }; + + useEffect(() => { refreshData(); }, []); + + const clearForm = () => { + setReg(""); setDate(todayUTC()); setAirport(""); setFbo(""); setFboPhone(""); + setPic(""); setSic(""); setCa1(""); + setOncomingPic(""); setOncomingSic(""); + setMaintenance(""); setNarrative(""); setEditingId(null); setSaveStatus(null); + }; + + const handleSave = async () => { + if (!reg.trim() && !date) { setSaveStatus({ type: "error", msg: "Registration and date are required." }); return; } + const record = { + id: editingId || generateId(), + date, registration: reg.trim().toUpperCase(), airport: airport.trim().toUpperCase(), + fbo: fbo.trim(), fboPhone: fboPhone.trim(), + pic: pic.trim(), sic: sic.trim(), ca1: ca1.trim(), + oncomingPic: oncomingPic.trim(), oncomingSic: oncomingSic.trim(), + maintenance: maintenance.trim(), narrative: narrative.trim(), + createdAt: editingId ? (passdowns.find((p) => p.id === editingId)?.createdAt || new Date().toISOString()) : new Date().toISOString(), + updatedAt: new Date().toISOString(), + lastSentAt: editingId ? (passdowns.find((p) => p.id === editingId)?.lastSentAt || "") : "", + }; + await savePassdown(record); + + // Add to lookups + if (record.registration) await addLookup("registration", record.registration); + if (record.airport) await addLookup("airport", record.airport); + if (record.fbo) await addLookup("fbo", record.fbo); + for (const name of [record.pic, record.sic, record.ca1, record.oncomingPic, record.oncomingSic]) { + if (name) await addLookup("crew", name); + } + + await refreshData(); + setSaveStatus({ type: "success", msg: editingId ? "Passdown updated." : "Passdown saved." }); + setTimeout(() => setSaveStatus(null), 2500); + if (!editingId) clearForm(); + setEditingId(null); + }; + + const handleEdit = (p) => { + setReg(p.registration || ""); setDate(p.date || ""); setAirport(p.airport || ""); + setFbo(p.fbo || ""); setFboPhone(p.fboPhone || ""); + setPic(p.pic || ""); setSic(p.sic || ""); setCa1(p.ca1 || ""); + setOncomingPic(p.oncomingPic || ""); setOncomingSic(p.oncomingSic || ""); + setMaintenance(p.maintenance || ""); setNarrative(p.narrative || ""); + setEditingId(p.id); + setView("form"); + }; + + const handleDelete = async (id) => { + await deletePassdown(id); + await refreshData(); + if (selectedPassdown?.id === id) setSelectedPassdown(null); + setView("history"); + }; + + const handleCopy = (p) => { + navigator.clipboard.writeText(passdownToPlainText(p)).then(() => { + setCopyStatus(true); + setTimeout(() => setCopyStatus(false), 2000); + }); + }; + + const handleSend = async (p) => { + // Open mailto with plain text body, PIC in TO, everyone else in CC + const to = p.pic && p.pic.includes("@") ? p.pic : ""; + const cc = [p.sic, p.ca1, p.oncomingPic, p.oncomingSic].filter((e) => e && e.includes("@")).join(","); + const subject = encodeURIComponent(`Passdown โ€” ${formatDatePilot(p.date)} โ€” ${p.registration || "N/A"}`); + const body = encodeURIComponent(passdownToPlainText(p)); + window.open(`mailto:${to}?cc=${cc}&subject=${subject}&body=${body}`); + // Simultaneously download .ptz file + await exportPassdownPtz(p); + // Stamp lastSentAt and persist + const updated = { ...p, lastSentAt: new Date().toISOString() }; + await savePassdown(updated); + await refreshData(); + setSelectedPassdown(updated); + setConfirmStale(false); + }; + + const handleSendWithStaleCheck = (p) => { + const today = todayUTC(); + if (p.date && p.date !== today) { + setConfirmStale(true); + } else { + handleSend(p); + } + }; + + const handleUpdateDateAndSend = async (p) => { + const today = todayUTC(); + const updated = { ...p, date: today, updatedAt: new Date().toISOString() }; + await savePassdown(updated); + await refreshData(); + setSelectedPassdown(updated); + handleSend(updated); + }; + + const handleImportFile = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ""; // reset so same file can be re-selected + try { + const entries = await parsePtzFile(file); + if (entries.length === 0) { + setImportStatus({ type: "error", msg: "No valid passdowns found in file." }); + setTimeout(() => setImportStatus(null), 4000); + return; + } + const existing = await getAllPassdowns(); + const existingIds = new Set(existing.map((p) => p.id)); + // Pre-select non-duplicates + const selected = new Set(entries.filter((p) => !existingIds.has(p.id)).map((p) => p.id)); + setImportPreview({ entries, existingIds }); + setImportSelected(selected); + setView("import"); + } catch (err) { + setImportStatus({ type: "error", msg: `Import failed: ${err.message}` }); + setTimeout(() => setImportStatus(null), 4000); + } + }; + + const handleImportCommit = async () => { + if (!importPreview) return; + const toImport = importPreview.entries.filter((p) => importSelected.has(p.id)); + if (toImport.length === 0) { + setImportPreview(null); + setView("history"); + return; + } + const imported = await importSelectedPassdowns(toImport); + await refreshData(); + setImportPreview(null); + setImportSelected(new Set()); + setImportStatus({ type: "success", msg: `Imported ${imported} passdown${imported !== 1 ? "s" : ""}.` }); + setTimeout(() => setImportStatus(null), 4000); + setView("history"); + }; + + const toggleImportEntry = (id) => { + setImportSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }; + + // Shared styles + const labelStyle = { + display: "block", fontSize: "11px", fontWeight: 700, color: theme.textMuted, + letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: "6px", + }; + const inputStyle = { + width: "100%", padding: "10px 12px", fontSize: "14px", fontWeight: 500, + borderRadius: "8px", border: `1px solid ${theme.border}`, + backgroundColor: "transparent", color: theme.text, outline: "none", + boxSizing: "border-box", transition: "border-color 0.2s", + }; + const textareaStyle = { + ...inputStyle, minHeight: "90px", resize: "vertical", fontFamily: "inherit", lineHeight: "1.5", + }; + const focus = { + onFocus: (e) => { e.currentTarget.style.borderColor = theme.borderFocus; }, + onBlur: (e) => { e.currentTarget.style.borderColor = theme.border; }, + }; + + const btnStyle = (primary) => ({ + padding: primary ? "10px 20px" : "8px 14px", fontSize: "13px", fontWeight: 600, + borderRadius: "6px", cursor: "pointer", border: "none", transition: "all 0.15s", + color: primary ? "#fff" : theme.accent, + backgroundColor: primary ? theme.accent : `${theme.accent}15`, + }); + + /* โ”€โ”€ FORM VIEW โ”€โ”€ */ + if (view === "form") { + return ( +
+
+ + {editingId ? "\u270e Editing" : "New Passdown"} + + +
+ +
+ {/* Crew section */} +
+
Crew (Email)
+ + + +
+ + {/* Row: Reg + Date */} +
+
+ + +
+
+ + setDate(e.target.value)} style={inputStyle} {...focus} /> +
+
+ + {/* Row: Airport + FBO */} +
+
+ + +
+
+ + +
+
+ + {/* FBO Phone */} +
+ + setFboPhone(e.target.value)} + onBlur={(e) => { if (fboPhone.trim()) setFboPhone(formatPhone(fboPhone.trim())); e.currentTarget.style.borderColor = theme.border; }} + onFocus={(e) => { e.currentTarget.style.borderColor = theme.borderFocus; }} + placeholder="e.g. (248) 666-3500" + style={inputStyle} /> +
+ + {/* Maintenance Status */} +
+ +
+ Oil levels, MELs, nuisances, open items +
+