Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db24c38e63 | ||
|
|
06868e9ed1 | ||
|
|
3ed643e072 | ||
|
|
f0f9898f7c | ||
|
|
73cae855ed | ||
|
|
de3a115ca6 | ||
|
|
1d7633484b | ||
|
|
5ee0ade54b | ||
|
|
e637f0814d | ||
|
|
1a169fdb4f | ||
|
|
3dc31697cd |
@@ -11576,14 +11576,18 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
// with award-style NEW flags computed against the log's prior confirmations.
|
// with award-style NEW flags computed against the log's prior confirmations.
|
||||||
type ConfirmationItem struct {
|
type ConfirmationItem struct {
|
||||||
Callsign string `json:"callsign"`
|
Callsign string `json:"callsign"`
|
||||||
QSODate string `json:"qso_date"` // ISO UTC
|
// Station is the callsign the QSO was made UNDER, which the download can now
|
||||||
Band string `json:"band"`
|
// span ("All my callsigns"): a list mixing F4BPO, F4BPO/P and TM2Q says
|
||||||
Mode string `json:"mode"`
|
// nothing useful unless each line says which of them it belongs to.
|
||||||
Country string `json:"country"`
|
Station string `json:"station"`
|
||||||
NewDXCC bool `json:"new_dxcc"`
|
QSODate string `json:"qso_date"` // ISO UTC
|
||||||
NewBand bool `json:"new_band"`
|
Band string `json:"band"`
|
||||||
NewMode bool `json:"new_mode"` // new mode CLASS (Phone/CW/Digital) for the entity
|
Mode string `json:"mode"`
|
||||||
NewSlot bool `json:"new_slot"`
|
Country string `json:"country"`
|
||||||
|
NewDXCC bool `json:"new_dxcc"`
|
||||||
|
NewBand bool `json:"new_band"`
|
||||||
|
NewMode bool `json:"new_mode"` // new mode CLASS (Phone/CW/Digital) for the entity
|
||||||
|
NewSlot bool `json:"new_slot"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSlotStats returns the worked/confirmed slot + DXCC tallies for the QSL
|
// GetSlotStats returns the worked/confirmed slot + DXCC tallies for the QSL
|
||||||
@@ -12365,6 +12369,7 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
|
|||||||
}
|
}
|
||||||
it := ConfirmationItem{
|
it := ConfirmationItem{
|
||||||
Callsign: q.Callsign,
|
Callsign: q.Callsign,
|
||||||
|
Station: strings.ToUpper(strings.TrimSpace(rec["station_callsign"])),
|
||||||
QSODate: q.QSODate.UTC().Format(time.RFC3339),
|
QSODate: q.QSODate.UTC().Format(time.RFC3339),
|
||||||
Band: q.Band,
|
Band: q.Band,
|
||||||
Mode: q.Mode,
|
Mode: q.Mode,
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.26.23",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"LoTW: the downloaded confirmations list gains a Station column, so a list spanning several callsigns says which one each confirmation belongs to. Shown only when the report actually carries more than the one station.",
|
||||||
|
"The band maps follow your IARU region (Settings → General): Region 2’s 40 m runs to 7300 kHz with phone from 7125 and its 80 m to 4000, Region 3’s 80 m to 3900 — the band edges and the CW/digital/phone shading all adjust."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"LoTW : la liste des confirmations téléchargées gagne une colonne Station, pour savoir à quel indicatif appartient chaque confirmation quand le téléchargement en couvre plusieurs. Affichée seulement si le rapport en contient effectivement.",
|
||||||
|
"Les cartes de bande suivent votre région IARU (Réglages → Général) : le 40 m de la Région 2 va jusqu'à 7300 kHz avec la phonie dès 7125 et son 80 m jusqu'à 4000, le 80 m de la Région 3 jusqu'à 3900 — les limites de bande et les zones CW/numérique/phonie s'ajustent."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.26.22",
|
"version": "0.26.22",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
|
||||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||||
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
||||||
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||||
@@ -114,59 +115,26 @@ interface Props {
|
|||||||
keyNav?: boolean;
|
keyNav?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BAND_RANGES: Record<string, [number, number]> = {
|
// Band edges and mode segments come from lib/bandplan, which knows the
|
||||||
'160m': [1800, 2000],
|
// operator's IARU region (Settings → General): a Region 2 operator's 40 m runs
|
||||||
'80m': [3500, 3800],
|
// to 7300 with phone from 7125, and drawing Region 1's plan under their spots
|
||||||
'60m': [5350, 5450],
|
// cut the top 100 kHz of the band off the map.
|
||||||
'40m': [7000, 7200],
|
//
|
||||||
'30m': [10100, 10150],
|
// Sub-band shading colours: these are IDENTITIES (which mode the segment is
|
||||||
'20m': [14000, 14350],
|
// for), not states, so they take categorical hues — never the status tokens.
|
||||||
'17m': [18068, 18168],
|
// All three are drawn from the cool end of the categorical order and laid down
|
||||||
'15m': [21000, 21450],
|
// at low opacity, so the band plan stays background context while the warm
|
||||||
'12m': [24890, 24990],
|
// spot pills keep the foreground. Checked with the palette validator in both
|
||||||
'10m': [28000, 29700],
|
// themes (violet failed the dark protan step; magenta clears every check).
|
||||||
'6m': [50000, 50500],
|
const SEG_COLOR: Record<SegMode, string> = {
|
||||||
'4m': [70000, 70500],
|
cw: 'var(--chart-7)', // magenta
|
||||||
'2m': [144000, 146000],
|
digi: 'var(--chart-1)', // blue
|
||||||
'70cm': [430000, 440000],
|
phone: 'var(--chart-2)', // aqua
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sub-band shading: CW / digital / phone.
|
|
||||||
//
|
|
||||||
// These are IDENTITIES (which mode the segment is for), not states, so they take
|
|
||||||
// categorical hues — never the status tokens. They used to use success / info /
|
|
||||||
// warning, which collided head-on with the spot pills drawn ON TOP of them: amber
|
|
||||||
// is "new band" in this very component's legend, so the whole SSB portion read as
|
|
||||||
// a giant "new band" wash. Status colours are reserved for status.
|
|
||||||
//
|
|
||||||
// All three are drawn from the COOL end of the categorical order and laid down at
|
|
||||||
// low opacity, so the band plan stays background context while the warm spot pills
|
|
||||||
// keep the foreground to themselves.
|
|
||||||
// Checked with the palette validator in BOTH themes. Violet was the first pick
|
|
||||||
// for CW and failed on the dark steps — violet and blue land 1.9 ΔE apart for a
|
|
||||||
// protan reader there, i.e. the same colour. Magenta clears every check on both
|
|
||||||
// surfaces (worst adjacent pair 13.0 light / 15.9 dark).
|
|
||||||
const SEG_CW = 'var(--chart-7)'; // magenta
|
|
||||||
const SEG_DIGI = 'var(--chart-1)'; // blue
|
|
||||||
const SEG_PHONE = 'var(--chart-2)'; // aqua
|
|
||||||
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
|
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
|
||||||
// are read on top of it.
|
// are read on top of it.
|
||||||
const SEG_OPACITY = 0.13;
|
const SEG_OPACITY = 0.13;
|
||||||
|
|
||||||
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
|
||||||
'160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
|
|
||||||
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
|
|
||||||
'60m': [[5350, 5450, SEG_PHONE]],
|
|
||||||
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
|
|
||||||
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
|
|
||||||
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
|
|
||||||
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
|
|
||||||
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
|
|
||||||
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
|
|
||||||
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
|
|
||||||
'6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Small coloured dot + label used in the band-map legend strip.
|
// Small coloured dot + label used in the band-map legend strip.
|
||||||
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
|
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -309,8 +277,11 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
for (const k of Object.keys(spotStatusRaw)) out[k] = applySpotDisplay(spotStatusRaw[k], dispOpts) as SpotStatusEntry;
|
for (const k of Object.keys(spotStatusRaw)) out[k] = applySpotDisplay(spotStatusRaw[k], dispOpts) as SpotStatusEntry;
|
||||||
return out;
|
return out;
|
||||||
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
|
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
|
||||||
const range = BAND_RANGES[band];
|
// Re-render when the operator changes their IARU region in Settings.
|
||||||
const segments = SEGMENT_COLORS[band] ?? [];
|
const [, setRegionTick] = useState(0);
|
||||||
|
useEffect(() => subscribeIaruRegion(() => setRegionTick((n) => n + 1)), []);
|
||||||
|
const range = bandRange(band);
|
||||||
|
const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]);
|
||||||
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
|
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
|
||||||
// The docked map follows the rig, so a band change must bring up THAT band's
|
// The docked map follows the rig, so a band change must bring up THAT band's
|
||||||
// remembered zoom.
|
// remembered zoom.
|
||||||
@@ -794,9 +765,9 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
))}
|
))}
|
||||||
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
|
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
|
||||||
<span className="mx-0.5 opacity-40">|</span>
|
<span className="mx-0.5 opacity-40">|</span>
|
||||||
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
|
<LegendDot colour={SEG_COLOR.cw} label={t("bmp.legendCW")} />
|
||||||
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
|
<LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
|
||||||
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
|
<LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||||
{t('bmp.footerHint')}
|
{t('bmp.footerHint')}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
type Confirmation = {
|
type Confirmation = {
|
||||||
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
callsign: string; station?: string; qso_date: string; band: string; mode: string; country: string;
|
||||||
new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
|
new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -619,6 +619,10 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
|||||||
<thead className="sticky top-0 bg-card">
|
<thead className="sticky top-0 bg-card">
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
<tr className="text-left text-muted-foreground border-b border-border">
|
||||||
<th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th><th className="py-1.5 px-2">{t('qslm.thCallsign')}</th>
|
<th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th><th className="py-1.5 px-2">{t('qslm.thCallsign')}</th>
|
||||||
|
{/* Which of the operator's callsigns the contact was made
|
||||||
|
under. Only worth a column when the list can hold more than
|
||||||
|
one — see "All my callsigns" on the download. */}
|
||||||
|
{shownConfs.some((c) => c.station) && <th className="py-1.5 px-2">{t('qslm.thStation')}</th>}
|
||||||
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thMode')}</th>
|
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thMode')}</th>
|
||||||
<th className="py-1.5 px-2">{t('qslm.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
|
<th className="py-1.5 px-2">{t('qslm.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -628,6 +632,9 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
|||||||
<tr key={i} className="border-b border-border/40">
|
<tr key={i} className="border-b border-border/40">
|
||||||
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
|
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
|
||||||
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
|
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
|
||||||
|
{shownConfs.some((x) => x.station) && (
|
||||||
|
<td className="py-1 px-2 font-mono text-muted-foreground">{c.station ?? ''}</td>
|
||||||
|
)}
|
||||||
<td className="py-1 px-2">{c.band}</td>
|
<td className="py-1 px-2">{c.band}</td>
|
||||||
<td className="py-1 px-2">{c.mode}</td>
|
<td className="py-1 px-2">{c.mode}</td>
|
||||||
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { setUseMiles } from '@/lib/units';
|
import { setUseMiles } from '@/lib/units';
|
||||||
|
import { iaruRegion, setIaruRegion, type IaruRegion } from '@/lib/bandplan';
|
||||||
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
|
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
|
||||||
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||||
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
||||||
@@ -689,8 +690,7 @@ function TelemetryToggle() {
|
|||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={on} disabled={!loaded}
|
<Checkbox checked={on} disabled={!loaded}
|
||||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
|
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
|
||||||
{t('settings.telemetry')}
|
<span title={t('settings.telemetryHint')}>{t('settings.telemetry')}</span>
|
||||||
<span className="text-xs text-muted-foreground">({t('settings.telemetryHint')})</span>
|
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1749,6 +1749,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||||
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
||||||
const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1');
|
const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1');
|
||||||
|
const [region, setRegion] = useState<IaruRegion>(() => iaruRegion());
|
||||||
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
|
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
|
||||||
// Declared HERE and not in ClusterPanel: that renderer is called as a plain
|
// Declared HERE and not in ClusterPanel: that renderer is called as a plain
|
||||||
// function by the PANELS map, so it must stay hooks-free.
|
// function by the PANELS map, so it must stay hooks-free.
|
||||||
@@ -6777,7 +6778,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
useLocalLogbook(); // SQLite → default logbook file (clears any per-profile path)
|
useLocalLogbook(); // SQLite → default logbook file (clears any per-profile path)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-72" title={t('gen.iaruHint')}><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="sqlite">{t('db.optSqlite')}</SelectItem>
|
<SelectItem value="sqlite">{t('db.optSqlite')}</SelectItem>
|
||||||
<SelectItem value="mysql">{t('db.optMysql')}</SelectItem>
|
<SelectItem value="mysql">{t('db.optMysql')}</SelectItem>
|
||||||
@@ -7147,7 +7148,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
const { lang, setLang, t } = useI18n();
|
const { lang, setLang, t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader title={t('sec.general')} hint={t('gen.hint')} />
|
<SectionHeader title={t('sec.general')} />
|
||||||
<div className="space-y-3 max-w-3xl">
|
<div className="space-y-3 max-w-3xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Label className="text-sm w-40">{t('settings.language')}</Label>
|
<Label className="text-sm w-40">{t('settings.language')}</Label>
|
||||||
@@ -7175,15 +7176,32 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
['us', t('gen.dateUS'), '07-30-2026 14:25']] as [DateFormat, string, string][]).map(([code, label, sample]) => (
|
['us', t('gen.dateUS'), '07-30-2026 14:25']] as [DateFormat, string, string][]).map(([code, label, sample]) => (
|
||||||
<button key={code} type="button" title={sample}
|
<button key={code} type="button" title={sample}
|
||||||
onClick={() => { setDateFormat(code); setDateFmtSel(code); }}
|
onClick={() => { setDateFormat(code); setDateFmtSel(code); }}
|
||||||
className={cn('flex flex-col items-start px-3 py-1 text-sm font-medium border-l border-border first:border-l-0',
|
className={cn('px-3 py-1.5 text-sm font-medium border-l border-border first:border-l-0',
|
||||||
dateFmtSel === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
dateFmtSel === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
{label}
|
{label}
|
||||||
<span className="text-[10px] font-mono opacity-70">{sample}</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{/* The IARU region drives the band edges and mode segments the band
|
||||||
|
maps draw — Region 2's 40 m runs to 7300, Region 1's stops at
|
||||||
|
7200 — and nothing else: spots and logging are region-blind. */}
|
||||||
|
<Label className="text-sm w-40 shrink-0">{t('gen.iaruRegion')}</Label>
|
||||||
|
<Select value={String(region)} onValueChange={(v) => {
|
||||||
|
const r = (v === '2' ? 2 : v === '3' ? 3 : 1) as IaruRegion;
|
||||||
|
setRegion(r); setIaruRegion(r);
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">{t('gen.iaruR1')}</SelectItem>
|
||||||
|
<SelectItem value="2">{t('gen.iaruR2')}</SelectItem>
|
||||||
|
<SelectItem value="3">{t('gen.iaruR3')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ThemeSelector />
|
<ThemeSelector />
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -7205,11 +7223,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
{/* Super Check Partial / N+1 — downloads the community MASTER.SCP list and
|
{/* Super Check Partial / N+1 — downloads the community MASTER.SCP list and
|
||||||
shows a two-column callsign helper (partial matches + one-edit calls). */}
|
shows a two-column callsign helper (partial matches + one-edit calls). */}
|
||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('scp.settingsHint')}>
|
||||||
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
|
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
|
||||||
{t('scp.enable')}
|
{t('scp.enable')}
|
||||||
</label>
|
</label>
|
||||||
<p className="text-[11px] text-muted-foreground">{t('scp.settingsHint')}</p>
|
|
||||||
{scp.enabled && (
|
{scp.enabled && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
|
<Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
|
||||||
@@ -7237,18 +7254,18 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
|
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
|
||||||
{t('gen.groupDigital')} <span className="text-xs text-muted-foreground">{t('gen.groupDigitalHint')}</span>
|
<span title={t('gen.groupDigitalHint')}>{t('gen.groupDigital')}</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
{/* Distances are computed in km everywhere and converted at display
|
{/* Distances are computed in km everywhere and converted at display
|
||||||
time — see lib/units. Changing this repaints the columns that
|
time — see lib/units. Changing this repaints the columns that
|
||||||
already carry a distance; nothing stored moves. */}
|
already carry a distance; nothing stored moves. */}
|
||||||
<Checkbox checked={milesUnit} onCheckedChange={(c) => { const v = !!c; setMilesUnit(v); setUseMiles(v); }} />
|
<Checkbox checked={milesUnit} onCheckedChange={(c) => { const v = !!c; setMilesUnit(v); setUseMiles(v); }} />
|
||||||
{t('gen.miles')} <span className="text-xs text-muted-foreground">{t('gen.milesHint')}</span>
|
{t('gen.miles')}
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
|
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
|
||||||
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
|
{t('gen.checkUpdates')}
|
||||||
</label>
|
</label>
|
||||||
<TelemetryToggle />
|
<TelemetryToggle />
|
||||||
|
|
||||||
@@ -7311,12 +7328,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
}}
|
}}
|
||||||
className="mt-0.5"
|
className="mt-0.5"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span title={t('gen.clubDesc')}>{t('gen.clubUse')}</span>
|
||||||
{t('gen.clubUse')}
|
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
|
||||||
{t('gen.clubDesc')}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-3 pl-6">
|
<div className="flex items-center gap-3 pl-6">
|
||||||
<Button variant="outline" size="sm" className="h-8" disabled={clubBusy}
|
<Button variant="outline" size="sm" className="h-8" disabled={clubBusy}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// IARU band plans, by region.
|
||||||
|
//
|
||||||
|
// The band maps used to carry one hard-coded table — Region 1's — so a US or
|
||||||
|
// Australian operator saw 40 m stop at 7200 while stations sat spotted at 7250,
|
||||||
|
// and the SSB wash started 25 kHz early. The region is a station-level fact the
|
||||||
|
// operator states once (Settings → General); everything drawing a band edge or
|
||||||
|
// a mode segment reads it from here.
|
||||||
|
//
|
||||||
|
// The tables are deliberately coarse: a band map's wash is CONTEXT, not a
|
||||||
|
// regulatory chart. Only the segments that differ between regions in ways an
|
||||||
|
// operator notices are split out; national fine print (US licence classes,
|
||||||
|
// beacon slots) does not belong in a background tint.
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
|
export type IaruRegion = 1 | 2 | 3;
|
||||||
|
export const KEY_IARU_REGION = 'opslog.iaruRegion';
|
||||||
|
|
||||||
|
export function iaruRegion(): IaruRegion {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(KEY_IARU_REGION);
|
||||||
|
return v === '2' ? 2 : v === '3' ? 3 : 1;
|
||||||
|
} catch { return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setIaruRegion(r: IaruRegion): void {
|
||||||
|
writeUiPref(KEY_IARU_REGION, String(r));
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The band maps capture the plan inside their render, so a change must reach
|
||||||
|
// them the way the distance unit reaches the grids.
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
export function subscribeIaruRegion(fn: () => void): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => { listeners.delete(fn); };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SegMode = 'cw' | 'digi' | 'phone';
|
||||||
|
type Seg = [number, number, SegMode];
|
||||||
|
|
||||||
|
interface Plan {
|
||||||
|
ranges: Record<string, [number, number]>;
|
||||||
|
segments: Record<string, Seg[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Region 1 — Europe / Africa / Middle East / northern Asia. The baseline the
|
||||||
|
// other two override.
|
||||||
|
const R1: Plan = {
|
||||||
|
ranges: {
|
||||||
|
'160m': [1800, 2000], '80m': [3500, 3800], '60m': [5350, 5450],
|
||||||
|
'40m': [7000, 7200], '30m': [10100, 10150], '20m': [14000, 14350],
|
||||||
|
'17m': [18068, 18168], '15m': [21000, 21450], '12m': [24890, 24990],
|
||||||
|
'10m': [28000, 29700], '6m': [50000, 50500], '4m': [70000, 70500],
|
||||||
|
'2m': [144000, 146000], '70cm': [430000, 440000],
|
||||||
|
},
|
||||||
|
segments: {
|
||||||
|
'160m': [[1800, 1838, 'cw'], [1838, 1840, 'digi'], [1840, 2000, 'phone']],
|
||||||
|
'80m': [[3500, 3580, 'cw'], [3580, 3600, 'digi'], [3600, 3800, 'phone']],
|
||||||
|
'60m': [[5350, 5450, 'phone']],
|
||||||
|
'40m': [[7000, 7040, 'cw'], [7040, 7100, 'digi'], [7100, 7200, 'phone']],
|
||||||
|
'30m': [[10100, 10130, 'cw'], [10130, 10150, 'digi']],
|
||||||
|
'20m': [[14000, 14070, 'cw'], [14070, 14100, 'digi'], [14100, 14350, 'phone']],
|
||||||
|
'17m': [[18068, 18095, 'cw'], [18095, 18110, 'digi'], [18110, 18168, 'phone']],
|
||||||
|
'15m': [[21000, 21070, 'cw'], [21070, 21150, 'digi'], [21150, 21450, 'phone']],
|
||||||
|
'12m': [[24890, 24915, 'cw'], [24915, 24940, 'digi'], [24940, 24990, 'phone']],
|
||||||
|
'10m': [[28000, 28070, 'cw'], [28070, 28300, 'digi'], [28300, 29700, 'phone']],
|
||||||
|
'6m': [[50000, 50100, 'cw'], [50100, 50500, 'phone']],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Region 2 — the Americas. 80 m runs to 4000 and 40 m to 7300, with phone from
|
||||||
|
// 7125 (the whole point of asking the region: a KP4 ragchew on 7250 must not
|
||||||
|
// hang past the top of the map).
|
||||||
|
const R2: Plan = {
|
||||||
|
ranges: {
|
||||||
|
...R1.ranges,
|
||||||
|
'80m': [3500, 4000], '40m': [7000, 7300],
|
||||||
|
'6m': [50000, 54000], '2m': [144000, 148000], '70cm': [420000, 450000],
|
||||||
|
},
|
||||||
|
segments: {
|
||||||
|
...R1.segments,
|
||||||
|
'80m': [[3500, 3570, 'cw'], [3570, 3600, 'digi'], [3600, 4000, 'phone']],
|
||||||
|
'40m': [[7000, 7040, 'cw'], [7040, 7125, 'digi'], [7125, 7300, 'phone']],
|
||||||
|
'6m': [[50000, 50100, 'cw'], [50100, 54000, 'phone']],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Region 3 — Asia-Pacific. 80 m to 3900; 40 m as Region 1's shape.
|
||||||
|
const R3: Plan = {
|
||||||
|
ranges: {
|
||||||
|
...R1.ranges,
|
||||||
|
'80m': [3500, 3900],
|
||||||
|
'6m': [50000, 54000], '2m': [144000, 148000], '70cm': [430000, 450000],
|
||||||
|
},
|
||||||
|
segments: {
|
||||||
|
...R1.segments,
|
||||||
|
'80m': [[3500, 3535, 'cw'], [3535, 3600, 'digi'], [3600, 3900, 'phone']],
|
||||||
|
'6m': [[50000, 50100, 'cw'], [50100, 54000, 'phone']],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function plan(): Plan {
|
||||||
|
const r = iaruRegion();
|
||||||
|
return r === 2 ? R2 : r === 3 ? R3 : R1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bandRange(band: string): [number, number] | undefined {
|
||||||
|
return plan().ranges[band];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bandSegments(band: string): Seg[] {
|
||||||
|
return plan().segments[band] ?? [];
|
||||||
|
}
|
||||||
@@ -130,7 +130,10 @@ const en: Dict = {
|
|||||||
'icmp.scopeNoStream': 'This radio does not send its scope over CI-V — its own screen still works.',
|
'icmp.scopeNoStream': 'This radio does not send its scope over CI-V — its own screen still works.',
|
||||||
'wk.catWarnTci': 'The CW keyer is set to the radio, but the CAT backend is "{backend}". Pick TCI in Settings → CAT, or another keying engine.',
|
'wk.catWarnTci': 'The CW keyer is set to the radio, but the CAT backend is "{backend}". Pick TCI in Settings → CAT, or another keying engine.',
|
||||||
'wk.tciHint': "Keying goes through the radio's own macro keyer, over the link already open — no WinKeyer and no second serial port. The radio can stop a message but cannot un-type one, so there is no type-ahead correction here.",
|
'wk.tciHint': "Keying goes through the radio's own macro keyer, over the link already open — no WinKeyer and no second serial port. The radio can stop a message but cannot un-type one, so there is no type-ahead correction here.",
|
||||||
|
'gen.iaruRegion': 'IARU region', 'gen.iaruHint': '(band plan drawn on the band maps)',
|
||||||
|
'gen.iaruR1': 'Region 1 — Europe, Africa, Middle East', 'gen.iaruR2': 'Region 2 — Americas', 'gen.iaruR3': 'Region 3 — Asia-Pacific',
|
||||||
'gen.miles': 'Distances in miles', 'gen.milesHint': '(instead of kilometres)',
|
'gen.miles': 'Distances in miles', 'gen.milesHint': '(instead of kilometres)',
|
||||||
|
'qslm.thStation': 'Station',
|
||||||
'qslm.qrzTitle': 'Open this callsign on QRZ.com',
|
'qslm.qrzTitle': 'Open this callsign on QRZ.com',
|
||||||
'qslm.lotwDetail': 'QSL details',
|
'qslm.lotwDetail': 'QSL details',
|
||||||
'qslm.lotwDetailTitle': 'Ask LoTW for the QSL date and the station details (grid, state, county) as well as the confirmation. LoTW takes about ten times longer to build that report — two minutes against twenty on the same account — and marking a confirmation needs none of it. Forced on when adding the QSOs not found, which have no other source for those fields.',
|
'qslm.lotwDetailTitle': 'Ask LoTW for the QSL date and the station details (grid, state, county) as well as the confirmation. LoTW takes about ten times longer to build that report — two minutes against twenty on the same account — and marking a confirmation needs none of it. Forced on when adding the QSOs not found, which have no other source for those fields.',
|
||||||
@@ -630,7 +633,10 @@ const fr: Dict = {
|
|||||||
'icmp.scopeNoStream': "Cette radio n'envoie pas son scope en CI-V — son propre écran fonctionne toujours.",
|
'icmp.scopeNoStream': "Cette radio n'envoie pas son scope en CI-V — son propre écran fonctionne toujours.",
|
||||||
'wk.catWarnTci': "Le manipulateur CW est réglé sur la radio, mais le backend CAT est « {backend} ». Choisissez TCI dans Réglages → CAT, ou un autre moteur de manipulation.",
|
'wk.catWarnTci': "Le manipulateur CW est réglé sur la radio, mais le backend CAT est « {backend} ». Choisissez TCI dans Réglages → CAT, ou un autre moteur de manipulation.",
|
||||||
'wk.tciHint': "La manipulation passe par le keyer à macros de la radio, sur la liaison déjà ouverte — ni WinKeyer ni second port série. La radio sait interrompre un message mais pas en effacer la fin, donc pas de correction en frappe anticipée ici.",
|
'wk.tciHint': "La manipulation passe par le keyer à macros de la radio, sur la liaison déjà ouverte — ni WinKeyer ni second port série. La radio sait interrompre un message mais pas en effacer la fin, donc pas de correction en frappe anticipée ici.",
|
||||||
|
'gen.iaruRegion': 'Région IARU', 'gen.iaruHint': '(plan de bande dessiné sur les cartes de bande)',
|
||||||
|
'gen.iaruR1': 'Région 1 — Europe, Afrique, Moyen-Orient', 'gen.iaruR2': 'Région 2 — Amériques', 'gen.iaruR3': 'Région 3 — Asie-Pacifique',
|
||||||
'gen.miles': 'Distances en miles', 'gen.milesHint': '(au lieu des kilomètres)',
|
'gen.miles': 'Distances en miles', 'gen.milesHint': '(au lieu des kilomètres)',
|
||||||
|
'qslm.thStation': 'Station',
|
||||||
'qslm.qrzTitle': 'Ouvrir cet indicatif sur QRZ.com',
|
'qslm.qrzTitle': 'Ouvrir cet indicatif sur QRZ.com',
|
||||||
'qslm.lotwDetail': 'Détails QSL',
|
'qslm.lotwDetail': 'Détails QSL',
|
||||||
'qslm.lotwDetailTitle': "Demander à LoTW la date du QSL et les détails de la station (locator, état, comté) en plus de la confirmation. LoTW met environ dix fois plus longtemps à construire ce rapport — deux minutes contre vingt sur le même compte — et marquer une confirmation n'en a pas besoin. Forcé quand on ajoute les QSO absents, qui n'ont pas d'autre source pour ces champs.",
|
'qslm.lotwDetailTitle': "Demander à LoTW la date du QSL et les détails de la station (locator, état, comté) en plus de la confirmation. LoTW met environ dix fois plus longtemps à construire ce rapport — deux minutes contre vingt sur le même compte — et marquer une confirmation n'en a pas besoin. Forcé quand on ajoute les QSO absents, qui n'ont pas d'autre source pour ces champs.",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||||
'opslog.distanceMiles', // distances shown in statute miles rather than km
|
'opslog.distanceMiles', // distances shown in statute miles rather than km
|
||||||
|
'opslog.iaruRegion', // IARU region (1/2/3) — band edges and segments on the band maps
|
||||||
'opslog.activeTab', // last selected tab
|
'opslog.activeTab', // last selected tab
|
||||||
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
|
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
|
||||||
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
|
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
|
||||||
|
|||||||
@@ -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.26.22';
|
export const APP_VERSION = '0.26.23';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
+1
-1
@@ -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.26.22"
|
appVersion = "0.26.23"
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user