Skip to content

Commit d6c6322

Browse files
patrickrbOptio Agentclaude
authored
feat(logging): measure distance & bearing from the on-air station's grid (#248)
The "how far, which way" readout on the logging form always computed distance and bearing from the account's home grid (currentUser.grid_locator), ignoring the grid of the station actually selected for the QSO. A portable/POTA/rover operator working from a different grid than their home account therefore saw a distance and beam heading measured from the wrong place. Resolve the origin from the selected station's grid_locator first, falling back to the account home grid only when the station has none. A new pure helper `resolveOriginGrid(stationGrid, homeGrid)` in @/lib/grid encapsulates the precedence, ignores a blank/malformed locator at either level, and normalizes the winner to trimmed uppercase — unit-tested alongside the rest of the grid math. Co-authored-by: Optio Agent <optio-agent@noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8d8212e commit d6c6322

3 files changed

Lines changed: 67 additions & 8 deletions

File tree

src/app/new-contact/page.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
kmToMiles,
4747
longPathBearingDeg,
4848
longPathKm,
49+
resolveOriginGrid,
4950
} from '@/lib/grid';
5051

5152
void _PageHeader;
@@ -55,6 +56,10 @@ interface Station {
5556
callsign: string;
5657
station_name: string;
5758
is_default: boolean;
59+
// The station's own Maidenhead locator — the grid the QSO is actually
60+
// transmitted from, used as the origin for the distance/bearing readout so a
61+
// portable/POTA op isn't measured from their home account grid.
62+
grid_locator?: string;
5863
}
5964

6065
interface PreviousContact {
@@ -482,15 +487,20 @@ export default function NewContactPage() {
482487

483488
const station = stations.find((s) => s.id.toString() === selectedStationId);
484489

485-
// Distance and bearing from the operator's home grid to the contact — the
486-
// "how far, which way" readout hams expect while logging. Prefers the
487-
// contact's explicit coordinates (from a QRZ lookup) and falls back to the
488-
// grid square typed into the form; renders nothing until both endpoints
489-
// resolve, so a half-typed grid never shows a bogus reading.
490+
// Distance and bearing from the operator's transmitting grid to the contact —
491+
// the "how far, which way" readout hams expect while logging. The origin is
492+
// the *selected station's* grid (where the QSO is actually made) and only
493+
// falls back to the account home grid when the station has none, so a
494+
// portable/POTA op isn't measured from home. Prefers the contact's explicit
495+
// coordinates (from a QRZ lookup) and falls back to the grid square typed into
496+
// the form; renders nothing until both endpoints resolve, so a half-typed grid
497+
// never shows a bogus reading.
490498
const pathInfo = (() => {
491-
const from = currentUser?.grid_locator
492-
? gridToLatLon(currentUser.grid_locator)
493-
: null;
499+
const originGrid = resolveOriginGrid(
500+
station?.grid_locator,
501+
currentUser?.grid_locator,
502+
);
503+
const from = originGrid ? gridToLatLon(originGrid) : null;
494504
const to =
495505
formData.latitude !== undefined && formData.longitude !== undefined
496506
? { lat: formData.latitude, lon: formData.longitude }

src/lib/grid.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,24 @@ export function gridPath(from: string, to: string): PathInfo | null {
165165
};
166166
}
167167

168+
// Pick the operator's transmitting grid for the distance/bearing readout on the
169+
// logging form. A QSO is made from the *station actually on the air*, which for
170+
// a POTA/portable/rover operator is often a different grid than their home
171+
// account — so the on-air station's locator wins, falling back to the account
172+
// home grid. A blank or malformed value at either level is ignored (rather than
173+
// yielding a bogus origin), and the winner is normalized to trimmed uppercase so
174+
// it feeds gridToLatLon like any other locator. Returns null when neither
175+
// resolves, so the readout simply shows nothing.
176+
export function resolveOriginGrid(
177+
stationGrid: string | null | undefined,
178+
homeGrid: string | null | undefined,
179+
): string | null {
180+
for (const grid of [stationGrid, homeGrid]) {
181+
if (grid && isValidGrid(grid)) return grid.trim().toUpperCase();
182+
}
183+
return null;
184+
}
185+
168186
const KM_PER_MILE = 1.609344;
169187

170188
// Kilometers → statute miles (US operators log distance in miles as often as km).

tests/grid.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
kmToMiles,
1010
longPathBearingDeg,
1111
longPathKm,
12+
resolveOriginGrid,
1213
EARTH_CIRCUMFERENCE_KM,
1314
} from '@/lib/grid';
1415

@@ -214,3 +215,33 @@ test.describe('kmToMiles', () => {
214215
expect(kmToMiles(100)).toBeCloseTo(62.137, 2);
215216
});
216217
});
218+
219+
test.describe('resolveOriginGrid', () => {
220+
test('prefers the on-air station grid over the account home grid', () => {
221+
// A POTA/portable op logging from a station in a different grid than their
222+
// home account — the readout origin must follow the station on the air.
223+
expect(resolveOriginGrid('IO91wm', 'FN31pr')).toBe('IO91WM');
224+
});
225+
226+
test('falls back to the home grid when the station has none', () => {
227+
expect(resolveOriginGrid(undefined, 'FN31pr')).toBe('FN31PR');
228+
expect(resolveOriginGrid('', 'FN31pr')).toBe('FN31PR');
229+
expect(resolveOriginGrid(null, 'FN31pr')).toBe('FN31PR');
230+
});
231+
232+
test('normalizes to trimmed uppercase', () => {
233+
expect(resolveOriginGrid(' fn31 ', undefined)).toBe('FN31');
234+
});
235+
236+
test('skips a malformed grid at either level', () => {
237+
// A half-configured station locator must not override a valid home grid…
238+
expect(resolveOriginGrid('nope', 'FN31pr')).toBe('FN31PR');
239+
// …and an invalid home grid with no station grid resolves to nothing.
240+
expect(resolveOriginGrid(undefined, 'nope')).toBeNull();
241+
});
242+
243+
test('returns null when neither grid resolves', () => {
244+
expect(resolveOriginGrid(undefined, undefined)).toBeNull();
245+
expect(resolveOriginGrid('', '')).toBeNull();
246+
});
247+
});

0 commit comments

Comments
 (0)