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.
123 lines
6.4 KiB
TypeScript
123 lines
6.4 KiB
TypeScript
import { Star } from 'lucide-react';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { useI18n } from '@/lib/i18n';
|
||
import { isQSLConfirmed } from '@/lib/qsl';
|
||
import type { WorkedBeforeView } from '@/types';
|
||
|
||
type WorkedBefore = WorkedBeforeView;
|
||
|
||
interface Props {
|
||
wb: WorkedBefore | null;
|
||
busy: boolean;
|
||
currentCall: string;
|
||
}
|
||
|
||
function fmtDate(s: any): string {
|
||
if (!s) return '';
|
||
const d = new Date(s);
|
||
if (isNaN(d.getTime())) return '';
|
||
const p = (n: number) => String(n).padStart(2, '0');
|
||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||
}
|
||
function fmtDateTime(s: any): string {
|
||
if (!s) return '';
|
||
const d = new Date(s);
|
||
if (isNaN(d.getTime())) return '';
|
||
const p = (n: number) => String(n).padStart(2, '0');
|
||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||
}
|
||
|
||
export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||
const { t } = useI18n();
|
||
const hasCall = currentCall.trim() !== '';
|
||
const count = wb?.count ?? 0;
|
||
const entries = wb?.entries ?? [];
|
||
|
||
return (
|
||
<aside className="flex flex-col bg-card border border-border rounded-lg shadow-sm min-h-0 overflow-hidden">
|
||
<header className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/40">
|
||
<div className="flex items-baseline gap-2">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||
{t('chp.workedBefore')}
|
||
</span>
|
||
{hasCall && (
|
||
<span className="font-mono text-sm font-bold text-primary tracking-wider">
|
||
{currentCall.toUpperCase()}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{hasCall && count > 0 && (
|
||
<Badge variant="accent" className="font-mono text-[11px] tracking-wider">
|
||
{count}×
|
||
</Badge>
|
||
)}
|
||
</header>
|
||
|
||
{!hasCall ? (
|
||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||
{t('chp.typeCallsign')}
|
||
</div>
|
||
) : busy && count === 0 ? (
|
||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
||
{t('chp.checking')}
|
||
</div>
|
||
) : count === 0 ? (
|
||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||
<Star className="size-6 text-primary fill-current" />
|
||
<div className="text-lg font-bold text-primary tracking-wider">{t('chp.new')}</div>
|
||
<div>{t('chp.noPriorQso')}</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="px-3 py-1.5 text-[11px] text-muted-foreground bg-muted/40 border-b border-border">
|
||
{t('chp.first')}: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||
{t('chp.last')}: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto">
|
||
<table className="w-full text-[11px] border-collapse">
|
||
<thead>
|
||
<tr>
|
||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.dateUtc')}</th>
|
||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.band')}</th>
|
||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.mode')}</th>
|
||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST tx</th>
|
||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST rx</th>
|
||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">QSL</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{entries.map((e) => (
|
||
<tr key={e.id} className="hover:bg-card">
|
||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{fmtDateTime(e.qso_date)}</td>
|
||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
|
||
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-accent text-accent-foreground">{e.band}</span>
|
||
</td>
|
||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
|
||
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-success-muted text-success-muted-foreground">{e.mode}</span>
|
||
</td>
|
||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_sent ?? ''}</td>
|
||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
|
||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
|
||
{isQSLConfirmed(e.lotw_rcvd) && (
|
||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-info-foreground bg-info mr-0.5" title={t('chp.lotwRcvd')}>L</span>
|
||
)}
|
||
{isQSLConfirmed(e.qsl_rcvd) && (
|
||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-success-foreground bg-success mr-0.5" title={t('chp.bureauRcvd')}>B</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{count > entries.length && (
|
||
<div className="text-center py-1.5 text-[11px] italic text-muted-foreground">
|
||
{t('chp.olderQsos', { n: count - entries.length })}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</aside>
|
||
);
|
||
}
|