From 74dfc3a72530c130a6f058ac878009dc494d3fea Mon Sep 17 00:00:00 2001 From: rouggy Date: Tue, 1 Sep 2026 23:47:03 +0200 Subject: [PATCH] feat(matrix): the DIG row cycles through your own digital modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One row per digital mode would be the honest layout, and there is no height for it: the matrix sits in a fixed panel beside a dozen widgets. So the row keeps its place and changes what it answers — DIG, then each digital mode the operator's own list holds, in the order they put them in, then back to DIG. It costs no round trip. The query behind the matrix already grouped by band AND mode; only the collapse to a class threw that away, so the same cell is now published under the raw mode name too. Digital only: PH and CW have nothing to cycle through. On a specific mode the you-are-here mark follows THAT mode, or every FT4 entry would light whichever digital row the rotation happened to rest on. A four-letter mode drops to 9px rather than widen a column sized for three characters and push the whole matrix sideways. Opens 0.27.8. --- changelog.json | 10 +++++ frontend/src/App.tsx | 1 + frontend/src/components/BandSlotGrid.tsx | 47 +++++++++++++++++++++--- frontend/src/components/DetailsPanel.tsx | 4 +- frontend/src/lib/i18n.tsx | 4 +- internal/qso/qso.go | 33 ++++++++++++----- 6 files changed, 82 insertions(+), 17 deletions(-) diff --git a/changelog.json b/changelog.json index 73dea24..fbc7c7d 100644 --- a/changelog.json +++ b/changelog.json @@ -1,4 +1,14 @@ [ + { + "version": "0.27.8", + "date": "", + "en": [ + "The band matrix’s DIG row is now a rotation: click it and it answers for FT8, then FT4, then each digital mode your mode list holds — in YOUR order — then back to DIG. One row per digital mode would be the honest layout and there is no height for it beside the other widgets, so the row keeps its place and changes what it says." + ], + "fr": [ + "La ligne DIG de la matrice devient une rotation : un clic et elle répond pour FT8, puis FT4, puis chaque mode numérique de votre liste — dans VOTRE ordre — puis retour à DIG. Une ligne par mode numérique serait la mise en page honnête et la hauteur manque à côté des autres widgets : la ligne garde donc sa place et change ce qu’elle dit." + ] + }, { "version": "0.27.7", "date": "", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1874a77..02f238a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7411,6 +7411,7 @@ export default function App() { band={band} mode={mode} bands={bands} + modes={modes} satellites={satellites} onEditQso={openEdit} {...(!callsign.trim() && selQso ? { diff --git a/frontend/src/components/BandSlotGrid.tsx b/frontend/src/components/BandSlotGrid.tsx index f9df86d..a90c767 100644 --- a/frontend/src/components/BandSlotGrid.tsx +++ b/frontend/src/components/BandSlotGrid.tsx @@ -15,7 +15,10 @@ interface Props { busy: boolean; currentBand: string; currentMode: string; - bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS + bands?: string[]; + // The operator's configured mode list, in THEIR order: the digital row + // rotates through it. + modes?: 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. @@ -121,10 +124,31 @@ function cellTitle(t: (k: string) => string, band: string, cls: string, status: return `${band} ${cls}: ${desc}${mine ? ' — ' + mine : ''}${current ? ' — ' + t('mx.current') : ''}`; } -export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) { +export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, modes, hasCall = true, lat, lon, forCall, onEditQso }: Props) { const { t } = useI18n(); // Cell drill-down: which band+class the operator clicked, or null. const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null); + + // The DIGITAL row is a rotation, not a fixed row. + // + // One row for every digital mode would be the honest layout and there is no + // height for it — the matrix sits in a fixed panel beside a dozen widgets. + // So the row keeps its place and changes what it answers: DIG (all of them), + // then each digital mode the operator actually uses, in the order their mode + // list gives, then back to DIG. The backend publishes the same cells under + // both the class name and the raw mode, so a rotation costs no round trip. + const digModes = useMemo( + () => (modes ?? []) + .map((m) => (m || '').toUpperCase().trim()) + .filter((m) => m !== '' && m !== 'CW' && !PHONE_MODES.has(m)), + [modes], + ); + const [digIdx, setDigIdx] = useState(0); // 0 = the DIG group itself + // A shorter mode list (the operator edited it) must not strand the rotation + // on a row that no longer exists. + const digPos = digModes.length ? digIdx % (digModes.length + 1) : 0; + const digRow = digPos === 0 ? 'DIG' : digModes[digPos - 1]; + const cycleDig = () => setDigIdx((i) => (digModes.length ? (i + 1) % (digModes.length + 1) : 0)); // 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( @@ -325,13 +349,26 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal - {CLASSES.map((cls) => { - const classCurrent = classMatchesMode(cls, currentMode); + {CLASSES.map((clsBase) => { + const cls = clsBase === 'DIG' ? digRow : clsBase; + // On a specific digital mode the "you are here" mark has to be that + // mode, not any digital one — otherwise every FT4 entry lights the + // FT8 row it happens to be cycled to. + const classCurrent = cls === clsBase + ? classMatchesMode(cls, currentMode) + : (currentMode || '').toUpperCase() === cls; return ( 3 ? 'text-[9px]' : 'text-[11px]', + clsBase === 'DIG' && digModes.length ? 'cursor-pointer hover:text-foreground' : '', classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground', )} > diff --git a/frontend/src/components/DetailsPanel.tsx b/frontend/src/components/DetailsPanel.tsx index 0911f22..d103730 100644 --- a/frontend/src/components/DetailsPanel.tsx +++ b/frontend/src/components/DetailsPanel.tsx @@ -71,6 +71,7 @@ interface Props { band: string; mode: string; bands?: string[]; // configured bands for the worked-before matrix columns + modes?: string[]; // configured modes, in order — the matrix cycles its digital row through them // The station's satellites, for the SAT_NAME dropdown. Passed in rather than // read here: the list lives in Preferences, and App already reloads it when // Preferences close — a panel reading it once at mount would need a restart. @@ -155,7 +156,7 @@ function Field({ label, span = 1, className, children }: { label: string; span?: ); } -export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, satellites = [], slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) { +export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, modes, satellites = [], slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) { const { t } = useI18n(); const [internalOpen, setInternalOpen] = useState('stats'); const open = tab ?? internalOpen; // controlled when `tab` is provided @@ -294,6 +295,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, currentBand={slotCall ? (slotBand ?? '') : band} currentMode={slotCall ? (slotMode ?? '') : mode} bands={bands} + modes={modes} hasCall={slotCall ? true : callsign.trim() !== ''} forCall={slotCall} onEditQso={onEditQso} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index ce778a3..ba01932 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -183,7 +183,7 @@ const en: Dict = { 'dec.emptyFiltered': 'No decode matches these filters.', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup', 'sec.ftx': 'FTx decodes', 'ftx.hint': 'What OpsLog does on its own with the digital decode stream.', 'ftx.enable': 'Auto-call', 'ftx.enableHint': 'answer a decode without clicking it', 'ftx.callWhen': 'Call a station that is:', 'ftx.watch': 'Watch list', 'ftx.watchHint': 'One callsign per line, wildcards allowed (4S7*, */P). A watched station is answered ahead of the criteria above.', 'ftx.watchOnlyIf': 'but only if it is also:', 'ftx.cooldown': 'Ignore a callsign for', 'ftx.warn': 'This keys your transmitter without asking. It answers CQ only, never while you are already transmitting, one station at a time, and every call is written to the log file with its reason. Halt stops it.', 'ftx.c_dxcc': 'a new DXCC entity', 'ftx.c_bandmode': 'a new band AND a new mode for the entity', 'ftx.c_band': 'a new band for the entity', 'ftx.c_mode': 'a new mode for the entity', 'ftx.c_slot': 'a new slot (band+mode never worked together)', 'ftx.c_grid': 'a new grid square', 'ftx.c_county': 'a new US county', 'ftx.c_pota': 'a new POTA park', 'ftx.c_pfx': 'a new WPX prefix', - 'sec.bands': 'Bands', 'sec.satellites': 'Satellites', 'sat.hint': 'The satellites this station works. They are offered as a dropdown on the satellite fields, in alphabetical order.', 'sat.listLabel': 'One satellite per line', 'sat.listHint': 'Written into SAT_NAME exactly as spelled here, so use the name LoTW and the awards expect — AO-91, not AO91. Leaving the list empty simply keeps the field a plain text box.', 'sec.modes': 'Modes & default RST', 'sec.dxhunter': 'DXHunter', 'dxh.intro': 'What you hunt — it decides the badges in the DX Cluster, the FT decodes and Chase new alike.', 'sec.cluster': 'DX Cluster', + 'sec.bands': 'Bands', 'sec.satellites': 'Satellites', 'sat.hint': 'The satellites this station works. They are offered as a dropdown on the satellite fields, in alphabetical order.', 'sat.listLabel': 'One satellite per line', 'sat.listHint': 'Written into SAT_NAME exactly as spelled here, so use the name LoTW and the awards expect — AO-91, not AO91. Leaving the list empty simply keeps the field a plain text box.', 'sec.modes': 'Modes & default RST', 'bsg.digCycle': 'Click to cycle through your digital modes', 'sec.dxhunter': 'DXHunter', 'dxh.intro': 'What you hunt — it decides the badges in the DX Cluster, the FT decodes and Chase new alike.', 'sec.cluster': 'DX Cluster', 'sec.udp': 'Connections', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.uscounties': 'US Counties', 'nav.maintenance': 'Maintenance', 'sec.databases': 'Databases', 'db.hint': 'The reference data OpsLog keeps on disk. One line each, with what it holds and when it was last refreshed.', 'db.update': 'Update', 'db.never': 'never downloaded', 'db.cty': 'Country file (cty.dat)', 'db.ctyDetail': '{n} entities · file dated {d}', 'db.clublog': 'Club Log country exceptions', 'db.clublogDetail': '{n} exceptions · {d}', 'db.lotwUsers': 'LoTW users', 'db.lotwDetail': '{n} callsigns · {d}', 'db.scp': 'Super Check Partial (MASTER.SCP)', 'db.scpDetail': '{n} callsigns · {d}', 'db.uls': 'US counties (FCC ULS)', 'db.ulsDetail': '{n} callsigns · {d}', 'db.rda': 'Russian districts (RDA)', 'db.rdaDetail': '{n} callsigns, with their dated activity periods', 'db.rdaNote': 'built in', 'db.refLists': 'Award reference lists', 'db.refListsHint': 'Only the awards with an online source appear here; the others are shipped or edited by hand.', 'db.refDetail': '{n} references · {d}', 'db.refUpdated': '{code}: {n} references.', 'db.noRefLists': 'No award has an online reference list.', 'sec.rda': 'Russian districts (RDA)', 'rda.hint': 'The offline district database, and the one bulk operation it feeds.', 'rda.dbTitle': 'District database', 'rda.dbCount': '{n} Russian callsigns, each with the district it operates from and, where it moved, the dated periods it operated from each one. Built into OpsLog — nothing to download.', 'rda.backfillTitle': 'Fill the district on existing QSOs', 'rda.backfillIntro': 'Goes through every contact with a Russian entity and assigns its RDA reference, using the district the station was in ON THE DAY of the contact.', 'rda.useCurrent': 'Also use the current district for stations with no recorded history', 'rda.useCurrentHint': '(true for the great majority — the database records a history precisely for the callsigns that moved — but it is an assumption, not a dated fact)', 'rda.backfillRun': 'Fill districts', 'rda.backfillDone': '{s} Russian QSOs — {d} from a dated record, {c} from the current district, {u} unknown, {k} already had one.', 'rda.cmpTitle': 'Compare the two district sources', 'rda.cmpRunning': 'Comparing…', 'rda.cmpNoConflict': 'No disagreement — both sources say the same district everywhere.', 'rda.cmpNoRussian': 'No Russian contacts to compare.', 'rda.backfillRunning': 'Filling…', 'rda.cmpRun': 'Compare', 'rda.cmpDone': '{s} Russian QSOs — {a} identical, {d} differ, {l} only in the log, {b} only in the database', 'rda.cmpCall': 'Callsign', 'rda.cmpDate': 'Date', 'rda.cmpFromLog': 'Log (CNTY)', 'rda.cmpFromDb': 'RDA database', 'rda.cmpListHint': 'Showing {n} of {d} — click a callsign to open the contact.', 'rda.cmpOpen': 'Open this QSO', 'rda.cmpKind': 'Database', 'rda.cmpDated': 'dated', 'rda.cmpCurrent': 'current', 'rda.neverOverwrites': 'A reference you assigned by hand is never overwritten.', 'rda.cmpKeep': "Keep", 'rda.cmpKeepLog': "Keep the log's district for this contact", 'rda.cmpKeepDb': "Keep the database's district for this contact", 'rda.cmpApply': "Apply {n} decisions", 'rda.cmpAllDb': "keep the database everywhere", 'rda.cmpAllLog': "keep the log everywhere", 'rda.cmpClear': "clear the decisions", 'rda.cmpApplyHint': 'The chosen district is written into the contact — into CNTY and as its award reference — so the disagreement is settled and the contact counts for that district. Settled rows leave the list.', 'sec.webpublish': 'Web publishing', 'wpub.hint': 'Publishes your log as a file for a website: a standalone HTML page or a CSV, written locally and optionally uploaded by FTP. It is refreshed when you log a QSO and, if you set an interval, on a timer.', 'wpub.enable': 'Publish the log to a file', 'wpub.fileSection': 'The file', 'wpub.format': 'Format', 'wpub.formatHtml': 'HTML page', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Output folder', 'wpub.browse': 'Browse…', 'wpub.fileName': 'File name', 'wpub.title': 'Page title', 'wpub.titlePh': 'blank = your callsign', 'wpub.count': 'Last N QSOs', 'wpub.every': 'Refresh every', 'wpub.everyHint': 'minutes — 0 = only when a QSO is logged', 'wpub.columns': 'Columns', 'wpub.columnsCount': '{n} of {total} chosen', 'wpub.columnsPick': 'Choose columns…', 'wpub.columnsSearch': 'Search a field…', 'wpub.removeColumn': 'Click to remove', 'wpub.columnsHint': 'Click to add or remove. The order shown here is the order in the file.', 'wpub.ftpEnable': 'Upload by FTP', 'wpub.ftpHost': 'Server / port', 'wpub.ftpUser': 'User', 'wpub.ftpPassword': 'Password', 'wpub.ftpFolder': 'Remote folder', 'wpub.ftpFileName': 'Remote file name', 'wpub.ftpTls': 'Use TLS (FTPS)', 'wpub.publishNow': 'Publish now', 'wpub.testFtp': 'Test connection', 'wpub.lastRun': 'Last run:', 'sec.adifmon': 'ADIF monitor', 'sec.foldersync': 'Sync across PCs', 'sync.hint': 'Point every OpsLog at the SAME folder — one your PCs already synchronise (Seafile, OneDrive, Dropbox, a NAS share). Each machine writes what it logs there and reads the others; the databases themselves are never shared.', 'sync.enable': 'Keep my contacts in step across my PCs', 'sync.machine': 'This PC', 'sync.folder': 'Folder', 'sync.choose': 'Choose…', 'sync.state': 'State', 'sync.thisPc': 'This PC', 'sync.lastSync': 'Last check', 'sync.sent': 'Sent', 'sync.received': 'Received', 'sync.never': 'never', 'sync.noPeers': 'No other PC has written to this folder yet.', 'sync.behind': 'new contacts waiting', 'sync.now': 'Synchronise now', 'sync.applied': '{n} change(s) taken from the folder.', 'sync.saved': 'Saved.', @@ -703,7 +703,7 @@ const fr: Dict = { 'dec.emptyFiltered': 'Aucun decode ne correspond a ces filtres.', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif", 'sec.ftx': 'Décodages FTx', 'ftx.hint': 'Ce qu’OpsLog fait de lui-même avec le flux de décodages numériques.', 'ftx.enable': 'Appel automatique', 'ftx.enableHint': 'répondre à un décodage sans cliquer', 'ftx.callWhen': 'Appeler une station qui est :', 'ftx.watch': 'Liste de surveillance', 'ftx.watchHint': 'Un indicatif par ligne, jokers acceptés (4S7*, */P). Une station surveillée est appelée avant les critères ci-dessus.', 'ftx.watchOnlyIf': 'mais seulement si elle est aussi :', 'ftx.cooldown': 'Ignorer un indicatif pendant', 'ftx.warn': 'Ceci met ton émetteur en marche sans te demander. Uniquement sur un CQ, jamais pendant que tu émets déjà, une station à la fois, et chaque appel est écrit dans le journal avec sa raison. Stop l’interrompt.', 'ftx.c_dxcc': 'une nouvelle entité DXCC', 'ftx.c_bandmode': 'une nouvelle bande ET un nouveau mode pour l’entité', 'ftx.c_band': 'une nouvelle bande pour l’entité', 'ftx.c_mode': 'un nouveau mode pour l’entité', 'ftx.c_slot': 'un nouveau slot (bande+mode jamais faits ensemble)', 'ftx.c_grid': 'un nouveau carré locator', 'ftx.c_county': 'un nouveau comté US', 'ftx.c_pota': 'un nouveau parc POTA', 'ftx.c_pfx': 'un nouveau préfixe WPX', - 'sec.bands': 'Bandes', 'sec.satellites': 'Satellites', 'sat.hint': "Les satellites que cette station travaille. Ils sont proposés en liste déroulante sur les champs satellite, par ordre alphabétique.", 'sat.listLabel': 'Un satellite par ligne', 'sat.listHint': "Inscrit dans SAT_NAME exactement tel qu'écrit ici : utilise le nom attendu par LoTW et les diplômes — AO-91, pas AO91. Une liste vide laisse simplement le champ en saisie libre.", 'sec.modes': 'Modes & RST par défaut', 'sec.dxhunter': 'DXHunter', 'dxh.intro': 'Ce que vous chassez — cela commande les badges du DX Cluster, des FT decodes et de Chase new.', 'sec.cluster': 'DX Cluster', + 'sec.bands': 'Bandes', 'sec.satellites': 'Satellites', 'sat.hint': "Les satellites que cette station travaille. Ils sont proposés en liste déroulante sur les champs satellite, par ordre alphabétique.", 'sat.listLabel': 'Un satellite par ligne', 'sat.listHint': "Inscrit dans SAT_NAME exactement tel qu'écrit ici : utilise le nom attendu par LoTW et les diplômes — AO-91, pas AO91. Une liste vide laisse simplement le champ en saisie libre.", 'sec.modes': 'Modes & RST par défaut', 'bsg.digCycle': 'Cliquer pour faire défiler vos modes numériques', 'sec.dxhunter': 'DXHunter', 'dxh.intro': 'Ce que vous chassez — cela commande les badges du DX Cluster, des FT decodes et de Chase new.', 'sec.cluster': 'DX Cluster', 'sec.udp': 'Connexions', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US', 'nav.maintenance': 'Maintenance', 'sec.databases': 'Bases de données', 'db.hint': 'Les données de référence qu’OpsLog garde sur disque. Une ligne chacune, avec ce qu’elle contient et sa dernière actualisation.', 'db.update': 'Mettre à jour', 'db.never': 'jamais téléchargée', 'db.cty': 'Fichier pays (cty.dat)', 'db.ctyDetail': '{n} entités · fichier daté du {d}', 'db.clublog': 'Exceptions pays Club Log', 'db.clublogDetail': '{n} exceptions · {d}', 'db.lotwUsers': 'Utilisateurs LoTW', 'db.lotwDetail': '{n} indicatifs · {d}', 'db.scp': 'Super Check Partial (MASTER.SCP)', 'db.scpDetail': '{n} indicatifs · {d}', 'db.uls': 'Comtés US (FCC ULS)', 'db.ulsDetail': '{n} indicatifs · {d}', 'db.rda': 'Districts russes (RDA)', 'db.rdaDetail': '{n} indicatifs, avec leurs périodes d’activité datées', 'db.rdaNote': 'intégrée', 'db.refLists': 'Listes de références des diplômes', 'db.refListsHint': 'Seuls les diplômes ayant une source en ligne apparaissent ici ; les autres sont livrés ou édités à la main.', 'db.refDetail': '{n} références · {d}', 'db.refUpdated': '{code} : {n} références.', 'db.noRefLists': 'Aucun diplôme n’a de liste de références en ligne.', 'sec.rda': 'Districts russes (RDA)', 'rda.hint': 'La base de districts hors ligne, et l’unique opération de masse qu’elle alimente.', 'rda.dbTitle': 'Base des districts', 'rda.dbCount': '{n} indicatifs russes, chacun avec le district d’où il émet et, pour ceux qui ont déménagé, les périodes datées passées dans chacun. Intégrée à OpsLog — rien à télécharger.', 'rda.backfillTitle': 'Renseigner le district sur les QSO existants', 'rda.backfillIntro': 'Parcourt tous les contacts avec une entité russe et attribue leur référence RDA, en utilisant le district où se trouvait la station LE JOUR du contact.', 'rda.useCurrent': 'Utiliser aussi le district actuel pour les stations sans historique connu', 'rda.useCurrentHint': '(vrai pour la grande majorité — la base enregistre un historique justement pour les indicatifs qui ont bougé — mais c’est une supposition, pas un fait daté)', 'rda.backfillRun': 'Renseigner les districts', 'rda.backfillDone': '{s} QSO russes — {d} depuis une période datée, {c} depuis le district actuel, {u} inconnus, {k} en avaient déjà un.', 'rda.cmpTitle': 'Comparer les deux sources de district', 'rda.cmpRunning': 'Comparaison…', 'rda.cmpNoConflict': 'Aucune divergence — les deux sources donnent partout le même district.', 'rda.cmpNoRussian': 'Aucun contact russe à comparer.', 'rda.backfillRunning': 'Remplissage…', 'rda.cmpRun': 'Comparer', 'rda.cmpDone': '{s} QSO russes — {a} identiques, {d} divergents, {l} seulement dans le log, {b} seulement dans la base', 'rda.cmpCall': 'Indicatif', 'rda.cmpDate': 'Date', 'rda.cmpFromLog': 'Log (CNTY)', 'rda.cmpFromDb': 'Base RDA', 'rda.cmpListHint': '{n} affichés sur {d} — cliquer un indicatif ouvre le contact.', 'rda.cmpOpen': 'Ouvrir ce QSO', 'rda.cmpKind': 'Base', 'rda.cmpDated': 'daté', 'rda.cmpCurrent': 'courant', 'rda.neverOverwrites': 'Une référence attribuée à la main n’est jamais écrasée.', 'rda.cmpKeep': "Garder", 'rda.cmpKeepLog': "Garder le district du log pour ce contact", 'rda.cmpKeepDb': "Garder le district de la base pour ce contact", 'rda.cmpApply': "Appliquer {n} décisions", 'rda.cmpAllDb': "garder la base partout", 'rda.cmpAllLog': "garder le log partout", 'rda.cmpClear': "effacer les décisions", 'rda.cmpApplyHint': "Le district choisi est écrit dans le contact — dans CNTY et comme référence de diplôme — donc la divergence est réglée et le contact compte pour ce district. Les lignes réglées quittent la liste.", 'sec.webpublish': 'Publication web', 'wpub.hint': "Publie ton journal dans un fichier destiné à un site web : une page HTML autonome ou un CSV, écrit en local et envoyé par FTP si tu le souhaites. Il est rafraîchi à chaque QSO enregistré et, si tu règles un intervalle, périodiquement.", 'wpub.enable': 'Publier le journal dans un fichier', 'wpub.fileSection': 'Le fichier', 'wpub.format': 'Format', 'wpub.formatHtml': 'Page HTML', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Dossier de sortie', 'wpub.browse': 'Parcourir…', 'wpub.fileName': 'Nom du fichier', 'wpub.title': 'Titre de la page', 'wpub.titlePh': 'vide = ton indicatif', 'wpub.count': 'N derniers QSO', 'wpub.every': 'Rafraîchir toutes les', 'wpub.everyHint': 'minutes — 0 = seulement à chaque QSO', 'wpub.columns': 'Colonnes', 'wpub.columnsCount': '{n} sur {total} choisis', 'wpub.columnsPick': 'Choisir les colonnes…', 'wpub.columnsSearch': 'Chercher un champ…', 'wpub.removeColumn': 'Cliquer pour retirer', 'wpub.columnsHint': 'Clique pour ajouter ou retirer. L ordre affiché ici est celui du fichier.', 'wpub.ftpEnable': 'Envoyer par FTP', 'wpub.ftpHost': 'Serveur / port', 'wpub.ftpUser': 'Utilisateur', 'wpub.ftpPassword': 'Mot de passe', 'wpub.ftpFolder': 'Dossier distant', 'wpub.ftpFileName': 'Nom du fichier distant', 'wpub.ftpTls': 'Utiliser TLS (FTPS)', 'wpub.publishNow': 'Publier maintenant', 'wpub.testFtp': 'Tester la connexion', 'wpub.lastRun': 'Dernière exécution :', 'sec.adifmon': 'Moniteur ADIF', 'sec.foldersync': 'Synchro entre PC', 'sync.hint': 'Fais pointer chaque OpsLog vers le MÊME dossier — un dossier que tes PC synchronisent déjà (Seafile, OneDrive, Dropbox, un partage NAS). Chaque machine y écrit ce qu’elle enregistre et lit celui des autres ; les bases de données, elles, ne sont jamais partagées.', 'sync.enable': 'Garder mes contacts à jour sur tous mes PC', 'sync.machine': 'Ce PC', 'sync.folder': 'Dossier', 'sync.choose': 'Choisir…', 'sync.state': 'État', 'sync.thisPc': 'Ce PC', 'sync.lastSync': 'Dernière vérification', 'sync.sent': 'Envoyés', 'sync.received': 'Reçus', 'sync.never': 'jamais', 'sync.noPeers': 'Aucun autre PC n’a encore écrit dans ce dossier.', 'sync.behind': 'nouveaux contacts en attente', 'sync.now': 'Synchroniser maintenant', 'sync.applied': '{n} changement(s) repris du dossier.', 'sync.saved': 'Enregistré.', diff --git a/internal/qso/qso.go b/internal/qso/qso.go index e855783..dbf58e3 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -2013,7 +2013,7 @@ type WorkedBefore struct { // at all about yesterday. type BandStatus struct { Band string `json:"band"` // ADIF lowercase band, e.g. "20m" - Class string `json:"class"` // "PH" | "CW" | "DIG" + Class string `json:"class"` // "PH" | "CW" | "DIG", or a raw digital mode ("FT8", "RTTY"…) Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w" // Call is "", "w" (worked with this callsign) or "c" (confirmed with it). Call string `json:"call,omitempty"` @@ -2402,17 +2402,32 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int, return wb, fmt.Errorf("scan band status: %w", err) } code := bandStatusCode(callW == 1, callC == 1, dxccConfirmed == 1) - k := cellKey{band: band, class: modeClass(mode)} - if cur, ok := best[k]; !ok || code > cur { - best[k] = code + keys := []cellKey{{band: band, class: modeClass(mode)}} + // The DIGITAL row can be cycled through the individual modes in the UI — + // FT8, then FT4, then RTTY — so the same cell is also published under the + // raw mode name. The query already grouped by mode; only the collapse to + // a class threw that away, and re-asking the database for it would be a + // second scan to learn what we had just read. + // + // Digital only: PH and CW have nothing to cycle through, and publishing + // "SSB" beside "PH" would just double the payload. + if um := strings.ToUpper(mode); modeClass(mode) == "DIG" && um != "" { + keys = append(keys, cellKey{band: band, class: um}) + } + for _, k := range keys { + if cur, ok := best[k]; !ok || code > cur { + best[k] = code + } } // Confirmed beats worked here too, and neither is ever erased by the // entity: this is only ever about the callsign. - switch { - case callC == 1: - callByCell[k] = "c" - case callW == 1 && callByCell[k] == "": - callByCell[k] = "w" + for _, k := range keys { + switch { + case callC == 1: + callByCell[k] = "c" + case callW == 1 && callByCell[k] == "": + callByCell[k] = "w" + } } } statusRows.Close()