feat: rate meter OP/Team display, on-air-only station widget, column-filter count
Rate meter: on a shared MySQL logbook it now shows two lines — OP (the active operator, accent) and Team (all operators, foreground) — each with the 10/60-min QSOs/hour; single-op keeps the one-line display. Live-stations widget: only stations currently on air are listed (offline ones are hidden rather than greyed). Recent QSOs footer: 'Showing X of Y' now reflects the AG-Grid COLUMN filters (the funnel icons) — the grid reports its displayed row count, so filtering a column shows how many QSOs remain instead of always the full total.
This commit is contained in:
+50
-23
@@ -637,6 +637,7 @@ export default function App() {
|
|||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
|
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
|
||||||
const [matchCount, setMatchCount] = useState<number | null>(null);
|
const [matchCount, setMatchCount] = useState<number | null>(null);
|
||||||
|
const [gridFilteredCount, setGridFilteredCount] = useState<number | null>(null); // rows after AG-Grid column filters, or null if none
|
||||||
// The selected tab is remembered across restarts. Only the always-present tabs
|
// The selected tab is remembered across restarts. Only the always-present tabs
|
||||||
// are restored: the conditional ones (flex/icom/contest/net/stats/qsl) depend on
|
// are restored: the conditional ones (flex/icom/contest/net/stats/qsl) depend on
|
||||||
// a feature or CAT backend that isn't known this early, and restoring one that
|
// a feature or CAT backend that isn't known this early, and restoring one that
|
||||||
@@ -1134,10 +1135,10 @@ export default function App() {
|
|||||||
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
|
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
|
||||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||||
useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
|
useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
|
||||||
const [qsoRate, setQsoRate] = useState<{ last10: number; last60: number }>({ last10: 0, last60: 0 });
|
const [qsoRate, setQsoRate] = useState<{ last10: number; last60: number; team10: number; team60: number }>({ last10: 0, last60: 0, team10: 0, team60: 0 });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showQsoRate) return;
|
if (!showQsoRate) return;
|
||||||
const load = () => { GetQSORate().then((r) => setQsoRate({ last10: r?.last10 ?? 0, last60: r?.last60 ?? 0 })).catch(() => {}); };
|
const load = () => { GetQSORate().then((r: any) => setQsoRate({ last10: r?.last10 ?? 0, last60: r?.last60 ?? 0, team10: r?.team_last10 ?? 0, team60: r?.team_last60 ?? 0 })).catch(() => {}); };
|
||||||
load();
|
load();
|
||||||
// Refresh on each logged QSO (immediate feedback) and on a 30s tick so the
|
// Refresh on each logged QSO (immediate feedback) and on a 30s tick so the
|
||||||
// trailing windows roll forward even when nothing new is logged.
|
// trailing windows roll forward even when nothing new is logged.
|
||||||
@@ -3887,21 +3888,39 @@ export default function App() {
|
|||||||
the last columns (profile / band map / compact) onto a 2nd row. */}
|
the last columns (profile / band map / compact) onto a 2nd row. */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{showQsoRate && (
|
{showQsoRate && (
|
||||||
<div className="flex items-center gap-2.5 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
|
<div className="flex items-center gap-2 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
|
||||||
title={t('rate.title')}>
|
title={t('rate.title')}>
|
||||||
|
{/* Contest-style rate: QSOs/hour projected from each window (10-min
|
||||||
|
count ×6; the 60-min count is already per hour). On a shared MySQL
|
||||||
|
logbook it shows both OP (the active operator, accent) and TEAM (all
|
||||||
|
operators, muted); single-op shows one line. */}
|
||||||
<Activity className={cn('size-3.5', (qsoRate.last10 + qsoRate.last60) > 0 ? 'text-primary' : 'text-muted-foreground')} />
|
<Activity className={cn('size-3.5', (qsoRate.last10 + qsoRate.last60) > 0 ? 'text-primary' : 'text-muted-foreground')} />
|
||||||
{/* Contest-style rate: QSOs/hour projected from each window
|
{dbConn?.backend === 'mysql' ? (
|
||||||
(10-min count ×6; the 60-min count is already per hour). Numbers
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
glow the brand accent when active, dim to muted when idle. */}
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="inline-flex items-baseline gap-1">
|
<span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">OP</span>
|
||||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">10′</span>
|
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span><span className="text-muted-foreground text-[7px]">10′</span></span>
|
||||||
<span className={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span>
|
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span><span className="text-muted-foreground text-[7px]">60′</span></span>
|
||||||
</span>
|
</div>
|
||||||
<span className="inline-flex items-baseline gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">60′</span>
|
<span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">Team</span>
|
||||||
<span className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span>
|
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.team10 > 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team10 * 6}</span><span className="text-muted-foreground text-[7px]">10′</span></span>
|
||||||
</span>
|
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.team60 > 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team60}</span><span className="text-muted-foreground text-[7px]">60′</span></span>
|
||||||
<span className="text-muted-foreground text-[9px] uppercase tracking-wider">Q/h</span>
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="inline-flex items-baseline gap-1">
|
||||||
|
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">10′</span>
|
||||||
|
<span className={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-baseline gap-1">
|
||||||
|
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">60′</span>
|
||||||
|
<span className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-muted-foreground text-[9px] uppercase tracking-wider self-center">Q/h</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -4259,16 +4278,16 @@ export default function App() {
|
|||||||
<Radio className="size-3.5 text-primary" />
|
<Radio className="size-3.5 text-primary" />
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider truncate">{t('live.stationsTitle')}</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wider truncate">{t('live.stationsTitle')}</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<span className="text-[10px] text-muted-foreground tabular-nums">{liveStations.filter((s) => s.online).length}/{liveStations.length}</span>
|
<span className="text-[10px] text-muted-foreground tabular-nums">{liveStations.filter((s) => s.online).length}</span>
|
||||||
<button type="button" className="text-muted-foreground hover:text-foreground shrink-0"
|
<button type="button" className="text-muted-foreground hover:text-foreground shrink-0"
|
||||||
onClick={() => { setShowLiveStations(false); writeUiPref('opslog.showLiveStations', '0'); }} title={t('live.stationsHide')}>
|
onClick={() => { setShowLiveStations(false); writeUiPref('opslog.showLiveStations', '0'); }} title={t('live.stationsHide')}>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 overflow-auto p-1.5 flex flex-col gap-1">
|
<div className="flex-1 min-h-0 overflow-auto p-1.5 flex flex-col gap-1">
|
||||||
{liveStations.length === 0 ? (
|
{liveStations.filter((s) => s.online).length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground italic px-1 py-2">{t('live.stationsEmpty')}</p>
|
<p className="text-xs text-muted-foreground italic px-1 py-2">{t('live.stationsEmpty')}</p>
|
||||||
) : liveStations.map((s, i) => {
|
) : liveStations.filter((s) => s.online).map((s, i) => {
|
||||||
const mc = modeAccent(s.mode);
|
const mc = modeAccent(s.mode);
|
||||||
return (
|
return (
|
||||||
<div key={i} className={cn('flex items-center gap-2 rounded-md px-2 py-1.5 border', s.online ? 'bg-muted/40 border-border' : 'border-transparent opacity-60')}>
|
<div key={i} className={cn('flex items-center gap-2 rounded-md px-2 py-1.5 border', s.online ? 'bg-muted/40 border-border' : 'border-transparent opacity-60')}>
|
||||||
@@ -4620,6 +4639,7 @@ export default function App() {
|
|||||||
rows={qsosWithAwards as any}
|
rows={qsosWithAwards as any}
|
||||||
total={total}
|
total={total}
|
||||||
awardCols={awardCols}
|
awardCols={awardCols}
|
||||||
|
onFilteredCountChange={setGridFilteredCount}
|
||||||
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||||
onUpdateFromCty={bulkUpdateFromCty}
|
onUpdateFromCty={bulkUpdateFromCty}
|
||||||
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
||||||
@@ -4655,11 +4675,18 @@ export default function App() {
|
|||||||
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
|
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
|
||||||
>clear</button>
|
>clear</button>
|
||||||
) : null}
|
) : null}
|
||||||
<span>
|
{gridFilteredCount != null ? (
|
||||||
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
|
<span>
|
||||||
<span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
|
Showing <span className="font-semibold text-foreground">{gridFilteredCount}</span> of{' '}
|
||||||
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
|
<span className="font-semibold text-foreground">{qsos.length}</span> (column filter)
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
|
||||||
|
<span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
|
||||||
|
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{qsos.length >= qsoLimit && qsos.length < total && (
|
{qsos.length >= qsoLimit && qsos.length < total && (
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ type Props = {
|
|||||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onExportCabrilloFiltered?: () => void;
|
onExportCabrilloFiltered?: () => void;
|
||||||
onDelete?: (ids: number[]) => void;
|
onDelete?: (ids: number[]) => void;
|
||||||
|
// Reports how many rows the grid shows after its COLUMN filters (the funnel
|
||||||
|
// icons), or null when no column filter is active — so the parent's "Showing X
|
||||||
|
// of Y" can reflect them. Fired on filter change and when the data updates.
|
||||||
|
onFilteredCountChange?: (count: number | null) => void;
|
||||||
// One column per defined award; the cell shows the reference this QSO counts
|
// One column per defined award; the cell shows the reference this QSO counts
|
||||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||||
awardCols?: { code: string; name: string }[];
|
awardCols?: { code: string; name: string }[];
|
||||||
@@ -245,7 +249,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
|||||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
|
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
@@ -360,6 +364,13 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Report the post-column-filter row count (funnel filters) to the parent, or
|
||||||
|
// null when no column filter is active, so "Showing X of Y" reflects them.
|
||||||
|
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
||||||
|
const api = e?.api ?? gridRef.current?.api;
|
||||||
|
if (!api || !onFilteredCountChange) return;
|
||||||
|
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
||||||
|
}, [onFilteredCountChange]);
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
@@ -467,6 +478,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
onGridReady={onGridReady}
|
onGridReady={onGridReady}
|
||||||
|
onFilterChanged={reportFilteredCount}
|
||||||
|
onModelUpdated={reportFilteredCount}
|
||||||
onColumnResized={saveColumnState}
|
onColumnResized={saveColumnState}
|
||||||
onColumnMoved={saveColumnState}
|
onColumnMoved={saveColumnState}
|
||||||
onColumnPinned={saveColumnState}
|
onColumnPinned={saveColumnState}
|
||||||
|
|||||||
Reference in New Issue
Block a user