feat(entry): normalise Name/QTH/Comment/Note case, upper-case the Recent QSOs search
One station reaches the log SHOUTED by QRZ, lower-cased by a hurried
operator and in whatever case an imported ADIF carried, so the same name
appears three ways across a log. Name and QTH are now title-cased word by
word; Comment and Note only get a capital first letter, because the rest
routinely holds callsigns and modes ("TNX QSO F5ABC, FT8 59") that
lower-casing would destroy.
Normalised on blur, never per keystroke — rewriting the value mid-word
fights the typist (the controlled-input trap in CLAUDE.md). Applied in
both entry layouts and in the QSO editor, since leaving the editor alone
would just reintroduce the mess on the first correction.
The Recent QSOs box only ever searches callsigns, so it upper-cases as
you type and carries an inline clear button.
This commit is contained in:
+33
-12
@@ -103,6 +103,7 @@ import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/Winkeye
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { formatDateTimeUTC } from '@/lib/dateFormat';
|
||||
import { titleCase, sentenceCase } from '@/lib/textCase';
|
||||
import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs';
|
||||
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
||||
|
||||
@@ -4222,7 +4223,8 @@ export default function App() {
|
||||
);
|
||||
const nameBlock = (
|
||||
<div className="flex flex-col flex-1 min-w-[110px]"><Label className="mb-1 h-3.5">{t('field.name')}</Label>
|
||||
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
|
||||
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }}
|
||||
onBlur={() => setName(titleCase)} />
|
||||
</div>
|
||||
);
|
||||
// In contest mode QTH is replaced by the sent (read-only, auto) and received
|
||||
@@ -4242,7 +4244,8 @@ export default function App() {
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col flex-1 min-w-[80px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
|
||||
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
|
||||
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }}
|
||||
onBlur={() => setQth(titleCase)} />
|
||||
</div>
|
||||
);
|
||||
const gridBlock = (
|
||||
@@ -4387,7 +4390,7 @@ export default function App() {
|
||||
);
|
||||
const commentSm = (
|
||||
<div className="flex flex-col w-40"><Label className="mb-1 h-3.5">{t('field.comment')}</Label>
|
||||
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||
<Input value={comment} onChange={(e) => setComment(e.target.value)} onBlur={() => setComment(sentenceCase)} />
|
||||
</div>
|
||||
);
|
||||
// Inline-label variants (label to the LEFT of the control, Log4OM-style) —
|
||||
@@ -4503,12 +4506,12 @@ export default function App() {
|
||||
// column). No flex-1 so they stay one row tall.
|
||||
const commentLine = (
|
||||
<div className="flex flex-col"><Label className="mb-1 h-3.5">{t('field.comment')}</Label>
|
||||
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||
<Input value={comment} onChange={(e) => setComment(e.target.value)} onBlur={() => setComment(sentenceCase)} />
|
||||
</div>
|
||||
);
|
||||
const noteLine = (
|
||||
<div className="flex flex-col"><Label className="mb-1 h-3.5">{t('field.note')}</Label>
|
||||
<Input value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
<Input value={note} onChange={(e) => setNote(e.target.value)} onBlur={() => setNote(sentenceCase)} />
|
||||
</div>
|
||||
);
|
||||
const logButtons = (
|
||||
@@ -5605,7 +5608,8 @@ export default function App() {
|
||||
it to its own line instead, which stays readable. */}
|
||||
<div className="flex gap-4 items-end flex-wrap">
|
||||
<div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
|
||||
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
|
||||
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }}
|
||||
onBlur={() => setName(titleCase)} />
|
||||
</div>
|
||||
{qthBlock}
|
||||
{gridBlock}
|
||||
@@ -6045,12 +6049,29 @@ export default function App() {
|
||||
|
||||
<TabsContent value="recent" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<div className="flex gap-2 p-2.5 border-b border-border/60">
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="Search callsign…"
|
||||
value={filterCallsign}
|
||||
onChange={(e) => setFilterCallsign(e.target.value)}
|
||||
/>
|
||||
{/* Callsign-only search: forced upper-case (a call is never
|
||||
lower-case, and the operator shouldn't have to hold shift)
|
||||
with an inline clear so wiping the filter is one click
|
||||
instead of a select-all + delete. */}
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
className="w-full pr-8 font-mono"
|
||||
placeholder="Search callsign…"
|
||||
value={filterCallsign}
|
||||
onChange={(e) => setFilterCallsign(e.target.value.toUpperCase())}
|
||||
/>
|
||||
{filterCallsign && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('btn.clear')}
|
||||
title={t('btn.clear')}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:text-foreground hover:bg-foreground/10"
|
||||
onClick={() => setFilterCallsign('')}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => refresh()}>
|
||||
<RefreshCw className="size-3.5" /> Refresh
|
||||
</Button>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Combobox } from '@/components/ui/combobox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { flagURL } from '@/lib/flags';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { titleCase, sentenceCase } from '@/lib/textCase';
|
||||
import type { QSOForm } from '@/types';
|
||||
|
||||
type QSO = QSOForm;
|
||||
@@ -514,7 +515,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||
{/* ── Left column ── */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div><Label>{t('qedit.name')}</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.name')}</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)}
|
||||
onBlur={() => set('name', titleCase(draft.name ?? '') as any)} /></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">{t('qedit.band')}</Label>
|
||||
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
||||
@@ -580,8 +582,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
||||
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
|
||||
</div>
|
||||
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)}
|
||||
onBlur={() => set('comment', sentenceCase(draft.comment ?? '') as any)} /></div>
|
||||
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)}
|
||||
onBlur={() => set('notes', sentenceCase(draft.notes ?? '') as any)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -602,7 +606,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
|
||||
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)}
|
||||
onBlur={() => set('qth', titleCase(draft.qth ?? '') as any)} /></div>
|
||||
<div><Label>{t('qedit.address')}</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
||||
</div>
|
||||
{/* Right column */}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Capitalisation helpers for the free-text QSO fields (Name / QTH / Comment /
|
||||
// Note).
|
||||
//
|
||||
// The same station reaches the log SHOUTED by QRZ, lower-cased by a hurried
|
||||
// operator, and in whatever case the other logger stored it in an imported
|
||||
// ADIF — so one callsign ends up as "JEAN", "jean" and "Jean" across a log,
|
||||
// and sorting or reading a QTH column becomes a mess.
|
||||
//
|
||||
// Both helpers are meant to run on BLUR, never per keystroke: rewriting the
|
||||
// value while the operator is still typing a word fights them mid-word (see
|
||||
// the controlled-input note in CLAUDE.md).
|
||||
|
||||
// titleCase upper-cases the first letter of every word and lower-cases the
|
||||
// rest. Separators are kept, so "SAINT-JULIEN" → "Saint-Julien" and
|
||||
// "o'brien" → "O'Brien".
|
||||
export function titleCase(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/(^|[\s\-'’/.])(\p{L})/gu, (_m, sep: string, ch: string) => sep + ch.toUpperCase());
|
||||
}
|
||||
|
||||
// sentenceCase upper-cases the first letter and leaves everything after it as
|
||||
// typed. A comment routinely carries callsigns, modes and abbreviations
|
||||
// ("TNX QSO F5ABC, FT8 59") that lower-casing would quietly destroy — which is
|
||||
// why this is NOT titleCase.
|
||||
export function sentenceCase(s: string): string {
|
||||
return s.replace(/^(\s*)(\p{L})/u, (_m, sp: string, ch: string) => sp + ch.toUpperCase());
|
||||
}
|
||||
Reference in New Issue
Block a user