chore: release v0.26.3
This commit is contained in:
+25
-2
@@ -22,7 +22,7 @@ import {
|
||||
GetSecretStatus, UnlockSecrets,
|
||||
RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||
GetDBConnectionInfo, GetLogbookRevision,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UILog, FlexZoomForSpot,
|
||||
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
||||
GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate,
|
||||
GetScpStatus, ScpLookup,
|
||||
@@ -1690,7 +1690,7 @@ export default function App() {
|
||||
const [clusterLockMode, setClusterLockMode] = useState(() => lsBool('opslog.clusterLockMode', false));
|
||||
// Status filter chips. Empty set = show every status (including
|
||||
// already-worked). Otherwise only matching spots pass.
|
||||
type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
|
||||
type SpotStatusKey = 'new' | 'new-band-mode' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
|
||||
// What the cluster can be FILTERED on. A superset of the entity status: a new
|
||||
// county or a new park is not a DXCC state, it is another dimension of the
|
||||
// same spot — which is why the grid shows them as separate badges. Filtering
|
||||
@@ -3059,6 +3059,10 @@ export default function App() {
|
||||
}
|
||||
if (s.band) setBand(s.band);
|
||||
if (catState.connected) tuneRigCAT(s.freq_hz, m);
|
||||
// The panadapter width follows the MODE of the spot, when the option is on.
|
||||
// AFTER the tune, deliberately: a view change must never delay the frequency
|
||||
// change the operator is waiting for. A no-op on any backend but a Flex.
|
||||
FlexZoomForSpot(m ?? '', s.freq_hz ?? 0).catch(() => {});
|
||||
if (m) applyModeFromSpot(m);
|
||||
onCallsignInput(s.dx_call, { force: true });
|
||||
applySpotPOTA((s as any).pota_ref);
|
||||
@@ -5676,6 +5680,20 @@ export default function App() {
|
||||
>
|
||||
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {band}
|
||||
</button>
|
||||
{/* "My bands" in one click.
|
||||
|
||||
No selection means EVERY band, which is what an operator wants
|
||||
the first time and never again: an RBN feed carries 4 m and
|
||||
70 cm, and the list above can only offer the bands you have
|
||||
configured — so a band you do not own could not be unticked,
|
||||
because it was never there to tick. Selecting your own list
|
||||
makes the filter finite, and the spot goes. */}
|
||||
{clusterBands.size !== bands.length && (
|
||||
<button type="button"
|
||||
onClick={() => setClusterBands(new Set(bands))}
|
||||
title={t('clu.myBandsTitle')}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground underline">{t('clu.myBands')}</button>
|
||||
)}
|
||||
{clusterBands.size > 0 && (
|
||||
<button type="button" onClick={() => setClusterBands(new Set())} className="text-[10px] text-muted-foreground hover:text-foreground underline">{t('clu.clear')}</button>
|
||||
)}
|
||||
@@ -5687,6 +5705,9 @@ export default function App() {
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{([
|
||||
{ k: 'new' as SpotFilterKey, label: 'NEW', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
|
||||
// NEW BAND+MODE sits between NEW and NEW BAND, which is where it
|
||||
// ranks: the entity is worked, but neither this band nor this mode.
|
||||
{ k: 'new-band-mode' as SpotFilterKey, label: 'NEW B+M', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
|
||||
{ k: 'new-band' as SpotFilterKey, label: 'NEW BAND', cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' },
|
||||
{ k: 'new-mode' as SpotFilterKey, label: 'NEW MODE', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||
{ k: 'new-slot' as SpotFilterKey, label: 'NEW SLOT', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||
@@ -7940,6 +7961,8 @@ export default function App() {
|
||||
// place a band map happens to be drawn.
|
||||
fitToBand={bandMapFit}
|
||||
onToggleFit={toggleBandMapFit}
|
||||
hideDigital={bandMapHideFt}
|
||||
onToggleHideDigital={toggleBandMapHideFt}
|
||||
keyNav
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { X, Plus, Loader2 } from 'lucide-react';
|
||||
import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta } from '../../wailsjs/go/main/App';
|
||||
import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta, AwardRefsNew } from '../../wailsjs/go/main/App';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
@@ -54,6 +54,34 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
|
||||
const entries = value ? value.split(';').filter(Boolean) : [];
|
||||
|
||||
// Which of the picked references have never been worked. Asked of the backend
|
||||
// only when the list of references actually changes — a handful of times per
|
||||
// QSO — and it answers from an in-memory set (see awardnew.go).
|
||||
//
|
||||
// The first question about an award may arrive before its set is built, in
|
||||
// which case the answer is "pending" and carries no verdict for it: we ask
|
||||
// again rather than badge on a guess. Attempts are capped so a logbook that
|
||||
// never finishes building cannot leave a timer running for the session.
|
||||
const [isNew, setIsNew] = useState<Record<string, boolean>>({});
|
||||
const entriesKey = entries.join(';');
|
||||
useEffect(() => {
|
||||
if (!entriesKey) { setIsNew({}); return; }
|
||||
let alive = true;
|
||||
let timer: number | undefined;
|
||||
let tries = 0;
|
||||
const ask = () => {
|
||||
AwardRefsNew(entriesKey.split(';'))
|
||||
.then((r: any) => {
|
||||
if (!alive) return;
|
||||
setIsNew(r?.new ?? {});
|
||||
if (r?.pending && ++tries < 10) timer = window.setTimeout(ask, 700);
|
||||
})
|
||||
.catch(() => { if (alive) setIsNew({}); }); // no badge beats a wrong one
|
||||
};
|
||||
ask();
|
||||
return () => { alive = false; if (timer) window.clearTimeout(timer); };
|
||||
}, [entriesKey]);
|
||||
|
||||
// When the reference set is cleared (a QSO was logged or the call was reset),
|
||||
// also blank the picker's own state — the search box and the highlighted /
|
||||
// selected reference — so the Awards tab starts fresh for the next contact.
|
||||
@@ -256,7 +284,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
}`}
|
||||
onClick={() => setSelectedEntry(selectedEntry === entry ? null : entry)}
|
||||
>
|
||||
<span className="font-mono font-semibold flex-1 truncate">{entry}</span>
|
||||
<span className="font-mono font-semibold truncate">{entry}</span>
|
||||
{isNew[entry] && (
|
||||
<span
|
||||
title={t('awrs.newRefHint')}
|
||||
className="shrink-0 px-1 rounded-sm text-[9px] leading-[14px] font-semibold tracking-wide bg-success-muted text-success border border-success-border"
|
||||
>
|
||||
{t('awrs.new')}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
<button
|
||||
className="shrink-0 hover:text-destructive"
|
||||
onClick={(e) => { e.stopPropagation(); removeEntry(entry); }}
|
||||
|
||||
@@ -25,6 +25,16 @@ interface Spot {
|
||||
band?: string;
|
||||
comment?: string;
|
||||
spotter?: string;
|
||||
// The park reference the POTA poller tagged this spot with. The map never
|
||||
// reads it — the click handler upstream turns it into the QSO's POTA award
|
||||
// reference — but it is declared so it cannot be lost by accident.
|
||||
//
|
||||
// It travelled only because the spot objects are passed through by reference
|
||||
// and the type was erased. The first refactor to copy a spot field by field
|
||||
// would have dropped the park silently, and the only symptom would be an
|
||||
// activation logged without its reference. Same mistake as the status entry
|
||||
// below, whose extra markers were arriving and undeclared.
|
||||
pota_ref?: string;
|
||||
}
|
||||
|
||||
// The FULL status entry, not the two fields the map used to declare. The extra
|
||||
@@ -83,11 +93,12 @@ interface Props {
|
||||
// globally from the band-map tab toolbar.
|
||||
hideDigital?: boolean;
|
||||
fitToBand?: boolean;
|
||||
// onToggleFit turns the zoom readout into the fit-to-band switch. Only the
|
||||
// docked map passes it: the Band Map tab has the same control in its own
|
||||
// toolbar, above maps that all obey it at once, and a second switch inside
|
||||
// each card would be four ways to change one setting.
|
||||
// onToggleFit and onToggleHideDigital add the two switches to this map's own
|
||||
// header. Only the docked map passes them: the Band Map tab has both in its
|
||||
// toolbar, above maps that all obey them at once, and a switch inside each
|
||||
// card as well would be four ways to change one setting.
|
||||
onToggleFit?: () => void;
|
||||
onToggleHideDigital?: () => void;
|
||||
// Mark stations that upload to LoTW (Settings → Appearance).
|
||||
showLotw?: boolean;
|
||||
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
|
||||
@@ -164,6 +175,7 @@ function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; labe
|
||||
function statusLabel(s: string, t: (k: string) => string): string {
|
||||
switch (s) {
|
||||
case 'new': return t('bmp.statusNew');
|
||||
case 'new-band-mode': return t('bmp.statusNewBandMode');
|
||||
case 'new-band': return t('bmp.statusNewBand');
|
||||
case 'new-mode': return t('bmp.statusNewMode');
|
||||
case 'new-slot': return t('bmp.statusNewSlot');
|
||||
@@ -191,7 +203,11 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot:
|
||||
// line = SVG leader stroke (visible on hover)
|
||||
// dot = small marker on the freq scale
|
||||
switch (s) {
|
||||
case 'new': return {
|
||||
// NEW and NEW BAND+MODE share the strongest colour: both mean "there is
|
||||
// something here you have never had", and a sixth shade in a palette read at
|
||||
// a glance is a shade nobody can name.
|
||||
case 'new':
|
||||
case 'new-band-mode': return {
|
||||
pill: 'bg-danger-muted text-danger-muted-foreground border-danger-border hover:bg-danger-muted',
|
||||
bar: 'bg-danger',
|
||||
line: 'stroke-danger',
|
||||
@@ -268,7 +284,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
||||
// last; ties broken by closeness to the rig freq).
|
||||
const MAX_VISIBLE_SPOTS = 30;
|
||||
|
||||
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, onToggleFit, keyNav = false, showLotw = false }: Props) {
|
||||
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, onToggleFit, onToggleHideDigital, keyNav = false, showLotw = false }: Props) {
|
||||
const { t } = useI18n();
|
||||
|
||||
// The two display options are applied ONCE here, on the whole map, so the
|
||||
@@ -366,6 +382,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
switch (spotStatus[k]?.status ?? '') {
|
||||
case 'new': return 0;
|
||||
case 'new-band-mode': return 0;
|
||||
case 'new-band': return 1;
|
||||
case 'new-slot': return 2;
|
||||
case 'new-call': return 2;
|
||||
@@ -564,16 +581,18 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
||||
title={t('bmp.zoomOut')}>
|
||||
<Minus className="size-3" />
|
||||
</button>
|
||||
{/* The readout IS the switch when the parent offers one — the header is
|
||||
already tight with four maps side by side, and a separate chip would
|
||||
cost width to say what this text says anyway. */}
|
||||
{onToggleFit ? (
|
||||
<button type="button" onClick={onToggleFit} title={t('bmp.fitTitle')}
|
||||
className={cn('shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-1 rounded border transition-colors',
|
||||
fitToBand ? 'border-primary bg-primary text-primary-foreground' : 'border-border hover:bg-muted')}>
|
||||
{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}
|
||||
</button>
|
||||
) : (
|
||||
{/* A readout, not a switch.
|
||||
|
||||
It WAS the switch for a day: clicking the px/kHz value toggled
|
||||
fit-to-band, and in fit it read "FIT". Nobody found it — a control
|
||||
whose label is a measurement does not look like a control, and once
|
||||
switched off there was no longer anything on screen saying the option
|
||||
existed. The switches below say what they are. */}
|
||||
{/* In fit mode the readout has nothing left to say — the lit FIT button
|
||||
says it, and printing the word twice side by side is just noise. The
|
||||
Band Map tab's cards have no button, so there the readout keeps
|
||||
reporting both states. */}
|
||||
{(!fitToBand || !onToggleFit) && (
|
||||
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
|
||||
)}
|
||||
<button type="button" onClick={() => changeZoom(1)} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
|
||||
@@ -581,6 +600,20 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
||||
title={t('bmp.zoomIn')}>
|
||||
<Plus className="size-3" />
|
||||
</button>
|
||||
{onToggleFit && (
|
||||
<button type="button" onClick={onToggleFit} title={t('bmp.fitTitle')}
|
||||
className={cn('shrink-0 text-[10px] font-semibold normal-case tracking-normal px-1 rounded border transition-colors',
|
||||
fitToBand ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
{t('bmp.fit')}
|
||||
</button>
|
||||
)}
|
||||
{onToggleHideDigital && (
|
||||
<button type="button" onClick={onToggleHideDigital} title={t('bmp.hideFtTitle')}
|
||||
className={cn('shrink-0 text-[10px] font-semibold normal-case tracking-normal px-1 rounded border transition-colors',
|
||||
hideDigital ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
{t('bmp.ftx')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={recenterOnRig}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||
title={t('bmp.scrollToRig')}>
|
||||
|
||||
@@ -62,6 +62,7 @@ const CATEGORIES: Array<{ key: Category; labelKey: string; colour: string }> = [
|
||||
function categoryOf(s: ChaseNewSpot): Category | null {
|
||||
switch (s.status) {
|
||||
case 'new': return 'dxcc';
|
||||
case 'new-band-mode': return 'band';
|
||||
case 'new-band': return 'band';
|
||||
case 'new-mode': return 'mode';
|
||||
case 'new-slot': return 'slot';
|
||||
|
||||
@@ -174,6 +174,7 @@ function cellText(value: any, color: string | null): any {
|
||||
function statusColor(s: SpotStatusEntry | undefined): string | null {
|
||||
switch (s?.status) {
|
||||
case 'new':
|
||||
case 'new-band-mode':
|
||||
case 'new-band':
|
||||
case 'new-mode':
|
||||
case 'new-slot':
|
||||
@@ -193,7 +194,7 @@ function statusColor(s: SpotStatusEntry | undefined): string | null {
|
||||
// (still loading, or entity unknown) are NOT dimmed — that would flicker.
|
||||
function isDull(s: SpotStatusEntry | undefined): boolean {
|
||||
if (!s || !s.status) return false;
|
||||
if (s.status === 'new' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot' || s.status === 'new-call') return false;
|
||||
if (s.status === 'new' || s.status === 'new-band-mode' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot' || s.status === 'new-call') return false;
|
||||
return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx || s.new_grid);
|
||||
}
|
||||
|
||||
@@ -259,6 +260,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
const s = statusFor(p);
|
||||
const parts: string[] = [];
|
||||
if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
|
||||
else if (s?.status === 'new-band-mode') parts.push(t('clg2.newBandMode'));
|
||||
else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
|
||||
else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
|
||||
else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot'));
|
||||
@@ -276,6 +278,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
const main = statusColor(s);
|
||||
if (main) {
|
||||
const label = s?.status === 'new' ? t('clg2.newDxcc')
|
||||
: s?.status === 'new-band-mode' ? t('clg2.newBandMode')
|
||||
: s?.status === 'new-band' ? t('clg2.newBand')
|
||||
: s?.status === 'new-mode' ? t('clg2.newMode')
|
||||
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
||||
@@ -310,6 +313,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
|
||||
if (s?.status === 'new-band-mode') return t('clg2.tipNewBandMode');
|
||||
if (s?.status === 'new-band') return t('clg2.tipNewBand');
|
||||
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
|
||||
if (s?.status === 'new-call') return t('clg2.tipNewCall');
|
||||
@@ -338,10 +342,18 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
// NEW BAND for this entity → the band cell is filled.
|
||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band' ? fillStyle(NEW) : null) as any,
|
||||
// NEW BAND for this entity → the band cell is filled. NEW BAND+MODE fills
|
||||
// it too: the band really is new, and leaving it plain would say the
|
||||
// opposite of the status column beside it.
|
||||
cellStyle: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
return (st === 'new-band' || st === 'new-band-mode' ? fillStyle(NEW) : null) as any;
|
||||
},
|
||||
cellRenderer: (p: any) => cellText(p.value, null),
|
||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
return st === 'new-band-mode' ? t('clg2.tipNewBandMode') : st === 'new-band' ? t('clg2.tipNewBand') : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: t('clg2.c.mode'), colId: 'mode',
|
||||
@@ -349,14 +361,18 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||
// Only NEW MODE pills the mode cell — there the mode itself is genuinely new
|
||||
// for the entity. NEW SLOT means band AND mode were each worked before (just
|
||||
// not together), so highlighting the mode cell would wrongly imply "CW is new";
|
||||
// that case is signalled by the Status badge alone.
|
||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-mode' ? fillStyle('var(--caution)') : null) as any,
|
||||
// NEW MODE and NEW BAND+MODE pill the mode cell — in both the mode itself is
|
||||
// genuinely new for the entity. NEW SLOT means band AND mode were each worked
|
||||
// before (just not together), so highlighting the mode cell would wrongly
|
||||
// imply "CW is new"; that case is signalled by the Status badge alone.
|
||||
cellStyle: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
return (st === 'new-mode' || st === 'new-band-mode' ? fillStyle('var(--caution)') : null) as any;
|
||||
},
|
||||
cellRenderer: (p: any) => cellText(p.value, null),
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
if (st === 'new-band-mode') return t('clg2.tipNewBandMode');
|
||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||
if (st === 'new-slot') return t('clg2.tipNewSlot');
|
||||
return undefined;
|
||||
|
||||
@@ -132,6 +132,9 @@ function catsOf(e: StatusEntry | undefined): Set<NewCat> {
|
||||
if (!e) return out;
|
||||
switch (e.status) {
|
||||
case 'new': out.add('dxcc'); break;
|
||||
// NEW BAND+MODE lights BOTH badges: the two facts are true at once, and
|
||||
// showing one of them would say the entity is already worked in the other.
|
||||
case 'new-band-mode': out.add('band'); out.add('mode'); break;
|
||||
case 'new-band': out.add('band'); break;
|
||||
case 'new-mode': out.add('mode'); break;
|
||||
case 'new-slot': out.add('slot'); break;
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
GetScpStatus, SetScpEnabled, DownloadScp,
|
||||
DownloadULSCounties, ULSStatus, BackfillUSCounties, BackfillRDA, RDADatabaseCount,
|
||||
GetCtyDatInfo, RefreshCtyDat, GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||
GetSpotColors, SaveSpotColors, ResetSpotColors, GetFlexZoom, SaveFlexZoom,
|
||||
ComputeStationInfo,
|
||||
GetUIPref, SetUIPref,
|
||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower,
|
||||
@@ -1283,6 +1284,54 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
||||
);
|
||||
}
|
||||
|
||||
// The panadapter statuses, in the order the cluster ranks them: most wanted
|
||||
// first, then the markers that are orthogonal to the entity, then the two
|
||||
// "nothing here" cases. Must match spotColorOrder in spotcolors.go — the Go side
|
||||
// is the authority, this list only decides the order of the rows.
|
||||
const SPOT_COLOR_ROWS = [
|
||||
'new', 'new-band-mode', 'new-band', 'new-mode', 'new-slot',
|
||||
'new-pota', 'new-county', 'new-pfx',
|
||||
'my-call', 'worked', 'none',
|
||||
];
|
||||
|
||||
// SmartSDR colours are #AARRGGBB — the alpha FIRST, which is not what CSS or an
|
||||
// <input type="color"> expects. These two convert between the radio's order and
|
||||
// the browser's, so the operator picks a colour in a normal picker and the radio
|
||||
// gets what it understands.
|
||||
function argbToCss(argb?: string): string {
|
||||
const v = (argb ?? '').trim();
|
||||
if (!/^#[0-9a-fA-F]{8}$/.test(v)) return '';
|
||||
return `#${v.slice(3)}${v.slice(1, 3)}`; // #AARRGGBB → #RRGGBBAA
|
||||
}
|
||||
function cssToArgb(rgb: string, alpha: string): string {
|
||||
const v = (rgb || '#000000').trim();
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(v)) return '';
|
||||
return `#${alpha}${v.slice(1)}`.toUpperCase();
|
||||
}
|
||||
|
||||
// ArgbInput edits one #AARRGGBB value as what it really is: a colour and an
|
||||
// opacity. A single text field would be asking an operator to type eight hex
|
||||
// digits in an order no other program uses.
|
||||
function ArgbInput({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||
const valid = /^#[0-9a-fA-F]{8}$/.test((value ?? '').trim());
|
||||
const alpha = valid ? value.slice(1, 3).toUpperCase() : 'FF';
|
||||
const rgb = valid ? `#${value.slice(3)}` : '#808080';
|
||||
return (
|
||||
<span className="flex items-center gap-1 shrink-0" title={label}>
|
||||
<input type="color" value={rgb}
|
||||
onChange={(e) => onChange(cssToArgb(e.target.value, alpha))}
|
||||
className="size-6 rounded border border-border bg-transparent p-0 cursor-pointer" />
|
||||
<select value={alpha}
|
||||
onChange={(e) => onChange(cssToArgb(rgb, e.target.value))}
|
||||
className="h-6 rounded border border-border bg-background text-[10px] px-0.5">
|
||||
{[['FF', '100%'], ['C0', '75%'], ['80', '50%'], ['40', '25%'], ['20', '12%'], ['00', 'off']].map(([a, l]) => (
|
||||
<option key={a} value={a}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// FlexBandPanel — everything that follows the band, in ONE row per band:
|
||||
// antennas and TX power.
|
||||
//
|
||||
@@ -1747,6 +1796,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
};
|
||||
// cty.dat is not downloaded by OpsLog — it is shipped and reloaded from disk —
|
||||
// so the page reports it rather than offering a button it cannot honour.
|
||||
// Panadapter spot palette. Saved on its own — it is a table of colours, not a
|
||||
// form: waiting for the modal's Save to see a colour on the waterfall would
|
||||
// make picking one a guessing game.
|
||||
type SpotColor = { text: string; bg?: string };
|
||||
const [spotColors, setSpotColors] = useState<{ enabled: boolean; colors: Record<string, SpotColor> }>({ enabled: true, colors: {} });
|
||||
useEffect(() => { GetSpotColors().then((c: any) => setSpotColors(c ?? { enabled: true, colors: {} })).catch(() => {}); }, []);
|
||||
const saveSpotColors = (next: { enabled: boolean; colors: Record<string, SpotColor> }) => {
|
||||
setSpotColors(next);
|
||||
SaveSpotColors(next as any).catch(() => {});
|
||||
};
|
||||
const setSpotColor = (k: string, patch: Partial<SpotColor>) => saveSpotColors({
|
||||
...spotColors,
|
||||
colors: { ...spotColors.colors, [k]: { ...(spotColors.colors[k] ?? { text: '' }), ...patch } },
|
||||
});
|
||||
// Panadapter auto-zoom. Saved immediately for the same reason as the palette:
|
||||
// the only way to judge a width is to click a spot and look at the radio.
|
||||
const [flexZoom, setFlexZoom] = useState<{ enabled: boolean; cw_khz: number; ssb_khz: number; digi_khz: number; centre: boolean }>(
|
||||
{ enabled: false, cw_khz: 25, ssb_khz: 200, digi_khz: 50, centre: false });
|
||||
useEffect(() => { GetFlexZoom().then((z: any) => setFlexZoom(z)).catch(() => {}); }, []);
|
||||
const saveFlexZoom = (next: typeof flexZoom) => { setFlexZoom(next); SaveFlexZoom(next as any).catch(() => {}); };
|
||||
const [ctyInfo, setCtyInfo] = useState<{ entities: number; file_mod_time?: string }>({ entities: 0 });
|
||||
const [ctyBusy, setCtyBusy] = useState(false);
|
||||
useEffect(() => { GetCtyDatInfo().then((i) => setCtyInfo(i as any)).catch(() => {}); }, []);
|
||||
@@ -2983,27 +3052,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<div className="col-span-2">
|
||||
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
|
||||
</div>
|
||||
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} />
|
||||
{t('cat.flexSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexSpotsHint')}</span>
|
||||
</label>
|
||||
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={!!catCfg.flex_decode_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_decode_spots: !!c }))} />
|
||||
{t('cat.flexDecodeSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexDecodeSpotsHint')}</span>
|
||||
</label>
|
||||
{catCfg.flex_decode_spots && (
|
||||
<div className="col-span-2 flex items-center gap-2 pl-6">
|
||||
<Label className="text-sm">{t('cat.flexDecodeSecs')}</Label>
|
||||
<Input type="number" className="w-24" min={10} max={3600}
|
||||
value={catCfg.flex_decode_secs || 120}
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, flex_decode_secs: parseInt(e.target.value) || 120 }))} />
|
||||
<span className="text-xs text-muted-foreground">{t('cat.flexDecodeSecsHint')}</span>
|
||||
</div>
|
||||
)}
|
||||
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox className="mt-0.5" checked={!!catCfg.flex_dvk_dax} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_dvk_dax: !!c }))} />
|
||||
<span>{t('cat.flexDvkDax')} <span className="text-xs text-muted-foreground">{t('cat.flexDvkDaxHint')}</span></span>
|
||||
</label>
|
||||
{/* What OpsLog DOES with a Flex — panadapter spots, decode spots,
|
||||
the DAX switch for voice messages — moved to Settings →
|
||||
FlexRadio, with the per-band antennas and power. What stays
|
||||
here is the link itself: an address and a port, which is what
|
||||
this page is about for every other backend too. */}
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'xiegu' && (
|
||||
@@ -3480,7 +3533,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
|
||||
function UltrabeamPanel() {
|
||||
const isSteppir = ultrabeam.type === 'steppir';
|
||||
const isSerial = isSteppir && ultrabeam.transport === 'serial';
|
||||
// Serial is no longer a SteppIR-only route: an Ultrabeam controller has an
|
||||
// RS232 port too, and a plain FTDI cable reaches it without the Ethernet
|
||||
// adapter the TCP route needs.
|
||||
const isSerial = ultrabeam.transport === 'serial';
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
@@ -3494,10 +3550,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('hw.motorType')}</Label>
|
||||
{/* Ultrabeam is TCP only; picking it forces the transport back to TCP
|
||||
so the serial fields never apply to it. */}
|
||||
{/* Both antennas take either route now, so picking a type no longer
|
||||
forces the transport. */}
|
||||
<Select value={ultrabeam.type ?? 'ultrabeam'}
|
||||
onValueChange={(v) => setUltrabeam((s) => ({ ...s, type: v, transport: v === 'ultrabeam' ? 'tcp' : s.transport }))}>
|
||||
onValueChange={(v) => setUltrabeam((s) => ({ ...s, type: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ultrabeam">Ultrabeam</SelectItem>
|
||||
@@ -3505,7 +3561,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{isSteppir && (
|
||||
{(
|
||||
<div className="space-y-1">
|
||||
<Label>{t('hw.motorTransport')}</Label>
|
||||
<Select value={ultrabeam.transport ?? 'tcp'}
|
||||
@@ -4728,7 +4784,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
{t(`ftx.c_${k}`)}
|
||||
</label>
|
||||
);
|
||||
const KEYS: (keyof AutoCallCriteria)[] = ['dxcc', 'band', 'mode', 'slot', 'grid', 'county', 'pota', 'pfx'];
|
||||
const KEYS: (keyof AutoCallCriteria)[] = ['dxcc', 'bandmode', 'band', 'mode', 'slot', 'grid', 'county', 'pota', 'pfx'];
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title={t('sec.ftx')} hint={t('ftx.hint')} />
|
||||
@@ -4898,7 +4954,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<div>
|
||||
<span className="text-sm font-medium">{t('clu.macros')}</span>
|
||||
<p className="text-xs text-muted-foreground">{t('clu.macrosHint')}</p>
|
||||
</div>
|
||||
{/* Two columns of six: twelve rows stacked would push everything else
|
||||
in this panel off the screen. */}
|
||||
@@ -5012,7 +5067,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('gsc.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5074,7 +5128,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">km</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('bo.nearKmHint')}</p>
|
||||
|
||||
{/* A live count, because a feed that is connected but silent looks
|
||||
exactly like one that is broken until a number moves. */}
|
||||
@@ -5117,7 +5170,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{t('clu.selfSpotHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7150,7 +7202,110 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
tunergenius: TunerGeniusPanelSettings,
|
||||
psu: PSUPanelSettings,
|
||||
pgxl: PGXLPanelSettings,
|
||||
flex: () => <FlexBandPanel bands={lists.bands ?? []} />,
|
||||
flex: () => (
|
||||
<div className="space-y-6">
|
||||
<FlexBandPanel bands={lists.bands ?? []} />
|
||||
{/* Rendered here rather than inside FlexBandPanel so these keep writing
|
||||
to the modal's own CAT settings, saved by its Save button. A panel
|
||||
that saved them itself would be overwritten by that same Save a
|
||||
moment later, using the values it had read on opening. */}
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">{t('cat.flexOptions')}</h4>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={!!catCfg.flex_decode_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_decode_spots: !!c }))} />
|
||||
{t('cat.flexDecodeSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexDecodeSpotsHint')}</span>
|
||||
</label>
|
||||
{catCfg.flex_decode_spots && (
|
||||
<div className="flex items-center gap-2 pl-6">
|
||||
<Label className="text-sm">{t('cat.flexDecodeSecs')}</Label>
|
||||
<Input type="number" className="w-24" min={10} max={3600}
|
||||
value={catCfg.flex_decode_secs || 120}
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, flex_decode_secs: parseInt(e.target.value) || 120 }))} />
|
||||
<span className="text-xs text-muted-foreground">{t('cat.flexDecodeSecsHint')}</span>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox className="mt-0.5" checked={!!catCfg.flex_dvk_dax} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_dvk_dax: !!c }))} />
|
||||
<span>{t('cat.flexDvkDax')} <span className="text-xs text-muted-foreground">{t('cat.flexDvkDaxHint')}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Auto-zoom on a spot click. Its own block: it changes the RADIO's
|
||||
display, where everything above changes what OpsLog draws on it. */}
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">{t('flexzoom.title')}</h4>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={flexZoom.enabled} onCheckedChange={(c) => saveFlexZoom({ ...flexZoom, enabled: !!c })} />
|
||||
{t('flexzoom.enable')}
|
||||
</label>
|
||||
{flexZoom.enabled && (
|
||||
<div className="pl-6 space-y-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{([['cw_khz', 'flexzoom.cw'], ['ssb_khz', 'flexzoom.ssb'], ['digi_khz', 'flexzoom.digi']] as const).map(([k, lbl]) => (
|
||||
<label key={k} className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{t(lbl)}</span>
|
||||
<Input type="number" min={1} max={14000} className="h-8 w-20"
|
||||
value={String((flexZoom as any)[k] ?? '')}
|
||||
onChange={(e) => saveFlexZoom({ ...flexZoom, [k]: parseInt(e.target.value, 10) || 0 } as any)} />
|
||||
<span className="text-muted-foreground">kHz</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
||||
<Checkbox className="mt-0.5" checked={flexZoom.centre} onCheckedChange={(c) => saveFlexZoom({ ...flexZoom, centre: !!c })} />
|
||||
<span>{t('flexzoom.centre')} <span className="text-muted-foreground">{t('flexzoom.centreHint')}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Spot colours, one row per status.
|
||||
|
||||
Saved on the spot rather than on the modal's Save: this is a table of
|
||||
colours, and having to close a dialog to see what a colour looks like
|
||||
on the waterfall turns picking one into a guessing game. */}
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">{t('spotcol.title')}</h4>
|
||||
<button type="button" className="text-[11px] text-muted-foreground hover:text-foreground underline"
|
||||
onClick={() => ResetSpotColors().then((c: any) => setSpotColors(c)).catch(() => {})}>
|
||||
{t('spotcol.reset')}
|
||||
</button>
|
||||
</div>
|
||||
{/* Whether spots are drawn at all comes first: colouring them is the
|
||||
next question, and the two were on different pages. */}
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} />
|
||||
{t('cat.flexSpots')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={spotColors.enabled}
|
||||
onCheckedChange={(c) => saveSpotColors({ ...spotColors, enabled: !!c })} />
|
||||
{t('spotcol.enable')}
|
||||
</label>
|
||||
{spotColors.enabled && (
|
||||
<div className="rounded-md border border-border divide-y divide-border max-w-2xl">
|
||||
{SPOT_COLOR_ROWS.map((k) => {
|
||||
const c = spotColors.colors[k] ?? { text: '', bg: '' };
|
||||
return (
|
||||
<div key={k} className="flex items-center gap-3 px-3 py-1.5">
|
||||
<span className="text-xs flex-1 min-w-0">{t('spotcol.s_' + k)}</span>
|
||||
{/* The preview is the point of the row: two colour boxes say
|
||||
nothing about what the pair looks like together. */}
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded"
|
||||
style={{ color: argbToCss(c.text) || undefined, background: argbToCss(c.bg) || undefined }}>
|
||||
DL1ABC
|
||||
</span>
|
||||
<ArgbInput label={t('spotcol.text')} value={c.text} onChange={(v) => setSpotColor(k, { text: v })} />
|
||||
<ArgbInput label={t('spotcol.bg')} value={c.bg ?? ''} onChange={(v) => setSpotColor(k, { bg: v })} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
audio: AudioPanel,
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ type ContestRun = { id: string; year: number; count: number; start: string; end:
|
||||
type Stats = {
|
||||
total: number; unique_calls: number; entities: number; continents: number;
|
||||
first_qso: string; last_qso: string;
|
||||
last_new_dxcc: string; last_new_dxcc_num: number; last_new_dxcc_call: string; last_new_dxcc_date: string;
|
||||
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
||||
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||
@@ -681,6 +682,16 @@ export function StatsPanel() {
|
||||
// real operating session (which is exactly when an hourly rate chart exists).
|
||||
const windowed = contest !== '' || (period === 'custom' && (stats?.rate?.length ?? 0) > 1);
|
||||
|
||||
// "last new: France - F5ABC - 2019-03-01": the entity most recently added to
|
||||
// the log, and the contact that added it. Empty when no dated entity exists.
|
||||
const lastNew = useMemo(() => {
|
||||
if (!stats?.last_new_dxcc_date) return '';
|
||||
const day = stats.last_new_dxcc_date.slice(0, 10);
|
||||
const who = stats.last_new_dxcc_call ? ` · ${stats.last_new_dxcc_call}` : '';
|
||||
const what = stats.last_new_dxcc || String(stats.last_new_dxcc_num || '');
|
||||
return `${t('stats.lastNewDxcc')}: ${what}${who} · ${day}`;
|
||||
}, [stats, t]);
|
||||
|
||||
const span = useMemo(() => {
|
||||
if (!stats?.first_qso) return '';
|
||||
const f = new Date(stats.first_qso), l = new Date(stats.last_qso);
|
||||
@@ -774,7 +785,11 @@ export function StatsPanel() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
||||
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} accent="var(--chart-1)" />
|
||||
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} accent="var(--chart-2)" />
|
||||
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" accent="var(--chart-5)" />
|
||||
{/* The last one ADDED rides on the entities tile: it is the same
|
||||
subject, and a tile of its own would push the five headline figures
|
||||
onto two rows for one line of text. */}
|
||||
<StatTile label={t('stats.entities')} value={nf(stats.entities)}
|
||||
sub={lastNew || "DXCC"} accent="var(--chart-5)" />
|
||||
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" accent="var(--chart-8)" />
|
||||
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
||||
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} accent="var(--success)" />
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
export type AutoCallCriteria = {
|
||||
dxcc: boolean; // entity never worked
|
||||
bandmode: boolean; // entity worked, but neither this band nor this mode
|
||||
band: boolean; // entity never worked on this band
|
||||
mode: boolean; // entity never worked in this mode
|
||||
slot: boolean; // band and mode each worked, never together
|
||||
@@ -37,7 +38,7 @@ export type AutoCallSettings = {
|
||||
};
|
||||
|
||||
export const emptyCriteria: AutoCallCriteria = {
|
||||
dxcc: false, band: false, mode: false, slot: false,
|
||||
dxcc: false, bandmode: false, band: false, mode: false, slot: false,
|
||||
grid: false, county: false, pota: false, pfx: false,
|
||||
};
|
||||
|
||||
@@ -101,6 +102,10 @@ function anyCriterion(c: AutoCallCriteria): boolean {
|
||||
// meets reports whether a decode satisfies at least one ticked criterion.
|
||||
function meets(c: AutoCallCriteria, e: DecodeStatus): boolean {
|
||||
if (c.dxcc && e.status === 'new') return true;
|
||||
// Each status is exclusive, so a station that is new on both counts matches
|
||||
// ONLY this criterion — ticking "new band" alone would not catch it, which is
|
||||
// the wrong way round: it is the better catch of the two.
|
||||
if (c.bandmode && e.status === 'new-band-mode') return true;
|
||||
if (c.band && e.status === 'new-band') return true;
|
||||
if (c.mode && e.status === 'new-mode') return true;
|
||||
if (c.slot && e.status === 'new-slot') return true;
|
||||
|
||||
+18
-17
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.26.2';
|
||||
export const APP_VERSION = '0.26.3';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Reference in New Issue
Block a user