feat(update): Later asks how much later
"Later" set updateInfo to null and nothing else, while the check behind it runs on a five-minute interval — so the notice came back four times an hour, all evening, for a version the operator had already declined. The button was doing exactly what it said and was useless anyway. It now offers 1, 4, 12 or 24 hours, and the deferral is recorded with the VERSION it applies to. That scoping is the part that matters: a release newer than the one put off is different news and appears at once, so a snooze can defer an update but never bury one. The cross hides it for an hour — the shortest of the four — rather than for the five minutes until the next poll, which is what made the notice feel broken. The background check honours the deferral; checkUpdateNow, behind About, deliberately does not, because asking is asking. Kept out of the portable UI prefs: a machine told to wait four hours has said nothing about the operator's other machines.
This commit is contained in:
+47
-4
@@ -2455,6 +2455,33 @@ export default function App() {
|
||||
if (wlNoticeTimer.current) window.clearTimeout(wlNoticeTimer.current);
|
||||
wlNoticeTimer.current = window.setTimeout(() => setWlNotice(null), 4000);
|
||||
}), []);
|
||||
// An update deferred stays deferred.
|
||||
//
|
||||
// "Later" only hid the card, and the check behind it runs every five
|
||||
// minutes — so the same notice came back four times an hour, all evening,
|
||||
// for a version already declined. It now records until WHEN, and for which
|
||||
// version: a release newer than the one put off is a different piece of
|
||||
// news and appears at once, so a snooze can never bury an update for good.
|
||||
//
|
||||
// Deliberately not a portable UI pref — a machine told to wait four hours
|
||||
// has said nothing about the operator's other machines.
|
||||
const SNOOZE_KEY = 'opslog.updateSnooze';
|
||||
const updateSnoozed = (version: string) => {
|
||||
try {
|
||||
const raw = localStorage.getItem(SNOOZE_KEY);
|
||||
if (!raw) return false;
|
||||
const s = JSON.parse(raw) as { v?: string; until?: number };
|
||||
return s?.v === version && Number(s?.until) > Date.now();
|
||||
} catch { return false; } // unreadable is not snoozed
|
||||
};
|
||||
const snoozeUpdate = (hours: number) => {
|
||||
try {
|
||||
if (updateInfo) localStorage.setItem(SNOOZE_KEY, JSON.stringify({ v: updateInfo.latest, until: Date.now() + hours * 3600_000 }));
|
||||
} catch { /* quota: the card just comes back, which is the old behaviour */ }
|
||||
setLaterOpen(false);
|
||||
setUpdateInfo(null);
|
||||
};
|
||||
const [laterOpen, setLaterOpen] = useState(false);
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||
// Fresh update check on demand (opening About), so it never shows a stale
|
||||
// "you're up to date". Clears updateInfo when the latest check finds nothing.
|
||||
@@ -2472,7 +2499,11 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
|
||||
const check = () => CheckForUpdate().then((u: any) => {
|
||||
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
|
||||
// The snooze is checked HERE and not in checkUpdateNow: opening About
|
||||
// is a question, and it deserves the answer whatever was deferred.
|
||||
if (u?.available && u?.latest && !updateSnoozed(String(u.latest))) {
|
||||
setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
|
||||
}
|
||||
}).catch(() => {});
|
||||
check();
|
||||
const id = window.setInterval(check, 5 * 60 * 1000);
|
||||
@@ -7422,16 +7453,28 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
|
||||
{updateInfo.downloadUrl ? t('upd.install') : t('upd.download')}
|
||||
</button>
|
||||
<button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.later')}</button>
|
||||
{laterOpen ? (
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
{t('upd.remindIn')}
|
||||
{[1, 4, 12, 24].map((h) => (
|
||||
<button key={h} onClick={() => snoozeUpdate(h)}
|
||||
className="h-6 px-1.5 rounded border border-border text-[11px] tabular-nums hover:bg-muted text-foreground">
|
||||
{h}h
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<button onClick={() => setLaterOpen(true)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.later')}</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!updating && (
|
||||
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
|
||||
<button onClick={() => snoozeUpdate(1)} className="text-muted-foreground hover:text-foreground shrink-0" title={t('upd.dismissHour')}>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -19,7 +19,7 @@ const en: Dict = {
|
||||
'live.offlineTip': 'Offline — no QSO logged in the last 5 minutes',
|
||||
'live.stationsTitle': 'Stations on air', 'live.stationsEmpty': 'No station reporting yet.', 'live.stationsHide': 'Hide',
|
||||
'upd.available': 'OpsLog v{v} available', 'upd.current': "You're on v{v}.",
|
||||
'upd.install': 'Update now', 'upd.download': 'Download', 'upd.later': 'Later',
|
||||
'upd.install': 'Update now', 'upd.download': 'Download', 'upd.later': 'Later', 'upd.remindIn': 'Remind me in', 'upd.dismissHour': 'Hide this for an hour',
|
||||
'upd.downloading': 'Downloading…', 'upd.installing': 'Installing…',
|
||||
'upd.restartNote': 'OpsLog will restart on the new version.',
|
||||
'upd.retry': 'Retry', 'upd.browser': 'Open page', 'upd.checking': 'Checking for updates…', 'upd.upToDate': "You're up to date",
|
||||
@@ -199,7 +199,7 @@ const en: Dict = {
|
||||
'wlc.title': 'Contest', 'wlc.pattern': 'Auto-add on', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'any spotted callsign CONTAINING this joins the watchlist as a contest entry (TM29WWA, HB9WWA, F4WWA/P)',
|
||||
'wlc.calls': 'And these callsigns, one per line', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'For the entries a pattern cannot catch: a station taking part under a callsign that says nothing about the event. Named here, it joins the contest watchlist the moment it is spotted. Commas and spaces work too.',
|
||||
// FTx decodes panel (Tools -> FT decodes)
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Set your station grid in Preferences to place the map.', 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} says {dec}, the rig is on {rig}', 'dec.bandDriftApp': 'the decoder', 'dec.bandDriftTip': 'The decoding application is announcing a band the radio is not on — it has most likely lost its CAT link and is repeating the last frequency it knew. Every decode below carries that band, so the NEW / NEW BAND verdicts are judged against it.', 'dec.cqOnly': 'CQ only', 'dec.sortTip': 'Sort this slot by this column. Click again to reverse it, once more for the order the decoder heard them in.',
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Set your station grid in Preferences to place the map.', 'ftmap.colour': 'One colour for every decode, whatever the band', 'ftmap.colourPerBand': 'Back to the colour per band', 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} says {dec}, the rig is on {rig}', 'dec.bandDriftApp': 'the decoder', 'dec.bandDriftTip': 'The decoding application is announcing a band the radio is not on — it has most likely lost its CAT link and is repeating the last frequency it knew. Every decode below carries that band, so the NEW / NEW BAND verdicts are judged against it.', 'dec.cqOnly': 'CQ only', 'dec.sortTip': 'Sort this slot by this column. Click again to reverse it, once more for the order the decoder heard them in.',
|
||||
'dec.allBands': 'All bands', 'dec.allModes': 'All modes', 'toast.qsoLogged': 'QSO logged', 'dec.contsHint': 'Continents: click to keep one or several', 'dec.allConts': 'All continents',
|
||||
'dec.minSnrTitle': 'Hide anything weaker than this SNR', 'dec.searchPh': 'Call, grid or message',
|
||||
'dec.clearFilters': 'Clear', 'dec.live': 'live', 'dec.tx': 'TX',
|
||||
@@ -840,7 +840,7 @@ const fr: Dict = {
|
||||
'wlc.title': 'Contest', 'wlc.pattern': 'Ajout auto sur', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'tout indicatif spotté CONTENANT ceci rejoint la watchlist comme entrée contest (TM29WWA, HB9WWA, F4WWA/P)',
|
||||
'wlc.calls': 'Et ces indicatifs, un par ligne', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'Pour les participants qu’aucun motif ne peut attraper : une station engagée sous un indicatif qui ne dit rien de l’événement. Nommée ici, elle rejoint la watchlist contest dès qu’elle est spottée. Les virgules et les espaces marchent aussi.',
|
||||
// Panneau des decodes FTx (Outils -> Decodes FT)
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Renseigne ton locator dans les Préférences pour placer la carte.', 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} annonce {dec}, le poste est sur {rig}', 'dec.bandDriftApp': 'le décodeur', 'dec.bandDriftTip': 'Le logiciel de décodage annonce une bande sur laquelle la radio n’est pas — il a très probablement perdu sa liaison CAT et répète la dernière fréquence connue. Tous les décodages ci-dessous portent cette bande, et les verdicts NOUVEAU / NOUVELLE BANDE sont jugés dessus.', 'dec.cqOnly': 'CQ seulement', 'dec.sortTip': 'Trier ce créneau sur cette colonne. Un second clic inverse, un troisième rend l’ordre dans lequel le décodeur les a entendus.',
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Renseigne ton locator dans les Préférences pour placer la carte.', 'ftmap.colour': 'Une seule couleur pour tous les décodages, quelle que soit la bande', 'ftmap.colourPerBand': 'Revenir à la couleur par bande', 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} annonce {dec}, le poste est sur {rig}', 'dec.bandDriftApp': 'le décodeur', 'dec.bandDriftTip': 'Le logiciel de décodage annonce une bande sur laquelle la radio n’est pas — il a très probablement perdu sa liaison CAT et répète la dernière fréquence connue. Tous les décodages ci-dessous portent cette bande, et les verdicts NOUVEAU / NOUVELLE BANDE sont jugés dessus.', 'dec.cqOnly': 'CQ seulement', 'dec.sortTip': 'Trier ce créneau sur cette colonne. Un second clic inverse, un troisième rend l’ordre dans lequel le décodeur les a entendus.',
|
||||
'dec.allBands': 'Toutes bandes', 'dec.allModes': 'Tous modes', 'toast.qsoLogged': 'QSO enregistré', 'dec.contsHint': 'Continents : clique pour en garder un ou plusieurs', 'dec.allConts': 'Tous continents',
|
||||
'dec.minSnrTitle': 'Masquer tout ce qui est plus faible que ce rapport', 'dec.searchPh': 'Indicatif, locator ou message',
|
||||
'dec.clearFilters': 'Effacer', 'dec.live': 'en direct', 'dec.tx': 'TX',
|
||||
@@ -919,7 +919,7 @@ const fr: Dict = {
|
||||
'awards.followHint': 'Diplômes affichés dans l’onglet Awards. Laisse la colonne de droite vide pour tous les afficher.', 'awards.available': 'Tous les diplômes', 'awards.followed': 'Suivis', 'awards.search': 'Filtrer…', 'awards.addAll': 'Tout ajouter', 'awards.clear': 'Vider', 'awards.allTracked': 'Tous les diplômes sont suivis.', 'awards.noneFollowed': 'Aucun pour l’instant — l’onglet Awards les montre tous.',
|
||||
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
|
||||
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.tunergenius': 'Tuner Genius', 'sec.pgxl': 'Amplificateur', 'sec.psu': 'Alimentation',
|
||||
'upd.available': 'OpsLog v{v} disponible', 'upd.current': 'Tu es en v{v}.', 'upd.install': 'Mettre à jour maintenant', 'upd.download': 'Télécharger', 'upd.later': 'Plus tard', 'upd.downloading': 'Téléchargement…', 'upd.installing': 'Installation…', 'upd.restartNote': 'OpsLog redémarrera sur la nouvelle version.', 'upd.retry': 'Réessayer', 'upd.browser': 'Ouvrir la page',
|
||||
'upd.available': 'OpsLog v{v} disponible', 'upd.current': 'Tu es en v{v}.', 'upd.install': 'Mettre à jour maintenant', 'upd.download': 'Télécharger', 'upd.later': 'Plus tard', 'upd.remindIn': 'Me rappeler dans', 'upd.dismissHour': 'Masquer pendant une heure', 'upd.downloading': 'Téléchargement…', 'upd.installing': 'Installation…', 'upd.restartNote': 'OpsLog redémarrera sur la nouvelle version.', 'upd.retry': 'Réessayer', 'upd.browser': 'Ouvrir la page',
|
||||
'psu.title': 'Alimentation de laboratoire (Modbus RTU)', 'psu.hint': 'Une alimentation programmable sur port série — BSIDE, Wanptek et les autres alimentations Modbus RTU utilisant les codes fonction 03 et 06. OpsLog affiche ce qu’elle débite et commute sa sortie.', 'psu.enable': 'Piloter cette alimentation', 'psu.port': 'Port COM', 'psu.baud': 'Vitesse', 'psu.address': 'Adresse Modbus', 'psu.wireHint': '9600 bauds, 8 bits de données, sans parité, 1 bit de stop, adresse 1 — les réglages d’usine. Ne les changez ici que si vous les avez changés sur l’alimentation.', 'psu.writeScope': 'OpsLog n’écrit jamais que la marche/arrêt de la sortie. La tension, le courant et les protections sont lus et affichés, jamais modifiés — ils restent sur la face avant de l’alimentation.', 'psu.output': 'Sortie', 'psu.setTo': 'réglée sur', 'psu.tripped': 'PROTECTION DÉCLENCHÉE', 'psu.offline': 'Ne répond pas',
|
||||
'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
||||
// Panneau Manipulateur CW
|
||||
|
||||
Reference in New Issue
Block a user