feat(entry): one padlock instead of five

Logging a contact from a piece of paper — a contest sheet, a friend's
report, a QSO worked on another radio — means the frequency, the band,
the mode, the date and both times all have to stop following the rig and
the clock at once. That is a single decision, and it was five clicks in
five different places, each of which had to be found first.

The padlock beside Start UTC now holds all five and releases all five,
with the same behaviour as before: locking pre-fills the times so the
fields are not empty, unlocking hands them back to the clock and snaps a
QSO already in progress to now.

The five per-field locks stay underneath. Everything downstream reads
them, and individually they say the right thing — "this value is
decoupled from the rig". Only the control is one.
This commit is contained in:
2026-09-08 00:04:56 +02:00
parent 3c7ea2d894
commit 5b7469ae44
3 changed files with 42 additions and 35 deletions
+36 -31
View File
@@ -566,7 +566,9 @@ function LockPad({ on, title, onToggle }: { on: boolean; title: string; onToggle
type="button"
tabIndex={-1}
onClick={onToggle}
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
// The whole tooltip, not a verb glued to a noun: the caller knows what
// this padlock does and can say it in the operator's own language.
title={title}
className={cn(
'inline-flex items-center justify-center size-3.5 rounded transition-colors',
on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
@@ -618,31 +620,34 @@ export default function App() {
});
const locksRef = useRef(locks);
useEffect(() => { locksRef.current = locks; }, [locks]);
const toggleLock = (k: LockKey) => {
setLocks((s) => {
const wasLocked = s[k];
const next = { ...s, [k]: !wasLocked };
if (wasLocked) {
// Unlocking → restore automatic behavior. Without this the locked
// value would linger forever: a stale Start time would never refresh
// even after a new callsign is entered.
if (k === 'start') {
// If a QSO is currently in progress (callsign typed), snap start
// to now since we missed the auto-start moment. Otherwise clear.
setQsoStartedAt(callsign.trim() ? new Date() : null);
} else if (k === 'end') {
// Drop the frozen end so the field tracks the live UTC clock.
setQsoEndedAt(null);
}
} else {
// Locking (manual / deferred entry) → pre-fill with today's date + the
// current UTC time so the fields aren't empty; the operator just adjusts.
const now = new Date();
if (k === 'start') setQsoStartedAt((d) => d ?? now);
else if (k === 'end') setQsoEndedAt((d) => d ?? now);
}
return next;
});
// ONE padlock, not five.
//
// Logging a contact from a piece of paper — a contest sheet, a friend's
// report, a QSO worked on another radio — means the frequency, the band, the
// mode, the date and both times all have to stop following the rig and the
// clock at once. That is a single decision, and it used to be five clicks in
// five different places, each of which had to be found first.
//
// The five per-field locks stay underneath, because everything downstream
// reads them and they say the right thing individually ("this value is
// decoupled from the rig"). Only the control is one.
const manualEntry = locks.start && locks.end && locks.band && locks.mode && locks.freq;
const setManualEntry = (on: boolean) => {
setLocks({ band: on, mode: on, freq: on, start: on, end: on });
if (on) {
// Pre-filled with today's date and the current UTC time so the fields are
// not empty; the operator only has to correct them.
const now = new Date();
setQsoStartedAt((d) => d ?? now);
setQsoEndedAt((d) => d ?? now);
} else {
// Back to automatic. Without this the frozen values would linger for
// ever: a start time held from a backdated entry would never refresh,
// even after a new callsign is typed. A QSO already in progress snaps its
// start to now, since the moment it would have been taken has passed.
setQsoStartedAt(callsign.trim() ? new Date() : null);
setQsoEndedAt(null);
}
};
const [band, setBand] = useState('20m');
const [mode, setMode] = useState('SSB');
@@ -5615,7 +5620,7 @@ export default function App() {
) : null;
const startBlock = (
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockPad on={locks.start} title="start time" onToggle={() => toggleLock('start')} /></Label>
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockPad on={manualEntry} title={manualEntry ? t('field.manualEntryOff') : t('field.manualEntryOn')} onToggle={() => setManualEntry(!manualEntry)} /></Label>
<Input
readOnly={!locks.start}
tabIndex={locks.start ? 0 : -1}
@@ -5634,7 +5639,7 @@ export default function App() {
);
const endBlock = (
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')} <LockPad on={locks.end} title="end time" onToggle={() => toggleLock('end')} /></Label>
<Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')}</Label>
<Input
readOnly={!locks.end}
tabIndex={locks.end ? 0 : -1}
@@ -5905,7 +5910,7 @@ export default function App() {
// used in the full layout to save vertical height.
const bandRow = (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')} <LockPad on={locks.band} title="band" onToggle={() => toggleLock('band')} /></Label>
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')}</Label>
<div className="flex-1 min-w-0">
<Select value={band} onValueChange={onBandUserChange}>
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
@@ -5916,7 +5921,7 @@ export default function App() {
);
const modeRow = (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')} <LockPad on={locks.mode} title="mode" onToggle={() => toggleLock('mode')} /></Label>
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')}</Label>
<div className="flex-1 min-w-0">
<Select value={mode} onValueChange={onModeUserChange}>
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
@@ -6014,7 +6019,7 @@ export default function App() {
};
const freqBlock = (
<div className="flex flex-col w-32">
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockPad on={locks.freq} title="frequency" onToggle={() => toggleLock('freq')} /></Label>
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')}</Label>
<Input
tabIndex={-1}
className="font-mono"
+2 -2
View File
@@ -72,7 +72,7 @@ const en: Dict = {
'field.band': 'Band', 'field.mode': 'Mode', 'field.country': 'Country', 'field.comment': 'Comment',
'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx',
'field.txFreq': 'TX Freq (MHz)', 'field.freqTuneHint': 'Type a frequency and press Enter to tune the radio here.', 'field.freq': 'Freq (MHz)', 'field.rxFreq': 'RX Freq (MHz)', 'field.rxBand': 'RX Band',
'field.startUtc': 'Start UTC', 'field.endUtc': 'End UTC', 'field.snt': 'Snt', 'field.rcv': 'Rcv',
'field.manualEntryOn': 'Manual entry — hold the frequency, band, mode and both times so you can log a contact from paper while the radio stays where it is', 'field.manualEntryOff': 'Back to following the radio and the clock', 'field.startUtc': 'Start UTC', 'field.endUtc': 'End UTC', 'field.snt': 'Snt', 'field.rcv': 'Rcv',
'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…',
// Language chooser
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
@@ -691,7 +691,7 @@ const fr: Dict = {
'field.band': 'Bande', 'field.mode': 'Mode', 'field.country': 'Pays', 'field.comment': 'Commentaire',
'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx',
'field.txFreq': 'Fréq TX (MHz)', 'field.freqTuneHint': 'Tape une fréquence et appuie sur Entrée pour y accorder la radio.', 'field.freq': 'Fréq (MHz)', 'field.rxFreq': 'Fréq RX (MHz)', 'field.rxBand': 'Bande RX',
'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç',
'field.manualEntryOn': 'Saisie manuelle — fige la fréquence, la bande, le mode et les deux heures, pour enregistrer un contact depuis une feuille pendant que la radio reste où elle est', 'field.manualEntryOff': 'Revenir au suivi de la radio et de lhorloge', 'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç',
'btn.logQso': 'Enregistrer', 'btn.clear': 'Effacer', 'btn.spot': 'Spot', 'btn.saving': '…',
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.newTag': 'Nouveau', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.',