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