feat(appearance): four QSL categories, each scoped to chosen channels
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.
This commit is contained in:
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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() {
|
||||
<span className="font-mono text-xs text-muted-foreground">{i + 1}.</span>
|
||||
<span className="font-medium">{t(LABELS[r.id] ?? r.id)}</span>
|
||||
</label>
|
||||
{/* Which channels this category looks at. None ticked = all of
|
||||
them, which is what an unnarrowed rule should mean. */}
|
||||
{r.enabled && r.id !== 'worked' && (
|
||||
<div className="flex items-center gap-3 flex-wrap pl-6 text-xs">
|
||||
{CHANNELS.map((c) => {
|
||||
const on = !r.channels?.length || r.channels.includes(c);
|
||||
return (
|
||||
<label key={c} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox checked={on} onCheckedChange={(v) => {
|
||||
const cur = r.channels?.length ? r.channels : [...CHANNELS];
|
||||
const next = v ? [...new Set([...cur, c])] : cur.filter((x) => x !== c);
|
||||
patchRule(r.id, { channels: next });
|
||||
}} />
|
||||
{CHANNEL_LABELS[c]?.startsWith('appr.') ? t(CHANNEL_LABELS[c]) : CHANNEL_LABELS[c]}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{r.enabled && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap pl-6">
|
||||
{PALETTE.map((c) => (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, { sent: string; rcvd: string }> = {
|
||||
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<string, string> | 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<string
|
||||
out.backgroundColor = `color-mix(in srgb, ${rule.color} ${pct}%, transparent)`;
|
||||
}
|
||||
if (style === 'bar' || style === 'both') {
|
||||
// inset shadow rather than a border: a border would shift the cell layout by
|
||||
// three pixels on coloured rows only, and the columns would no longer line up.
|
||||
// inset shadow rather than a border: a border would shift the cells three
|
||||
// pixels on coloured rows only, and the columns would stop lining up.
|
||||
out.boxShadow = `inset 3px 0 0 ${rule.color}`;
|
||||
}
|
||||
return out;
|
||||
|
||||
Reference in New Issue
Block a user