chore: release v0.23.2

This commit is contained in:
2026-08-02 23:51:46 +02:00
parent 3da1d71323
commit 9846354bf9
18 changed files with 514 additions and 24 deletions
+26 -6
View File
@@ -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<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
// 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 (
<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)}>
LoTW
</span>
@@ -5248,6 +5265,9 @@ export default function App() {
</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. */}
{whatsNew && (
<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 { 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
</Badge>
) : 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 (
<section
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',
)}
>
<div className="flex items-center gap-2 min-w-[220px]">
<div className="flex items-center gap-2 min-w-[220px] flex-1">
{newOne ? (
<>
<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
</span>
)}
{sunBlock}
</div>
<div className="flex flex-col gap-2">
+11 -1
View File
@@ -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,
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
{open === 'stats' && (
<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>
)}
+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>
);
}
+4 -4
View File
@@ -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 laudio 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é. 🎉',
+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).
// 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';
+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 TailLogFile(arg1:number):Promise<string>;
export function TestCloudlogUpload():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);
}
export function TailLogFile(arg1) {
return window['go']['main']['App']['TailLogFile'](arg1);
}
export function TestCloudlogUpload() {
return window['go']['main']['App']['TestCloudlogUpload']();
}
+4
View File
@@ -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"];
}
}