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:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ComputeQSOAwardRefs, IsNewUSCounty } from '../../wailsjs/go/main/App';
|
||||
import { ComputeQSOAwardRefs, IsNewUSCounty, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
@@ -153,6 +154,15 @@ function Field({ label, span = 1, className, children }: { label: string; span?:
|
||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||
// The station's satellites, read once. Alphabetical because that is the only
|
||||
// order a list of birds has, and an operator hunting AO-91 in a list sorted
|
||||
// by when it was added is worse off than with a plain text box.
|
||||
const [satellites, setSatellites] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
GetListsSettings()
|
||||
.then((l: any) => setSatellites([...((l?.satellites ?? []) as string[])].filter(Boolean).sort()))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||
|
||||
// Live award detection: run the SAME engine used at log time over the current
|
||||
@@ -478,7 +488,18 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
||||
{satelliteMode && (
|
||||
<>
|
||||
<Field label={t('detp.satName')} span={3}>
|
||||
<Input value={details.sat_name} onChange={(e) => onChange({ sat_name: e.target.value })} />
|
||||
{/* The station's own satellites, in alphabetical order, with the
|
||||
box still open to anything typed: SAT_NAME is compared
|
||||
character for character by the awards and by LoTW, so a
|
||||
remembered spelling beats a fresh one every pass — but a bird
|
||||
worked once and never added to the list must not be
|
||||
impossible to log. */}
|
||||
<Combobox
|
||||
value={details.sat_name}
|
||||
options={satellites}
|
||||
placeholder={t('detp.satName')}
|
||||
onChange={(v) => onChange({ sat_name: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.satelliteMode')} span={3}>
|
||||
<Input value={details.sat_mode} onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user