From ee3d0c200797d858e016aefabc8e62e1bd524b4f Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 25 Mar 2026 05:00:31 +0000 Subject: chore: add .gitignore, pull-crash-logs script, updated agent permissions - .gitignore: exclude agent artifacts (.claudomator-env, .agent-home/, crash-logs/) - scripts/pull-crash-logs: download Crashlytics crash reports via Firebase CLI - .claude/settings.local.json: add Android SDK/emulator/adb permission rules Co-Authored-By: Claude Sonnet 4.6 --- scripts/pull-crash-logs | 227 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100755 scripts/pull-crash-logs (limited to 'scripts') diff --git a/scripts/pull-crash-logs b/scripts/pull-crash-logs new file mode 100755 index 0000000..7458e18 --- /dev/null +++ b/scripts/pull-crash-logs @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# pull-crash-logs — Download recent Crashlytics crash reports via Firebase CLI +# +# Usage: ./scripts/pull-crash-logs [--limit N] [--app-id APP_ID] [--out DIR] +# Example: ./scripts/pull-crash-logs --limit 20 --out /tmp/crashes +# +# Requirements: +# - firebase-tools installed and authenticated (firebase login) +# - FIREBASE_PROJECT set in env, or edit PROJECT_ID below +# - firebase-admin or curl + gcloud token for REST fallback + +set -euo pipefail + +# ── Config ────────────────────────────────────────────────────────────────── +PROJECT_ID="${FIREBASE_PROJECT:-nav-test-c2872}" +LIMIT=10 +OUT_DIR="$(pwd)/crash-logs" + +# ── Arg parsing ───────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --limit) LIMIT="$2"; shift 2 ;; + --app-id) APP_ID="$2"; shift 2 ;; + --out) OUT_DIR="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +mkdir -p "$OUT_DIR" + +echo "=== Firebase Crashlytics crash log pull ===" +echo " Project : $PROJECT_ID" +echo " Limit : $LIMIT" +echo " Output : $OUT_DIR" +echo "" + +# ── Check tooling ──────────────────────────────────────────────────────────── +require() { command -v "$1" &>/dev/null || { echo "ERROR: '$1' not found. Install it first." >&2; exit 1; }; } + +# Prefer firebase CLI → fall back to REST via gcloud token +if command -v firebase &>/dev/null; then + MODE="firebase-cli" +elif command -v gcloud &>/dev/null; then + MODE="rest" +else + echo "ERROR: neither firebase-tools nor gcloud found." >&2 + echo " Install firebase-tools: npm install -g firebase-tools" >&2 + echo " Or gcloud SDK: https://cloud.google.com/sdk/docs/install" >&2 + exit 1 +fi + +echo "Mode: $MODE" +echo "" + +# ── Pull via firebase CLI ──────────────────────────────────────────────────── +if [[ "$MODE" == "firebase-cli" ]]; then + # List Android apps in project to find app ID if not provided + if [[ -z "${APP_ID:-}" ]]; then + echo "Fetching app list..." + APP_ID=$(firebase apps:list --project "$PROJECT_ID" --json 2>/dev/null \ + | python3 -c " +import json, sys +apps = json.load(sys.stdin) +android = [a for a in apps.get('result', []) if a.get('platform') == 'ANDROID'] +if not android: + print('', end='') +else: + print(android[0]['appId'], end='') +" 2>/dev/null || true) + + if [[ -z "$APP_ID" ]]; then + echo "WARNING: Could not auto-detect app ID. Set --app-id manually." >&2 + echo " Run: firebase apps:list --project $PROJECT_ID" >&2 + else + echo " Auto-detected app ID: $APP_ID" + fi + fi + + # Crashlytics data via firebase crashlytics:symbols or REST + # firebase CLI doesn't expose crash issue listing directly; + # use the REST API path below with a gcloud-obtained token. + echo "" + echo "NOTE: firebase CLI does not expose crash issue listing." + echo "Switching to REST API (requires gcloud auth)..." + MODE="rest" +fi + +# ── Pull via Crashlytics REST API ──────────────────────────────────────────── +if [[ "$MODE" == "rest" ]]; then + require curl + require python3 + + # Try gcloud first, then fall back to firebase-tools stored token + TOKEN=$(gcloud auth print-access-token 2>/dev/null) || TOKEN="" + + if [[ -z "$TOKEN" ]]; then + FIREBASE_CONFIG="$HOME/.config/configstore/firebase-tools.json" + if [[ -f "$FIREBASE_CONFIG" ]]; then + TOKEN=$(python3 -c " +import json, sys +with open('$FIREBASE_CONFIG') as f: + d = json.load(f) +tokens = d.get('tokens', {}) +print(tokens.get('access_token', ''), end='') +" 2>/dev/null) + fi + fi + + if [[ -z "$TOKEN" ]]; then + echo "ERROR: Not authenticated. Run: firebase login --no-localhost" >&2 + exit 1 + fi + + if [[ -z "${APP_ID:-}" ]]; then + echo "Fetching app list from Firebase Management API..." + APPS_JSON=$(curl -s \ + -H "Authorization: Bearer $TOKEN" \ + "https://firebase.googleapis.com/v1beta1/projects/${PROJECT_ID}/androidApps") + + APP_ID=$(echo "$APPS_JSON" | python3 -c " +import json, sys +data = json.load(sys.stdin) +apps = data.get('apps', []) +if not apps: + print('', end='') +else: + print(apps[0]['appId'], end='') +" 2>/dev/null || true) + + if [[ -z "$APP_ID" ]]; then + echo "ERROR: No Android apps found in project $PROJECT_ID" >&2 + echo "Response: $APPS_JSON" >&2 + exit 1 + fi + echo " Auto-detected app ID: $APP_ID" + fi + + # Crashlytics issue list via v1alpha1 API + ISSUES_URL="https://firebasecrashlytics.googleapis.com/v1alpha1/projects/${PROJECT_ID}/apps/${APP_ID}/issues?pageSize=${LIMIT}&orderBy=eventCount+desc" + + echo "" + echo "Fetching top $LIMIT crash issues..." + ISSUES_FILE="$OUT_DIR/issues.json" + + curl -s \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + "$ISSUES_URL" \ + -o "$ISSUES_FILE" + + ISSUE_COUNT=$(python3 -c " +import json, sys +with open('$ISSUES_FILE') as f: + data = json.load(f) +issues = data.get('issues', []) +print(len(issues)) +" 2>/dev/null || echo "0") + + echo " Found $ISSUE_COUNT issues. Saved to $ISSUES_FILE" + echo "" + + # Pretty-print summary + python3 -c " +import json, sys + +with open('$ISSUES_FILE') as f: + data = json.load(f) + +issues = data.get('issues', []) +if not issues: + print('No crash issues found (or API returned an error).') + print('Raw response:') + print(json.dumps(data, indent=2)) + sys.exit(0) + +print(f'{'ID':<30} {'EVENTS':>8} {'USERS':>7} TITLE') +print('-' * 90) +for issue in issues: + issue_id = issue.get('issueId', 'N/A')[:30] + title = issue.get('title', 'N/A')[:50] + event_cnt = issue.get('eventCount', '?') + user_cnt = issue.get('impactedUsersCount', '?') + print(f'{issue_id:<30} {str(event_cnt):>8} {str(user_cnt):>7} {title}') +" + + # Pull individual events for each issue + echo "" + echo "Fetching events per issue..." + python3 -c " +import json, sys, subprocess, os + +with open('$ISSUES_FILE') as f: + data = json.load(f) + +issues = data.get('issues', []) +token = '$TOKEN' +project = '$PROJECT_ID' +app_id = '$APP_ID' +out_dir = '$OUT_DIR' + +for issue in issues[:5]: # cap at 5 issues for detail pull + issue_id = issue.get('issueId', '') + if not issue_id: + continue + + url = (f'https://firebasecrashlytics.googleapis.com/v1alpha1/' + f'projects/{project}/apps/{app_id}/issues/{issue_id}/events?pageSize=5') + + out_file = os.path.join(out_dir, f'events_{issue_id[:20]}.json') + result = subprocess.run( + ['curl', '-s', '-H', f'Authorization: Bearer {token}', url], + capture_output=True, text=True + ) + with open(out_file, 'w') as f: + f.write(result.stdout) + + try: + events = json.loads(result.stdout).get('events', []) + print(f' {issue_id[:30]}: {len(events)} events → {out_file}') + except Exception: + print(f' {issue_id[:30]}: parse error → {out_file}') +" +fi + +echo "" +echo "Done. Files written to: $OUT_DIR" +ls -lh "$OUT_DIR" -- cgit v1.2.3 From 5d358cd570075d36a61f9a37bb80c64f8a0a7e2a Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Fri, 3 Apr 2026 08:05:24 +0000 Subject: chore(stories): add claudomator stories for unused results and faked data 6 stories covering findings from codebase audit: - ct-remove-mock-tidal: remove random fake tidal currents near Solent - ct-wire-tidal-to-map: wire tidalCurrentState to MapHandler (never called) - ct-wire-anchor-to-map: wire anchorWatchState to map overlay (text-only) - ct-show-wind-direction: display TWD fetched but not shown in sheet - ct-show-swell-direction: display swell dir fetched but not shown - ct-fix-weather-fallback: remove silent SF fallback in WeatherActivity Co-Authored-By: Claude Sonnet 4.6 --- scripts/.claude/ct-fix-weather-fallback.yaml | 64 +++++++++++ scripts/.claude/ct-remove-mock-tidal.yaml | 83 ++++++++++++++ scripts/.claude/ct-show-swell-direction.yaml | 75 +++++++++++++ scripts/.claude/ct-show-wind-direction.yaml | 92 ++++++++++++++++ scripts/.claude/ct-wind-wiring.txt | 156 +++++++++++++++++++++++++++ scripts/.claude/ct-wire-anchor-to-map.yaml | 82 ++++++++++++++ scripts/.claude/ct-wire-tidal-to-map.yaml | 75 +++++++++++++ 7 files changed, 627 insertions(+) create mode 100644 scripts/.claude/ct-fix-weather-fallback.yaml create mode 100644 scripts/.claude/ct-remove-mock-tidal.yaml create mode 100644 scripts/.claude/ct-show-swell-direction.yaml create mode 100644 scripts/.claude/ct-show-wind-direction.yaml create mode 100644 scripts/.claude/ct-wind-wiring.txt create mode 100644 scripts/.claude/ct-wire-anchor-to-map.yaml create mode 100644 scripts/.claude/ct-wire-tidal-to-map.yaml (limited to 'scripts') diff --git a/scripts/.claude/ct-fix-weather-fallback.yaml b/scripts/.claude/ct-fix-weather-fallback.yaml new file mode 100644 index 0000000..97823c7 --- /dev/null +++ b/scripts/.claude/ct-fix-weather-fallback.yaml @@ -0,0 +1,64 @@ +name: "Fix WeatherActivity silent fallback to San Francisco" +description: "WeatherActivity silently uses lat=37.8, lon=-122.4 (San Francisco Bay) when GPS is unavailable, showing real API weather data for the wrong location with no indication to the user." +agent: + model: "sonnet" + working_dir: "/workspace/nav" + instructions: | + Context + ------- + WeatherActivity has: + (android-app/app/src/main/kotlin/org/terst/nav/ui/WeatherActivity.kt, lines ~23-24) + + private val defaultLat = 37.8 + private val defaultLon = -122.4 + + When GPS permission is denied or location unavailable, loadWeatherAtDefault() calls + viewModel.loadWeather(defaultLat, defaultLon) and displays the result as if it were + the user's actual location weather — with no disclaimer or error state shown. + + Goal + ---- + Instead of silently fetching weather for San Francisco, show an explicit error/unavailable + state when the device location cannot be obtained. Do not fetch any weather data in that path. + + Remove defaultLat/defaultLon and loadWeatherAtDefault() entirely. + When location is unavailable, set UiState.Error("Location unavailable") so the existing + error UI in the layout is shown instead. + + Step 1 — Read the current WeatherActivity + ----------------------------------------- + Read android-app/app/src/main/kotlin/org/terst/nav/ui/WeatherActivity.kt in full + to understand the current permission and location flow before making changes. + + Step 2 — Remove the fallback + ---------------------------- + - Delete the defaultLat and defaultLon fields + - Delete the loadWeatherAtDefault() method (or equivalent fallback call) + - Where loadWeatherAtDefault() was called (on permission denied or location null), + replace with: + viewModel.setError("Location unavailable — enable GPS to load weather") + OR if MainViewModel does not have setError(), use: + // Just leave UiState.Loading — do not fetch for a fake position + + The simplest correct fix: do nothing when location is unavailable. + The existing "Loading" spinner is less harmful than wrong-location data. + + Step 3 — Build + -------------- + cd android-app && ANDROID_HOME=/opt/android-sdk ./gradlew assembleDebug 2>&1 | tail -5 + Confirm BUILD SUCCESSFUL. + + Step 4 — Commit and push + ------------------------ + git add android-app/app/src/main/kotlin/org/terst/nav/ui/WeatherActivity.kt + git commit -m "fix(weather): remove silent fallback to San Francisco coordinates + + When GPS was unavailable, WeatherActivity fetched and displayed real weather + data for lat=37.8 lon=-122.4 (San Francisco Bay) with no indication to the + user. Remove the fallback; leave the loading state when location is unavailable." + git push github main && git push local main +timeout: "15m" +tags: + - "weather" + - "bug" + - "ux" diff --git a/scripts/.claude/ct-remove-mock-tidal.yaml b/scripts/.claude/ct-remove-mock-tidal.yaml new file mode 100644 index 0000000..86d4e76 --- /dev/null +++ b/scripts/.claude/ct-remove-mock-tidal.yaml @@ -0,0 +1,83 @@ +name: "Remove MockTidalCurrentGenerator" +description: "LocationService generates random fake tidal current arrows near a hardcoded Solent coordinate (50.8, -1.3) every 60 seconds regardless of boat position. Remove the mock generator; leave the layer empty until a real tidal API is integrated." +agent: + model: "sonnet" + working_dir: "/workspace/nav" + instructions: | + Context + ------- + In LocationService.onCreate() there is a coroutine loop: + (android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt, lines ~113-119) + + serviceScope.launch { + while (true) { + val currents = MockTidalCurrentGenerator.generateMockCurrents() + _tidalCurrentState.update { it.copy(currents = currents) } + kotlinx.coroutines.delay(60000) + } + } + + MockTidalCurrentGenerator.generateMockCurrents() produces 10 TidalCurrent objects with: + - Random speedKnots (0–5 kn) + - Random directionDegrees (0–360°) + - Positions scattered within 0.05° of lat=50.8, lon=-1.3 (Solent, UK) + regardless of actual boat position. + (android-app/app/src/main/kotlin/org/terst/nav/MockTidalCurrentGenerator.kt) + + TidalCurrentState has an isVisible flag (defaults false) and a currents list. + MapHandler.updateTidalCurrents() respects isVisible — when false it clears the layer. + + Goal + ---- + 1. Remove the mock generator loop from LocationService.onCreate() + 2. Delete MockTidalCurrentGenerator.kt + 3. Ensure _tidalCurrentState starts with isVisible=false and empty currents (the default) + 4. Remove the import of MockTidalCurrentGenerator from LocationService + + Do NOT remove TidalCurrentState, _tidalCurrentState, or the tidalCurrentState public flow — + those are needed when a real tidal API is added later. + Do NOT remove ACTION_TOGGLE_TIDAL_VISIBILITY handling in onStartCommand — keep the + visibility toggle working for future use. + + Step 1 — Remove the mock loop from LocationService + --------------------------------------------------- + In android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt: + + Delete the entire block (including the comment): + // Mock tidal current data generator + serviceScope.launch { + while (true) { + val currents = MockTidalCurrentGenerator.generateMockCurrents() + _tidalCurrentState.update { it.copy(currents = currents) } + kotlinx.coroutines.delay(60000) // Update every minute + } + } + + Remove the import: + import org.terst.nav.MockTidalCurrentGenerator + + Step 2 — Delete MockTidalCurrentGenerator.kt + --------------------------------------------- + Delete android-app/app/src/main/kotlin/org/terst/nav/MockTidalCurrentGenerator.kt + + Step 3 — Build + -------------- + cd android-app && ANDROID_HOME=/opt/android-sdk ./gradlew assembleDebug 2>&1 | tail -5 + Confirm BUILD SUCCESSFUL with no references to MockTidalCurrentGenerator remaining. + + Step 4 — Commit and push + ------------------------ + git add -A + git commit -m "chore: remove MockTidalCurrentGenerator + + The mock generator was producing 10 random tidal current arrows near a + hardcoded Solent coordinate (50.8, -1.3) every 60 seconds regardless of + boat position. Remove it entirely. The tidal overlay infrastructure + (TidalCurrentState, mapHandler.updateTidalCurrents) is retained for when + a real tidal current API is integrated." + git push github main && git push local main +timeout: "15m" +tags: + - "cleanup" + - "tidal" + - "mock-data" diff --git a/scripts/.claude/ct-show-swell-direction.yaml b/scripts/.claude/ct-show-swell-direction.yaml new file mode 100644 index 0000000..6021bf0 --- /dev/null +++ b/scripts/.claude/ct-show-swell-direction.yaml @@ -0,0 +1,75 @@ +name: "Display swell direction in instrument sheet" +description: "MarineConditions.swellDirDeg is fetched from Open-Meteo and stored but never shown. The Swell cell shows height and period but drops the direction." +agent: + model: "sonnet" + working_dir: "/workspace/nav" + instructions: | + Context + ------- + fetchCurrentConditions() populates MarineConditions.swellDirDeg from + swell_wave_direction (degrees true from Open-Meteo). + (android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt, line 13) + + The instrument sheet expanded section has a Swell column: + (android-app/app/src/main/res/layout/layout_instruments_sheet.xml) + + + + + + + MainActivity's marineConditions collector sets swellHt and swellPer but ignores swellDirDeg: + (android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt, marineConditions collector) + + InstrumentHandler.updateConditions() has swellPer but no swellDir parameter. + (android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt) + + Goal + ---- + Show swell direction by updating value_swell_per to contain both direction and period + (e.g. "045° / 8 s"), avoiding the need for a fourth TextView in an already-tight column. + Alternatively add a third sub-label TextView — use whichever fits better visually. + + The simplest approach: combine direction and period into value_swell_per as "DIR° / Xs". + + Step 1 — Update updateConditions() in InstrumentHandler + -------------------------------------------------------- + In android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt, + the swellPer parameter already exists. No layout change needed if we combine + direction + period into that field. + + Step 2 — Update the marineConditions collector in MainActivity + -------------------------------------------------------------- + In android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt, + in observeDataSources(), update the swellPer line in updateConditions() to combine + direction and period: + + swellPer = when { + c.swellDirDeg != null && c.swellPeriodS != null -> + "%.0f° / %.0f s".format(Locale.getDefault(), c.swellDirDeg, c.swellPeriodS) + c.swellPeriodS != null -> + "%.0f s".format(Locale.getDefault(), c.swellPeriodS) + c.swellDirDeg != null -> + "%.0f°".format(Locale.getDefault(), c.swellDirDeg) + else -> "—" + } + + Step 3 — Build + -------------- + cd android-app && ANDROID_HOME=/opt/android-sdk ./gradlew assembleDebug 2>&1 | tail -5 + Confirm BUILD SUCCESSFUL. + + Step 4 — Commit and push + ------------------------ + git add android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt + git commit -m "feat(instruments): show swell direction alongside swell period + + swellDirDeg was fetched from Open-Meteo and stored in MarineConditions + but never displayed. Combines direction and period in the swell sub-label + as e.g. '045° / 8 s'." + git push github main && git push local main +timeout: "15m" +tags: + - "instruments" + - "swell" + - "forecast" diff --git a/scripts/.claude/ct-show-wind-direction.yaml b/scripts/.claude/ct-show-wind-direction.yaml new file mode 100644 index 0000000..3ac9276 --- /dev/null +++ b/scripts/.claude/ct-show-wind-direction.yaml @@ -0,0 +1,92 @@ +name: "Display forecast wind direction (TWD) in instrument sheet" +description: "MarineConditions.windDirDeg is fetched from Open-Meteo and stored in the model but never displayed anywhere. Show it as a sub-label under the TWS cell." +agent: + model: "sonnet" + working_dir: "/workspace/nav" + instructions: | + Context + ------- + fetchCurrentConditions() in WeatherRepository populates MarineConditions.windDirDeg + from the Open-Meteo hourly winddirection_10m field (in degrees true). + (android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt, line ~63) + (android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt, line 9) + + In MainActivity.observeDataSources(), the marineConditions collector reads windSpeedKt + (displays as TWS) but never reads windDirDeg: + (android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt, marineConditions collector) + + The instrument sheet layout has a TWS cell: + (android-app/app/src/main/res/layout/layout_instruments_sheet.xml) + + + Goal + ---- + Show wind direction as a secondary line below the TWS value (e.g. "045°") using the + same pattern used for curr_dir below curr_spd in the conditions section. + + Step 1 — Add value_twd TextView to layout + ------------------------------------------ + In android-app/app/src/main/res/layout/layout_instruments_sheet.xml, + find the TWS LinearLayout: + + + + + + + Add a third child TextView for the direction: + + + + + + + + Step 2 — Add valueTwd field to InstrumentHandler + ------------------------------------------------- + In android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt: + + Add constructor parameter: + private val valueTwd: TextView + + Add twd parameter to updateDisplay(): + twd: String? = null + + Add in the updateDisplay body: + twd?.let { valueTwd.text = it } + + Step 3 — Wire in MainActivity + ------------------------------ + In android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt, + in setupHandlers(), add to InstrumentHandler constructor: + valueTwd = findViewById(R.id.value_twd) + + In setupHandlers(), add "—" initial value: + instrumentHandler?.updateDisplay(..., twd = "—") + + In observeDataSources(), in the marineConditions collector, add: + instrumentHandler?.updateDisplay( + tws = c.windSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) } ?: "—", + twd = c.windDirDeg?.let { "%.0f°".format(Locale.getDefault(), it) } ?: "—" + ) + + Step 4 — Build + -------------- + cd android-app && ANDROID_HOME=/opt/android-sdk ./gradlew assembleDebug 2>&1 | tail -5 + Confirm BUILD SUCCESSFUL. + + Step 5 — Commit and push + ------------------------ + git add android-app/app/src/main/res/layout/layout_instruments_sheet.xml \ + android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt \ + android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt + git commit -m "feat(instruments): display forecast wind direction (TWD) under TWS cell + + windDirDeg was fetched from Open-Meteo and stored in MarineConditions + but never shown. Adds value_twd sub-label below the TWS primary value." + git push github main && git push local main +timeout: "15m" +tags: + - "instruments" + - "wind" + - "forecast" diff --git a/scripts/.claude/ct-wind-wiring.txt b/scripts/.claude/ct-wind-wiring.txt new file mode 100644 index 0000000..09768ab --- /dev/null +++ b/scripts/.claude/ct-wind-wiring.txt @@ -0,0 +1,156 @@ +Context +------- +MainViewModel.addGpsPoint() (android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt, +line 67) builds TrackPoint with hardcoded wind zeroes: + + windSpeedKnots = 0.0, windAngleDeg = 0.0, isTrueWind = false + +LocationService already exposes live NMEA wind data: + LocationService.nmeaWindDataFlow: SharedFlow (companion object, line 362) + +WindData (android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt): + windAngle: Double // degrees, relative or true + windSpeed: Double // knots + isTrueWind: Boolean + timestampMs: Long + +TrackPoint (android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt) already has +windSpeedKnots, windAngleDeg, isTrueWind fields — they just aren't populated. + +Goal +---- +Cache the latest WindData in MainViewModel and use it when building TrackPoints. +Default to zero wind if no NMEA wind sentence has arrived yet. + +Step 1 — Red: write failing tests +---------------------------------- +Create android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelWindTest.kt + +Setup boilerplate needed (Dispatchers.setMain / resetMain with UnconfinedTestDispatcher): + + import androidx.arch.core.executor.testing.InstantTaskExecutorRule + import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.ExperimentalCoroutinesApi + import kotlinx.coroutines.test.UnconfinedTestDispatcher + import kotlinx.coroutines.test.resetMain + import kotlinx.coroutines.test.runTest + import kotlinx.coroutines.test.setMain + import org.junit.After + import org.junit.Assert.assertEquals + import org.junit.Before + import org.junit.Rule + import org.junit.Test + import org.terst.nav.sensors.WindData + import org.terst.nav.ui.MainViewModel + + @OptIn(ExperimentalCoroutinesApi::class) + class MainViewModelWindTest { + + @get:Rule val instantTask = InstantTaskExecutorRule() + private val dispatcher = UnconfinedTestDispatcher() + + @Before fun setUp() { Dispatchers.setMain(dispatcher) } + @After fun tearDown() { Dispatchers.resetMain() } + + @Test fun `addGpsPoint uses zero wind when no wind update received`() = runTest { + val vm = MainViewModel() + vm.startTrack() + vm.addGpsPoint(37.0, -122.0, 5.0, 90.0) + val pt = vm.trackPoints.value.first() + assertEquals(0.0, pt.windSpeedKnots, 0.001) + assertEquals(0.0, pt.windAngleDeg, 0.001) + assertEquals(false, pt.isTrueWind) + } + + @Test fun `addGpsPoint uses latest wind data after updateWind`() = runTest { + val vm = MainViewModel() + vm.updateWind(WindData(windAngle = 45.0, windSpeed = 15.5, isTrueWind = true, timestampMs = 1000L)) + vm.startTrack() + vm.addGpsPoint(37.0, -122.0, 5.0, 90.0) + val pt = vm.trackPoints.value.first() + assertEquals(15.5, pt.windSpeedKnots, 0.001) + assertEquals(45.0, pt.windAngleDeg, 0.001) + assertEquals(true, pt.isTrueWind) + } + + @Test fun `addGpsPoint reflects wind update between fixes`() = runTest { + val vm = MainViewModel() + vm.startTrack() + vm.addGpsPoint(37.0, -122.0, 5.0, 90.0) + vm.updateWind(WindData(windAngle = 180.0, windSpeed = 8.0, isTrueWind = false, timestampMs = 2000L)) + vm.addGpsPoint(37.1, -122.0, 5.0, 90.0) + val pts = vm.trackPoints.value + assertEquals(0.0, pts[0].windSpeedKnots, 0.001) + assertEquals(8.0, pts[1].windSpeedKnots, 0.001) + } + } + +Run and confirm RED: + cd android-app && ./gradlew :app:testDebugUnitTest --tests "*MainViewModelWind*" + +Step 2 — Green: implement +-------------------------- +In android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt: + +1. Add import: + import org.terst.nav.sensors.WindData + +2. Add field after trackRepository: + private var latestWind: WindData? = null + +3. Add method: + fun updateWind(wind: WindData) { + latestWind = wind + } + +4. Update addGpsPoint() to use latestWind: + fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) { + val wind = latestWind + val point = TrackPoint( + lat = lat, lon = lon, + sogKnots = sogKnots, cogDeg = cogDeg, + windSpeedKnots = wind?.windSpeed ?: 0.0, + windAngleDeg = wind?.windAngle ?: 0.0, + isTrueWind = wind?.isTrueWind ?: false, + timestampMs = System.currentTimeMillis() + ) + if (trackRepository.addPoint(point)) { + _trackPoints.value = trackRepository.getPoints() + } + } + +Run and confirm GREEN: + cd android-app && ./gradlew :app:testDebugUnitTest --tests "*MainViewModelWind*" + +Step 3 — Wire: feed wind updates from LocationService +------------------------------------------------------ +In android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt, +inside observeDataSources(), add after the anchorWatchState collector: + + lifecycleScope.launch { + LocationService.nmeaWindDataFlow.collect { wind -> + viewModel.updateWind(wind) + } + } + +No additional imports needed (LocationService is already imported). + +Step 4 — Verify all tests pass +------------------------------- +cd android-app && ./gradlew :app:testDebugUnitTest + +Confirm all existing tests still pass alongside the 3 new wind tests. + +Step 5 — Commit and push +------------------------- +git add -A && git commit -m "feat(track): wire NMEA wind data into GPS track points + +MainViewModel caches the latest WindData from LocationService.nmeaWindDataFlow +via updateWind(). addGpsPoint() populates TrackPoint wind fields from the cache, +defaulting to zero if no NMEA wind sentence has arrived yet. + +MainActivity.observeDataSources() feeds LocationService.nmeaWindDataFlow +into viewModel.updateWind() alongside the existing GPS and anchor observers. + +3 new unit tests in MainViewModelWindTest verify zero-default, wind capture, +and mid-track wind update behaviour." && git push origin main diff --git a/scripts/.claude/ct-wire-anchor-to-map.yaml b/scripts/.claude/ct-wire-anchor-to-map.yaml new file mode 100644 index 0000000..9ef8513 --- /dev/null +++ b/scripts/.claude/ct-wire-anchor-to-map.yaml @@ -0,0 +1,82 @@ +name: "Wire anchor watch state to map overlay" +description: "LocationService.anchorWatchState is collected in MainActivity but only updates SafetyFragment text — MapHandler.updateAnchorWatch() is never called, so the anchor point and radius circle never appear on the map" +agent: + model: "sonnet" + working_dir: "/workspace/nav" + instructions: | + Context + ------- + LocationService.anchorWatchState: StateFlow + (android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt, companion, line ~353) + + MapHandler.updateAnchorWatch(state: AnchorWatchState) is fully implemented — it shows + an anchor icon and radius circle on the map, or clears them when inactive: + (android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt, line ~139) + + In MainActivity.observeDataSources(), anchorWatchState IS collected, but only + to update a text field in SafetyFragment: + LocationService.anchorWatchState.collect { state -> + safetyFragment.updateAnchorStatus(...) + } + There is no call to mapHandler?.updateAnchorWatch(state) anywhere. + + AnchorWatchState: + (android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt) + data class AnchorWatchState( + val isActive: Boolean, + val anchorLocation: Location?, + val watchCircleRadiusMeters: Double, + val setTimeMillis: Long + ) + + The map style must be loaded before calling updateAnchorWatch — gate on loadedStyleFlow. + + Goal + ---- + In the existing anchorWatchState collector in observeDataSources(), add a call to + mapHandler?.updateAnchorWatch(state) alongside the existing safetyFragment update. + Gate the map call on the style being loaded. + + Step 1 — Update the existing collector + --------------------------------------- + In android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt, + find the anchorWatchState collector in observeDataSources(): + + lifecycleScope.launch { + LocationService.anchorWatchState.collect { state -> + safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive") + } + } + + Replace with: + + lifecycleScope.launch { + LocationService.anchorWatchState.collect { state -> + safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive") + if (loadedStyleFlow.value != null) { + mapHandler?.updateAnchorWatch(state) + } + } + } + + No import changes needed. + + Step 2 — Build + -------------- + cd android-app && ANDROID_HOME=/opt/android-sdk ./gradlew assembleDebug 2>&1 | tail -5 + Confirm BUILD SUCCESSFUL. + + Step 3 — Commit and push + ------------------------ + git add android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt + git commit -m "fix(map): wire anchor watch state to map overlay + + The anchorWatchState collector in observeDataSources() was only updating + SafetyFragment text. Now also calls mapHandler.updateAnchorWatch(state) + when the map style is loaded, showing the anchor icon and radius circle." + git push github main && git push local main +timeout: "15m" +tags: + - "map" + - "anchor" + - "wiring" diff --git a/scripts/.claude/ct-wire-tidal-to-map.yaml b/scripts/.claude/ct-wire-tidal-to-map.yaml new file mode 100644 index 0000000..b5a1923 --- /dev/null +++ b/scripts/.claude/ct-wire-tidal-to-map.yaml @@ -0,0 +1,75 @@ +name: "Wire tidal current state to map overlay" +description: "LocationService.tidalCurrentState is emitted but never collected in MainActivity — MapHandler.updateTidalCurrents() is never called, so the tidal arrow layer is always empty" +agent: + model: "sonnet" + working_dir: "/workspace/nav" + instructions: | + Context + ------- + LocationService exposes a public StateFlow: + LocationService.tidalCurrentState: StateFlow + (android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt, companion object, line ~354) + + MapHandler has a fully-implemented method: + fun updateTidalCurrents(state: TidalCurrentState) + (android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt, line ~155) + + MainActivity.observeDataSources() + (android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt) + never subscribes to tidalCurrentState, so updateTidalCurrents() is never called. + The tidal arrow overlay on the map is always empty regardless of what data arrives. + + The map style must be loaded before calling updateTidalCurrents — use loadedStyleFlow + as a gate, the same way updateTrackLayer is gated (see the combine() pattern already + in observeDataSources for track points). + + Goal + ---- + Subscribe to LocationService.tidalCurrentState in MainActivity.observeDataSources() + and forward each emission to mapHandler?.updateTidalCurrents(state). + + The subscription should be gated on loadedStyleFlow being non-null (style loaded), + matching the pattern used for track points. + + Step 1 — Add the observer + ------------------------- + In android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt, + inside observeDataSources(), add after the existing collectors: + + lifecycleScope.launch { + loadedStyleFlow.filterNotNull().collect { + LocationService.tidalCurrentState.collect { state -> + mapHandler?.updateTidalCurrents(state) + } + } + } + + Or equivalently using combine(), consistent with the track pattern: + + lifecycleScope.launch { + loadedStyleFlow.filterNotNull() + .combine(LocationService.tidalCurrentState) { _, state -> state } + .collect { state -> mapHandler?.updateTidalCurrents(state) } + } + + No import changes needed — LocationService is already imported. + + Step 2 — Build + -------------- + cd android-app && ANDROID_HOME=/opt/android-sdk ./gradlew assembleDebug 2>&1 | tail -5 + Confirm BUILD SUCCESSFUL. + + Step 3 — Commit and push + ------------------------ + git add android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt + git commit -m "fix(map): wire tidal current state to map overlay in MainActivity + + LocationService.tidalCurrentState was emitted but never collected. + Subscribe in observeDataSources() gated on style load, forwarding + each TidalCurrentState to mapHandler.updateTidalCurrents()." + git push github main && git push local main +timeout: "15m" +tags: + - "map" + - "tidal" + - "wiring" -- cgit v1.2.3