# LocalSky Radar — Phased Build Plan

> Derived from the design handoff spec, WidgetKit pitfalls, and Apple rendering modes guidance.
> Use as a punchlist. Check items off as they ship. Each phase is gated on the previous one compiling and running.

---

## Phase 0 — Project Scaffolding

**Goal:** Bootable Xcode project with two targets, app group, and signing configured. Nothing visual yet.

- [ ] Create Xcode project (macOS 14 Sonoma+ deployment target)
  - [ ] Main app target: `LocalSkyRadar` (no main window — onboarding/settings only)
  - [ ] Widget Extension target: `LocalSkyRadarWidgetExtension`
  - [ ] Shared framework (optional, for common models/utilities)
- [ ] Configure App Group entitlement (`group.com.yourapp.localsky`) on **both** targets
- [ ] Verify app group container is writable from both targets (write/read test)
- [ ] Add `CLLocationManager` capability (location usage descriptions in Info.plist)
- [ ] Add URL scheme `localsky` for deep linking
- [ ] Create `docs/observations.md` for code smells and notes
- [ ] Bootstrap test target(s) — at minimum, render tests for widget views

**Gate:** Clean build, both targets compile. App group round-trip test passes.

---

## Phase 1 — Data Layer & Coordinate Math

**Goal:** Fetch real aircraft data, compute distances/bearings, resolve location. Testable in isolation.

- [x] Define data models (Codable, Identifiable):
  - [x] `Aircraft` — id, callsign, type, registration, lat, lon, altitudeFt, groundSpeedKt, trackDeg, trend, source, distNM, bearingDeg
  - [x] `Airport` — icao, name, lat, lon
  - [x] `RadarEntry` (TimelineEntry) — date, center, aircraft, rangeNM, rangeMode, selectedAircraftId, locationMode, theme, scopeShape
- [x] Implement `RadarDataService`:
  - [x] `fetchAircraft(lat:lon:radius:)` → `GET https://api.airplanes.live/v2/point/{lat}/{lon}/100`
  - [x] Decode API response into `Aircraft` array
  - [x] Compute `distNM` and `bearingDeg` from center using equirectangular projection
  - [x] Sort by distance
- [x] Implement coordinate math utilities:
  - [x] `equirectangularDist(lat1:lon1:lat2:lon2:)` → NM
  - [x] `bearingFrom(lat1:lon1:lat2:lon2:)` → degrees 0-360
  - [x] `projectToScope(ac:center:rangeNM:cx:cy:R:)` → (x, y) for SVG placement
- [ ] Implement `adaptiveRange(aircraft:)` matching spec algorithm
- [ ] Implement location resolution:
  - [ ] `CLLocationManager` → user coords
  - [ ] Nearest-major-airport resolver (within 200 NM)
  - [ ] ICAO code → airport lookup (local DB or API)
  - [ ] Fallback to last-known center
- [ ] Write unit tests:
  - [ ] Distance/bearing math (known coordinate pairs, e.g. KSFO→KOAK ≈ 11.5 NM, bearing ~115°)
  - [ ] Adaptive range with synthetic aircraft arrays (edge cases: 0, 1, 2, 3, 4, 5+ aircraft)
  - [ ] API response decoding (recorded sample JSON)

**Gate:** Tests green. Service returns real aircraft data from KSFO coords.

---

## Phase 2 — Design Tokens & Theming

**Goal:** SwiftUI color assets matching all three themes. Verifiable in isolation.

- [x] Create `Theme` enum (muted, green, gray)
- [x] Map all design tokens to SwiftUI `Color`:
  - [x] Muted theme (18 tokens: frameBg, scopeBg, ocean, land, bay, coast, ring, ringStrong, ringLabel, tick, center, grid, ac, acDim, acHi, trail, text/textDim/textFaint, accent, warn, panel, panelLine, chip, chipText, pos, neg, sweep)
- [ ] Green theme (all tokens, including glow filter)
- [ ] Gray theme (all tokens)
- [x] Create `ScopeStyle` enum (circle, square)
- [x] Create `RangeMode` enum (adaptive, nm50, nm75, nm100)
- [x] Create `LocationMode` enum (current, airport with ICAO)
- [x] Typography: SF Pro Text and SF Mono at all spec sizes/weights

**Gate:** All token colors match spec values within tolerance. Render test passes.

---

## Phase 3 — Radar Face (Static)

