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
9 changes: 8 additions & 1 deletion src/app/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions src/lib/modes.ts
Original file line number Diff line number Diff line change
@@ -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];
56 changes: 56 additions & 0 deletions tests/modes.spec.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Loading