feat(entry): type a frequency or a band into the call field

An operator reading a cluster page sees 28500 and wants to be there. The
hands are on the callsign field; reaching for the frequency box, or the
mouse, is the part that loses the station. So the call field takes a bare
number on Enter: kHz for a frequency, or a band on its own.

Ambiguity resolves to the band on purpose -- 40 is 40 m, not 40 kHz --
because neither of those kHz values is anywhere a radio tunes, and it
reads the way an operator says it. Anything with a letter in it is a
callsign and behaves exactly as before; a number that is neither a band
nor a plausible frequency does nothing at all.

Bands go through the ordinary band change, so the antennas, the per-band
power table and the outbound integrations hear about it as they would
from the dropdown. Also adds 4 m to the QSY table, so a band that can now
be typed is a band that has a frequency to go to.
This commit is contained in:
2026-08-24 17:42:42 +02:00
parent 1f94d0ef08
commit ea0789b191
2 changed files with 65 additions and 2 deletions
+61
View File
@@ -292,6 +292,37 @@ function shortCatError(err?: string): string {
}
// bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' if outside
// every known allocation (used to auto-fill the band when the freq changes).
// A number typed into the CALLSIGN field is a QSY, not a callsign.
//
// It is where the hands already are: an operator watching a cluster or a
// spotting page reads "28500" and wants to be there, and reaching for the
// frequency box — or worse, the mouse — is the part that loses the station. So
// the call field takes it, on Enter, and nothing else in the entry form changes.
//
// Two forms, and the AMBIGUOUS ones resolve to the band deliberately. "160" is
// the 160 m band, not 160 kHz; "40" is 40 m, not 40 kHz. Neither number is a
// frequency anyone tunes to, so reading them as bands costs nothing and reads
// the way an operator says it out loud.
const ENTRY_BAND_WORDS: Record<string, string> = {
'2200': '2200m', '630': '630m', '160': '160m', '80': '80m', '60': '60m',
'40': '40m', '30': '30m', '20': '20m', '17': '17m', '15': '15m', '12': '12m',
'10': '10m', '6': '6m', '4': '4m', '2': '2m', '70': '70cm',
};
// entryQSYCommand reads what was typed. Returns null for anything that is not
// unambiguously one of the two — a callsign must never be mistaken for a QSY.
function entryQSYCommand(text: string): { band?: string; hz?: number } | null {
const v = text.trim();
if (!/^\d{1,7}$/.test(v)) return null; // digits only: a callsign has letters
const band = ENTRY_BAND_WORDS[v];
if (band) return { band };
const khz = Number(v);
// 1500 kHz to 1.3 GHz: below that is not a band anyone works, above it is a
// typo. A bare "500" is neither a band nor a frequency, so nothing happens.
if (khz >= 1500 && khz <= 1_300_000) return { hz: Math.round(khz * 1000) };
return null;
}
function bandForMHz(mhz: number): string {
if (!mhz || isNaN(mhz)) return '';
const plan: [number, number, string][] = [
@@ -702,6 +733,7 @@ export default function App() {
'15m': { SSB: 21300000, CW: 21030000, FT8: 21074000, FT4: 21140000, RTTY: 21080000, default: 21300000 },
'12m': { SSB: 24950000, CW: 24910000, FT8: 24915000, FT4: 24919000, RTTY: 24920000, default: 24950000 },
'10m': { SSB: 28500000, CW: 28030000, FT8: 28074000, FT4: 28180000, RTTY: 28080000, default: 28500000 },
'4m': { SSB: 70200000, CW: 70100000, FT8: 70154000, default: 70200000 },
'6m': { SSB: 50150000, CW: 50080000, FT8: 50313000, FT4: 50318000, default: 50150000 },
'2m': { SSB: 144300000, CW: 144050000, FT8: 144174000, default: 144300000 },
'70cm': { SSB: 432300000, CW: 432050000, FT8: 432174000, default: 432300000 },
@@ -5469,6 +5501,25 @@ export default function App() {
noteManualEdit();
SetCATFrequency(Math.round(mhz * 1_000_000)).catch(() => {});
};
// Carry out what was typed in the call field. Bands go through the ordinary
// band change, so the antennas, the power table and the outbound integrations
// all hear about it exactly as they would from the dropdown.
const applyEntryQSY = (q: { band?: string; hz?: number }) => {
if (q.band) {
onBandUserChange(q.band);
showToast(q.band);
return;
}
const hz = q.hz ?? 0;
if (hz <= 0) return;
noteManualEdit();
setFreqMhz((hz / 1_000_000).toFixed(6));
const b = bandForMHz(hz / 1_000_000);
if (b) setBand(b);
if (catState.enabled && catState.connected) SetCATFrequency(hz).catch(() => {});
showToast((hz / 1_000_000).toFixed(3) + ' MHz');
};
// Mouse-wheel over a kHz digit of the top frequency readout: step the frequency
// and (if CAT is connected) QSY the rig. The display updates optimistically on
// every notch; the actual radio tune is debounced so a fast scroll doesn't flood
@@ -6835,6 +6886,16 @@ export default function App() {
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
e.preventDefault();
// A number in the call field is a QSY. Checked BEFORE ESM and before
// logging: both would otherwise act on a "callsign" of 28500.
if (e.target === callsignRef.current) {
const q = entryQSYCommand(callsignRef.current?.value ?? '');
if (q) {
applyEntryQSY(q);
setCallsign(''); // the field goes back to what it is for
return;
}
}
// ESM (Enter Sends Message): fire the stage-appropriate CW macro instead
// of logging. Falls through to the normal log when ESM isn't active.
if (esmHandleEnter(e.target as HTMLElement)) return;