Compare commits

...
2 Commits
Author SHA1 Message Date
rouggy 9846354bf9 chore: release v0.23.2 2026-08-02 23:51:46 +02:00
rouggy 3da1d71323 chore: release v0.23.1 2026-08-02 20:29:30 +02:00
19 changed files with 593 additions and 39 deletions
+73 -2
View File
@@ -9121,6 +9121,49 @@ func (a *App) GetLogFilePath() string {
return applog.Path() return applog.Path()
} }
// TailLogFile returns the END of the diagnostic log — the last maxBytes, cut
// back to a line boundary so the first line is never a fragment.
//
// The tail, not the file: it rotates at 10 MB, and the live viewer polls this
// every second. Shipping megabytes across the Wails bridge on a timer would cost
// more than the whole rest of the UI.
func (a *App) TailLogFile(maxBytes int) (string, error) {
p := applog.Path()
if p == "" {
return "", fmt.Errorf("log file not initialised")
}
if maxBytes <= 0 || maxBytes > 4*1024*1024 {
maxBytes = 256 * 1024
}
f, err := os.Open(p)
if err != nil {
return "", err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return "", err
}
start := int64(0)
if fi.Size() > int64(maxBytes) {
start = fi.Size() - int64(maxBytes)
}
if _, err := f.Seek(start, io.SeekStart); err != nil {
return "", err
}
buf, err := io.ReadAll(f)
if err != nil {
return "", err
}
s := string(buf)
if start > 0 {
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[i+1:]
}
}
return s, nil
}
// UILog lets the frontend write to the same diagnostic log as the backend. // UILog lets the frontend write to the same diagnostic log as the backend.
// //
// Frontend-only logic (entry-strip auto-fill, debounce ordering, which field the // Frontend-only logic (entry-strip auto-fill, debounce ordering, which field the
@@ -11377,13 +11420,18 @@ func (a *App) consumeUDPEvents() {
// after the configured duration. De-duped per call in the Flex backend. // after the configured duration. De-duped per call in the Flex backend.
if a.catFlexDecodeSpots && a.cat != nil { if a.catFlexDecodeSpots && a.cat != nil {
now := time.Now() now := time.Now()
if t, seen := lastDecodeSpot[ev.DecodeCall]; seen && now.Sub(t) < decodeSpotWindow { // Key by call AND 100 kHz slot, not by call alone: with two
// receivers running (50.313 and 50.400, say) a station heard on
// both is two legitimate spots on two panadapters, and a
// call-only key threw the second away.
key := fmt.Sprintf("%s@%d", ev.DecodeCall, ev.DecodeFreqHz/100_000)
if t, seen := lastDecodeSpot[key]; seen && now.Sub(t) < decodeSpotWindow {
continue // spotted this call very recently — don't re-hammer the radio continue // spotted this call very recently — don't re-hammer the radio
} }
if len(lastDecodeSpot) > 4000 { if len(lastDecodeSpot) > 4000 {
lastDecodeSpot = map[string]time.Time{} // bound memory on long sessions lastDecodeSpot = map[string]time.Time{} // bound memory on long sessions
} }
lastDecodeSpot[ev.DecodeCall] = now lastDecodeSpot[key] = now
secs := a.catFlexDecodeSecs secs := a.catFlexDecodeSecs
if secs <= 0 { if secs <= 0 {
secs = 120 secs = 120
@@ -15476,6 +15524,12 @@ type SpotStatus struct {
// when that database has been downloaded); POTA from the spot's tagged park. // when that database has been downloaded); POTA from the spot's tagged park.
NewCounty bool `json:"new_county"` NewCounty bool `json:"new_county"`
NewPOTA bool `json:"new_pota"` NewPOTA bool `json:"new_pota"`
// NewPfx flags a CQ WPX prefix never worked before, and Pfx is that prefix.
// Also orthogonal: a common entity on a worked band can still carry a prefix
// that has never been in the log, which is exactly what a WPX chaser is
// scanning the cluster for.
NewPfx bool `json:"new_pfx"`
Pfx string `json:"pfx,omitempty"`
} }
// ClusterSpotStatuses takes a batch of spots and returns slot status for // ClusterSpotStatuses takes a batch of spots and returns slot status for
@@ -15531,6 +15585,16 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
// lookup) and worked POTA parks. Both built once per batch. // lookup) and worked POTA parks. Both built once per batch.
workedCounties, _ := a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey) workedCounties, _ := a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
workedPOTA, _ := a.qso.WorkedPOTARefs(a.ctx) workedPOTA, _ := a.qso.WorkedPOTARefs(a.ctx)
// Worked WPX prefixes, derived from the callsigns we already loaded — no
// extra query. Derived rather than read from the stored PFX column: that
// column is only filled when an import supplied it, and deriving keeps this
// in step with the WPX award, which does the same thing.
workedPfx := make(map[string]struct{}, len(workedCalls))
for c := range workedCalls {
if p := award.WPXPrefix(c); p != "" {
workedPfx[p] = struct{}{}
}
}
for i, q := range spots { for i, q := range spots {
out[i] = SpotStatus{ out[i] = SpotStatus{
Call: q.Call, Call: q.Call,
@@ -15540,6 +15604,13 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok { if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok {
out[i].WorkedCall = true out[i].WorkedCall = true
} }
// NEW PFX: the spot's CQ WPX prefix, never worked before.
if p := award.WPXPrefix(q.Call); p != "" {
out[i].Pfx = p
if _, done := workedPfx[p]; !done {
out[i].NewPfx = true
}
}
// NEW POTA: the spot's tagged park, never worked before. // NEW POTA: the spot's tagged park, never worked before.
if ref := strings.ToUpper(strings.TrimSpace(q.POTARef)); ref != "" { if ref := strings.ToUpper(strings.TrimSpace(q.POTARef)); ref != "" {
if _, done := workedPOTA[ref]; !done { if _, done := workedPOTA[ref]; !done {
+36 -2
View File
@@ -1,4 +1,36 @@
[ [
{
"version": "0.23.2",
"date": "",
"en": [
"Lookup: the locator now keeps whichever is more precise. A 4-character square from QRZ or HamQTH no longer overwrites the full 6-character locator confirmed on a previous contact.",
"LoTW badge: when ARRL's own user list has not been rebuilt for a while, the tooltip says so with its date — a station shown as \"uploaded 7 days ago\" may simply be sitting in a frozen list.",
"FlexRadio decode spots: with two WSJT-X instances on two slices, decodes are no longer placed on the wrong panadapter, and a station heard on both is now spotted on both.",
"DX cluster: a spot glued to the login prompt is no longer dropped. Clusters that print the prompt without a newline cost you the first spot after connecting.",
"Help → Diagnostic log: read the log live, without leaving OpsLog. Filter the lines, and follow the end or scroll back freely.",
"Entity stats: the DX station's sunrise and sunset (UTC) now show on the right, next to Most Wanted. Midnight sun and polar night are named as such."
],
"fr": [
"Recherche : le locator garde désormais le plus précis des deux. Un carré en 4 caractères venant de QRZ ou HamQTH n'écrase plus le locator complet en 6 caractères confirmé lors d'un contact précédent.",
"Badge LoTW : quand la liste ARRL elle-même n'a pas été régénérée depuis un moment, l'infobulle le dit avec sa date — une station annoncée « dernier envoi il y a 7 jours » peut simplement figurer dans une liste figée.",
"Spots de décodes FlexRadio : avec deux instances WSJT-X sur deux slices, les décodes ne s'affichent plus sur le mauvais panadapter, et une station entendue sur les deux est désormais spottée sur les deux.",
"Cluster DX : un spot collé à l'invite de connexion n'est plus perdu. Les clusters qui affichent leur invite sans retour à la ligne coûtaient le premier spot après la connexion.",
"Aide → Journal de diagnostic : lire le journal en direct sans quitter OpsLog. Filtrage des lignes, et suivi de la fin qu'on peut relâcher pour remonter.",
"Statistiques d'entité : le lever et le coucher du soleil de la station DX (UTC) s'affichent à droite, à côté du Most Wanted. Soleil de minuit et nuit polaire sont nommés."
]
},
{
"version": "0.23.1",
"date": "",
"en": [
"DX cluster: NEW COUNTY is green, like NEW POTA.",
"CW: pressing ESC during a macro no longer turns the report from 599 into 59 when \"ESC clears the callsign too\" is on."
],
"fr": [
"Cluster DX : NOUVEAU COMTÉ passe en vert, comme NOUVEAU POTA.",
"CW : appuyer sur Échap pendant une macro ne fait plus passer le report de 599 à 59 quand l'option « Échap efface aussi l'indicatif » est cochée."
]
},
{ {
"version": "0.23.0", "version": "0.23.0",
"date": "", "date": "",
@@ -6,13 +38,15 @@
"Station Control: Dingtian IOT relay boards (2 to 32 relays, LAN or WiFi) can now be added.", "Station Control: Dingtian IOT relay boards (2 to 32 relays, LAN or WiFi) can now be added.",
"ACOM amplifiers: the card now shows TUNE while the tuner is running, instead of an unknown state.", "ACOM amplifiers: the card now shows TUNE while the tuner is running, instead of an unknown state.",
"DX cluster: the status pills are gone. The call, band and mode turn yellow when that one is new, and a callsign already in the log turns blue.", "DX cluster: the status pills are gone. The call, band and mode turn yellow when that one is new, and a callsign already in the log turns blue.",
"Reports: starting OpsLog with the rig already in CW no longer leaves the RST fields on 59 — they now follow the mode you are actually on." "Reports: starting OpsLog with the rig already in CW no longer leaves the RST fields on 59 — they now follow the mode you are actually on. (Thanks HB9HBY)",
"DX cluster: NEW PFX marks a spot whose CQ WPX prefix has never been worked, in the status column and as a filter."
], ],
"fr": [ "fr": [
"Contrôle station : les cartes relais Dingtian IOT (2 à 32 relais, LAN ou WiFi) peuvent désormais être ajoutées.", "Contrôle station : les cartes relais Dingtian IOT (2 à 32 relais, LAN ou WiFi) peuvent désormais être ajoutées.",
"Amplificateurs ACOM : la carte affiche TUNE pendant l'accord, au lieu d'un état inconnu.", "Amplificateurs ACOM : la carte affiche TUNE pendant l'accord, au lieu d'un état inconnu.",
"Cluster DX : les pastilles de statut disparaissent. L'indicatif, la bande et le mode passent en jaune quand celui-ci est nouveau, et un indicatif déjà dans le log passe en bleu.", "Cluster DX : les pastilles de statut disparaissent. L'indicatif, la bande et le mode passent en jaune quand celui-ci est nouveau, et un indicatif déjà dans le log passe en bleu.",
"Reports : lancer OpsLog avec le poste déjà en CW ne laisse plus les RST à 59 — ils suivent maintenant le mode réellement en cours." "Reports : lancer OpsLog avec le poste déjà en CW ne laisse plus les RST à 59 — ils suivent maintenant le mode réellement en cours. (Merci HB9HBY)",
"Cluster DX : NOUVEAU PFX signale un spot dont le préfixe CQ WPX n'a jamais été contacté, dans la colonne de statut et en filtre."
] ]
}, },
{ {
+52 -13
View File
@@ -120,6 +120,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead'; import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead';
import { flagURL } from '@/lib/flags'; import { flagURL } from '@/lib/flags';
import { LogViewer } from '@/components/LogViewer';
type QSO = QSOForm; type QSO = QSOForm;
type ImportResult = adifModels.ImportResult; type ImportResult = adifModels.ImportResult;
@@ -492,6 +493,14 @@ export default function App() {
const [bandRx, setBandRx] = useState('20m'); const [bandRx, setBandRx] = useState('20m');
const [countries, setCountries] = useState<string[]>([]); const [countries, setCountries] = useState<string[]>([]);
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] }); const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
// Refs for the report defaults. applyModePreset is called from long-lived
// closures — the global key handler is the one that bit us — whose dependency
// lists deliberately exclude the mode, so reading the state variables there
// gives whatever they were when that closure was built.
const modePresetsRef = useRef(modePresets);
useEffect(() => { modePresetsRef.current = modePresets; }, [modePresets]);
const rstListsRef = useRef(rstLists);
useEffect(() => { rstListsRef.current = rstLists; }, [rstLists]);
const [rstSent, setRstSent] = useState('59'); const [rstSent, setRstSent] = useState('59');
const [rstRcvd, setRstRcvd] = useState('59'); const [rstRcvd, setRstRcvd] = useState('59');
const [grid, setGrid] = useState(''); const [grid, setGrid] = useState('');
@@ -539,7 +548,7 @@ export default function App() {
}, []); }, []);
// LoTW-user lookup for the entered callsign (from the cached ARRL activity list), // LoTW-user lookup for the entered callsign (from the cached ARRL activity list),
// debounced. Drives a colour-coded LoTW badge by last-upload recency. // debounced. Drives a colour-coded LoTW badge by last-upload recency.
const [lotwInfo, setLotwInfo] = useState<{ is_user: boolean; last_upload?: string; days_ago: number } | null>(null); const [lotwInfo, setLotwInfo] = useState<{ is_user: boolean; last_upload?: string; days_ago: number; feed_date?: string; feed_stale?: boolean } | null>(null);
useEffect(() => { useEffect(() => {
const c = callsign.trim(); const c = callsign.trim();
if (!c) { setLotwInfo(null); return; } if (!c) { setLotwInfo(null); return; }
@@ -1382,7 +1391,7 @@ export default function App() {
// county or a new park is not a DXCC state, it is another dimension of the // 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 // same spot — which is why the grid shows them as separate badges. Filtering
// is an OR across all of them, as it already was for a worked callsign. // is an OR across all of them, as it already was for a worked callsign.
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county'; type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county' | 'new-pfx';
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter')); const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
// Mode filter chips. Empty set = show every mode. Categories map the // Mode filter chips. Empty set = show every mode. Categories map the
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital). // inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
@@ -1478,7 +1487,7 @@ export default function App() {
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked". // Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on // Keyed by `${call}|${band}|${mode}` so two spots of the same call on
// different slots don't share the same colour. // different slots don't share the same colour.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean }>>({}); const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean; new_pfx?: boolean; pfx?: string }>>({});
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots // Live mirror of spotStatus so the incoming-spot buffer can tell which slots
// still need resolving without re-subscribing the cluster:spot listener. // still need resolving without re-subscribing the cluster:spot listener.
const spotStatusRef = useRef(spotStatus); const spotStatusRef = useRef(spotStatus);
@@ -1539,6 +1548,7 @@ export default function App() {
const [showAbout, setShowAbout] = useState(false); const [showAbout, setShowAbout] = useState(false);
// "What's new": the changelog for the version(s) since the operator last ran, // "What's new": the changelog for the version(s) since the operator last ran,
// shown once on the first launch after an update (EN/FR per the UI language). // shown once on the first launch after an update (EN/FR per the UI language).
const [showLogViewer, setShowLogViewer] = useState(false);
const [whatsNew, setWhatsNew] = useState<Array<{ version: string; date: string; en: string[]; fr: string[] }> | null>(null); const [whatsNew, setWhatsNew] = useState<Array<{ version: string; date: string; en: string[]; fr: string[] }> | null>(null);
// Which language the What's-new modal shows — defaults to the UI language but // Which language the What's-new modal shows — defaults to the UI language but
// toggleable, so a French operator running the English UI can still read it. // toggleable, so a French operator running the English UI can still read it.
@@ -2187,8 +2197,9 @@ export default function App() {
// Prefer the user's configured preset RST; otherwise fall back to the mode // Prefer the user's configured preset RST; otherwise fall back to the mode
// category default (CW/RTTY/PSK → 599, phone → 59, digital → first option) // category default (CW/RTTY/PSK → 599, phone → 59, digital → first option)
// so switching SSB→CW flips 59→599 even without a configured preset. // so switching SSB→CW flips 59→599 even without a configured preset.
const p = modePresets.find((x) => x.name === m); // Read through the refs, never the state: see their declaration.
const fallback = rstOptions(m, rstLists)[0] || ''; const p = modePresetsRef.current.find((x) => x.name === m);
const fallback = rstOptions(m, rstListsRef.current)[0] || '';
setRstSent(p?.default_rst_sent || fallback); setRstSent(p?.default_rst_sent || fallback);
setRstRcvd(p?.default_rst_rcvd || fallback); setRstRcvd(p?.default_rst_rcvd || fallback);
} }
@@ -2461,6 +2472,8 @@ export default function App() {
worked_call: !!(r as any).worked_call, worked_call: !!(r as any).worked_call,
new_county: !!(r as any).new_county, new_county: !!(r as any).new_county,
new_pota: !!(r as any).new_pota, new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx,
pfx: (r as any).pfx,
}; };
} }
return next; return next;
@@ -2916,6 +2929,8 @@ export default function App() {
worked_call: !!(r as any).worked_call, worked_call: !!(r as any).worked_call,
new_county: !!(r as any).new_county, new_county: !!(r as any).new_county,
new_pota: !!(r as any).new_pota, new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx,
pfx: (r as any).pfx,
}; };
} }
return next; return next;
@@ -3024,7 +3039,11 @@ export default function App() {
setWb(null); // clear the Worked-before grid for the just-cleared callsign setWb(null); // clear the Worked-before grid for the just-cleared callsign
setLookupError(''); setLookupError('');
rstUserEditedRef.current = false; rstUserEditedRef.current = false;
applyModePreset(mode); // modeRef, not `mode`: resetEntry is reached from the global key handler,
// whose effect does not re-run on a mode change. Pressing ESC during a CW
// macro therefore reset the report using the mode as it was when that
// handler was built — 'SSB' — and turned 599 into 59.
applyModePreset(modeRef.current);
setDetails((d) => ({ setDetails((d) => ({
...d, ...d,
state: '', cnty: '', address: '', lat: undefined, lon: undefined, state: '', cnty: '', address: '', lat: undefined, lon: undefined,
@@ -3276,10 +3295,16 @@ export default function App() {
if (!ue.has('name') && empty(r?.name) && last.name) setName(last.name); if (!ue.has('name') && empty(r?.name) && last.name) setName(last.name);
if (!ue.has('qth') && empty(r?.qth) && last.qth) setQth(last.qth); if (!ue.has('qth') && empty(r?.qth) && last.qth) setQth(last.qth);
if (!ue.has('country') && empty(r?.country) && last.country) setCountry(last.country); if (!ue.has('country') && empty(r?.country) && last.country) setCountry(last.country);
// Grid: a REAL provider grid always wins. Otherwise the last QSO's precise // Grid: the provider (QRZ / HamQTH) leads, but only while it is at least as
// locator beats the coarse cty.dat entity centroid the provider block set — so // PRECISE as what we already worked. It used to lead whenever it answered at
// a French call resolves to its real JNxx, not the country's JN16 centroid. // all, so a 4-character square — which is what a provider returns when the
const adoptLastGrid = !ue.has('grid') && empty(r?.grid) && !!last.grid; // station only published a rough locator, and what the cty.dat entity
// centroid gives — overwrote the full 6-character locator confirmed on a
// previous contact. Comparing lengths keeps the finer of the two, whichever
// side it came from.
const provGrid = String(r?.grid ?? '').trim();
const lastGrid = String(last.grid ?? '').trim();
const adoptLastGrid = !ue.has('grid') && !!lastGrid && lastGrid.length > provGrid.length;
if (adoptLastGrid) setGrid(last.grid); if (adoptLastGrid) setGrid(last.grid);
setDetails((d) => { setDetails((d) => {
// When we adopt the last QSO's locator, take its coordinates too (derive them // When we adopt the last QSO's locator, take its coordinates too (derive them
@@ -3601,6 +3626,8 @@ export default function App() {
{ type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog }, { type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
] : []), ] : []),
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: t('logview.title'), action: 'help.log' },
{ type: 'separator' },
{ type: 'item', label: t('help.donate'), action: 'help.donate', accent: true }, { type: 'item', label: t('help.donate'), action: 'help.donate', accent: true },
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: t('help.about'), action: 'help.about' }, { type: 'item', label: t('help.about'), action: 'help.about' },
@@ -3634,6 +3661,7 @@ export default function App() {
case 'tools.refreshCty': refreshCtyDat(); break; case 'tools.refreshCty': refreshCtyDat(); break;
case 'tools.downloadRefs': downloadRefs(); break; case 'tools.downloadRefs': downloadRefs(); break;
case 'help.about': setShowAbout(true); checkUpdateNow(); break; case 'help.about': setShowAbout(true); checkUpdateNow(); break;
case 'help.log': setShowLogViewer(true); break;
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break; case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
case 'help.sendlog': sendLogToDeveloper(); break; case 'help.sendlog': sendLogToDeveloper(); break;
// Opens in the system browser, NOT the app WebView: a payment page must show // Opens in the system browser, NOT the app WebView: a payment page must show
@@ -4042,8 +4070,14 @@ export default function App() {
const cls = d >= 0 && d < 7 ? 'border-success text-success bg-success/15' const cls = d >= 0 && d < 7 ? 'border-success text-success bg-success/15'
: d < 30 ? 'border-warning text-warning bg-warning/15' : d < 30 ? 'border-warning text-warning bg-warning/15'
: 'border-danger text-danger bg-danger/15'; : 'border-danger text-danger bg-danger/15';
// ARRL regenerates lotw-user-activity.csv on its own schedule and has been
// seen frozen for a week (2026-08-02: newest line 2026-07-27). "Uploaded 7
// days ago" then says nothing about the station, so the tooltip names the
// day the feed itself was built whenever that day is not recent.
const tip = t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })
+ (lotwInfo.feed_stale && lotwInfo.feed_date ? ' — ' + t('lotw.feedStale', { date: lotwInfo.feed_date }) : '');
return ( return (
<div className="shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}> <div className="shrink-0" title={tip}>
<span className={cn('inline-flex items-center rounded-md border px-1.5 py-1 text-[9px] font-extrabold tracking-wide leading-none', cls)}> <span className={cn('inline-flex items-center rounded-md border px-1.5 py-1 text-[9px] font-extrabold tracking-wide leading-none', cls)}>
LoTW LoTW
</span> </span>
@@ -4351,7 +4385,8 @@ export default function App() {
const matches = clusterStatusFilter.has(st) const matches = clusterStatusFilter.has(st)
|| (!!e?.worked_call && clusterStatusFilter.has('worked')) || (!!e?.worked_call && clusterStatusFilter.has('worked'))
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota')) || (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|| (!!e?.new_county && clusterStatusFilter.has('new-county')); || (!!e?.new_county && clusterStatusFilter.has('new-county'))
|| (!!e?.new_pfx && clusterStatusFilter.has('new-pfx'));
if (!matches) return false; if (!matches) return false;
} }
if (clusterHideWorked) { if (clusterHideWorked) {
@@ -4470,7 +4505,8 @@ export default function App() {
// Same colours as the badges in the grid — a filter that does not // Same colours as the badges in the grid — a filter that does not
// look like what it selects has to be learned twice. // look like what it selects has to be learned twice.
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' }, { k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-info-muted text-info-muted-foreground border-info-border' }, { k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
{ k: 'new-pfx' as SpotFilterKey, label: 'NEW PFX', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.) // (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
]).map((s) => { ]).map((s) => {
const on = clusterStatusFilter.has(s.k); const on = clusterStatusFilter.has(s.k);
@@ -5229,6 +5265,9 @@ export default function App() {
</div> </div>
)} )}
{/* Live diagnostic log (Help → …). Polls only while open. */}
<LogViewer open={showLogViewer} onOpenChange={setShowLogViewer} />
{/* What's new — shown once on the first launch after an update. */} {/* What's new — shown once on the first launch after an update. */}
{whatsNew && ( {whatsNew && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => setWhatsNew(null)}> <div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => setWhatsNew(null)}>
+39 -3
View File
@@ -1,7 +1,8 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Star, Radio } from 'lucide-react'; import { Star, Radio, Sunrise, Sunset } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { sunTimes } from '@/lib/sun';
import type { WorkedBeforeView } from '@/types'; import type { WorkedBeforeView } from '@/types';
type WorkedBefore = WorkedBeforeView; type WorkedBefore = WorkedBeforeView;
@@ -13,6 +14,10 @@ interface Props {
currentMode: string; currentMode: string;
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
// to an entity with no position at all, and the block simply does not appear.
lat?: number;
lon?: number;
} }
// Compact column label for a band tag: keep the classic V/U for 2m/70cm, // Compact column label for a band tag: keep the classic V/U for 2m/70cm,
@@ -84,7 +89,7 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`; return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
} }
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: Props) { export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon }: Props) {
// Columns from the operator's configured bands (so the matrix shows only the // Columns from the operator's configured bands (so the matrix shows only the
// bands they actually use), falling back to the built-in default set. // bands they actually use), falling back to the built-in default set.
const cols = useMemo( const cols = useMemo(
@@ -151,6 +156,36 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
</Badge> </Badge>
) : null; ) : null;
// Sunrise / sunset AT THE DX, in UTC. A glance tells you whether the path is
// about to open or close on their side, which is the reason to look at a DX
// station's grey line at all. Recomputed only when the position changes.
const sun = useMemo(
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
[lat, lon],
);
const sunBlock = sun ? (
<div className="ml-auto flex items-center gap-3 text-xs shrink-0"
title="Sunrise / sunset at the DX station (UTC)">
{sun.polarDay ? (
<span className="font-semibold text-warning">midnight sun</span>
) : sun.polarNight ? (
<span className="font-semibold text-info">polar night</span>
) : (
<>
<span className="flex items-center gap-1">
<Sunrise className="size-3.5 text-warning" />
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
</span>
<span className="flex items-center gap-1">
<Sunset className="size-3.5 text-info" />
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
</span>
<span className="text-muted-foreground">UTC</span>
</>
)}
</div>
) : null;
return ( return (
<section <section
className={cn( className={cn(
@@ -158,7 +193,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg', newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
)} )}
> >
<div className="flex items-center gap-2 min-w-[220px]"> <div className="flex items-center gap-2 min-w-[220px] flex-1">
{newOne ? ( {newOne ? (
<> <>
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]"> <Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
@@ -207,6 +242,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
Type a callsign to see entity stats Type a callsign to see entity stats
</span> </span>
)} )}
{sunBlock}
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
+8 -4
View File
@@ -53,6 +53,8 @@ export type SpotStatusEntry = {
worked_call?: boolean; worked_call?: boolean;
new_county?: boolean; new_county?: boolean;
new_pota?: boolean; new_pota?: boolean;
new_pfx?: boolean;
pfx?: string;
}; };
type Props = { type Props = {
@@ -123,9 +125,9 @@ function cellText(value: any, color: string | null): any {
return <span style={color ? { color, fontWeight: 700 } : undefined}>{txt}</span>; return <span style={color ? { color, fontWeight: 700 } : undefined}>{txt}</span>;
} }
// statusColor is the colour for a resolved status in the Status column. County // statusColor is the colour for a resolved status in the Status column. County,
// and POTA keep their own tokens: they are orthogonal to the band/mode/DXCC // POTA and prefix keep their own tokens: they are orthogonal to the band/mode/
// story and an operator filters on them separately. // DXCC story and an operator filters on them separately.
function statusColor(s: SpotStatusEntry | undefined): string | null { function statusColor(s: SpotStatusEntry | undefined): string | null {
switch (s?.status) { switch (s?.status) {
case 'new': case 'new':
@@ -191,6 +193,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
else if (s?.worked_call) parts.push(t('clg2.wkdCall')); else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
if (s?.new_county) parts.push(t('clg2.newCounty')); if (s?.new_county) parts.push(t('clg2.newCounty'));
if (s?.new_pota) parts.push(t('clg2.newPota')); if (s?.new_pota) parts.push(t('clg2.newPota'));
if (s?.new_pfx) parts.push(t('clg2.newPfx'));
return parts.join(' '); return parts.join(' ');
}, },
cellRenderer: (p: any) => { cellRenderer: (p: any) => {
@@ -205,8 +208,9 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
: t('clg2.wkdCall'); : t('clg2.wkdCall');
parts.push({ text: label, color: main }); parts.push({ text: label, color: main });
} }
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: 'var(--info)' }); if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: 'var(--success)' });
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: 'var(--success)' }); if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: 'var(--success)' });
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: 'var(--caution)' });
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>; if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
return ( return (
<span style={{ whiteSpace: 'nowrap' }}> <span style={{ whiteSpace: 'nowrap' }}>
+11 -1
View File
@@ -215,6 +215,15 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
} }
return null; return null;
}, [operatorGrid, remoteGrid, details.lat, details.lon]); }, [operatorGrid, remoteGrid, details.lat, details.lon]);
// Where the DX actually is, for its sunrise/sunset. The locator wins when we
// have one — it is what the operator can see and check — and the provider's
// raw coordinates cover entities that resolve through cty.dat with no grid.
const dxLL = useMemo(() => {
const byGrid = gridToLatLon(remoteGrid);
if (byGrid) return byGrid;
if (details.lat != null && details.lon != null) return { lat: details.lat, lon: details.lon };
return null;
}, [remoteGrid, details.lat, details.lon]);
const fmtDeg = (n: number) => `${Math.round(n)}°`; const fmtDeg = (n: number) => `${Math.round(n)}°`;
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`; const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
@@ -263,7 +272,8 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}> <div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
{open === 'stats' && ( {open === 'stats' && (
<div className="px-3 py-2.5"> <div className="px-3 py-2.5">
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} /> <BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''}
lat={dxLL?.lat} lon={dxLL?.lon} />
</div> </div>
)} )}
+137
View File
@@ -0,0 +1,137 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { useI18n } from '@/lib/i18n';
import { TailLogFile, GetLogFilePath } from '../../wailsjs/go/main/App';
// LogViewer shows the tail of opslog.log and keeps it current while open.
//
// This exists because diagnosing a problem used to mean asking the operator to
// find a file in their data directory, open it in a text editor, and re-open it
// after every attempt. Half the round-trips in a bug report were spent on that.
//
// Three behaviours matter more than they look:
// • Auto-scroll is a checkbox the operator owns, AND it releases itself the
// moment they scroll up — otherwise the line they are reading is yanked away
// a second later, which defeats the one thing this window is for. Scrolling
// back to the bottom re-arms it, so following is never a dead end.
// • The filter keeps only matching LINES rather than highlighting inside a
// 256 KB blob: with "cluster" or "acom" typed in, the window becomes exactly
// the subsystem trace you would have grepped for.
// • It polls only while open. A closed dialog costs nothing.
const TAIL_BYTES = 256 * 1024;
const POLL_MS = 1000;
export function LogViewer({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
const { t } = useI18n();
const [text, setText] = useState('');
const [path, setPath] = useState('');
const [query, setQuery] = useState('');
const [autoScroll, setAutoScroll] = useState(true);
// Shown next to the checkbox so a QUIET log still proves it is being polled —
// without it there is no way to tell 'nothing new' from 'window is dead'.
const [lastPull, setLastPull] = useState('');
const boxRef = useRef<HTMLPreElement>(null);
const autoRef = useRef(true);
useEffect(() => { autoRef.current = autoScroll; }, [autoScroll]);
// stick pins the view to the bottom on the NEXT frame. Setting scrollTop
// straight away is not enough while the dialog is still animating in: the box
// has no height yet, scrollHeight is wrong, and the scroll silently does
// nothing — leaving the operator staring at the top of a 256 KB buffer, i.e.
// at lines from hours ago, which looks exactly like a frozen window.
const stick = useCallback(() => {
if (!autoRef.current) return;
requestAnimationFrame(() => {
const el = boxRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
}, []);
const pull = useCallback(() => {
TailLogFile(TAIL_BYTES).then((s: any) => {
setText(String(s ?? ''));
setLastPull(new Date().toLocaleTimeString());
}).catch(() => {});
}, []);
useEffect(() => {
if (!open) return;
setAutoScroll(true);
autoRef.current = true;
GetLogFilePath().then((p: any) => setPath(String(p ?? ''))).catch(() => {});
pull();
// Re-assert the bottom after the open animation has settled. A quiet log
// never changes, so the content-driven scroll below would never fire again
// and the first attempt would be the only one.
const t1 = window.setTimeout(stick, 120);
const t2 = window.setTimeout(stick, 450);
const id = window.setInterval(pull, POLL_MS);
return () => { window.clearInterval(id); window.clearTimeout(t1); window.clearTimeout(t2); };
}, [open, pull, stick]);
const all = useMemo(() => text.split('\n'), [text]);
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return all;
return all.filter((l) => l.toLowerCase().includes(q));
}, [all, query]);
// Stick to the bottom after each refresh, but only while auto-scroll is on.
useEffect(() => { if (open) stick(); }, [shown, open, stick]);
// Scrolling away from the bottom releases auto-scroll; coming back re-arms it.
function onScroll() {
const el = boxRef.current;
if (!el) return;
setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 24);
}
const body = shown.join('\n');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>{t('logview.title')}</DialogTitle>
<DialogDescription className="font-mono text-[11px] break-all">{path}</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<Input className="h-8 flex-1 font-mono text-xs" value={query} placeholder={t('logview.searchPh')}
onChange={(e) => setQuery(e.target.value)} />
{query.trim() !== '' && (
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{t('logview.matches', { n: shown.length, total: all.length })}
</span>
)}
</div>
<pre ref={boxRef} onScroll={onScroll}
className="h-[60vh] overflow-auto rounded-md border border-border bg-muted/30 p-2 text-[11px] leading-snug font-mono whitespace-pre-wrap break-all">
{body.trim() === '' ? t('logview.empty') : body}
</pre>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox checked={autoScroll} onCheckedChange={(c) => {
const on = !!c;
setAutoScroll(on);
if (on) { const el = boxRef.current; if (el) el.scrollTop = el.scrollHeight; }
}} />
{t('logview.autoScroll')}
</label>
{lastPull && <span className="text-xs text-muted-foreground tabular-nums">{t('logview.updated', { time: lastPull })}</span>}
<div className="flex-1" />
<Button variant="outline" size="sm" onClick={() => navigator.clipboard?.writeText(body)}>
{t('logview.copy')}
</Button>
<Button variant="outline" size="sm" onClick={() => { setQuery(''); setAutoScroll(true); pull(); }}>
{t('logview.toEnd')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
File diff suppressed because one or more lines are too long
+83
View File
@@ -0,0 +1,83 @@
// Sunrise / sunset for a station, in UTC.
//
// Built on greyline.ts's sunPosition() rather than on a second implementation of
// the solar equations: the map's terminator and the entry strip's sunrise must
// never disagree, and there is a whole comment block over there about a sign
// error that produced six months of plausible-looking wrong answers. One source
// of astronomy, one place to be wrong.
//
// Everything is UTC. Amateur radio runs on UTC, the log is in UTC, and a local
// time would raise the question "local to whom — me or the DX?".
import { sunPosition } from './greyline';
const DEG = Math.PI / 180;
// Standard altitude of the Sun's centre at rise/set: half a solar diameter below
// the horizon plus atmospheric refraction.
const H0 = -0.833;
// solarAltitude returns the Sun's elevation in degrees at a place and instant.
function solarAltitude(date: Date, latDeg: number, lonDeg: number): number {
const { dec, gha } = sunPosition(date);
const ha = (gha + lonDeg) * DEG; // local hour angle
const d = dec * DEG;
const lat = latDeg * DEG;
return Math.asin(Math.sin(lat) * Math.sin(d) + Math.cos(lat) * Math.cos(d) * Math.cos(ha)) / DEG;
}
export type SunTimes = {
rise: string; // "HH:MM" UTC, "" when the Sun does not rise that day
set: string;
polarDay: boolean; // Sun stays up all day
polarNight: boolean; // Sun never comes up
};
// sunTimes scans the UTC day a minute at a time and reports where the Sun
// crosses the rise/set altitude.
//
// A scan, not the closed-form hour-angle formula, because the closed form needs
// special-casing for the polar cases and for days where the declination shifts
// enough to matter, and it silently returns NaN rather than saying "no sunrise
// here today". 1440 evaluations is nothing, and the failure mode is honest: no
// crossing found means no sunrise, which is a real answer above the Arctic
// circle. The crossing minute is then refined by linear interpolation, so the
// result is accurate to well under a minute despite the coarse scan.
export function sunTimes(date: Date, latDeg: number, lonDeg: number): SunTimes {
const out: SunTimes = { rise: '', set: '', polarDay: false, polarNight: false };
if (!isFinite(latDeg) || !isFinite(lonDeg)) return out;
const start = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
const minute = 60000;
let prev = solarAltitude(new Date(start), latDeg, lonDeg);
const anyUp = prev > H0;
let anyDown = prev <= H0;
let sawUp = anyUp;
for (let i = 1; i <= 1440; i++) {
const alt = solarAltitude(new Date(start + i * minute), latDeg, lonDeg);
if (alt > H0) sawUp = true; else anyDown = true;
if (prev <= H0 && alt > H0 && !out.rise) {
out.rise = fmt(start + (i - 1 + frac(prev, alt)) * minute);
} else if (prev > H0 && alt <= H0 && !out.set) {
out.set = fmt(start + (i - 1 + frac(prev, alt)) * minute);
}
prev = alt;
}
if (!out.rise && !out.set) {
out.polarDay = sawUp && !anyDown;
out.polarNight = !sawUp;
}
return out;
}
// frac is where between two samples the altitude crosses H0.
function frac(a: number, b: number): number {
const d = b - a;
return d === 0 ? 0 : (H0 - a) / d;
}
function fmt(ms: number): string {
const d = new Date(ms);
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // 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). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.23.0'; export const APP_VERSION = '0.23.2';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -989,6 +989,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>; export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
export function TailLogFile(arg1:number):Promise<string>;
export function TestCloudlogUpload():Promise<string>; export function TestCloudlogUpload():Promise<string>;
export function TestClublogUpload():Promise<string>; export function TestClublogUpload():Promise<string>;
+4
View File
@@ -1926,6 +1926,10 @@ export function SyncPOTAHunterLog(arg1, arg2) {
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2); return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
} }
export function TailLogFile(arg1) {
return window['go']['main']['App']['TailLogFile'](arg1);
}
export function TestCloudlogUpload() { export function TestCloudlogUpload() {
return window['go']['main']['App']['TestCloudlogUpload'](); return window['go']['main']['App']['TestCloudlogUpload']();
} }
+8
View File
@@ -1442,6 +1442,8 @@ export namespace lotwusers {
is_user: boolean; is_user: boolean;
last_upload?: string; last_upload?: string;
days_ago: number; days_ago: number;
feed_date?: string;
feed_stale: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Info(source); return new Info(source);
@@ -1452,6 +1454,8 @@ export namespace lotwusers {
this.is_user = source["is_user"]; this.is_user = source["is_user"];
this.last_upload = source["last_upload"]; this.last_upload = source["last_upload"];
this.days_ago = source["days_ago"]; this.days_ago = source["days_ago"];
this.feed_date = source["feed_date"];
this.feed_stale = source["feed_stale"];
} }
} }
@@ -2904,6 +2908,8 @@ export namespace main {
worked_call: boolean; worked_call: boolean;
new_county: boolean; new_county: boolean;
new_pota: boolean; new_pota: boolean;
new_pfx: boolean;
pfx?: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new SpotStatus(source); return new SpotStatus(source);
@@ -2920,6 +2926,8 @@ export namespace main {
this.worked_call = source["worked_call"]; this.worked_call = source["worked_call"];
this.new_county = source["new_county"]; this.new_county = source["new_county"];
this.new_pota = source["new_pota"]; this.new_pota = source["new_pota"];
this.new_pfx = source["new_pfx"];
this.pfx = source["pfx"];
} }
} }
export class StartupStatus { export class StartupStatus {
+12
View File
@@ -648,6 +648,18 @@ func parseShowDX(line string) (Spot, bool) {
} }
func parseSpot(line string) (Spot, bool) { func parseSpot(line string) (Spot, bool) {
// A cluster prints its prompt WITHOUT a trailing newline, so the first spot
// to arrive gets glued onto it and the line reads
// "login: DX de YO2CK-#: 21074.0 PY4PTO FT8 …". The anchor then fails
// and the spot is dropped — silently, apart from an UNPARSED log line.
//
// Cutting everything ahead of "DX de" rather than un-anchoring the pattern:
// an un-anchored match would also accept a talk/announce line that merely
// QUOTES a spot, and start injecting other people's conversations as real
// spots.
if i := strings.Index(line, "DX de"); i > 0 {
line = line[i:]
}
m := spotRE.FindStringSubmatch(line) m := spotRE.FindStringSubmatch(line)
if m == nil { if m == nil {
return Spot{}, false return Spot{}, false
+6
View File
@@ -14,6 +14,12 @@ func TestParseSpot(t *testing.T) {
`DX de DK0SWL: 14195.5 W1AW CQ Field Day 1745Z`, `DX de DK0SWL: 14195.5 W1AW CQ Field Day 1745Z`,
"W1AW", 14195.5, "20m", "", "W1AW", 14195.5, "20m", "",
}, },
{
// The cluster prints its prompt with no trailing newline, so the
// first spot arrives glued to it. Seen live on 2026-08-02.
`login: DX de YO2CK-#: 21074.0 PY4PTO FT8 -16 dB 1733Z`,
"PY4PTO", 21074.0, "15m", "",
},
{ {
`DX de F4XYZ-#: 7074.0 3DA0RU FT8 -10 0823Z JN18`, `DX de F4XYZ-#: 7074.0 3DA0RU FT8 -10 0823Z JN18`,
"3DA0RU", 7074.0, "40m", "JN18", "3DA0RU", 7074.0, "40m", "JN18",
+16 -4
View File
@@ -86,7 +86,13 @@ type Server struct {
stopped bool stopped bool
mu sync.Mutex mu sync.Mutex
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets // WSJT: dial frequency from the last Status, added to Decode audio offsets
// keyed by the WSJT-X instance id, NOT one value per listener. Two instances
// share one multicast group, so a single value was overwritten by whichever
// sent Status last: decodes from the 50.313 receiver were placed on the
// 50.400 panadapter. WSJT-X requires --rig-name for a second instance, so the
// id is distinct whenever there is more than one.
dialHz map[string]int64
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
} }
@@ -211,15 +217,21 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
// offsets can be turned into RF frequencies for the panadapter. // offsets can be turned into RF frequencies for the panadapter.
if w.FreqHz > 0 && !w.IsDecode { if w.FreqHz > 0 && !w.IsDecode {
s.mu.Lock() s.mu.Lock()
s.lastDialHz = w.FreqHz if s.dialHz == nil {
s.dialHz = map[string]int64{}
}
s.dialHz[w.ProgramID] = w.FreqHz
s.mu.Unlock() s.mu.Unlock()
} }
if w.IsDecode { if w.IsDecode {
s.mu.Lock() s.mu.Lock()
dial := s.lastDialHz dial := s.dialHz[w.ProgramID]
s.mu.Unlock() s.mu.Unlock()
if dial <= 0 { if dial <= 0 {
return // no dial freq yet (Status not seen) → can't place the spot // No Status from THIS instance yet. Guessing with another
// instance's dial is what produced the wrong-panadapter spots,
// so drop the decode and wait — Status arrives every second.
return
} }
ev.DecodeCall = w.DecodeCall ev.DecodeCall = w.DecodeCall
ev.DecodeFreqHz = dial + w.DeltaFreqHz ev.DecodeFreqHz = dial + w.DeltaFreqHz
+74
View File
@@ -0,0 +1,74 @@
# QRZ.com XML API — what a free account returns, and what a subscription adds
Captured 2026-08-02 on F4BPO's two accounts, same callsign looked up twice
within a minute. Kept because the difference is the answer to a question that
comes back regularly: *"someone told me their logger gets the 6-character
locator from QRZ without paying"*. It does not.
## Free account — `SubExp: non-subscriber`
```xml
<Callsign>
<call>F4BPO</call>
<fname>Gregory</fname>
<name>Salaun</name>
<addr2>Veigy-Foncenex</addr2>
<country>France</country>
</Callsign>
<Session>
<SubExp>non-subscriber</SubExp>
<Message>A subscription is required to access the complete record.</Message>
</Session>
```
**Five fields.** No `grid`, no `lat`/`lon`, no `dxcc`, no zones, no email, no
street, no image.
## Subscriber — `SubExp: Sat Jul 3 21:45:38 2027`
```xml
<Callsign>
<call>F4BPO</call> <dxcc>227</dxcc> <nickname>Greg</nickname>
<fname>Gregory</fname> <name>Salaun</name>
<addr2>Veigy-Foncenex</addr2><zip>74140</zip> <country>France</country>
<lat>46.103333</lat> <lon>6.290000</lon> <grid>JN36dc</grid>
<ccode>97</ccode> <land>France</land> <class>2</class>
<codes>E</codes> <qslmgr>Lotw, Buro, Direct</qslmgr>
<email></email> <u_views>56966</u_views>
<bio>4414</bio> <biodate>2026-07-08 15:30:58</biodate>
<image>https://cdn-xml.qrz.com/o/f4bpo/F4BPO_Greg_png.jpg</image>
<imageinfo>800:1200:221041</imageinfo>
<moddate>2025-08-24 19:55:31</moddate>
<eqsl>0</eqsl> <mqsl>1</mqsl> <lotw>1</lotw>
<cqzone>14</cqzone> <ituzone>27</ituzone>
<geoloc>user</geoloc> <name_fmt>Gregory "Greg" Salaun</name_fmt>
<serial>1000134</serial>
</Callsign>
```
## Consequences
- **A free QRZ account can never fill the locator, the coordinates, the zones
or the e-mail.** If someone reports a logger doing it on a free account, they
are either using HamQTH (free, returns a 6-character `grid`) or they hold an
XML subscription without realising it — the QRZ.com account and the "XML
Logbook Data" subscription are sold separately. `SubExp` in the session block
settles it in one request.
- **Profile edits take a few minutes to reach the XML feed.** Changing the grid
on the website and looking the callsign up immediately returns the OLD value,
which looks exactly like a stale cache. Re-test after a few minutes before
suspecting the logger. (This cost an hour on 2026-08-02.)
## Fields OpsLog does not use yet
`qrz.go` maps call, name, addr1/2, zip, state, county, country, grid, lat, lon,
dxcc, cqzone, ituzone, cont, email, qslmgr and image. Left on the table:
- **`geoloc`** — `user` when the operator entered the position by hand, versus a
geocoded value derived from the postal address. This is a direct statement of
how much the coordinates and the locator can be trusted, and it is exactly
the question the grid-precision rule in App.tsx tries to answer by comparing
string lengths. Worth wiring if the locator ever misbehaves again.
- **`eqsl` / `mqsl` / `lotw`** — the station's QSL preferences as flags, more
reliable than parsing the free-text `qslmgr`.
- `nickname` / `name_fmt`, `class`, `moddate`, `bio`.
+24 -2
View File
@@ -29,6 +29,13 @@ type Info struct {
IsUser bool `json:"is_user"` IsUser bool `json:"is_user"`
LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD
DaysAgo int `json:"days_ago"` // days since last upload (-1 if unknown) DaysAgo int `json:"days_ago"` // days since last upload (-1 if unknown)
// FeedDate is the newest upload date anywhere in ARRL's file, i.e. when ARRL
// last regenerated it, and FeedStale says that date is more than two days old.
// Without them "last uploaded 7 days ago" is unreadable: it may mean the
// station went quiet, or that the FEED has been frozen for a week — which is
// exactly what ARRL served on 2026-08-02, its newest line dated 2026-07-27.
FeedDate string `json:"feed_date,omitempty"`
FeedStale bool `json:"feed_stale"`
} }
// Manager holds the parsed list + cache location. // Manager holds the parsed list + cache location.
@@ -36,6 +43,7 @@ type Manager struct {
mu sync.RWMutex mu sync.RWMutex
users map[string]time.Time // UPPER(callsign) → last-upload date (UTC) users map[string]time.Time // UPPER(callsign) → last-upload date (UTC)
updated time.Time // when the cache was last refreshed updated time.Time // when the cache was last refreshed
newest time.Time // newest upload date in the file = when ARRL built it
dir string dir string
client *http.Client client *http.Client
} }
@@ -100,6 +108,7 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
// parse loads the CSV bytes into the map and returns the count. // parse loads the CSV bytes into the map and returns the count.
func (m *Manager) parse(data []byte) int { func (m *Manager) parse(data []byte) int {
users := make(map[string]time.Time, 1<<20) users := make(map[string]time.Time, 1<<20)
var newest time.Time
sc := bufio.NewScanner(bytes.NewReader(data)) sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 1024*1024), 1024*1024) sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() { for sc.Scan() {
@@ -120,16 +129,29 @@ func (m *Manager) parse(data []byte) int {
continue continue
} }
users[call] = t users[call] = t
if t.After(newest) {
newest = t
}
} }
if len(users) == 0 { if len(users) == 0 {
return 0 return 0
} }
m.mu.Lock() m.mu.Lock()
m.users = users m.users = users
m.newest = newest
m.mu.Unlock() m.mu.Unlock()
return len(users) return len(users)
} }
// feedInfo fills the feed-age fields shared by every Lookup result.
func (m *Manager) feedInfo(i Info) Info {
if !m.newest.IsZero() {
i.FeedDate = m.newest.Format("2006-01-02")
i.FeedStale = time.Since(m.newest) > 48*time.Hour
}
return i
}
// Lookup reports whether callsign is a LoTW user and how long ago it uploaded. // Lookup reports whether callsign is a LoTW user and how long ago it uploaded.
// Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P") // Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P")
// the longest slash-separated segment — the base call LoTW is keyed on. // the longest slash-separated segment — the base call LoTW is keyed on.
@@ -154,13 +176,13 @@ func (m *Manager) Lookup(callsign string) Info {
t, ok = m.users[base] t, ok = m.users[base]
} }
if !ok { if !ok {
return Info{DaysAgo: -1} return m.feedInfo(Info{DaysAgo: -1})
} }
days := int(time.Since(t).Hours() / 24) days := int(time.Since(t).Hours() / 24)
if days < 0 { if days < 0 {
days = 0 days = 0
} }
return Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days} return m.feedInfo(Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days})
} }
// Count returns how many callsigns are loaded. // Count returns how many callsigns are loaded.
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.23.0" appVersion = "0.23.2"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.