From 1605e30f60e4193297c1ed81485bde8943321c68 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Thu, 13 Aug 2026 09:46:52 +0200 Subject: [PATCH] feat(appearance): four QSL categories, each scoped to chosen channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the four fixed rules with the model Logger32 uses and the operator asked for: Worked / Confirmed / QSL sent / To be sent, and inside each, which channels count — paper QSL, LoTW, eQSL, QRZ.com. No ticks means every channel, because a rule the operator has not narrowed must not quietly become a rule about nothing. "To be sent" is FIRST in the order, and that is the substantive decision here. A contact can be confirmed on LoTW and still owe a paper card; the colour an operator scans for is the one meaning "something is still owed". Placed after "confirmed" that row goes green and the card never gets printed. "Worked" is the catch-all — nothing sent, nothing requested, nothing back — and is off by default, since turning it on paints every remaining row. R and Q both count as owed: ADIF says requested and queued, and both mean the card has not gone out. --- appearance.go | 49 ++++++++++-- appearance_test.go | 57 +++++++++++--- changelog.json | 6 +- frontend/src/components/AppearancePanel.tsx | 36 +++++++-- frontend/src/lib/i18n.tsx | 4 +- frontend/src/lib/rowColors.ts | 83 ++++++++++++++------- frontend/wailsjs/go/models.ts | 2 + 7 files changed, 184 insertions(+), 53 deletions(-) diff --git a/appearance.go b/appearance.go index 62990a2..9e2f584 100644 --- a/appearance.go +++ b/appearance.go @@ -19,8 +19,16 @@ type RowColorRule struct { ID string `json:"id"` Color string `json:"color"` Enabled bool `json:"enabled"` + // Channels this rule looks at: qsl (paper), lotw, eqsl, qrz. + // + // Empty means ALL of them — what an operator expects from a rule they have + // not narrowed, and what keeps a config written before this field meaningful. + Channels []string `json:"channels"` } +// The QSL channels a rule can be scoped to. +var rowColorChannels = []string{"qsl", "lotw", "eqsl", "qrz"} + // RowColorSettings is the whole appearance block. type RowColorSettings struct { Enabled bool `json:"enabled"` @@ -47,21 +55,30 @@ type RowColorSettings struct { // The rule ids, in priority order. The frontend matches on these and holds the // labels, so a translated name never has to travel through the settings. +// The four categories, in the order they are tested — first match wins. +// +// "To send" comes FIRST on purpose. A contact can be confirmed on LoTW and +// still owe a paper card, and the colour an operator scans for is the one that +// means "there is something left to do here". Put after "confirmed", that row +// would go green and the card would never be printed. +// +// "Worked" is the catch-all: a contact with nothing sent, nothing asked for and +// nothing back. Off by default — with it on, every remaining row is painted. var rowColorOrder = []string{ - "confirmed_lotw", // LoTW confirmation received - "confirmed_paper", // card or eQSL received - "sent_waiting", // sent by some route, nothing back yet - "to_send", // a card is requested / queued and has not gone out + "to_send", // requested or queued on a watched channel, not gone out + "confirmed", // a watched channel has a confirmation back + "sent", // gone out on a watched channel, nothing back yet + "worked", // none of the above } // Defaults: green for done, amber for waiting, blue for owed. Deliberately // muted — they are composited at low opacity over a dark grid, and a saturated // value there reads as an error state rather than a status. var rowColorDefaults = map[string]string{ - "confirmed_lotw": "#16a34a", - "confirmed_paper": "#0ea5e9", - "sent_waiting": "#f59e0b", - "to_send": "#a855f7", + "to_send": "#a855f7", + "confirmed": "#16a34a", + "sent": "#f59e0b", + "worked": "#64748b", } // hexColor guards what reaches the stylesheet. The value is interpolated into a @@ -94,12 +111,28 @@ func normRowColors(s RowColorSettings) RowColorSettings { } else if out.Intensity > 45 { out.Intensity = 45 } + ok := map[string]bool{} + for _, c := range rowColorChannels { + ok[c] = true + } for _, id := range rowColorOrder { r := byID[id] r.ID = id if !hexColor.MatchString(strings.TrimSpace(r.Color)) { r.Color = rowColorDefaults[id] } + // Keep only channels we know, in the canonical order, de-duplicated. + want := map[string]bool{} + for _, c := range r.Channels { + want[strings.ToLower(strings.TrimSpace(c))] = true + } + chans := []string{} + for _, c := range rowColorChannels { + if want[c] { + chans = append(chans, c) + } + } + r.Channels = chans out.Rules = append(out.Rules, r) } return out diff --git a/appearance_test.go b/appearance_test.go index 27ba2b8..9c5612e 100644 --- a/appearance_test.go +++ b/appearance_test.go @@ -6,8 +6,8 @@ import "testing" // that is not plainly a hex colour has to be refused rather than passed on. func TestRowColorsRefuseAnythingButHex(t *testing.T) { in := RowColorSettings{Enabled: true, Rules: []RowColorRule{ - {ID: "confirmed_lotw", Color: "#123abc", Enabled: true}, - {ID: "sent_waiting", Color: "red; background:url(x)", Enabled: true}, + {ID: "confirmed", Color: "#123abc", Enabled: true}, + {ID: "sent", Color: "red; background:url(x)", Enabled: true}, {ID: "to_send", Color: "", Enabled: true}, }} got := normRowColors(in) @@ -16,11 +16,11 @@ func TestRowColorsRefuseAnythingButHex(t *testing.T) { for _, r := range got.Rules { byID[r.ID] = r } - if byID["confirmed_lotw"].Color != "#123abc" { - t.Errorf("a valid colour was rewritten: %q", byID["confirmed_lotw"].Color) + if byID["confirmed"].Color != "#123abc" { + t.Errorf("a valid colour was rewritten: %q", byID["confirmed"].Color) } - if byID["sent_waiting"].Color != rowColorDefaults["sent_waiting"] { - t.Errorf("an injection attempt survived: %q", byID["sent_waiting"].Color) + if byID["sent"].Color != rowColorDefaults["sent"] { + t.Errorf("an injection attempt survived: %q", byID["sent"].Color) } if byID["to_send"].Color != rowColorDefaults["to_send"] { t.Errorf("an empty colour was kept: %q", byID["to_send"].Color) @@ -34,7 +34,7 @@ func TestRowColorsKeepPriorityOrder(t *testing.T) { // Saved in a jumbled order, as a hand-edited settings row could be. got := normRowColors(RowColorSettings{Rules: []RowColorRule{ {ID: "to_send", Color: "#111111"}, - {ID: "confirmed_lotw", Color: "#222222"}, + {ID: "confirmed", Color: "#222222"}, }}) if len(got.Rules) != len(rowColorOrder) { t.Fatalf("got %d rules, want every one present", len(got.Rules)) @@ -44,9 +44,13 @@ func TestRowColorsKeepPriorityOrder(t *testing.T) { t.Errorf("rule %d is %q, want %q", i, got.Rules[i].ID, id) } } - // The saved colours survived the reordering. - if got.Rules[0].Color != "#222222" { - t.Errorf("confirmed_lotw lost its colour: %q", got.Rules[0].Color) + // The saved colours survived the reordering: each stayed with its own rule. + byID := map[string]string{} + for _, r := range got.Rules { + byID[r.ID] = r.Color + } + if byID["to_send"] != "#111111" || byID["confirmed"] != "#222222" { + t.Errorf("colours moved between rules: %v", byID) } } @@ -75,3 +79,36 @@ func TestRowColorsNormaliseStyleAndIntensity(t *testing.T) { } } } + +// Channels are normalised to the canonical order, and anything unknown is +// dropped. Empty stays empty, because empty means "every channel" — a rule the +// operator has not narrowed must not silently become a rule about nothing. +func TestRowColorChannelsNormalise(t *testing.T) { + got := normRowColors(RowColorSettings{Rules: []RowColorRule{ + {ID: "confirmed", Color: "#111111", Channels: []string{"LOTW", "pigeon", "qsl", "lotw"}}, + {ID: "sent", Color: "#222222"}, + }}) + byID := map[string][]string{} + for _, r := range got.Rules { + byID[r.ID] = r.Channels + } + if len(byID["confirmed"]) != 2 || byID["confirmed"][0] != "qsl" || byID["confirmed"][1] != "lotw" { + t.Errorf("confirmed channels = %v, want [qsl lotw] — canonical order, deduped, unknown dropped", byID["confirmed"]) + } + if len(byID["sent"]) != 0 { + t.Errorf("sent channels = %v, want empty (meaning every channel)", byID["sent"]) + } +} + +// The order IS the priority, and "to send" leads. A contact confirmed on LoTW +// that still owes a paper card must read as owing the card — put after +// "confirmed" it would go green and never be printed. +func TestToSendOutranksConfirmed(t *testing.T) { + got := normRowColors(RowColorSettings{}) + if got.Rules[0].ID != "to_send" { + t.Errorf("first rule is %q, want to_send", got.Rules[0].ID) + } + if got.Rules[len(got.Rules)-1].ID != "worked" { + t.Errorf("last rule is %q, want worked as the catch-all", got.Rules[len(got.Rules)-1].ID) + } +} diff --git a/changelog.json b/changelog.json index a960554..f2c8b89 100644 --- a/changelog.json +++ b/changelog.json @@ -8,7 +8,8 @@ "Band map: stations that upload to LoTW now carry the same L badge as the cluster list, switchable in Appearance.", "LoTW: contacts TQSL leaves out — already uploaded, or outside the certificate date range — are no longer reported as uploaded.", "FT8: a contact is logged with the grid the station actually sent, not the home square from QRZ — expeditions were logged in the wrong place.", - "SPE amplifier: switching it off is shown at once, instead of needing a second press of OFF." + "SPE amplifier: switching it off is shown at once, instead of needing a second press of OFF.", + "Appearance: row colouring is now Worked / Confirmed / QSL sent / To be sent, each scoped to the channels you pick — paper, LoTW, eQSL, QRZ.com." ], "fr": [ "Apparence : la coloration des lignes se fait par défaut sur une barre à gauche, la ligne remplie et son intensité restant proposées.", @@ -16,7 +17,8 @@ "Band map : les stations qui utilisent LoTW portent le même badge L que la liste du cluster, activable dans Apparence.", "LoTW : les contacts que TQSL écarte — déjà envoyés, ou hors de la plage de dates du certificat — ne sont plus annoncés comme envoyés.", "FT8 : un contact est enregistré avec le locator réellement émis par la station, et non le carré du domicile depuis QRZ — les expéditions étaient logguées au mauvais endroit.", - "Amplificateur SPE : l extinction s affiche immédiatement, au lieu de demander un second appui sur OFF." + "Amplificateur SPE : l extinction s affiche immédiatement, au lieu de demander un second appui sur OFF.", + "Apparence : la coloration des lignes devient Contacté / Confirmé / QSL envoyée / À envoyer, chacune limitée aux canaux choisis — papier, LoTW, eQSL, QRZ.com." ] }, { diff --git a/frontend/src/components/AppearancePanel.tsx b/frontend/src/components/AppearancePanel.tsx index 5f03bf1..4d7751b 100644 --- a/frontend/src/components/AppearancePanel.tsx +++ b/frontend/src/components/AppearancePanel.tsx @@ -17,10 +17,17 @@ const PALETTE = [ // The rule ids the backend orders; the labels live here so a translation never // travels through the settings row. const LABELS: Record = { - confirmed_lotw: 'appr.confirmedLotw', - confirmed_paper: 'appr.confirmedPaper', - sent_waiting: 'appr.sentWaiting', - to_send: 'appr.toSend', + to_send: 'appr.ruleToSend', + confirmed: 'appr.ruleConfirmed', + sent: 'appr.ruleSent', + worked: 'appr.ruleWorked', +}; + +// The channels a rule can be scoped to. "worked" is the catch-all — it means +// nothing on ANY channel — so narrowing it would say nothing. +const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const; +const CHANNEL_LABELS: Record = { + qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com', }; export function AppearancePanel() { @@ -37,7 +44,7 @@ export function AppearancePanel() { setCfg(next); SaveRowColors(next as any).catch(() => {}); }; - const patchRule = (id: string, patch: Partial<{ color: string; enabled: boolean }>) => { + const patchRule = (id: string, patch: Partial<{ color: string; enabled: boolean; channels: string[] }>) => { if (!cfg) return; save({ ...cfg, rules: cfg.rules.map((r) => (r.id === id ? { ...r, ...patch } : r)) }); }; @@ -104,6 +111,25 @@ export function AppearancePanel() { {i + 1}. {t(LABELS[r.id] ?? r.id)} + {/* Which channels this category looks at. None ticked = all of + them, which is what an unnarrowed rule should mean. */} + {r.enabled && r.id !== 'worked' && ( +
+ {CHANNELS.map((c) => { + const on = !r.channels?.length || r.channels.includes(c); + return ( + + ); + })} +
+ )} {r.enabled && (
{PALETTE.map((c) => ( diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 9fc87a7..9cddfa0 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -114,7 +114,7 @@ const en: Dict = { 'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists', 'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions', 'sec.confirmations': 'Confirmations', 'sec.external': 'External services', - 'sec.general': 'General', 'sec.appearance': 'Appearance', 'appr.enable': 'Colour whole rows by QSL status', 'appr.enableHint': '(in the log grid, like Logger32)', 'appr.orderHint': 'A contact is often several of these at once — the first rule that matches decides the colour.', 'appr.confirmedLotw': 'Confirmed on LoTW', 'appr.confirmedPaper': 'Confirmed by card or eQSL', 'appr.sentWaiting': 'Sent, no answer yet', 'appr.toSend': 'Queued to send (card, LoTW or eQSL)', 'appr.custom': 'Pick any colour', 'appr.style': 'Style', 'appr.styleBar': 'Left stripe', 'appr.styleTint': 'Filled row', 'appr.styleBoth': 'Both', 'appr.intensity': 'Strength', 'appr.bandmapLotw': 'Mark LoTW users on the band map', 'appr.bandmapLotwHint': '(the same L badge the cluster list uses)', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup', + 'sec.general': 'General', 'sec.appearance': 'Appearance', 'appr.enable': 'Colour whole rows by QSL status', 'appr.enableHint': '(in the log grid, like Logger32)', 'appr.orderHint': 'A contact is often several of these at once — the first rule that matches decides the colour.', 'appr.ruleToSend': 'To be sent', 'appr.ruleConfirmed': 'Confirmed', 'appr.ruleSent': 'QSL sent', 'appr.ruleWorked': 'Worked, nothing sent', 'appr.chQsl': 'Paper QSL', 'appr.custom': 'Pick any colour', 'appr.style': 'Style', 'appr.styleBar': 'Left stripe', 'appr.styleTint': 'Filled row', 'appr.styleBoth': 'Both', 'appr.intensity': 'Strength', 'appr.bandmapLotw': 'Mark LoTW users on the band map', 'appr.bandmapLotwHint': '(the same L badge the cluster list uses)', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup', 'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster', 'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.uscounties': 'US Counties', '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', @@ -541,7 +541,7 @@ const fr: Dict = { 'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes', 'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération", 'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes', - 'sec.general': 'Général', 'sec.appearance': 'Apparence', 'appr.enable': 'Colorer les lignes entières selon le statut QSL', 'appr.enableHint': '(dans le tableau du log, comme Logger32)', 'appr.orderHint': "Un contact est souvent plusieurs de ces états à la fois — la première règle qui correspond décide de la couleur.", 'appr.confirmedLotw': 'Confirmé sur LoTW', 'appr.confirmedPaper': 'Confirmé par carte ou eQSL', 'appr.sentWaiting': 'Envoyé, sans réponse', 'appr.toSend': 'En attente d envoi (carte, LoTW ou eQSL)', 'appr.custom': 'Choisir une couleur', 'appr.style': 'Style', 'appr.styleBar': 'Barre à gauche', 'appr.styleTint': 'Ligne remplie', 'appr.styleBoth': 'Les deux', 'appr.intensity': 'Intensité', 'appr.bandmapLotw': 'Marquer les utilisateurs LoTW sur la band map', 'appr.bandmapLotwHint': '(le même badge L que la liste du cluster)', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif", + 'sec.general': 'Général', 'sec.appearance': 'Apparence', 'appr.enable': 'Colorer les lignes entières selon le statut QSL', 'appr.enableHint': '(dans le tableau du log, comme Logger32)', 'appr.orderHint': "Un contact est souvent plusieurs de ces états à la fois — la première règle qui correspond décide de la couleur.", 'appr.ruleToSend': 'À envoyer', 'appr.ruleConfirmed': 'Confirmé', 'appr.ruleSent': 'QSL envoyée', 'appr.ruleWorked': 'Contacté, rien envoyé', 'appr.chQsl': 'QSL papier', 'appr.custom': 'Choisir une couleur', 'appr.style': 'Style', 'appr.styleBar': 'Barre à gauche', 'appr.styleTint': 'Ligne remplie', 'appr.styleBoth': 'Les deux', 'appr.intensity': 'Intensité', 'appr.bandmapLotw': 'Marquer les utilisateurs LoTW sur la band map', 'appr.bandmapLotwHint': '(le même badge L que la liste du cluster)', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif", 'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster', 'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US', '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', diff --git a/frontend/src/lib/rowColors.ts b/frontend/src/lib/rowColors.ts index 8b07e68..7e564c0 100644 --- a/frontend/src/lib/rowColors.ts +++ b/frontend/src/lib/rowColors.ts @@ -1,10 +1,15 @@ -// Row colouring for the log grid, by QSL / LoTW status. +// Row colouring for the log grid, by QSL status. // -// The rules are ORDERED and the first match wins: a contact is usually several -// things at once, and one confirmed on LoTW and by card is confirmed, not -// "sent, awaiting reply". +// Four categories, each scoped to the channels the operator cares about — +// paper QSL, LoTW, eQSL, QRZ.com. The rules are ORDERED and the first match +// wins, because a contact is usually several of them at once. -export type RowColorRule = { id: string; color: string; enabled: boolean }; +export type RowColorRule = { + id: string; + color: string; + enabled: boolean; + channels?: string[]; // empty = every channel +}; export type RowColorSettings = { enabled: boolean; style?: 'bar' | 'tint' | 'both'; @@ -13,39 +18,65 @@ export type RowColorSettings = { rules: RowColorRule[]; }; +export const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const; + +// The two QSO fields behind each channel. QRZ.com and Club Log call theirs an +// "upload status" rather than a QSL flag, but they carry the same Y / R letters. +const FIELDS: Record = { + qsl: { sent: 'qsl_sent', rcvd: 'qsl_rcvd' }, + lotw: { sent: 'lotw_sent', rcvd: 'lotw_rcvd' }, + eqsl: { sent: 'eqsl_sent', rcvd: 'eqsl_rcvd' }, + qrz: { sent: 'qrzcom_qso_upload_status', rcvd: 'qrzcom_qso_download_status' }, +}; + // ADIF QSL fields are single letters. Y is the only one that means "yes"; -// R (requested) and Q (queued) mean it has not gone out yet, which is a -// different state and the one an operator looks for when deciding what to send. +// R (requested) and Q (queued) mean it has not gone out yet — a different state, +// and the one an operator looks for when deciding what to send. const yes = (v: any) => String(v ?? '').trim().toUpperCase() === 'Y'; const owed = (v: any) => { const s = String(v ?? '').trim().toUpperCase(); return s === 'R' || s === 'Q'; }; -export function matchRowRule(q: any): string | null { - if (!q) return null; - if (yes(q.lotw_rcvd)) return 'confirmed_lotw'; - if (yes(q.qsl_rcvd) || yes(q.eqsl_rcvd)) return 'confirmed_paper'; - if (yes(q.qsl_sent) || yes(q.lotw_sent) || yes(q.eqsl_sent)) return 'sent_waiting'; - // Any route still queued, not just the paper card. LoTW marks a pending upload - // as R, which is the commonest "not gone out yet" state in a digital log. - if (owed(q.qsl_sent) || owed(q.lotw_sent) || owed(q.eqsl_sent)) return 'to_send'; +const chansOf = (r: RowColorRule): readonly string[] => + r.channels && r.channels.length ? r.channels : CHANNELS; + +function ruleMatches(q: any, r: RowColorRule): boolean { + const cs = chansOf(r); + switch (r.id) { + case 'confirmed': + return cs.some((c) => yes(q[FIELDS[c]?.rcvd])); + case 'sent': + return cs.some((c) => yes(q[FIELDS[c]?.sent])); + case 'to_send': + return cs.some((c) => owed(q[FIELDS[c]?.sent])); + case 'worked': + // The catch-all: nothing sent, nothing asked for, nothing back. + return !CHANNELS.some((c) => yes(q[FIELDS[c].rcvd]) || yes(q[FIELDS[c].sent]) || owed(q[FIELDS[c].sent])); + default: + return false; + } +} + +// Walks the rules IN ORDER — the order is the priority, and the settings panel +// shows it numbered so it can be read rather than guessed. +export function matchRowRule(q: any, cfg: RowColorSettings | null): RowColorRule | null { + if (!q || !cfg?.rules) return null; + for (const r of cfg.rules) { + if (r.enabled && ruleMatches(q, r)) return r; + } return null; } -// The colour is never a fill. +// The colour is never a fill by default. // // A log where nearly every contact has SOME QSL state ends up with every row -// painted, and colour that is always present stops being information — it -// becomes a striped background with the data behind it. The default is a stripe -// down the left edge: same signal, nothing lost to read it. A tint is offered -// for operators who want the block, at a strength they choose. +// painted, and colour that is always present stops being information. The +// default is a stripe down the left edge; a tint is offered at a chosen strength. export function rowStyleFor(q: any, cfg: RowColorSettings | null): Record | undefined { if (!cfg?.enabled) return undefined; - const id = matchRowRule(q); - if (!id) return undefined; - const rule = cfg.rules?.find((r) => r.id === id); - if (!rule?.enabled || !rule.color) return undefined; + const rule = matchRowRule(q, cfg); + if (!rule?.color) return undefined; const style = cfg.style ?? 'bar'; const pct = Math.max(5, Math.min(45, cfg.intensity ?? 12)); @@ -54,8 +85,8 @@ export function rowStyleFor(q: any, cfg: RowColorSettings | null): Record