@@ -1,6 +1,6 @@
import { useEffect , useMemo , useState } from 'react' ;
import { Award as AwardIcon , RefreshCw , Loader2 , CheckCircle2 , Search , Pencil } from 'lucide-react' ;
import { GetAwards } from '../../wailsjs/go/main/App' ;
import { Award as AwardIcon , RefreshCw , Loader2 , Search , Pencil , X , Grid3x3 , List , BarChart3 } from 'lucide-react' ;
import { GetAwardDefs , GetAward , AwardCellQSOs , GetAwardStats } from '../../wailsjs/go/main/App' ;
import { Input } from '@/components/ui/input' ;
import { Button } from '@/components/ui/button' ;
import { cn } from '@/lib/utils' ;
@@ -10,7 +10,7 @@ type BandCount = { band: string; worked: number; confirmed: number };
type AwardRef = {
ref : string ; name? : string ; group? : string ; subgrp? : string ;
worked : boolean ; confirmed : boolean ; validated : boolean ;
bands : string [ ] ; confirmed_bands : string [ ] ;
bands : string [ ] ; confirmed_bands : string [ ] ; validated_bands : string [ ] ;
} ;
type AwardResult = {
code : string ; name : string ; dimension : string ;
@@ -18,6 +18,28 @@ type AwardResult = {
bands : BandCount [ ] ; refs : AwardRef [ ] ;
} ;
type AwardStatRow = { label : string ; cells : number [ ] ; total : number ; grand_total : number } ;
type AwardStats = { code : string ; bands : string [ ] ; rows : AwardStatRow [ ] } ;
// Fixed band columns for the matrix view (Log4OM-style).
const GRID_BANDS = [ '160m' , '80m' , '60m' , '40m' , '30m' , '20m' , '17m' , '15m' , '12m' , '10m' , '6m' , '2m' , '70cm' ] ;
// Per-band status for a reference, highest first.
type CellStatus = 'validated' | 'confirmed' | 'worked' | 'none' ;
function cellStatus ( r : AwardRef , band : string ) : CellStatus {
if ( r . validated_bands ? . includes ( band ) ) return 'validated' ;
if ( r . confirmed_bands ? . includes ( band ) ) return 'confirmed' ;
if ( r . bands ? . includes ( band ) ) return 'worked' ;
return 'none' ;
}
const CELL_STYLE : Record < CellStatus , string > = {
validated : 'bg-emerald-500 text-white' ,
confirmed : 'bg-amber-400 text-amber-950' ,
worked : 'bg-stone-400 text-white' ,
none : '' ,
} ;
const CELL_LABEL : Record < CellStatus , string > = { validated : 'V' , confirmed : 'C' , worked : 'W' , none : '' } ;
function pct ( n : number , total : number ) : number {
if ( total <= 0 ) return 0 ;
return Math . min ( 100 , Math . round ( ( n / total ) * 100 ) ) ;
@@ -34,35 +56,78 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
) ;
}
type AwardListItem = { code : string ; name : string ; valid? : boolean } ;
export function AwardsPanel() {
const [ results , setResults ] = useState < AwardResult [ ] > ( [ ] ) ;
const [ awardList , setAwardList ] = useState < AwardListItem [ ] > ( [ ] ) ;
// Computed results are cached per award code — each award is scanned only the
// first time it's selected (or when explicitly rescanned).
const [ byCode , setByCode ] = useState < Record < string , AwardResult > > ( { } ) ;
const [ loading , setLoading ] = useState ( false ) ;
const [ err , setErr ] = useState ( '' ) ;
const [ selected , setSelected ] = useState < string > ( 'DXCC ' ) ;
const [ selected , setSelected ] = useState < string > ( '' ) ;
const [ refSearch , setRefSearch ] = useState ( '' ) ;
const [ editing , setEditing ] = useState ( false ) ;
const [ view , setView ] = useState < 'grid' | 'list' | 'stats' > ( 'grid' ) ;
const [ refFilter , setRefFilter ] = useState < 'all' | 'worked' | 'notworked' | 'worked_notconf' > ( 'all' ) ;
const [ cell , setCell ] = useState < { ref : string ; band : string ; name? : string } | null > ( null ) ;
const [ stats , setStats ] = useState < AwardStats | null > ( null ) ;
const [ statsLoading , setStatsLoading ] = useState ( false ) ;
async function load() {
// Lazily fetch the statistics matrix when the Stats view is shown.
useEffect ( ( ) = > {
if ( view !== 'stats' || ! selected ) return ;
setStatsLoading ( true ) ;
GetAwardStats ( selected )
. then ( ( s ) = > setStats ( s as any ) )
. catch ( ( ) = > setStats ( null ) )
. finally ( ( ) = > setStatsLoading ( false ) ) ;
} , [ view , selected ] ) ;
// Compute one award (cached). force=true bypasses the cache (Rescan).
async function compute ( code : string , force = false ) {
if ( ! code ) return ;
if ( ! force && byCode [ code ] ) { setSelected ( code ) ; return ; }
setSelected ( code ) ;
setLoading ( true ) ;
setErr ( '' ) ;
try {
setResults ( ( await GetAwards ( ) ) as any ) ;
const r = ( await GetAward ( code ) ) as any as AwardResult ;
setByCode ( ( m ) = > ( { . . . m , [ code ] : r } ) ) ;
} catch ( e : any ) {
setErr ( String ( e ? . message ? ? e ) ) ;
} finally {
setLoading ( false ) ;
}
}
useEffect ( ( ) = > { load ( ) ; } , [ ] ) ;
const current = results . find ( ( r ) = > r . code === selected ) ? ? results [ 0 ] ;
// Load the award list (no QSO scan), then compute only the first award.
async function loadList() {
try {
const defs = ( ( await GetAwardDefs ( ) ) ? ? [ ] ) as any [ ] ;
const list : AwardListItem [ ] = defs . map ( ( d ) = > ( { code : d.code , name : d.name , valid : d.valid } ) ) ;
setAwardList ( list ) ;
const first = list . find ( ( a ) = > a . code === selected ) ? ? list [ 0 ] ;
if ( first ) compute ( first . code ) ;
} catch ( e : any ) {
setErr ( String ( e ? . message ? ? e ) ) ;
}
}
useEffect ( ( ) = > { loadList ( ) ; } , [ ] ) ;
const current = byCode [ selected ] ;
const filteredRefs = useMemo ( ( ) = > {
if ( ! current ) return [ ] ;
const q = refSearch . trim ( ) . toUpperCase ( ) ;
if ( ! q ) return current . refs ;
return current . refs . filter ( ( r ) = > r . ref . includes ( q ) || ( r . name ? ? '' ) . toUpperCase ( ) . includes ( q ) ) ;
} , [ current , refSearch ] ) ;
return current . refs . filter ( ( r ) = > {
if ( refFilter === 'worked' && ! r . worked ) return false ;
if ( refFilter === 'notworked' && r . worked ) return false ;
if ( refFilter === 'worked_notconf' && ! ( r . worked && ! r . confirmed ) ) return false ;
if ( q && ! ( r . ref . includes ( q ) || ( r . name ? ? '' ) . toUpperCase ( ) . includes ( q ) || ( r . group ? ? '' ) . toUpperCase ( ) . includes ( q ) ) ) return false ;
return true ;
} ) ;
} , [ current , refSearch , refFilter ] ) ;
return (
< div className = "flex h-full min-h-0" >
@@ -75,34 +140,44 @@ export function AwardsPanel() {
< Button variant = "ghost" size = "sm" className = "h-7 px-2" onClick = { ( ) = > setEditing ( true ) } title = "Edit awards" >
< Pencil className = "size-3.5" / >
< / Button >
< Button variant = "ghost " size = "sm" className = "h-7 px-2" onClick = { load } disabled = { loading } title = "Recalculate" >
{ loading ? < Loader2 className = "size-3.5 animate-spin" / > : < RefreshCw className = "size-3.5" / > }
< Button variant = "outline " size = "sm" className = "h-7 px-2" onClick = { ( ) = > compute ( selected , true ) } disabled = { loading || ! selected }
title = "Rescan all QSOs and recompute this award" >
{ loading ? < Loader2 className = "size-3.5 mr-1 animate-spin" / > : < RefreshCw className = "size-3.5 mr-1" / > }
Rescan
< / Button >
< / div >
< AwardEditor open = { editing } onClose = { ( ) = > setEditing ( false ) } onSaved = { load } / >
< AwardEditor open = { editing } onClose = { ( ) = > setEditing ( false ) } onSaved = { ( ) = > { setByCode ( { } ) ; loadList ( ) ; } } / >
< div className = "flex-1 overflow-auto" >
{ err && < div className = "p-3 text-xs text-destructive" > { err } < / div > }
{ results . map ( ( r ) = > (
< button
key = { r . code }
onClick = { ( ) = > { setSelected ( r . code ) ; setRefSearch ( '' ) ; } }
className = { cn (
'w-full text-left px-3 py-2 border-b border-border/40 hover:bg-accent/40 transition-colors' ,
current ? . code === r . code && 'bg-accent/60' ,
) }
>
< div className = "flex items-baseline justify-between gap-2" >
< span className = "font-semibold text-sm" > { r . code } < / span >
< span className = "text-[11px] font-mono text-muted-foreground" >
< span className = "text-emerald-600" > { r . confirmed } < / span >
/ < span className = "text-foreground " > { r . worked } < / span >
{ r . total > 0 && < span className = "text-muted-foreground/70" > of { r . total } < / span > }
< / span >
< / div >
< div className = "text-[11px] text-muted-foreground truncate mb-1 " > { r . name } < / div >
< ProgressBar worked = { r . worked } confirmed = { r . confirmed } total = { r . total } / >
< / button >
) ) }
{ awardList . map ( ( a ) = > {
const r = byCode [ a . code ] ;
return (
< button
key = { a . code }
onClick = { ( ) = > { setRefSearch ( '' ) ; compute ( a . code ) ; } }
className = { cn (
'w-full text-left px-3 py-2 border-b border-border/40 hover:bg-accent/40 transition-colors' ,
selected === a . code && 'bg-accent/60' ,
a . valid === false && 'opacity-50' ,
) }
>
< div className = "flex items-baseline justify-between gap-2" >
< span className = "font-semibold text-sm " > { a . code } < / span >
{ r ? (
< span className = "text-[11px] font-mono text-muted-foreground" >
< span className = "text-emerald-600" > { r . confirmed } < / span >
/ < span className = "text-foreground " > { r . worked } < / span >
{ r . total > 0 && < span className = "text-muted-foreground/70" > of { r . total } < / span > }
< / span >
) : (
< span className = "text-[11px] font-mono text-muted-foreground/50" > { selected === a . code && loading ? '…' : '—' } < / span >
) }
< / div >
< div className = "text-[11px] text-muted-foreground truncate mb-1" > { a . name } < / div >
{ r && < ProgressBar worked = { r . worked } confirmed = { r . confirmed } total = { r . total } / > }
< / button >
) ;
} ) }
< / div >
< / div >
@@ -146,55 +221,202 @@ export function AwardsPanel() {
< / div >
) }
{ /* References table */ }
< div className = "flex items-center gap-2 px-4 py-2" >
{ /* References toolbar */ }
< div className = "flex items-center gap-2 px-4 py-2 flex-wrap " >
< div className = "relative" >
< Search className = "size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" / >
< Input className = "h-7 w-56 pl-7 text-xs" placeholder = "Filter references…" value = { refSearch } onChange = { ( e ) = > setRefSearch ( e . target . value ) } / >
< / div >
< span className = "text-[11px] text-muted-foreground" > { filteredRefs . length } reference { filteredRefs . length > 1 ? 's' : '' } < / span >
< div className = "flex items-center rounded-md border border-border overflow-hidden text-xs" >
{ ( [ [ 'all' , 'All' ] , [ 'worked' , 'Wkd' ] , [ 'notworked' , 'Not wkd' ] , [ 'worked_notconf' , 'Wkd not cfmd' ] ] as const ) . map ( ( [ k , label ] ) = > (
< button key = { k } onClick = { ( ) = > setRefFilter ( k ) }
className = { cn ( 'px-2 py-1' , refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground' ) } >
{ label }
< / button >
) ) }
< / div >
< span className = "text-[11px] text-muted-foreground" > { filteredRefs . length } ref { filteredRefs . length > 1 ? 's' : '' } < / span >
< div className = "flex-1" / >
{ /* Legend */ }
< div className = "flex items-center gap-2 text-[10px] text-muted-foreground" >
< span className = "inline-flex items-center gap-1" > < span className = "size-3 rounded-sm bg-stone-400" / > W < / span >
< span className = "inline-flex items-center gap-1" > < span className = "size-3 rounded-sm bg-amber-400" / > C < / span >
< span className = "inline-flex items-center gap-1" > < span className = "size-3 rounded-sm bg-emerald-500" / > V < / span >
< / div >
< div className = "flex items-center rounded-md border border-border overflow-hidden" >
< button className = { cn ( 'px-1.5 py-1' , view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50' ) } title = "Grid view" onClick = { ( ) = > setView ( 'grid' ) } > < Grid3x3 className = "size-3.5" / > < / button >
< button className = { cn ( 'px-1.5 py-1' , view === 'list' ? 'bg-accent' : 'hover:bg-accent/50' ) } title = "List view" onClick = { ( ) = > setView ( 'list' ) } > < List className = "size-3.5" / > < / button >
< button className = { cn ( 'px-1.5 py-1' , view === 'stats' ? 'bg-accent' : 'hover:bg-accent/50' ) } title = "Statistics" onClick = { ( ) = > setView ( 'stats' ) } > < BarChart3 className = "size-3.5" / > < / button >
< / div >
< / div >
< div className = "flex-1 overflow-auto px-4 pb-3" >
< table className = "w-full text-xs" >
< thead className = "sticky top-0 bg-card" >
< tr className = "text-left text-muted-foreground border-b border-border " >
< th className = "py-1 pr-2 font-medium w-24" > Ref < / th >
< th className = "py-1 pr-2 font-medium" > Name < / th >
< th className = "py-1 pr-2 font-medium w-40" > Group < / th >
< th className = "py-1 pr-2 font-medium w-24" > Status < / th >
< th className = "py-1 font-medium" > Bands < / th >
< / tr >
< / thead >
< tbody >
{ filteredRefs . map ( ( r ) = > (
< tr key = { r . ref } className = { cn ( 'border-b border-border/30' , ! r . worked && 'opacity-50' ) } >
< td className = "py-1 pr-2 font-mono font-semibold" > { r . ref } < / td >
< td className = "py-1 pr-2 text-muted-foreground truncate max-w-[240px]" > { r . name } < / td >
< td className = "py-1 pr-2 text-muted-foreground truncate max-w-[150px]" > { r . group } < / td >
< td className = "py-1 pr-2" >
{ ! r . worked ? (
< span className = "text-muted-foreground/70" > — missing < / span >
) : r . validated ? (
< span className = "inline-flex items-center gap-1 text-sky-600" > < CheckCircle2 className = "size-3" / > valid . < / span >
) : r . confirmed ? (
< span className = "inline-flex items-center gap-1 text-emerald-600" > < CheckCircle2 className = "size-3" / > conf . < / span >
) : (
< span className = "text-amber-600" > worked < / span >
) }
< / td >
< td className = "py-1 font-mono text-muted-foreground" >
{ r . bands . map ( ( b ) = > (
< span key = { b } className = { cn ( 'mr-1' , r . confirmed_bands . includes ( b ) && 'text-emerald-600 font-semibold' ) } > { b } < / span >
) ) }
< / td >
{ /* Statistics matrix: status × band, by mode category */ }
{ view === 'stats' ? (
< div className = "flex-1 overflow-auto px-4 pb-3 " >
{ statsLoading || ! stats ? (
< div className = "p-4 text-xs text-muted-foreground flex items-center gap-2" > < Loader2 className = "size-3.5 animate-spin" / > Computing … < / div >
) : (
< table className = "text-xs border-separate" style = { { borderSpacing : 0 } } >
< thead className = "sticky top-0 z-10" >
< tr className = "bg-card" >
< th className = "sticky left-0 z-20 bg-card text-left py-1 pr-3 font-medium border-b border-border" > Statistic < / th >
{ stats . bands . map ( ( b ) = > < th key = { b } className = "py-1 px-1 font-mono font-medium border-b border-border text-center w-10" > { b } < / th > ) }
< th className = "py-1 px-2 font-medium border-b border-border text-center" > Total < / th >
< th className = "py-1 px-2 font-medium border-b border-border text-center" > Grand < / th >
< / tr >
< / thead >
< tbody >
{ stats . rows . map ( ( row , i ) = > {
const isGroupStart = row . label === 'WORKED' || /^WORKED / . test ( row . label ) ;
return (
< tr key = { row . label } className = { cn ( i % 3 === 0 && i > 0 && 'border-t-2 border-border' ) } >
< td className = { cn ( 'sticky left-0 bg-card py-0.5 pr-3 border-b border-border/30 whitespace-nowrap' ,
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground' ) } > { row . label } < / td >
{ row . cells . map ( ( c , j ) = > (
< td key = { j } className = { cn ( 'text-center py-0.5 border-b border-l border-border/20 font-mono' , c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30' ) } > { c || '' } < / td >
) ) }
< td className = "text-center py-0.5 px-2 border-b border-l border-border font-mono font-semibold" > { row . total || '' } < / td >
< td className = "text-center py-0.5 px-2 border-b border-l border-border/20 font-mono text-muted-foreground" > { row . grand_total || '' } < / td >
< / tr >
) ;
} ) }
< / tbody >
< / table >
) }
< / div >
) : view === 'grid' ? (
< div className = "flex-1 overflow-auto px-4 pb-3" >
< table className = "text-xs border-separate" style = { { borderSpacing : 0 } } >
< thead className = "sticky top-0 z-10" >
< tr className = "bg-card" >
< th className = "sticky left-0 z-20 bg-card text-left py-1 pr-2 font-medium border-b border-border w-24" > Ref < / th >
< th className = "text-left py-1 pr-3 font-medium border-b border-border" > Description < / th >
{ GRID_BANDS . map ( ( b ) = > (
< th key = { b } className = "py-1 px-1 font-mono font-medium border-b border-border text-center w-9" > { b } < / th >
) ) }
< / tr >
) ) }
< / tbody >
< / table >
< / div >
< / thead >
< tbody >
{ filteredRefs . map ( ( r ) = > (
< tr key = { r . ref } className = { cn ( 'hover:bg-accent/20' , ! r . worked && 'opacity-60' ) } >
< td className = "sticky left-0 bg-card hover:bg-accent/20 py-0.5 pr-2 font-mono font-semibold border-b border-border/30" > { r . ref } < / td >
< td className = "py-0.5 pr-3 text-muted-foreground truncate max-w-[260px] border-b border-border/30" >
{ r . name } { r . group ? < span className = "text-muted-foreground/60" > · { r . group } < / span > : '' }
< / td >
{ GRID_BANDS . map ( ( b ) = > {
const s = cellStatus ( r , b ) ;
return (
< td key = { b } className = "border-b border-l border-border/30 p-0 text-center" >
{ s === 'none' ? < span className = "block w-9 h-5" / > : (
< button
className = { cn ( 'block w-9 h-5 text-[10px] font-bold' , CELL_STYLE [ s ] , 'hover:brightness-110' ) }
title = { ` ${ r . ref } · ${ b } — click to view QSOs ` }
onClick = { ( ) = > setCell ( { ref : r.ref , band : b , name : r.name } ) }
> { CELL_LABEL [ s ] } < / button >
) }
< / td >
) ;
} ) }
< / tr >
) ) }
< / tbody >
< / table >
< / div >
) : (
< div className = "flex-1 overflow-auto px-4 pb-3" >
< table className = "w-full text-xs" >
< thead className = "sticky top-0 bg-card" >
< tr className = "text-left text-muted-foreground border-b border-border" >
< th className = "py-1 pr-2 font-medium w-24" > Ref < / th >
< th className = "py-1 pr-2 font-medium" > Name < / th >
< th className = "py-1 pr-2 font-medium w-40" > Group < / th >
< th className = "py-1 pr-2 font-medium w-24" > Status < / th >
< th className = "py-1 font-medium" > Bands < / th >
< / tr >
< / thead >
< tbody >
{ filteredRefs . map ( ( r ) = > (
< tr key = { r . ref } className = { cn ( 'border-b border-border/30' , ! r . worked && 'opacity-50' ) } >
< td className = "py-1 pr-2 font-mono font-semibold" > { r . ref } < / td >
< td className = "py-1 pr-2 text-muted-foreground truncate max-w-[240px]" > { r . name } < / td >
< td className = "py-1 pr-2 text-muted-foreground truncate max-w-[150px]" > { r . group } < / td >
< td className = "py-1 pr-2" >
{ ! r . worked ? < span className = "text-muted-foreground/70" > — missing < / span >
: r . validated ? < span className = "text-emerald-600" > validated < / span >
: r . confirmed ? < span className = "text-amber-600" > confirmed < / span >
: < span className = "text-stone-500" > worked < / span > }
< / td >
< td className = "py-1 font-mono text-muted-foreground" >
{ r . bands . map ( ( b ) = > (
< span key = { b } className = { cn ( 'mr-1' , r . validated_bands . includes ( b ) ? 'text-emerald-600 font-semibold' : r . confirmed_bands . includes ( b ) && 'text-amber-600 font-semibold' ) } > { b } < / span >
) ) }
< / td >
< / tr >
) ) }
< / tbody >
< / table >
< / div >
) }
< / >
) }
< / div >
{ cell && current && (
< CellQSOModal code = { current . code } cell = { cell } onClose = { ( ) = > setCell ( null ) } / >
) }
< / div >
) ;
}
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
function CellQSOModal ( { code , cell , onClose } : { code : string ; cell : { ref : string ; band : string ; name? : string } ; onClose : ( ) = > void } ) {
const [ qsos , setQsos ] = useState < any [ ] > ( [ ] ) ;
const [ loading , setLoading ] = useState ( true ) ;
useEffect ( ( ) = > {
setLoading ( true ) ;
AwardCellQSOs ( code , cell . ref , cell . band )
. then ( ( r ) = > setQsos ( ( r ? ? [ ] ) as any ) )
. catch ( ( ) = > setQsos ( [ ] ) )
. finally ( ( ) = > setLoading ( false ) ) ;
} , [ code , cell . ref , cell . band ] ) ;
const fmt = ( s : any ) = > { const d = new Date ( s ) ; return isNaN ( d . getTime ( ) ) ? '' : d . toISOString ( ) . slice ( 0 , 16 ) . replace ( 'T' , ' ' ) ; } ;
return (
< div className = "fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick = { onClose } >
< div className = "bg-card border border-border rounded-lg shadow-xl w-[640px] max-h-[80vh] flex flex-col" onClick = { ( e ) = > e . stopPropagation ( ) } >
< div className = "flex items-center gap-2 px-4 py-2.5 border-b" >
< span className = "font-semibold text-sm" > { code } · < span className = "font-mono" > { cell . ref } < / span > · { cell . band } < / span >
{ cell . name && < span className = "text-xs text-muted-foreground truncate" > { cell . name } < / span > }
< div className = "flex-1" / >
< button onClick = { onClose } className = "text-muted-foreground hover:text-foreground" > < X className = "size-4" / > < / button >
< / div >
< div className = "flex-1 overflow-auto" >
{ loading ? (
< div className = "p-4 text-xs text-muted-foreground flex items-center gap-2" > < Loader2 className = "size-3.5 animate-spin" / > Loading … < / div >
) : qsos . length === 0 ? (
< div className = "p-4 text-xs text-muted-foreground" > No QSOs . < / div >
) : (
< table className = "w-full text-xs" >
< thead className = "sticky top-0 bg-card text-left text-muted-foreground border-b border-border" >
< tr > < th className = "py-1 px-3 font-medium" > Date ( UTC ) < / th > < th className = "py-1 pr-2 font-medium" > Callsign < / th > < th className = "py-1 pr-2 font-medium" > Band < / th > < th className = "py-1 pr-2 font-medium" > Mode < / th > < th className = "py-1 pr-3 font-medium" > QSL < / th > < / tr >
< / thead >
< tbody >
{ qsos . map ( ( q , i ) = > (
< tr key = { q . id ? ? i } className = "border-b border-border/30" >
< td className = "py-1 px-3 font-mono" > { fmt ( q . qso_date ) } < / td >
< td className = "py-1 pr-2 font-mono font-semibold" > { q . callsign } < / td >
< td className = "py-1 pr-2" > { q . band } < / td >
< td className = "py-1 pr-2" > { q . mode } < / td >
< td className = "py-1 pr-3 text-muted-foreground" > { [ q . lotw_rcvd === 'Y' && 'LoTW' , q . qsl_rcvd === 'Y' && 'QSL' , q . eqsl_rcvd === 'Y' && 'eQSL' ] . filter ( Boolean ) . join ( ', ' ) } < / td >
< / tr >
) ) }
< / tbody >
< / table >
) }
< / div >
< div className = "px-4 py-2 border-t text-[11px] text-muted-foreground" > { qsos . length } QSO { qsos . length > 1 ? 's' : '' } < / div >
< / div >
< / div >
) ;
}