Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions src/app/new-contact/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
kmToMiles,
longPathBearingDeg,
longPathKm,
resolveOriginGrid,
} from '@/lib/grid';

void _PageHeader;
Expand All @@ -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 {
Expand Down Expand Up @@ -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 }
Expand Down
18 changes: 18 additions & 0 deletions src/lib/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
31 changes: 31 additions & 0 deletions tests/grid.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
kmToMiles,
longPathBearingDeg,
longPathKm,
resolveOriginGrid,
EARTH_CIRCUMFERENCE_KM,
} from '@/lib/grid';

Expand Down Expand Up @@ -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();
});
});
Loading