diff --git a/app.go b/app.go index 67d9f42..6b913b3 100644 --- a/app.go +++ b/app.go @@ -9121,6 +9121,49 @@ func (a *App) GetLogFilePath() string { 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. // // 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. if a.catFlexDecodeSpots && a.cat != nil { 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 } if len(lastDecodeSpot) > 4000 { lastDecodeSpot = map[string]time.Time{} // bound memory on long sessions } - lastDecodeSpot[ev.DecodeCall] = now + lastDecodeSpot[key] = now secs := a.catFlexDecodeSecs if secs <= 0 { secs = 120 diff --git a/changelog.json b/changelog.json index 9081238..31bab49 100644 --- a/changelog.json +++ b/changelog.json @@ -1,4 +1,24 @@ [ + { + "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": "", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 944dbaf..6e763c9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -120,6 +120,7 @@ import { import { cn } from '@/lib/utils'; import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead'; import { flagURL } from '@/lib/flags'; +import { LogViewer } from '@/components/LogViewer'; type QSO = QSOForm; type ImportResult = adifModels.ImportResult; @@ -547,7 +548,7 @@ export default function App() { }, []); // LoTW-user lookup for the entered callsign (from the cached ARRL activity list), // 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(() => { const c = callsign.trim(); if (!c) { setLotwInfo(null); return; } @@ -1547,6 +1548,7 @@ export default function App() { const [showAbout, setShowAbout] = useState(false); // "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). + const [showLogViewer, setShowLogViewer] = useState(false); const [whatsNew, setWhatsNew] = useState | null>(null); // 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. @@ -3293,10 +3295,16 @@ export default function App() { 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('country') && empty(r?.country) && last.country) setCountry(last.country); - // Grid: a REAL provider grid always wins. Otherwise the last QSO's precise - // locator beats the coarse cty.dat entity centroid the provider block set — so - // a French call resolves to its real JNxx, not the country's JN16 centroid. - const adoptLastGrid = !ue.has('grid') && empty(r?.grid) && !!last.grid; + // Grid: the provider (QRZ / HamQTH) leads, but only while it is at least as + // PRECISE as what we already worked. It used to lead whenever it answered at + // all, so a 4-character square — which is what a provider returns when the + // 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); setDetails((d) => { // When we adopt the last QSO's locator, take its coordinates too (derive them @@ -3618,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: 'separator' }, + { type: 'item', label: t('logview.title'), action: 'help.log' }, + { type: 'separator' }, { type: 'item', label: t('help.donate'), action: 'help.donate', accent: true }, { type: 'separator' }, { type: 'item', label: t('help.about'), action: 'help.about' }, @@ -3651,6 +3661,7 @@ export default function App() { case 'tools.refreshCty': refreshCtyDat(); break; case 'tools.downloadRefs': downloadRefs(); 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.sendlog': sendLogToDeveloper(); break; // Opens in the system browser, NOT the app WebView: a payment page must show @@ -4059,8 +4070,14 @@ export default function App() { const cls = d >= 0 && d < 7 ? 'border-success text-success bg-success/15' : d < 30 ? 'border-warning text-warning bg-warning/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 ( -
+
LoTW @@ -5248,6 +5265,9 @@ export default function App() {
)} + {/* Live diagnostic log (Help → …). Polls only while open. */} + + {/* What's new — shown once on the first launch after an update. */} {whatsNew && (
setWhatsNew(null)}> diff --git a/frontend/src/components/BandSlotGrid.tsx b/frontend/src/components/BandSlotGrid.tsx index 278d55b..32f7836 100644 --- a/frontend/src/components/BandSlotGrid.tsx +++ b/frontend/src/components/BandSlotGrid.tsx @@ -1,7 +1,8 @@ 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 { cn } from '@/lib/utils'; +import { sunTimes } from '@/lib/sun'; import type { WorkedBeforeView } from '@/types'; type WorkedBefore = WorkedBeforeView; @@ -13,6 +14,10 @@ interface Props { currentMode: string; 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 + // 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, @@ -84,7 +89,7 @@ function cellTitle(band: string, cls: string, status: string, current: boolean): 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 // bands they actually use), falling back to the built-in default set. const cols = useMemo( @@ -151,6 +156,36 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal ) : 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 ? ( +
+ {sun.polarDay ? ( + midnight sun + ) : sun.polarNight ? ( + polar night + ) : ( + <> + + + {sun.rise || '—'} + + + + {sun.set || '—'} + + UTC + + )} +
+ ) : null; + return (
-
+
{newOne ? ( <> @@ -207,6 +242,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal Type a callsign to see entity stats )} + {sunBlock}
diff --git a/frontend/src/components/DetailsPanel.tsx b/frontend/src/components/DetailsPanel.tsx index f065bd6..9e58eee 100644 --- a/frontend/src/components/DetailsPanel.tsx +++ b/frontend/src/components/DetailsPanel.tsx @@ -215,6 +215,15 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, } return null; }, [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 fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`; @@ -263,7 +272,8 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
{open === 'stats' && (
- +
)} diff --git a/frontend/src/components/LogViewer.tsx b/frontend/src/components/LogViewer.tsx new file mode 100644 index 0000000..ab04d9c --- /dev/null +++ b/frontend/src/components/LogViewer.tsx @@ -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(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 ( + + + + {t('logview.title')} + {path} + + +
+ setQuery(e.target.value)} /> + {query.trim() !== '' && ( + + {t('logview.matches', { n: shown.length, total: all.length })} + + )} +
+ +
+          {body.trim() === '' ? t('logview.empty') : body}
+        
+ +
+ + {lastPull && {t('logview.updated', { time: lastPull })}} +
+ + +
+ +
+ ); +} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 85ee9b1..e74b1bb 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -24,7 +24,7 @@ const en: Dict = { 'upd.restartNote': 'OpsLog will restart on the new version.', 'upd.retry': 'Retry', 'upd.browser': 'Open page', 'upd.checking': 'Checking for updates…', 'upd.upToDate': "You're up to date", 'rate.title': 'QSO rate (QSOs/hour) — projected from the last 10 / 60 minutes', - 'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)', + 'lotw.feedStale': "ARRL's user list itself was last built on {date}, so this figure may be older than the station", 'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)', 'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools', 'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…', 'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit', @@ -40,7 +40,7 @@ const en: Dict = { 'cwd.tipOff': 'CW decoder · click to enable (decodes RX audio in CW mode)', 'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode', 'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss', 'alert.pending': '{n} recent spot alert(s) — click to view', 'alert.noneShort': 'No recent alerts', 'alert.recent': 'Recent alerts', 'alert.clear': 'Clear', - 'menu.help': 'Help', 'prof.switchTitle': 'Switch station profile', 'prof.manage': 'Manage profiles…', 'help.about': 'About OpsLog', 'help.donate': '♥ Support OpsLog (donate)', 'tools.duplicates': 'Find duplicates…', + 'menu.help': 'Help', 'prof.switchTitle': 'Switch station profile', 'prof.manage': 'Manage profiles…', 'logview.title': "Diagnostic log", 'logview.empty': "(log is empty)", 'logview.searchPh': "Filter lines — e.g. cluster, acom, cat", 'logview.matches': "{n} of {total} lines", 'logview.autoScroll': "Follow the end", 'logview.updated': "refreshed {time}", 'logview.copy': "Copy", 'logview.toEnd': "Jump to end", 'help.about': 'About OpsLog', 'help.donate': '♥ Support OpsLog (donate)', 'tools.duplicates': 'Find duplicates…', 'help.sendLog': 'Send log to F4BPO', 'help.sendLogBusy': 'Sending log…', 'help.sendLogOk': 'Log sent to F4BPO — thanks, 73!', 'help.sendLogFail': 'Could not send log: {err}', // Duplicates modal @@ -445,7 +445,7 @@ const fr: Dict = { 'live.offlineTip': 'Hors ligne — aucun QSO loggé depuis 5 minutes', 'live.stationsTitle': 'Stations on air', 'live.stationsEmpty': 'Aucune station ne reporte pour le moment.', 'live.stationsHide': 'Masquer', 'rate.title': 'Rythme QSO (QSO/heure) — projeté sur les 10 / 60 dernières minutes', - 'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)', + 'lotw.feedStale': "la liste ARRL elle-même date du {date}, ce chiffre peut donc être plus vieux que la station", 'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)', 'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils', 'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…', 'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter', @@ -461,7 +461,7 @@ const fr: Dict = { 'cwd.tipOff': 'Décodeur CW · clic pour activer (décode l’audio RX en mode CW)', 'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest', 'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer', 'alert.pending': '{n} alerte(s) de spot récente(s) — cliquer pour voir', 'alert.noneShort': 'Aucune alerte récente', 'alert.recent': 'Alertes récentes', 'alert.clear': 'Effacer', - 'menu.help': 'Aide', 'prof.switchTitle': 'Changer de profil de station', 'prof.manage': 'Gérer les profils…', 'help.about': 'À propos d\'OpsLog', 'help.donate': '♥ Soutenir OpsLog (don)', 'tools.duplicates': 'Trouver les doublons…', + 'menu.help': 'Aide', 'prof.switchTitle': 'Changer de profil de station', 'prof.manage': 'Gérer les profils…', 'logview.title': "Journal de diagnostic", 'logview.empty': "(journal vide)", 'logview.searchPh': "Filtrer les lignes — ex. cluster, acom, cat", 'logview.matches': "{n} lignes sur {total}", 'logview.autoScroll': "Suivre la fin", 'logview.updated': "actualisé {time}", 'logview.copy': "Copier", 'logview.toEnd': "Aller à la fin", 'help.about': 'À propos d\'OpsLog', 'help.donate': '♥ Soutenir OpsLog (don)', 'tools.duplicates': 'Trouver les doublons…', 'help.sendLog': 'Envoyer le journal à F4BPO', 'help.sendLogBusy': 'Envoi du journal…', 'help.sendLogOk': 'Journal envoyé à F4BPO — merci, 73 !', 'help.sendLogFail': 'Échec de l\'envoi du journal : {err}', 'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉', diff --git a/frontend/src/lib/sun.ts b/frontend/src/lib/sun.ts new file mode 100644 index 0000000..f84a280 --- /dev/null +++ b/frontend/src/lib/sun.ts @@ -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())}`; +} diff --git a/frontend/src/version.ts b/frontend/src/version.ts index a8ccb1c..8da6415 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -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.23.1'; +export const APP_VERSION = '0.23.2'; // Author / credits, shown in Help -> About. export const APP_AUTHOR = 'F4BPO'; diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 1f242db..1fde2bc 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -989,6 +989,8 @@ export function SwitchCATRig(arg1:number):Promise; export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise; +export function TailLogFile(arg1:number):Promise; + export function TestCloudlogUpload():Promise; export function TestClublogUpload():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 71713cd..341fe1e 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1926,6 +1926,10 @@ export function 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() { return window['go']['main']['App']['TestCloudlogUpload'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 6829cc5..400086c 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1442,6 +1442,8 @@ export namespace lotwusers { is_user: boolean; last_upload?: string; days_ago: number; + feed_date?: string; + feed_stale: boolean; static createFrom(source: any = {}) { return new Info(source); @@ -1452,6 +1454,8 @@ export namespace lotwusers { this.is_user = source["is_user"]; this.last_upload = source["last_upload"]; this.days_ago = source["days_ago"]; + this.feed_date = source["feed_date"]; + this.feed_stale = source["feed_stale"]; } } diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go index 160fba7..5f012ac 100644 --- a/internal/cluster/cluster.go +++ b/internal/cluster/cluster.go @@ -648,6 +648,18 @@ func parseShowDX(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) if m == nil { return Spot{}, false diff --git a/internal/cluster/cluster_test.go b/internal/cluster/cluster_test.go index 86108af..a74b650 100644 --- a/internal/cluster/cluster_test.go +++ b/internal/cluster/cluster_test.go @@ -14,6 +14,12 @@ func TestParseSpot(t *testing.T) { `DX de DK0SWL: 14195.5 W1AW CQ Field Day 1745Z`, "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`, "3DA0RU", 7074.0, "40m", "JN18", diff --git a/internal/integrations/udp/server.go b/internal/integrations/udp/server.go index e9b15db..c7ccc28 100644 --- a/internal/integrations/udp/server.go +++ b/internal/integrations/udp/server.go @@ -86,7 +86,13 @@ type Server struct { stopped bool 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 } @@ -211,15 +217,21 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { // offsets can be turned into RF frequencies for the panadapter. if w.FreqHz > 0 && !w.IsDecode { 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() } if w.IsDecode { s.mu.Lock() - dial := s.lastDialHz + dial := s.dialHz[w.ProgramID] s.mu.Unlock() 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.DecodeFreqHz = dial + w.DeltaFreqHz diff --git a/internal/lookup/QRZ-XML-FIELDS.md b/internal/lookup/QRZ-XML-FIELDS.md new file mode 100644 index 0000000..cd08693 --- /dev/null +++ b/internal/lookup/QRZ-XML-FIELDS.md @@ -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 + + F4BPO + Gregory + Salaun + Veigy-Foncenex + France + + + non-subscriber + A subscription is required to access the complete record. + +``` + +**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 + + F4BPO 227 Greg + Gregory Salaun + Veigy-Foncenex74140 France + 46.103333 6.290000 JN36dc + 97 France 2 + E Lotw, Buro, Direct + 56966 + 4414 2026-07-08 15:30:58 + https://cdn-xml.qrz.com/o/f4bpo/F4BPO_Greg_png.jpg + 800:1200:221041 + 2025-08-24 19:55:31 + 0 1 1 + 14 27 + user Gregory "Greg" Salaun + 1000134 + +``` + +## 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`. diff --git a/internal/lotwusers/lotwusers.go b/internal/lotwusers/lotwusers.go index 88ed3f4..2fc677d 100644 --- a/internal/lotwusers/lotwusers.go +++ b/internal/lotwusers/lotwusers.go @@ -29,6 +29,13 @@ type Info struct { IsUser bool `json:"is_user"` LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD 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. @@ -36,6 +43,7 @@ type Manager struct { mu sync.RWMutex users map[string]time.Time // UPPER(callsign) → last-upload date (UTC) updated time.Time // when the cache was last refreshed + newest time.Time // newest upload date in the file = when ARRL built it dir string 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. func (m *Manager) parse(data []byte) int { users := make(map[string]time.Time, 1<<20) + var newest time.Time sc := bufio.NewScanner(bytes.NewReader(data)) sc.Buffer(make([]byte, 1024*1024), 1024*1024) for sc.Scan() { @@ -120,16 +129,29 @@ func (m *Manager) parse(data []byte) int { continue } users[call] = t + if t.After(newest) { + newest = t + } } if len(users) == 0 { return 0 } m.mu.Lock() m.users = users + m.newest = newest m.mu.Unlock() 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. // 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. @@ -154,13 +176,13 @@ func (m *Manager) Lookup(callsign string) Info { t, ok = m.users[base] } if !ok { - return Info{DaysAgo: -1} + return m.feedInfo(Info{DaysAgo: -1}) } days := int(time.Since(t).Hours() / 24) if 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. diff --git a/telemetry.go b/telemetry.go index aac8c91..8728598 100644 --- a/telemetry.go +++ b/telemetry.go @@ -21,7 +21,7 @@ import ( const ( // appVersion is stamped on every heartbeat (and could feed the About box). - appVersion = "0.23.1" + appVersion = "0.23.2" // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // to https://us.i.posthog.com for a US project.