**Goal:** SwiftUI view that draws the radar scope with static sample data. No interactivity yet.

- [x] Radar face base view:
  - [x] Background fill (`scopeBg`)
  - [x] Circle clip shape (for circle mode) vs. no clip (square mode)
- [x] Basemap layers (drawn in order):
  - [x] Graticule crosshairs (`grid` color)
- [x] Range rings:
  - [x] Every 25 NM up to active range
  - [x] Inner rings: `ring`, 1pt stroke
  - [x] Outer ring: `ringStrong`, 1.3pt stroke
- [x] Bearing ticks & cardinals:
  - [x] 30° tick marks at outer ring edge
- [x] Center marker (ring r=4 + dot r=1.3 + crosshair, `center` color)
- [x] Airport code label below center
- [x] Aircraft chevron markers:
  - [x] Chevron shape pointing in track direction
  - [x] Size scale: `s = diameter / 300`
  - [x] Tier colors: `acHi` (selected/nearest), `acDim` (≥18k ft), `ac` (normal)
- [x] Vignette overlay (radial gradient, transparent at 58%, 55% black at 100%)
- [x] Overlaid labels:
  - [x] Bottom-left chip: `{N} · {rangeLabel}`

**Gate:** Radar face renders correctly in all 3 themes, both shapes. Render tests pass.

---

## Phase 4 — Widget Layouts (Static)

**Goal:** Extra Large and Medium widget views rendering with static sample data. No timeline provider yet.

- [x] Extra Large layout (720×382):
  - [x] Two-panel structure (left radar, right detail+list)
  - [x] Left panel: radar face + overlaid labels
  - [x] Right panel header: status dot + text, update timestamp
  - [x] Hero aircraft card: callsign, distance+bearing, subtext, stats grid (ALT/GS/TRK)
  - [x] Nearby aircraft table: header row, 6 data rows, column layout
  - [x] Corner radius 22, panel padding 18pt
- [x] Medium layout (338×158):
  - [x] Two-panel structure (left radar, right list)
  - [x] Left panel: mini radar face + bottom-left chip
  - [x] Right panel: header row, status row, 3 aircraft rows
  - [x] Corner radius 22, panel padding 13/14/11pt
- [x] Selected row styling: `chip` background, 2pt `accent` left inset, `accent` callsign

**Gate:** Both widgets render pixel-accurately vs. mockups in all themes and both rendering modes.

---

## Phase 5 — WidgetKit Integration (Live Timeline)

**Goal:** Widgets pull real data via TimelineProvider. 60-second refresh. No interactivity yet.

- [x] Implement `RadarTimelineProvider`:
  - [x] `placeholder()` — static sample data for gallery preview
  - [x] `get()` — fetch live data, build `RadarEntry`, return timeline with 60s policy
  - [x] Handle API failure → last-known data or empty state
- [ ] Implement `WidgetCenter.shared.reloadAllTimelines()` trigger from main app
- [x] Register widget configurations in `LocalSkyWidgetBundle`:
  - [x] `ExtraLargeWidget` with `kind: "extraLarge"`
  - [x] `MediumWidget` with `kind: "medium"`
  - [x] `LargeWidget` with `kind: "large"`
- [ ] Wire `containerBackground` with rendering-mode-aware background
- [x] Implement `widgetURL` deep link (`localsky://open?airport={code}`)

**Gate:** Widgets appear in gallery, show live aircraft data, refresh every 60 seconds.

---

## Phase 6 — Configuration & App Intents

