feat(lookup): a cache TTL of 0 switches the cache off

Wanted for the case where the answers are moving: an operator correcting their
own QRZ record, or chasing a DXpedition whose page changes during the operation,
otherwise waits out thirty days before OpsLog will ask again. Clearing the cache
by hand works once; this is the setting for a whole session.

Nothing is read from it and nothing is written to it — rows stored while it is
off would only sit there going stale, waiting for the day it comes back on.
Switching off is NOT clearing: what it already holds stays, and the Clear cache
button remains the way to throw that away.

Two distinctions the code now has to keep, both load-bearing:

An EXPLICIT stored zero is off; an ABSENT key is the thirty-day default. Every
operator who has never opened this setting has nothing stored, and reading that
blank as a zero would silently switch the cache off for all of them.

The CONSTRUCTOR's zero is still the default, not off. At startup the settings
have not been read yet, and beginning with no cache would hammer the provider
for the first seconds of every launch. Only SetTTL, called once the operator's
settings are known, can switch it off.

A negative lifetime is meaningless and is ignored rather than rounded into
either meaning.

The input had to change too: it derived its value from the stored number on
every keystroke, so the box could not be emptied — and 0 was unreachable
outright, since parseInt('0') || 30 is 30.
This commit is contained in:
2026-08-17 09:21:12 +02:00
parent 72ec3cbb97
commit 3f95763ca6
6 changed files with 148 additions and 16 deletions
+23 -3
View File
@@ -2368,6 +2368,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}
function LookupPanel() {
// The cache lifetime as TYPED, so the box can be emptied and re-filled.
// Re-seeded when the settings arrive from the backend — which is after the
// first render, so it cannot simply be the initial value.
const [ttlText, setTtlText] = useState(String(lookup.cache_ttl_days));
useEffect(() => { setTtlText(String(lookup.cache_ttl_days)); }, [lookup.cache_ttl_days]);
// Per-row provider editor — kept inline because it's only used twice
// and needs closure access to the parent state.
const row = (
@@ -2504,16 +2509,31 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="flex gap-3 items-end">
<div className="space-y-1 w-40">
<Label>{t('lk.ttl')}</Label>
{/* Raw text, not the stored number. Deriving the value from the
number on every keystroke made the box impossible to empty
and "0" itself unreachable, since parseInt('0') || 30 is 30.
Zero is now a real setting, so it has to be typeable. */}
<Input
type="number" min={1} max={3650}
value={lookup.cache_ttl_days}
onChange={(e) => setLookup((s) => ({ ...s, cache_ttl_days: parseInt(e.target.value) || 30 }))}
type="number" min={0} max={3650}
value={ttlText}
onChange={(e) => {
const raw = e.target.value;
setTtlText(raw);
const n = parseInt(raw, 10);
if (Number.isFinite(n) && n >= 0) {
setLookup((s) => ({ ...s, cache_ttl_days: Math.min(n, 3650) }));
}
}}
onBlur={() => setTtlText(String(lookup.cache_ttl_days))}
/>
</div>
<Button variant="outline" onClick={clearCache} disabled={clearing}>
{clearing ? t('lk.clearing') : t('lk.clearCache')}
</Button>
</div>
{lookup.cache_ttl_days === 0 && (
<p className="text-[11px] text-warning mt-2">{t('lk.cacheOff')}</p>
)}
</div>
</>
);
+2 -2
View File
@@ -252,7 +252,7 @@ const en: Dict = {
'lk.display': 'Display', 'lk.qrzNickname': 'QRZ.com: use the nickname as the name', 'lk.qrzNicknameHint': 'Logs the name the operator goes by on the air rather than their registered first and last name. Falls back to the full name when no nickname is published. QRZ.com only — HamQTH already does this.', 'lk.showPics': 'Show QRZ profile pictures',
'lk.showPicsHint': 'Display the photo from QRZ.com next to the worked-before matrix. May noticeably slow lookups during busy contest days; turn off if you operate fast.',
'lk.cache': 'Cache', 'lk.cacheHint': "Successful lookups are cached locally so the same callsign isn't fetched twice. TTL controls how long before a fresh query is made.",
'lk.ttl': 'TTL (days)', 'lk.clearing': 'Clearing…', 'lk.clearCache': 'Clear cache now',
'lk.ttl': 'TTL (days, 0 = no cache)', 'lk.cacheOff': 'Cache off: every callsign is fetched from the provider each time it is typed. Slower, and it counts against a QRZ.com daily quota.', 'lk.clearing': 'Clearing…', 'lk.clearCache': 'Clear cache now',
// Bands panel
'bnd.hint': "Pick the bands you actually use. The entry strip, the band-slot grid and the band-map switcher only show what's on the right. Order on the right = display order.",
'bnd.available': 'Available', 'bnd.allSelected': 'All catalog bands selected.', 'bnd.customPh': 'Custom band (e.g. 4m)',
@@ -687,7 +687,7 @@ const fr: Dict = {
'lk.display': 'Affichage', 'lk.qrzNickname': 'QRZ.com : utiliser le surnom comme nom', 'lk.qrzNicknameHint': "Enregistre le nom sous lequel l'opérateur se présente à l'air plutôt que ses prénom et nom déclarés. Retombe sur le nom complet si aucun surnom n'est publié. QRZ.com uniquement — HamQTH le fait déjà.", 'lk.showPics': 'Afficher les photos de profil QRZ',
'lk.showPicsHint': 'Affiche la photo de QRZ.com à côté de la matrice « déjà contacté ». Peut ralentir les recherches en contest ; désactive si tu opères vite.',
'lk.cache': 'Cache', 'lk.cacheHint': "Les recherches réussies sont mises en cache localement pour ne pas re-interroger le même indicatif. Le TTL contrôle la durée avant une nouvelle requête.",
'lk.ttl': 'TTL (jours)', 'lk.clearing': 'Effacement…', 'lk.clearCache': 'Vider le cache',
'lk.ttl': 'TTL (jours, 0 = pas de cache)', 'lk.cacheOff': 'Cache désactivé : chaque indicatif est demandé au fournisseur à chaque saisie. Plus lent, et cela compte dans le quota quotidien QRZ.com.', 'lk.clearing': 'Effacement…', 'lk.clearCache': 'Vider le cache',
'bnd.hint': "Choisis les bandes que tu utilises vraiment. Le bandeau de saisie, la grille de bandes et le sélecteur de carte n'affichent que celles de droite. L'ordre à droite = ordre d'affichage.",
'bnd.available': 'Disponibles', 'bnd.allSelected': 'Toutes les bandes du catalogue sont sélectionnées.', 'bnd.customPh': 'Bande perso (ex. 4m)',
'bnd.selected': 'Sélectionnés ({n})', 'bnd.none': 'Aucune bande — choisis à gauche.',