feat(lists): a satellite list, offered on the entry form

SAT_NAME is compared character for character by the awards and by LoTW:
AO-91 and AO91 are two different satellites to everything downstream, and
typing it afresh on every pass is how one of them ends up in a log. So
the station keeps its own list (Preferences → Lists → Satellites) and the
field offers it, alphabetically — while still accepting anything typed,
because a bird worked once and never added to the list must not be
impossible to log.

Not seeded with a shipped list of two dozen birds: an empty list means
this station does not work satellites, and filling the dropdown with
names nobody here has heard makes the field harder to use, not easier.

Also fixes the RDA district comparison: its conflict list was capped at
about six visible rows of a list holding up to two hundred, in a panel
that would not scroll to show the rest, and nothing in it could be acted
on. Taller, scrolling, and a callsign opens the contact.
This commit is contained in:
2026-08-25 19:30:29 +02:00
parent 12b0a861e0
commit 1e87081058
7 changed files with 145 additions and 32 deletions
+68 -5
View File
@@ -175,6 +175,11 @@ interface Props {
flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane
icomAvailable?: boolean; // CAT backend is Icom → offer the Icom console as a Main pane
yaesuAvailable?: boolean; // CAT backend is Yaesu → offer the Yaesu console as a Main pane
// Opens a QSO in the editor. Settings is not where a log is edited — but the
// RDA comparison lists contacts whose district is in dispute, and a list of
// things to fix that cannot be acted on is a list to write down and look up
// again later.
onEditQSO?: (id: number) => void;
}
// Pretty little card showing what OpsLog will stamp on each QSO based on
@@ -200,6 +205,7 @@ type SectionId =
| 'lookup'
| 'lists-bands'
| 'lists-modes'
| 'lists-satellites'
| 'cluster'
| 'backup'
| 'database'
@@ -310,6 +316,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
{ kind: 'group', label: t('nav.lists'), icon: Database, defaultOpen: true, children: [
{ kind: 'item', label: t('sec.bands'), id: 'lists-bands' },
{ kind: 'item', label: t('sec.modes'), id: 'lists-modes' },
{ kind: 'item', label: t('sec.satellites'), id: 'lists-satellites' },
]},
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
@@ -339,7 +346,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
// Map section id → i18n key (breadcrumb / placeholders).
const SECTION_KEY: Partial<Record<SectionId, string>> = {
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
'external-services': 'sec.external', appearance: 'sec.appearance', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
'external-services': 'sec.external', appearance: 'sec.appearance', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes', 'lists-satellites': 'sec.satellites',
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
adifmon: 'sec.adifmon',
foldersync: 'sec.foldersync',
@@ -361,6 +368,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
lookup: 'Callsign Lookup',
'lists-bands': 'Bands',
'lists-modes': 'Modes & default RST',
'lists-satellites': 'Satellites',
cluster: 'DX Cluster',
backup: 'Database backup',
database: 'Database',
@@ -1516,7 +1524,7 @@ function brandOfBackend(backend: string, kenwoodLink?: string): { brand: string;
}
}
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable, yaesuAvailable }: Props) {
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable, yaesuAvailable, onEditQSO }: Props) {
const { t } = useI18n();
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
const [loading, setLoading] = useState(true);
@@ -1545,7 +1553,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [activeProfile, setActiveProfile] = useState<Profile | null>(null);
const updateActive = (patch: Partial<Profile>) =>
setActiveProfile((p) => (p ? { ...p, ...patch } : p));
const [lists, setLists] = useState<ListsSettings>({ bands: [], modes: [], rst_phone: [], rst_cw: [], rst_digital: [] });
const [lists, setLists] = useState<ListsSettings>({ bands: [], modes: [], rst_phone: [], rst_cw: [], rst_digital: [], satellites: [] });
// RST report lists edited as free text (one/space-separated values).
const [rstText, setRstText] = useState({ phone: '', cw: '', digital: '' });
// Custom band drafts (catalog covers ADIF spec but the user may have
@@ -2931,6 +2939,40 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
);
}
function SatellitesPanel() {
const sats = lists.satellites ?? [];
return (
<>
<SectionHeader title={t('sec.satellites')} hint={t('sat.hint')} />
<div className="space-y-3 max-w-xl">
<div className="space-y-1">
<Label>{t('sat.listLabel')}</Label>
{/* Raw text, one per line, parsed on change not a row-per-entry
editor with add and delete buttons. The list is short, edited
twice a year, and usually arrives pasted from a satellite
tracker; a textarea takes that paste in one gesture. */}
<textarea
className="w-full h-56 rounded-md border border-input bg-background p-2 font-mono text-xs"
value={sats.join('\n')}
placeholder={'AO-7\nAO-91\nRS-44\nSO-50'}
onChange={(e) => {
const next = e.target.value.split('\n').map((v) => v.trim());
setLists((s) => ({ ...s, satellites: next }));
}}
onBlur={() => setLists((s) => ({
// Tidied when the field is LEFT, never while typing: dropping an
// empty line as it is typed makes the Enter key look broken.
...s,
satellites: Array.from(new Set((s.satellites ?? []).map((v) => v.trim().toUpperCase()).filter(Boolean))).sort(),
}))}
/>
<p className="text-xs text-muted-foreground">{t('sat.listHint')}</p>
</div>
</div>
</>
);
}
function ModesPanel() {
const selected = lists.modes ?? [];
const selectedSet = new Set(selected.map((m) => (m.name ?? '').toUpperCase()));
@@ -7402,7 +7444,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{/* The disagreements themselves. A count alone would say "there is a
problem" and leave the operator with no way to look at it. */}
{rdaCmp?.conflicts?.length > 0 && (
<div className="max-h-56 overflow-auto rounded border border-border">
<>
<p className="text-[11px] text-muted-foreground">
{t('rda.cmpListHint', { n: rdaCmp.conflicts.length, d: rdaCmp.disagree ?? 0 })}
</p>
{/* Tall enough to work through. It was capped at 224 px about six
rows of a list that can hold two hundred inside a panel that
does not scroll to reveal what the box could not show. */}
<div className="max-h-[28rem] overflow-y-auto overscroll-contain rounded border border-border">
<table className="w-full text-[11px]">
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
<tr>
@@ -7416,7 +7465,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<tbody>
{rdaCmp.conflicts.map((c: any) => (
<tr key={c.qso_id} className="border-b border-border/30">
<td className="py-1 px-2 font-mono font-semibold">{c.callsign}</td>
{/* The callsign opens the contact. A list of things to fix
that cannot be acted on is a list of things to write
down and look up again later. */}
<td className="py-1 px-2 font-mono font-semibold">
{onEditQSO ? (
<button type="button"
onClick={() => { onEditQSO(c.qso_id); onClose(); }}
title={t('rda.cmpOpen')}
className="underline decoration-dotted underline-offset-2 hover:text-primary">
{c.callsign}
</button>
) : c.callsign}
</td>
<td className="py-1 pr-2 font-mono">{c.date}</td>
<td className="py-1 pr-2 font-mono">{c.from_log}{c.confirmed ? ' ✓' : ''}</td>
<td className="py-1 pr-2 font-mono">{c.from_db}</td>
@@ -7428,6 +7489,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</tbody>
</table>
</div>
</>
)}
</div>
</div>
@@ -7447,6 +7509,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
lookup: LookupPanel,
'lists-bands': BandsPanel,
'lists-modes': ModesPanel,
'lists-satellites': SatellitesPanel,
cluster: ClusterPanel,
udp: UDPIntegrationsPanelWrapper,
// Module-scope components, wrapped so their props can be passed. The nested