**Goal:** Widget edit sheet with 4 settings. Stable intent type (never change it — Pitfall #1).

- [x] Define **one** `WidgetConfigurationIntent` shared across both widgets:
  - [x] `@Parameter` scopeShape (circle/square, default: circle)
  - [x] `@Parameter` rangeMode (adaptive/50/75/100, default: adaptive)
  - [x] `@Parameter` locationMode (current/airport, default: current)
  - [x] `@Parameter` airportICAO (String, default: "")
  - [x] `@Parameter` theme (muted/green/gray, default: muted)
- [x] Wire intent parameters into `RadarEntry` → affects timeline provider
- [ ] Verify `extract.actionsdata` contains all parameters (Pitfall #3)
- [ ] Test parameter changes trigger timeline reload
- [x] Persist settings in App Group UserDefaults

**Gate:** Edit sheet shows all 4 settings. Changing a setting updates the widget within 60s.

---

## Phase 7 — Interactivity (Tap-to-Select)

**Goal:** Tap aircraft row or map marker to select/deselect. Hero card updates.

- [x] Implement `SelectAircraftIntent: AppIntent`:
  - [x] `@Parameter` aircraftId
  - [x] Store in shared UserDefaults (`selectedAircraftId`)
- [ ] Wire aircraft rows as `Button(intent: SelectAircraftIntent(...))`
- [ ] Wire map markers as `Button(intent: SelectAircraftIntent(...))` with ≥44pt tap target
- [ ] Handle out-of-range selected aircraft → fallback to nearest
- [ ] Trigger `WidgetCenter.shared.reloadAllTimelines()` on selection change
- [ ] Test selection persistence across app restarts

**Gate:** Tap row or marker → hero updates → selection persists → tap again → deselects.

---

## Phase 8 — Main App & Polish

**Goal:** Minimal app presence, onboarding, settings, and production readiness.

- [ ] Minimal app UI:
  - [ ] Onboarding: location permission request
  - [ ] Settings: widget configuration mirror (for users who can't access edit sheet)
  - [ ] Deep link handler (`localsky://open?airport=...`)
- [ ] Background data polling:
  - [ ] BGTaskScheduler for 60s refresh when app is backgrounded
  - [ ] Trigger widget reload on new data
- [ ] Error handling & edge cases:
  - [ ] Network timeout → retry with backoff
  - [ ] Location denied → show fallback state
  - [ ] API rate limit → graceful degradation
- [ ] App Store assets:
  - [ ] App icon
  - [ ] Marketing screenshots (widget gallery views)
  - [ ] Privacy policy (location data, airplanes.live)
- [ ] Code signing & distribution:
  - [ ] Verify app group entitlement matches on both targets (Pitfall #5)
  - [ ] Test on physical device (chronod caching — Pitfall #2)

**Gate:** App installs, requests location, widgets appear and refresh. Ready for TestFlight.

---

## Phase 9 — Large Widgets (Lower Priority)

**Goal:** Tower / Approach / Instrument variants for the 360×382 Large size.

- [ ] Design review: confirm Large variant layouts from spec
- [x] Implement `LargeWidget` with sub-layouts (tower/approach/instrument)
- [x] Register in widget bundle
- [ ] Render tests for all variants

---

## Phase 10 — iOS Port (Optional)

**Goal:** Same widgets on iOS 17+ Lock Screen / StandBy mode.

- [ ] iOS target with same widget extension
- [ ] Adapt layouts for iOS widget sizes
- [ ] Test Lock Screen and StandBy rendering modes
- [ ] iOS-specific location permissions flow

---

## Dev Workflow Checklist

Use this loop for every widget visual change:

1. **Render test first** — `swift test --filter <WidgetRenderTests>` → check `/tmp/localsky_*.png`
2. **If config/gallery change** — rebuild → install → bust chronod cache (§2) → verify descriptor → single host restart
3. **Screenshot both modes** — full color + monochrome, before marking done
4. **Never change intent type** — add `@Parameter`s to existing intent instead

## Verification Commands

```bash
# Bust chronod cache (macOS only, after config changes)
DB=~/Library/Group\ Containers/group.com.apple.chronod/chronod/chrono.sql
pkill -9 chronod
sqlite3 "$DB" "DELETE FROM Descriptors WHERE bundleIdentifier LIKE '%localsky.widget';
           DELETE FROM ExtensionMetadata WHERE bundleIdentifier LIKE '%localsky.widget';"

# Verify app group entitlements match
codesign -d --entitlements :- /path/to/LocalSkyRadar.app/Contents/PlugIns/LocalSkyRadarWidgetExtension.appex 2>/dev/null | grep -o '[0-9A-Z]\{10\}\.com\..*localsky'
codesign -d --entitlements :- /path/to/LocalSkyRadar.app 2>/dev/null | grep -o '[0-9A-Z]\{10\}\.com\..*localsky'

# Verify intent parameters compiled in
F=/path/to/LocalSkyRadar.app/Contents/PlugIns/LocalSkyRadarWidgetExtension.appex/Contents/Resources/Metadata.appintents/extract.actionsdata
python3 -c 'import re,json,sys; j=json.loads(re.search(r"\{\"actions\".*\}",open(sys.argv[1],encoding="utf-8",errors="ignore").read()).group(0)); print([p["name"] for p in j["actions"]["WidgetConfigurationIntent"]["parameters"]])' "$F"
```
