diff --git a/src/app/new-contact/page.tsx b/src/app/new-contact/page.tsx index 2970bdc..c0ab0c2 100644 --- a/src/app/new-contact/page.tsx +++ b/src/app/new-contact/page.tsx @@ -46,6 +46,7 @@ import { kmToMiles, longPathBearingDeg, longPathKm, + resolveOriginGrid, } from '@/lib/grid'; void _PageHeader; @@ -55,6 +56,10 @@ interface Station { callsign: string; station_name: string; is_default: boolean; + // The station's own Maidenhead locator — the grid the QSO is actually + // transmitted from, used as the origin for the distance/bearing readout so a + // portable/POTA op isn't measured from their home account grid. + grid_locator?: string; } interface PreviousContact { @@ -482,15 +487,20 @@ export default function NewContactPage() { const station = stations.find((s) => s.id.toString() === selectedStationId); - // Distance and bearing from the operator's home grid to the contact — the - // "how far, which way" readout hams expect while logging. Prefers the - // contact's explicit coordinates (from a QRZ lookup) and falls back to the - // grid square typed into the form; renders nothing until both endpoints - // resolve, so a half-typed grid never shows a bogus reading. + // Distance and bearing from the operator's transmitting grid to the contact — + // the "how far, which way" readout hams expect while logging. The origin is + // the *selected station's* grid (where the QSO is actually made) and only + // falls back to the account home grid when the station has none, so a + // portable/POTA op isn't measured from home. Prefers the contact's explicit + // coordinates (from a QRZ lookup) and falls back to the grid square typed into + // the form; renders nothing until both endpoints resolve, so a half-typed grid + // never shows a bogus reading. const pathInfo = (() => { - const from = currentUser?.grid_locator - ? gridToLatLon(currentUser.grid_locator) - : null; + const originGrid = resolveOriginGrid( + station?.grid_locator, + currentUser?.grid_locator, + ); + const from = originGrid ? gridToLatLon(originGrid) : null; const to = formData.latitude !== undefined && formData.longitude !== undefined ? { lat: formData.latitude, lon: formData.longitude } diff --git a/src/lib/grid.ts b/src/lib/grid.ts index 83ed5e1..c38cae9 100644 --- a/src/lib/grid.ts +++ b/src/lib/grid.ts @@ -165,6 +165,24 @@ export function gridPath(from: string, to: string): PathInfo | null { }; } +// Pick the operator's transmitting grid for the distance/bearing readout on the +// logging form. A QSO is made from the *station actually on the air*, which for +// a POTA/portable/rover operator is often a different grid than their home +// account — so the on-air station's locator wins, falling back to the account +// home grid. A blank or malformed value at either level is ignored (rather than +// yielding a bogus origin), and the winner is normalized to trimmed uppercase so +// it feeds gridToLatLon like any other locator. Returns null when neither +// resolves, so the readout simply shows nothing. +export function resolveOriginGrid( + stationGrid: string | null | undefined, + homeGrid: string | null | undefined, +): string | null { + for (const grid of [stationGrid, homeGrid]) { + if (grid && isValidGrid(grid)) return grid.trim().toUpperCase(); + } + return null; +} + const KM_PER_MILE = 1.609344; // Kilometers → statute miles (US operators log distance in miles as often as km). diff --git a/tests/grid.spec.ts b/tests/grid.spec.ts index d1b7474..b095967 100644 --- a/tests/grid.spec.ts +++ b/tests/grid.spec.ts @@ -9,6 +9,7 @@ import { kmToMiles, longPathBearingDeg, longPathKm, + resolveOriginGrid, EARTH_CIRCUMFERENCE_KM, } from '@/lib/grid'; @@ -214,3 +215,33 @@ test.describe('kmToMiles', () => { expect(kmToMiles(100)).toBeCloseTo(62.137, 2); }); }); + +test.describe('resolveOriginGrid', () => { + test('prefers the on-air station grid over the account home grid', () => { + // A POTA/portable op logging from a station in a different grid than their + // home account — the readout origin must follow the station on the air. + expect(resolveOriginGrid('IO91wm', 'FN31pr')).toBe('IO91WM'); + }); + + test('falls back to the home grid when the station has none', () => { + expect(resolveOriginGrid(undefined, 'FN31pr')).toBe('FN31PR'); + expect(resolveOriginGrid('', 'FN31pr')).toBe('FN31PR'); + expect(resolveOriginGrid(null, 'FN31pr')).toBe('FN31PR'); + }); + + test('normalizes to trimmed uppercase', () => { + expect(resolveOriginGrid(' fn31 ', undefined)).toBe('FN31'); + }); + + test('skips a malformed grid at either level', () => { + // A half-configured station locator must not override a valid home grid… + expect(resolveOriginGrid('nope', 'FN31pr')).toBe('FN31PR'); + // …and an invalid home grid with no station grid resolves to nothing. + expect(resolveOriginGrid(undefined, 'nope')).toBeNull(); + }); + + test('returns null when neither grid resolves', () => { + expect(resolveOriginGrid(undefined, undefined)).toBeNull(); + expect(resolveOriginGrid('', '')).toBeNull(); + }); +});