Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f044b959e | ||
|
|
68a49be8c1 | ||
|
|
8eb82d6cdb | ||
|
|
d327db3f57 |
@@ -4518,31 +4518,31 @@ func (a *App) GetOperators() ([]string, error) {
|
||||
// QSORate is the live QSO-rate meter shown in the header: how many QSOs were
|
||||
// logged in the trailing 10 and 60 minutes.
|
||||
type QSORate struct {
|
||||
Last10 int `json:"last10"`
|
||||
Last60 int `json:"last60"`
|
||||
Last10 int `json:"last10"` // active operator, last 10 min
|
||||
Last60 int `json:"last60"` // active operator, last 60 min
|
||||
TeamLast10 int `json:"team_last10"` // ALL operators (the whole station), last 10 min
|
||||
TeamLast60 int `json:"team_last60"` // ALL operators, last 60 min
|
||||
}
|
||||
|
||||
// GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes.
|
||||
// Cheap (scans only the most recent rows); polled by the header and refreshed on
|
||||
// each qso:logged event.
|
||||
// GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes, both
|
||||
// for the active operator (their own performance) AND for all operators combined
|
||||
// (the team/station rate). Cheap (one scan of the most recent rows); polled by the
|
||||
// header and refreshed on each qso:logged event.
|
||||
func (a *App) GetQSORate() QSORate {
|
||||
if a.qso == nil {
|
||||
return QSORate{}
|
||||
}
|
||||
// Per-operator on a shared logbook: count only the ACTIVE profile's operator
|
||||
// so each op sees their own performance, not the cumulative station rate. An
|
||||
// empty operator (single-op / station owner) matches all their QSOs.
|
||||
operator := ""
|
||||
if a.profiles != nil {
|
||||
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||
operator = p.Operator
|
||||
}
|
||||
}
|
||||
counts, err := a.qso.RecentRate(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute)
|
||||
if err != nil || len(counts) < 2 {
|
||||
op, all, err := a.qso.RecentRateBreakdown(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute)
|
||||
if err != nil || len(op) < 2 || len(all) < 2 {
|
||||
return QSORate{}
|
||||
}
|
||||
return QSORate{Last10: counts[0], Last60: counts[1]}
|
||||
return QSORate{Last10: op[0], Last60: op[1], TeamLast10: all[0], TeamLast60: all[1]}
|
||||
}
|
||||
|
||||
// GetContestRuns lists the (contest, year) pairs actually present in the log, so
|
||||
|
||||
+37
-10
@@ -637,6 +637,7 @@ export default function App() {
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
|
||||
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
|
||||
// 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,12 +3888,28 @@ export default function App() {
|
||||
the last columns (profile / band map / compact) onto a 2nd row. */}
|
||||
<div className="flex items-center gap-2">
|
||||
{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')}>
|
||||
{/* 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')} />
|
||||
{/* 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. */}
|
||||
{dbConn?.backend === 'mysql' ? (
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">OP</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="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>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">Team</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 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>
|
||||
</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>
|
||||
@@ -3901,7 +3918,9 @@ export default function App() {
|
||||
<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">Q/h</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground text-[9px] uppercase tracking-wider self-center">Q/h</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4259,16 +4278,16 @@ export default function App() {
|
||||
<Radio className="size-3.5 text-primary" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider truncate">{t('live.stationsTitle')}</span>
|
||||
<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"
|
||||
onClick={() => { setShowLiveStations(false); writeUiPref('opslog.showLiveStations', '0'); }} title={t('live.stationsHide')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
) : liveStations.map((s, i) => {
|
||||
) : liveStations.filter((s) => s.online).map((s, i) => {
|
||||
const mc = modeAccent(s.mode);
|
||||
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')}>
|
||||
@@ -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</button>
|
||||
) : null}
|
||||
{gridFilteredCount != null ? (
|
||||
<span>
|
||||
Showing <span className="font-semibold text-foreground">{gridFilteredCount}</span> of{' '}
|
||||
<span className="font-semibold text-foreground">{qsos.length}</span> (column filter)
|
||||
</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 className="flex items-center gap-2">
|
||||
{qsos.length >= qsoLimit && qsos.length < total && (
|
||||
|
||||
@@ -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<any>(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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.20.2';
|
||||
export const APP_VERSION = '0.20.3';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
@@ -2407,6 +2407,8 @@ export namespace main {
|
||||
export class QSORate {
|
||||
last10: number;
|
||||
last60: number;
|
||||
team_last10: number;
|
||||
team_last60: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new QSORate(source);
|
||||
@@ -2416,6 +2418,8 @@ export namespace main {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.last10 = source["last10"];
|
||||
this.last60 = source["last60"];
|
||||
this.team_last10 = source["team_last10"];
|
||||
this.team_last60 = source["team_last60"];
|
||||
}
|
||||
}
|
||||
export class RelayAutoRule {
|
||||
|
||||
+19
-19
@@ -1921,21 +1921,20 @@ func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, boo
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// RecentRate counts QSOs whose start time falls within each trailing window from
|
||||
// `now` — the live "QSO rate" meter shown in the header. When operator is non-empty
|
||||
// (multi-op on a shared logbook) only that operator's QSOs are counted, so each op
|
||||
// sees their OWN performance, not the cumulative rate; empty operator matches every
|
||||
// QSO. It scans only the most recently inserted rows (ORDER BY id DESC LIMIT), since
|
||||
// any QSO in the last hour was inserted recently; that keeps it cheap even on a large
|
||||
// log. qso_date is the repo's text column, parsed with parseTimeLoose (backend-format
|
||||
// agnostic).
|
||||
func (r *Repo) RecentRate(ctx context.Context, now time.Time, operator string, windows ...time.Duration) ([]int, error) {
|
||||
counts := make([]int, len(windows))
|
||||
// 2000 rows covers a full hour for one operator even in a busy multi-op run
|
||||
// (other operators' rows are discarded before counting).
|
||||
// RecentRateBreakdown counts, in ONE pass over the most recent rows, QSOs whose
|
||||
// start time falls within each trailing window from `now` — for a specific operator
|
||||
// (their own rate, `op`) AND for ALL operators combined (the team/station rate,
|
||||
// `all`). The header rate meter shows both. It scans only recently inserted rows
|
||||
// (ORDER BY id DESC LIMIT), since any QSO in the last hour was inserted recently, so
|
||||
// it stays cheap on a large log. qso_date is parsed with parseTimeLoose (backend-
|
||||
// format agnostic).
|
||||
func (r *Repo) RecentRateBreakdown(ctx context.Context, now time.Time, operator string, windows ...time.Duration) (op []int, all []int, err error) {
|
||||
op = make([]int, len(windows))
|
||||
all = make([]int, len(windows))
|
||||
// 2000 rows covers a full hour even in a busy multi-op run.
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`)
|
||||
if err != nil {
|
||||
return counts, err
|
||||
return op, all, err
|
||||
}
|
||||
defer rows.Close()
|
||||
now = now.UTC()
|
||||
@@ -1943,23 +1942,24 @@ func (r *Repo) RecentRate(ctx context.Context, now time.Time, operator string, w
|
||||
for rows.Next() {
|
||||
var oper, dateStr sql.NullString
|
||||
if err := rows.Scan(&oper, &dateStr); err != nil {
|
||||
return counts, err
|
||||
}
|
||||
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
|
||||
continue // a different operator's QSO — not part of my rate
|
||||
return op, all, err
|
||||
}
|
||||
t := parseTimeLoose(dateStr.String).UTC()
|
||||
if t.IsZero() || t.After(now) {
|
||||
continue
|
||||
}
|
||||
mine := strings.ToUpper(strings.TrimSpace(oper.String)) == opFilter
|
||||
age := now.Sub(t)
|
||||
for i, w := range windows {
|
||||
if age <= w {
|
||||
counts[i]++
|
||||
all[i]++
|
||||
if mine {
|
||||
op[i]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts, rows.Err()
|
||||
}
|
||||
return op, all, rows.Err()
|
||||
}
|
||||
|
||||
// ExistingDedupeKeys returns a set of every QSO key currently in the DB,
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.20.2"
|
||||
appVersion = "0.20.3"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
@@ -159,14 +160,26 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
|
||||
_ = os.Rename(oldExe, exe) // roll back
|
||||
return fmt.Errorf("install new exe: %w", err)
|
||||
}
|
||||
applog.Printf("update: installed new exe, relaunching")
|
||||
// Clear the "downloaded from the internet" mark (NTFS Zone.Identifier stream).
|
||||
// Otherwise Windows SmartScreen wants to prompt "are you sure you want to open
|
||||
// this?" — but since we launch the exe programmatically that prompt never shows,
|
||||
// and the launch is silently blocked. This is exactly why the relaunch failed.
|
||||
_ = os.Remove(exe + ":Zone.Identifier")
|
||||
applog.Printf("update: installed new exe, scheduling relaunch")
|
||||
|
||||
// Relaunch with a flag so the fresh instance waits for THIS one to exit and
|
||||
// free the single-instance mutex instead of bailing out immediately.
|
||||
cmd := exec.Command(exe, "--post-update")
|
||||
cmd.Dir = dir
|
||||
// Relaunch via a detached, hidden PowerShell that WAITS for this process to exit
|
||||
// (so the single-instance mutex is free) and THEN starts the new exe. Launching
|
||||
// the new exe directly while we're still alive raced the mutex and often left
|
||||
// nothing running; waiting for our own exit first makes the restart reliable,
|
||||
// and the launcher outlives us.
|
||||
quoted := strings.ReplaceAll(exe, "'", "''")
|
||||
ps := fmt.Sprintf(
|
||||
"Wait-Process -Id %d -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; Start-Process -FilePath '%s' -ArgumentList '--post-update'",
|
||||
os.Getpid(), quoted)
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("relaunch: %w", err)
|
||||
return fmt.Errorf("schedule relaunch: %w", err)
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wruntime.Quit(a.ctx)
|
||||
|
||||
Reference in New Issue
Block a user