From b03d3c853be01490d1a629c83ff54827523b4e9a Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Fri, 24 Jul 2026 14:07:03 +0000 Subject: [PATCH] fix(grid): accept 8-char extended locators in logging form & stations API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Claude Opus 4.8 (1M context) --- src/app/api/stations/[id]/route.ts | 14 +++++++------- src/app/api/stations/route.ts | 14 +++++++------- src/app/new-contact/page.tsx | 10 ++++------ src/lib/grid.ts | 13 +++++++++++++ tests/grid.spec.ts | 23 +++++++++++++++++++++++ 5 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/app/api/stations/[id]/route.ts b/src/app/api/stations/[id]/route.ts index 8dc66e0..43362bd 100644 --- a/src/app/api/stations/[id]/route.ts +++ b/src/app/api/stations/[id]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { Station } from '@/models/Station'; import { verifyToken } from '@/lib/auth'; import { encryptString } from '@/lib/lotw'; +import { gridLocatorError } from '@/lib/grid'; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { @@ -61,14 +62,13 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ data.callsign = data.callsign.toUpperCase(); } - // Validate grid locator if provided + // Validate grid locator if provided. Uses the canonical validator so an + // 8-char extended locator (the on-air grid a VHF/microwave op transmits from, + // and the origin of the logging-form distance readout) saves rather than 400s. if (data.grid_locator) { - const gridRegex = /^[A-R]{2}[0-9]{2}([A-X]{2})?$/; - if (!gridRegex.test(data.grid_locator.toUpperCase())) { - return NextResponse.json( - { error: 'Invalid grid locator format' }, - { status: 400 } - ); + const gridError = gridLocatorError(data.grid_locator); + if (gridError) { + return NextResponse.json({ error: gridError }, { status: 400 }); } data.grid_locator = data.grid_locator.toUpperCase(); } diff --git a/src/app/api/stations/route.ts b/src/app/api/stations/route.ts index 4c5a839..021855b 100644 --- a/src/app/api/stations/route.ts +++ b/src/app/api/stations/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { Station } from '@/models/Station'; import { verifyToken } from '@/lib/auth'; +import { gridLocatorError } from '@/lib/grid'; export async function GET(request: NextRequest) { try { @@ -47,14 +48,13 @@ export async function POST(request: NextRequest) { ); } - // Validate grid locator if provided + // Validate grid locator if provided. Uses the canonical validator so an + // 8-char extended locator (the on-air grid a VHF/microwave op transmits from, + // and the origin of the logging-form distance readout) saves rather than 400s. if (data.grid_locator) { - const gridRegex = /^[A-R]{2}[0-9]{2}([A-X]{2})?$/; - if (!gridRegex.test(data.grid_locator.toUpperCase())) { - return NextResponse.json( - { error: 'Invalid grid locator format' }, - { status: 400 } - ); + const gridError = gridLocatorError(data.grid_locator); + if (gridError) { + return NextResponse.json({ error: gridError }, { status: 400 }); } } diff --git a/src/app/new-contact/page.tsx b/src/app/new-contact/page.tsx index c0ab0c2..f3dcb75 100644 --- a/src/app/new-contact/page.tsx +++ b/src/app/new-contact/page.tsx @@ -40,6 +40,7 @@ import { frequencyToBand, AMATEUR_BANDS } from '@/lib/bands'; import { AMATEUR_MODES, defaultRstForMode } from '@/lib/modes'; import { gridToLatLon, + gridLocatorError, distanceKm, bearingDeg, compassPoint, @@ -349,12 +350,9 @@ export default function NewContactPage() { ? null : 'Invalid callsign format'; }; - const validateGridLocator = (grid: string): string | null => { - if (!grid.trim()) return null; - return /^[A-R]{2}[0-9]{2}([A-X]{2})?$/i.test(grid) - ? null - : 'Invalid grid locator format (e.g., FN31pr)'; - }; + // Delegates to the canonical validator in @/lib/grid so the logging form and + // the stations API stay in lockstep and both accept 8-char extended locators. + const validateGridLocator = (grid: string): string | null => gridLocatorError(grid); const validateFrequency = (frequency: string): string | null => { if (!frequency.trim()) return null; const freq = parseFloat(frequency); diff --git a/src/lib/grid.ts b/src/lib/grid.ts index c38cae9..e6eec80 100644 --- a/src/lib/grid.ts +++ b/src/lib/grid.ts @@ -23,6 +23,19 @@ export function isValidGrid(grid: string): boolean { return GRID_RE.test(grid.trim().toUpperCase()); } +// Validation helper for form/API inputs where the grid is optional: returns null +// when the field is blank OR a well-formed locator, and a human-readable error +// message otherwise. Centralizing this on isValidGrid keeps the logging form and +// the stations API from re-deriving their own regex — which is how they drifted +// behind the 8-char (extended) locators the rest of the app already accepts, +// silently rejecting a valid VHF/microwave grid on save. +export function gridLocatorError(grid: string): string | null { + if (!grid.trim()) return null; + return isValidGrid(grid) + ? null + : 'Invalid grid locator format (e.g., FN31, FN31pr, or FN31pr55)'; +} + // Convert a Maidenhead locator to the latitude/longitude of the *center* of the // square (4-char), subsquare (6-char), or extended square (8-char). Returns // null for anything that isn't a valid locator. Centering matches diff --git a/tests/grid.spec.ts b/tests/grid.spec.ts index b095967..46b30d9 100644 --- a/tests/grid.spec.ts +++ b/tests/grid.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '@playwright/test'; import { isValidGrid, + gridLocatorError, gridToLatLon, distanceKm, bearingDeg, @@ -41,6 +42,28 @@ test.describe('isValidGrid', () => { }); }); +test.describe('gridLocatorError', () => { + test('treats blank/whitespace as no error (grid is optional)', () => { + expect(gridLocatorError('')).toBeNull(); + expect(gridLocatorError(' ')).toBeNull(); + }); + + test('accepts 4-, 6-, and 8-character locators like isValidGrid', () => { + expect(gridLocatorError('FN31')).toBeNull(); + expect(gridLocatorError('FN31pr')).toBeNull(); + // The regression this guards: an 8-char extended locator is valid app-wide + // (isValidGrid, gridToLatLon) and must not be rejected by form/API checks. + expect(gridLocatorError('FN31pr55')).toBeNull(); + expect(gridLocatorError(' jn58td99 ')).toBeNull(); + }); + + test('returns a helpful message for a malformed locator', () => { + expect(gridLocatorError('nope')).toContain('Invalid grid locator'); + expect(gridLocatorError('FN31p')).toContain('Invalid grid locator'); + expect(gridLocatorError('FN3155')).toContain('Invalid grid locator'); + }); +}); + test.describe('gridToLatLon', () => { test('returns the center of a 4-character square', () => { // JJ00 straddles the prime meridian / equator origin of the grid; its