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; recent_zebra?: string; // '' = alternate (default), 'off' = every row alike recent_zebra_color?: string; // '' = follow the theme rules: RowColorRule[]; }; // The banding the grid theme applies on its own. Repeated here because turning // the option OFF means painting every row explicitly — the theme would // otherwise keep striping underneath whatever we do per row. const ZEBRA_DEFAULT = 'color-mix(in srgb, var(--muted) 40%, var(--card))'; 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 — 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. // rowIndex is optional: only the Recent QSOs grid bands its rows, and the other // callers have nothing to say about odd and even. export function rowStyleFor(q: any, cfg: RowColorSettings | null, rowIndex?: number): Record | undefined { const out: Record = {}; // Banding first, and INDEPENDENT of cfg.enabled: it is legibility, not QSL // status, and an operator who wants plain rows should not have to turn the // status colours off to get them. if (typeof rowIndex === 'number') { if (cfg?.recent_zebra === 'off') { out.backgroundColor = 'var(--card)'; } else if (rowIndex % 2 === 1) { out.backgroundColor = cfg?.recent_zebra_color || ZEBRA_DEFAULT; } } const empty = () => (Object.keys(out).length ? out : undefined); if (!cfg?.enabled) return empty(); const rule = matchRowRule(q, cfg); if (!rule?.color) return empty(); const style = cfg.style ?? 'bar'; const pct = Math.max(5, Math.min(45, cfg.intensity ?? 12)); if (style === 'tint' || style === 'both') { // Overrides the banding on purpose: the QSL colour is information, the // banding is only there to help the eye keep its line. out.backgroundColor = `color-mix(in srgb, ${rule.color} ${pct}%, transparent)`; } if (style === 'bar' || style === 'both') { // A background gradient, not a border and not a shadow. // // A border would shift the cells three pixels on coloured rows only, and // the columns would stop lining up. An inset box-shadow was the first fix // for that and drew the stripe correctly — but on the grid's transformed, // absolutely-positioned rows it also bled a hairline of the same colour // along the row edges, so every coloured row got a horizontal rule it was // never meant to have. A gradient paints inside the box and nothing else: // three pixels of colour, then transparent, and no edge to bleed from. out.backgroundImage = `linear-gradient(to right, ${rule.color} 0, ${rule.color} 3px, transparent 3px)`; } return out; }