From 4cdd9c5614b04579f405814ef748a8e19eb5b92f Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Fri, 24 Jul 2026 08:08:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(search):=20offer=20every=20stored=20mode?= =?UTF-8?q?=20in=20the=20mode=20filter=20(JS8/FST4/JT65/Q65/PSK63/Olivia/?= =?UTF-8?q?=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search mode dropdown hard-coded a 12-entry list that, among digital modes, only knew FT4/FT8/PSK31/MFSK. But ADIF import promotes a WSJT-X / fldigi SUBMODE to the effective mode (resolveAdifMode in @/lib/adif), so a QSO can land in the log as JS8, FST4, JT65, JT9, Q65, PSK63, Olivia or Contestia — none of which the dropdown offered, leaving those contacts unfilterable by mode. Introduce a canonical mode list at @/lib/modes (mirroring @/lib/bands: a server-imports-free single source of truth) and point the search filter at it. Matching is already case-insensitive on the server (UPPER(mode) = UPPER($n)), so the uppercase values match stored data. Guarded by tests/modes.spec.ts, in the same spirit as bands.spec.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/search/page.tsx | 9 ++++++- src/lib/modes.ts | 40 +++++++++++++++++++++++++++++ tests/modes.spec.ts | 56 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 src/lib/modes.ts create mode 100644 tests/modes.spec.ts diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 3589fc6..dcdbc2e 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -22,6 +22,7 @@ import QRZSyncIndicator from '@/components/QRZSyncIndicator'; import DynamicContactMap from '@/components/DynamicContactMap'; import { useUser } from '@/contexts/UserContext'; import { AMATEUR_BANDS } from '@/lib/bands'; +import { AMATEUR_MODES } from '@/lib/modes'; interface Contact { id: number; @@ -81,7 +82,13 @@ interface PaginationInfo { pages: number; } -const MODES = ['AM', 'FM', 'FT8', 'MFSK', 'RTTY', 'SSB', 'CW', 'FT4', 'PSK31', 'DMR', 'DSTAR', 'YSF']; +// Mode options come from the canonical mode list (@/lib/modes) — the set of +// modes Nextlog can store. The previous hard-coded list knew only +// FT4/FT8/PSK31/MFSK among the digital modes, so a QSO whose mode was promoted +// from a WSJT-X / fldigi SUBMODE on import (JS8, FST4, JT65, Q65, PSK63, Olivia, +// Contestia, …) could never be filtered by mode. Matching is case-insensitive +// on the server (UPPER(mode) = UPPER($n)), so the uppercase values match stored data. +const MODES = AMATEUR_MODES; // Band options come from the canonical band plan (@/lib/bands) — the same list // the logging forms (new-contact, QuickLogCard) store — so every band an // operator can log is also filterable here. The previous hard-coded lowercase diff --git a/src/lib/modes.ts b/src/lib/modes.ts new file mode 100644 index 0000000..849b828 --- /dev/null +++ b/src/lib/modes.ts @@ -0,0 +1,40 @@ +// Amateur-radio operating modes — the single source of truth for the modes +// Nextlog can store, shared by the contact-search mode filter (and available to +// any other UI that needs to offer a mode list). +// +// Like @/lib/bands this module is intentionally free of server-only imports (no +// `pg`, no db pool) so it can be pulled into client components without dragging +// the database driver into the browser bundle. +// +// Why a canonical list matters: modes are stored flat in `contacts.mode`, and +// two paths write that column — +// 1. the logging forms (new-contact, QuickLogCard), which offer a short menu; +// 2. ADIF import, where resolveAdifMode (@/lib/adif) *promotes* a WSJT-X / +// fldigi SUBMODE to the effective mode — so a run logged under the generic +// MFSK/PSK parent lands as "FT4", "JS8", "FST4", "OLIVIA", etc. +// The search dropdown previously hard-coded a 12-entry list that only knew +// FT4/FT8/PSK31/MFSK, so a logged JS8, FST4, JT65, Q65, PSK63, Olivia or +// Contestia QSO could never be filtered by mode. Search matches modes with +// case-insensitive equality (UPPER(mode) = UPPER($n) in @/lib/contact-search), +// so these uppercase values match whatever import/logging stored. + +// Ordered so the everyday modes an operator reaches for most sit at the top of +// the dropdown, then the weak-signal digital modes, then keyboard/FSK, image, +// and digital-voice modes. Values are the uppercase form used across the app. +export const AMATEUR_MODES = [ + // Phone + CW — the bread and butter, and the modes the logging forms default to. + 'SSB', 'CW', 'FT8', 'FT4', 'AM', 'FM', + // WSJT-X / weak-signal digital. FT8/FT4 above; the rest are frequently logged + // (or imported) as their own mode via SUBMODE promotion. + 'JS8', 'FST4', 'JT65', 'JT9', 'Q65', 'MSK144', + // Keyboard-to-keyboard / FSK / PSK digital. + 'RTTY', 'PSK31', 'PSK63', 'MFSK', 'OLIVIA', 'CONTESTIA', 'HELL', 'DOMINO', 'THOR', + // Image. + 'SSTV', + // Digital voice. + 'DMR', 'DSTAR', 'C4FM', 'YSF', 'M17', 'FREEDV', + // Packet / ATV. + 'PACKET', 'ATV', +] as const; + +export type AmateurMode = (typeof AMATEUR_MODES)[number]; diff --git a/tests/modes.spec.ts b/tests/modes.spec.ts new file mode 100644 index 0000000..5c34248 --- /dev/null +++ b/tests/modes.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test'; +import { AMATEUR_MODES } from '@/lib/modes'; + +// The mode list is the canonical set of operating modes Nextlog can store, and +// the single source of truth the contact-search mode dropdown draws from. These +// tests guard that source of truth the same way bands.spec.ts guards the band +// plan: no duplicates, canonical uppercase, and — crucially — every mode an +// operator can end up with in the log is offered as a filter option. + +test.describe('modes module', () => { + test('AMATEUR_MODES has no duplicates and is canonical uppercase', () => { + expect(new Set(AMATEUR_MODES).size).toBe(AMATEUR_MODES.length); + for (const mode of AMATEUR_MODES) { + expect(mode).toBe(mode.toUpperCase()); + expect(mode.trim()).toBe(mode); + expect(mode.length).toBeGreaterThan(0); + } + }); + + test('offers every mode the logging forms can store', () => { + // The new-contact page and QuickLogCard let an operator pick any of these, + // so each must be filterable back out in search. + const loggingFormModes = ['SSB', 'CW', 'FT8', 'FT4', 'RTTY', 'PSK31', 'AM', 'FM']; + for (const mode of loggingFormModes) { + expect(AMATEUR_MODES).toContain(mode); + } + }); + + test('offers the digital submodes ADIF import promotes to the stored mode', () => { + // resolveAdifMode (@/lib/adif) promotes a WSJT-X / fldigi SUBMODE to the + // effective mode on import — an FT4 run logged under MFSK becomes "FT4", + // JS8 becomes "JS8", etc. The old hard-coded search dropdown listed only + // FT4/FT8/PSK31/MFSK, so a logged JS8, FST4, JT65, Q65, OLIVIA or CONTESTIA + // QSO could never be filtered by mode. Guard that they are now offered. + const importPromotedSubmodes = [ + 'JS8', 'FST4', 'JT65', 'JT9', 'Q65', 'MSK144', 'PSK63', 'OLIVIA', 'CONTESTIA', + ]; + for (const mode of importPromotedSubmodes) { + expect(AMATEUR_MODES).toContain(mode); + } + }); + + test('keeps every mode the previous search dropdown already offered (no regression)', () => { + const legacySearchModes = [ + 'AM', 'FM', 'FT8', 'MFSK', 'RTTY', 'SSB', 'CW', 'FT4', 'PSK31', 'DMR', 'DSTAR', 'YSF', + ]; + for (const mode of legacySearchModes) { + expect(AMATEUR_MODES).toContain(mode); + } + }); + + test('leads with the everyday phone and CW modes so the dropdown is fast to scan', () => { + // Operators reach for SSB/CW/FT8 far more than Olivia; keep them at the top. + expect(AMATEUR_MODES.slice(0, 4)).toEqual(['SSB', 'CW', 'FT8', 'FT4']); + }); +});