Skip to content

Commit ca9dbee

Browse files
patrickrbOptio Agentclaude
authored
fix(grid): accept 8-char extended locators in logging form & stations API (#249)
Commit #246 added canonical 8-character (extended) Maidenhead support to `src/lib/grid.ts` (`isValidGrid`, `gridToLatLon`), and #248 uses the on-air station's grid as the distance/bearing origin. But three input validators kept their own 4/6-only regex and drifted behind: - `new-contact` form's inline `validateGridLocator` - `POST /api/stations` - `PUT /api/stations/[id]` Result: typing an 8-char grid (e.g. `FN31pr55`) into the logging form showed "Invalid grid locator format" and blocked save, and saving an 8-char station grid — the exact locator used as the distance origin — 400'd. VHF/UHF/ microwave and satellite operators log 8-char locators for the extra precision, so this silently rejected valid input. Centralize the check as `gridLocatorError(grid)` in the canonical grid module (null for blank/valid, message otherwise) and route all three call sites through it, so the form and the API can never drift from `isValidGrid` again. Tested: added unit coverage for `gridLocatorError` (blank, 4/6/8-char, malformed); `grid.spec.ts` 30/30 pass; typecheck, lint, and build all clean. Co-authored-by: Optio Agent <optio-agent@noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d6c6322 commit ca9dbee

5 files changed

Lines changed: 54 additions & 20 deletions

File tree

src/app/api/stations/[id]/route.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
22
import { Station } from '@/models/Station';
33
import { verifyToken } from '@/lib/auth';
44
import { encryptString } from '@/lib/lotw';
5+
import { gridLocatorError } from '@/lib/grid';
56

67
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
78
try {
@@ -61,14 +62,13 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
6162
data.callsign = data.callsign.toUpperCase();
6263
}
6364

64-
// Validate grid locator if provided
65+
// Validate grid locator if provided. Uses the canonical validator so an
66+
// 8-char extended locator (the on-air grid a VHF/microwave op transmits from,
67+
// and the origin of the logging-form distance readout) saves rather than 400s.
6568
if (data.grid_locator) {
66-
const gridRegex = /^[A-R]{2}[0-9]{2}([A-X]{2})?$/;
67-
if (!gridRegex.test(data.grid_locator.toUpperCase())) {
68-
return NextResponse.json(
69-
{ error: 'Invalid grid locator format' },
70-
{ status: 400 }
71-
);
69+
const gridError = gridLocatorError(data.grid_locator);
70+
if (gridError) {
71+
return NextResponse.json({ error: gridError }, { status: 400 });
7272
}
7373
data.grid_locator = data.grid_locator.toUpperCase();
7474
}

src/app/api/stations/route.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import { Station } from '@/models/Station';
33
import { verifyToken } from '@/lib/auth';
4+
import { gridLocatorError } from '@/lib/grid';
45

56
export async function GET(request: NextRequest) {
67
try {
@@ -47,14 +48,13 @@ export async function POST(request: NextRequest) {
4748
);
4849
}
4950

50-
// Validate grid locator if provided
51+
// Validate grid locator if provided. Uses the canonical validator so an
52+
// 8-char extended locator (the on-air grid a VHF/microwave op transmits from,
53+
// and the origin of the logging-form distance readout) saves rather than 400s.
5154
if (data.grid_locator) {
52-
const gridRegex = /^[A-R]{2}[0-9]{2}([A-X]{2})?$/;
53-
if (!gridRegex.test(data.grid_locator.toUpperCase())) {
54-
return NextResponse.json(
55-
{ error: 'Invalid grid locator format' },
56-
{ status: 400 }
57-
);
55+
const gridError = gridLocatorError(data.grid_locator);
56+
if (gridError) {
57+
return NextResponse.json({ error: gridError }, { status: 400 });
5858
}
5959
}
6060

src/app/new-contact/page.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { frequencyToBand, AMATEUR_BANDS } from '@/lib/bands';
4040
import { AMATEUR_MODES, defaultRstForMode } from '@/lib/modes';
4141
import {
4242
gridToLatLon,
43+
gridLocatorError,
4344
distanceKm,
4445
bearingDeg,
4546
compassPoint,
@@ -349,12 +350,9 @@ export default function NewContactPage() {
349350
? null
350351
: 'Invalid callsign format';
351352
};
352-
const validateGridLocator = (grid: string): string | null => {
353-
if (!grid.trim()) return null;
354-
return /^[A-R]{2}[0-9]{2}([A-X]{2})?$/i.test(grid)
355-
? null
356-
: 'Invalid grid locator format (e.g., FN31pr)';
357-
};
353+
// Delegates to the canonical validator in @/lib/grid so the logging form and
354+
// the stations API stay in lockstep and both accept 8-char extended locators.
355+
const validateGridLocator = (grid: string): string | null => gridLocatorError(grid);
358356
const validateFrequency = (frequency: string): string | null => {
359357
if (!frequency.trim()) return null;
360358
const freq = parseFloat(frequency);

src/lib/grid.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ export function isValidGrid(grid: string): boolean {
2323
return GRID_RE.test(grid.trim().toUpperCase());
2424
}
2525

26+
// Validation helper for form/API inputs where the grid is optional: returns null
27+
// when the field is blank OR a well-formed locator, and a human-readable error
28+
// message otherwise. Centralizing this on isValidGrid keeps the logging form and
29+
// the stations API from re-deriving their own regex — which is how they drifted
30+
// behind the 8-char (extended) locators the rest of the app already accepts,
31+
// silently rejecting a valid VHF/microwave grid on save.
32+
export function gridLocatorError(grid: string): string | null {
33+
if (!grid.trim()) return null;
34+
return isValidGrid(grid)
35+
? null
36+
: 'Invalid grid locator format (e.g., FN31, FN31pr, or FN31pr55)';
37+
}
38+
2639
// Convert a Maidenhead locator to the latitude/longitude of the *center* of the
2740
// square (4-char), subsquare (6-char), or extended square (8-char). Returns
2841
// null for anything that isn't a valid locator. Centering matches

tests/grid.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { test, expect } from '@playwright/test';
22
import {
33
isValidGrid,
4+
gridLocatorError,
45
gridToLatLon,
56
distanceKm,
67
bearingDeg,
@@ -41,6 +42,28 @@ test.describe('isValidGrid', () => {
4142
});
4243
});
4344

45+
test.describe('gridLocatorError', () => {
46+
test('treats blank/whitespace as no error (grid is optional)', () => {
47+
expect(gridLocatorError('')).toBeNull();
48+
expect(gridLocatorError(' ')).toBeNull();
49+
});
50+
51+
test('accepts 4-, 6-, and 8-character locators like isValidGrid', () => {
52+
expect(gridLocatorError('FN31')).toBeNull();
53+
expect(gridLocatorError('FN31pr')).toBeNull();
54+
// The regression this guards: an 8-char extended locator is valid app-wide
55+
// (isValidGrid, gridToLatLon) and must not be rejected by form/API checks.
56+
expect(gridLocatorError('FN31pr55')).toBeNull();
57+
expect(gridLocatorError(' jn58td99 ')).toBeNull();
58+
});
59+
60+
test('returns a helpful message for a malformed locator', () => {
61+
expect(gridLocatorError('nope')).toContain('Invalid grid locator');
62+
expect(gridLocatorError('FN31p')).toContain('Invalid grid locator');
63+
expect(gridLocatorError('FN3155')).toContain('Invalid grid locator');
64+
});
65+
});
66+
4467
test.describe('gridToLatLon', () => {
4568
test('returns the center of a 4-character square', () => {
4669
// JJ00 straddles the prime meridian / equator origin of the grid; its

0 commit comments

Comments
 (0)