From 68a49be8c1805842106e0f0ee9ed13cb27e8c954 Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 20 Jul 2026 01:08:17 +0200 Subject: [PATCH] feat: rate meter OP/Team display, on-air-only station widget, column-filter count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/App.tsx | 73 +++++++++++++++------- frontend/src/components/RecentQSOsGrid.tsx | 15 ++++- 2 files changed, 64 insertions(+), 24 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a2704c0..76b9156 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -637,6 +637,7 @@ export default function App() { const [filterOpen, setFilterOpen] = useState(false); const [activeFilter, setActiveFilter] = useState({ conditions: [], match: 'AND' }); const [matchCount, setMatchCount] = useState(null); + const [gridFilteredCount, setGridFilteredCount] = useState(null); // rows after AG-Grid column filters, or null if none // 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 // 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. const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1'); 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(() => { 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(); // Refresh on each logged QSO (immediate feedback) and on a 30s tick so the // 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. */}
{showQsoRate && ( -
+ {/* 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. */} 0 ? 'text-primary' : 'text-muted-foreground')} /> - {/* Contest-style rate: QSOs/hour projected from each window - (10-min count ×6; the 60-min count is already per hour). Numbers - glow the brand accent when active, dim to muted when idle. */} - - 10′ - 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6} - - - 60′ - 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60} - - Q/h + {dbConn?.backend === 'mysql' ? ( +
+
+ OP + 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}10′ + 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}60′ +
+
+ Team + 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team10 * 6}10′ + 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team60}60′ +
+
+ ) : ( + <> + + 10′ + 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6} + + + 60′ + 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60} + + + )} + Q/h
)} @@ -4259,16 +4278,16 @@ export default function App() { {t('live.stationsTitle')}
- {liveStations.filter((s) => s.online).length}/{liveStations.length} + {liveStations.filter((s) => s.online).length}
- {liveStations.length === 0 ? ( + {liveStations.filter((s) => s.online).length === 0 ? (

{t('live.stationsEmpty')}

- ) : liveStations.map((s, i) => { + ) : liveStations.filter((s) => s.online).map((s, i) => { const mc = modeAccent(s.mode); return (
@@ -4620,6 +4639,7 @@ export default function App() { rows={qsosWithAwards as any} total={total} awardCols={awardCols} + onFilteredCountChange={setGridFilteredCount} onRowDoubleClicked={(q) => openEdit(q.id as number)} onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} @@ -4655,11 +4675,18 @@ export default function App() { onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }} >clear ) : null} - - Showing {qsos.length} of{' '} - {(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total} - {(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''} - + {gridFilteredCount != null ? ( + + Showing {gridFilteredCount} of{' '} + {qsos.length} (column filter) + + ) : ( + + Showing {qsos.length} of{' '} + {(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total} + {(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''} + + )}
{qsos.length >= qsoLimit && qsos.length < total && ( diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index ca4e46e..1ca19fe 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -49,6 +49,10 @@ type Props = { onExportCabrilloSelected?: (ids: number[]) => void; onExportCabrilloFiltered?: () => 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 // for (from row.award_refs[CODE], attached by the parent). Hidden by default. 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[] => (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 gridRef = useRef(null); 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(() => { if (restoringRef.current) return; // ignore the events fired by a column rebuild const state = gridRef.current?.api?.getColumnState(); @@ -467,6 +478,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC defaultColDef={defaultColDef} rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }} onGridReady={onGridReady} + onFilterChanged={reportFilteredCount} + onModelUpdated={reportFilteredCount} onColumnResized={saveColumnState} onColumnMoved={saveColumnState} onColumnPinned={saveColumnState}