An operator's screenshot had it side by side: the Awards panel showed Morocco validated on five bands, and the band/mode matrix two inches above showed the entity as merely worked. ADIF's QSL_Rcvd enumeration has both Y and V — "received" and "verified" — and V is what a LoTW download writes for a confirmation the ARRL has validated. The award engine's isYes accepted "Y" or "V". Everything else in the app compared against 'Y' alone: the worked-before status grid, the slot statistics, the row colouring by QSL status, the awards QSO list, the call history badges. So the confirmations an operator cares most about were the ones that did not count. Now one definition per side, named so the next reader finds the other: qso.ConfirmedValues on the Go side, used by the queries themselves, and isQSLConfirmed in lib/qsl on the frontend, which rowColors and the panels call instead of testing the letter. The Go test drives the real query shape against a real database with a 'V' row — the constant being right is not the point, the queries using it is.
97 lines
3.7 KiB
TypeScript
97 lines
3.7 KiB
TypeScript
import { isQSLConfirmed } from '@/lib/qsl';
|
|
// Row colouring for the log grid, by QSL status.
|
|
//
|
|
// 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;
|
|
channels?: string[]; // empty = every channel
|
|
};
|
|
export type RowColorSettings = {
|
|
enabled: boolean;
|
|
style?: 'bar' | 'tint' | 'both';
|
|
intensity?: number;
|
|
bandmap_lotw?: boolean;
|
|
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 — a different state,
|
|
// and the one an operator looks for when deciding what to send.
|
|
// Y or V — see lib/qsl. A LoTW-verified contact is confirmed, and colouring it
|
|
// as unconfirmed is the same bug the band/mode matrix had.
|
|
const yes = (v: any) => isQSLConfirmed(v);
|
|
const owed = (v: any) => {
|
|
const s = String(v ?? '').trim().toUpperCase();
|
|
return s === 'R' || s === 'Q';
|
|
};
|
|
|
|
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 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. 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 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));
|
|
const out: Record<string, string> = {};
|
|
if (style === 'tint' || style === 'both') {
|
|
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 cells three
|
|
// pixels on coloured rows only, and the columns would stop lining up.
|
|
out.boxShadow = `inset 3px 0 0 ${rule.color}`;
|
|
}
|
|
return out;
|
|
}
|