Grid on the UDP path — WSJT-X and MSHV can only send a FOUR-character grid, that is all the FT8 protocol carries. The enrichment rule there is "fill only what is empty", so the coarse JN05 always won and QRZ's JN05JG was thrown away: roughly 100 km of accuracy discarded on every digital QSO. The lookup grid is now taken when it EXTENDS the received square. A lookup that disagrees outright (JN18 against JN05) is not a refinement — the station may be portable and what came over the air is then the better record. Both rules are pinned by tests. Manual fetch in the QSO editor — it read the CACHE, valid for 30 days. An operator who upgraded their QRZ subscription went on getting the thin free-account record and clearing the cached row by hand was the only way out. The button now forces a lookup that bypasses the cache, reaches the provider and REFRESHES the stored row, with a 15 s budget instead of 2 s: a deliberate click must reach the network, where giving up early would fall back to cty.dat and look like nothing happened. The automatic type-ahead lookup keeps the cache, which is where it earns its keep. Same fetch: it used `??`, which only guards against null. Go marshals an unset string as "", so a QRZ record with no grid BLANKED the grid already in the QSO. The lookup still wins — that is the point of asking for it — but an empty result no longer erases a good value.
117 lines
5.3 KiB
TypeScript
117 lines
5.3 KiB
TypeScript
import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
|
|
import { writeUiPref } from './uiPref';
|
|
import { GetUIPref } from '../../wailsjs/go/main/App';
|
|
|
|
// Theme system. Each choice maps to a `data-theme` value on <html> that the
|
|
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
|
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
|
// travels with the data/ folder like the language).
|
|
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
|
|
|
|
// Selectable, concrete themes (excludes 'auto') in display order.
|
|
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
|
'light-warm', 'light-cool', 'light-sage', 'dim-slate', 'dark-warm', 'dark-graphite', 'high-contrast',
|
|
];
|
|
|
|
export const LS_KEY = 'opslog.theme';
|
|
// A fresh install starts DARK. A shack is usually a dim room and the screen is
|
|
// looked at for hours; every other logger defaults the same way. Graphite
|
|
// specifically, because that is what 'auto' already resolves to for a dark
|
|
// system — so the two paths agree instead of landing on different darks.
|
|
const DEFAULT: ThemeChoice = 'dark-graphite';
|
|
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
|
|
|
|
function systemDark(): boolean {
|
|
try { return window.matchMedia('(prefers-color-scheme: dark)').matches; } catch { return false; }
|
|
}
|
|
|
|
// Resolve 'auto' to a concrete data-theme value (graphite dark / warm light).
|
|
function resolve(choice: ThemeChoice): string {
|
|
if (choice === 'auto') return systemDark() ? 'dark-graphite' : 'light-warm';
|
|
return choice;
|
|
}
|
|
|
|
function readStored(): ThemeChoice {
|
|
try {
|
|
const v = localStorage.getItem(LS_KEY) as ThemeChoice | null;
|
|
if (v && ALL.includes(v)) return v;
|
|
} catch { /* private mode */ }
|
|
return DEFAULT;
|
|
}
|
|
|
|
// Stamp the resolved theme onto <html data-theme>.
|
|
export function applyThemeToDom(choice: ThemeChoice): void {
|
|
try { document.documentElement.setAttribute('data-theme', resolve(choice)); } catch { /* no DOM */ }
|
|
}
|
|
|
|
// Call before the first render (main.tsx) so there is no flash of the default
|
|
// palette while React boots.
|
|
export function initTheme(): void { applyThemeToDom(readStored()); }
|
|
|
|
type Ctx = { theme: ThemeChoice; setTheme: (t: ThemeChoice) => void };
|
|
const ThemeCtx = createContext<Ctx>({ theme: DEFAULT, setTheme: () => {} });
|
|
export function useTheme(): Ctx { return useContext(ThemeCtx); }
|
|
|
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored());
|
|
// Set once the operator changes the theme by hand, so the self-heal below
|
|
// never clobbers a fresh choice with a value it read a moment earlier.
|
|
const userPicked = useRef(false);
|
|
|
|
const setTheme = useCallback((t: ThemeChoice) => {
|
|
userPicked.current = true;
|
|
setThemeState(t);
|
|
applyThemeToDom(t);
|
|
writeUiPref(LS_KEY, t);
|
|
}, []);
|
|
|
|
// Self-heal the persisted theme. The synchronous boot read (localStorage) can
|
|
// miss it when the WebView cleared its storage, OR when syncPortablePrefs ran
|
|
// while the backend was still starting (settings store not wired yet → GetUIPref
|
|
// returned "" with no error, so nothing was restored) — the "restart lands on
|
|
// the light theme sometimes" bug. Re-read the portable pref from the DB once the
|
|
// backend is up and apply it, retrying briefly to ride out a slow startup.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
let tries = 0;
|
|
const load = () => {
|
|
tries += 1;
|
|
GetUIPref(LS_KEY).then((raw) => {
|
|
// Backend ANSWERED (settings store ready). Apply a valid stored value; an
|
|
// empty/invalid one means the theme is genuinely unset → keep the default.
|
|
// Either way we're done — do NOT retry (retrying only matters while the
|
|
// backend is still starting, which now surfaces as a rejected promise).
|
|
if (cancelled || userPicked.current) return;
|
|
const v = raw as ThemeChoice;
|
|
if (v && ALL.includes(v)) {
|
|
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
|
|
applyThemeToDom(v); // idempotent — safe to call unconditionally
|
|
setThemeState(v);
|
|
}
|
|
}).catch(() => {
|
|
// Settings store not ready yet — a brief startup window before the backend
|
|
// has opened the local DB and built the store (the frontend can query it
|
|
// first). Keep retrying well past the old 2.4s cap so a dark theme isn't
|
|
// lost to the light default after an update cleared localStorage.
|
|
if (!cancelled && !userPicked.current && tries < 120) window.setTimeout(load, 300);
|
|
});
|
|
};
|
|
load();
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
// While in 'auto', re-resolve when the OS light/dark preference flips.
|
|
useEffect(() => {
|
|
if (theme !== 'auto') return;
|
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
const onChange = () => applyThemeToDom('auto');
|
|
mq.addEventListener('change', onChange);
|
|
return () => mq.removeEventListener('change', onChange);
|
|
}, [theme]);
|
|
|
|
// Keep the DOM attribute in sync with state.
|
|
useEffect(() => { applyThemeToDom(theme); }, [theme]);
|
|
|
|
return <ThemeCtx.Provider value={{ theme, setTheme }}>{children}</ThemeCtx.Provider>;
|
|
}
|