fix(logs): 24h-Uhrzeit per Dropdown und konsistentes html lang

Ersetzt natives type=time (AM/PM je nach System) durch Stunde/Minute-Auswahl, wandelt 12h-Werte beim Laden um und stellt html lang auf de/en zurück.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 11:01:03 +02:00
parent 658bc6c0c9
commit 34c7d2d65c
6 changed files with 137 additions and 20 deletions
+25
View File
@@ -129,6 +129,31 @@ select.input-text {
cursor: pointer;
}
.time-input-24h {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.time-input-24h__select {
flex: 1 1 0;
min-width: 0;
padding-left: 12px;
padding-right: 12px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.time-input-24h__sep {
flex-shrink: 0;
font-size: 18px;
font-weight: 600;
line-height: 1;
color: var(--app-text-muted);
user-select: none;
}
.themed-select {
position: relative;
width: 100%;
@@ -0,0 +1,58 @@
import { useId, useMemo } from 'react'
import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js'
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
interface EventTimeInput24hProps {
value: string
onChange: (value: string) => void
disabled?: boolean
'aria-label'?: string
}
export default function EventTimeInput24h({
value,
onChange,
disabled = false,
'aria-label': ariaLabel
}: EventTimeInput24hProps) {
const baseId = useId()
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
return (
<div className="time-input-24h">
<select
id={`${baseId}-hours`}
className="input-text time-input-24h__select"
value={hours}
onChange={(e) => onChange(joinTimeHHMM(e.target.value, minutes))}
disabled={disabled}
aria-label={ariaLabel ? `${ariaLabel} (h)` : undefined}
>
{HOURS.map((hour) => (
<option key={hour} value={hour}>
{hour}
</option>
))}
</select>
<span className="time-input-24h__sep" aria-hidden="true">
:
</span>
<select
id={`${baseId}-minutes`}
className="input-text time-input-24h__select"
value={minutes}
onChange={(e) => onChange(joinTimeHHMM(hours, e.target.value))}
disabled={disabled}
aria-label={ariaLabel ? `${ariaLabel} (min)` : undefined}
>
{MINUTES.map((minute) => (
<option key={minute} value={minute}>
{minute}
</option>
))}
</select>
</div>
)
}
+7 -9
View File
@@ -22,8 +22,8 @@ import {
hasAnySignature
} from '../utils/signatures.js'
import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, currentLocalTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
import { resolveIntlLocale } from '../utils/dateTimeFormat.js'
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
import EventTimeInput24h from './EventTimeInput24h.tsx'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
@@ -927,7 +927,7 @@ export default function LogEntryEditor({
const handleSaveEvent = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly || !evTime) return
if (readOnly || !isValidTimeHHMM(evTime)) return
const eventData = buildEventFromForm()
const isEdit = editingEventIndex !== null
@@ -1369,13 +1369,11 @@ export default function LogEntryEditor({
<Clock size={12} style={{ display: 'inline', marginRight: 4 }} />
{t('logs.event_time')} *
</label>
<input
type="time"
className="input-text"
lang={resolveIntlLocale(i18n.language)}
<EventTimeInput24h
value={evTime}
onChange={(e) => setEvTime(e.target.value)}
onChange={setEvTime}
disabled={saving}
aria-label={t('logs.event_time')}
/>
</div>
@@ -1613,7 +1611,7 @@ export default function LogEntryEditor({
type="button"
className="btn secondary"
onClick={handleSaveEvent}
disabled={saving || !evTime}
disabled={saving || !isValidTimeHHMM(evTime)}
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
>
{editingEventIndex !== null ? <Save size={16} /> : <Plus size={16} />}
+1 -7
View File
@@ -1,15 +1,9 @@
/** BCP 47 locales that use 24-hour clock for Intl and native pickers. */
/** BCP 47 locales that use 24-hour clock for Intl formatting. */
export function resolveIntlLocale(language?: string): string {
const lng = (language ?? 'en').toLowerCase()
return lng.startsWith('de') ? 'de-DE' : 'en-GB'
}
/** `lang` for `<html>` and `<input type="time">` (24h-friendly). */
export function resolveDocumentLang(language?: string): string {
const lng = (language ?? 'en').toLowerCase()
return lng.startsWith('de') ? 'de' : 'en-GB'
}
const APP_DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
+45 -2
View File
@@ -17,13 +17,56 @@ export interface LogEventPayload {
remarks: string
}
/** Local time as HH:MM for HTML `<input type="time">`. */
/** Local time as HH:MM (24-hour). */
export function currentLocalTimeHHMM(date: Date = new Date()): string {
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
/** Parse 24h or 12h (AM/PM) time strings to HH:MM. */
export function parseTimeToHHMM(value: string): string | null {
const trimmed = value.trim()
if (!trimmed) return null
const amPm = trimmed.match(/^(\d{1,2}):(\d{2})(?::\d{2})?\s*(AM|PM)$/i)
if (amPm) {
let hours = parseInt(amPm[1], 10)
const minutes = parseInt(amPm[2], 10)
const isPm = amPm[3].toUpperCase() === 'PM'
if (hours < 1 || hours > 12 || minutes < 0 || minutes > 59) return null
if (hours === 12) hours = isPm ? 12 : 0
else if (isPm) hours += 12
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
}
const h24 = trimmed.match(/^(\d{1,2}):(\d{2})(?::\d{2})?$/)
if (h24) {
const hours = parseInt(h24[1], 10)
const minutes = parseInt(h24[2], 10)
if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
}
}
return null
}
export function isValidTimeHHMM(value: string): boolean {
return parseTimeToHHMM(value) !== null
}
export function splitTimeHHMM(value: string): { hours: string; minutes: string } {
const parsed = parseTimeToHHMM(value) ?? currentLocalTimeHHMM()
return { hours: parsed.slice(0, 2), minutes: parsed.slice(3, 5) }
}
export function joinTimeHHMM(hours: string, minutes: string): string {
const h = Math.min(23, Math.max(0, parseInt(hours, 10) || 0))
const m = Math.min(59, Math.max(0, parseInt(minutes, 10) || 0))
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
@@ -35,7 +78,7 @@ export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<strin
const e = event as Record<string, unknown>
const timeRaw = String(e.time ?? '').trim()
const normalized: LogEventPayload = {
time: timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw,
time: parseTimeToHHMM(timeRaw) ?? (timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw),
mgk: '',
rwk: '',
windPressure: '',
+1 -2
View File
@@ -1,5 +1,4 @@
import type { i18n as I18nInstance } from 'i18next'
import { resolveDocumentLang } from './dateTimeFormat.js'
const SITE_ORIGIN = 'https://kapteins-daagbok.eu'
@@ -35,7 +34,7 @@ export function updatePageSeo(lng?: string) {
if (!i18nRef?.isInitialized) return
const lang = normalizeSeoLang(lng ?? i18nRef.language)
document.documentElement.lang = resolveDocumentLang(lang)
document.documentElement.lang = lang
const title = i18nRef.t('seo.title')
document.title = title