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:
@@ -129,6 +129,31 @@ select.input-text {
|
|||||||
cursor: pointer;
|
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 {
|
.themed-select {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,8 +22,8 @@ import {
|
|||||||
hasAnySignature
|
hasAnySignature
|
||||||
} from '../utils/signatures.js'
|
} from '../utils/signatures.js'
|
||||||
import type { SignatureValue } from '../types/signatures.js'
|
import type { SignatureValue } from '../types/signatures.js'
|
||||||
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, currentLocalTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
|
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
import { resolveIntlLocale } from '../utils/dateTimeFormat.js'
|
import EventTimeInput24h from './EventTimeInput24h.tsx'
|
||||||
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
||||||
import { signLogEntry } from '../services/entrySigning.js'
|
import { signLogEntry } from '../services/entrySigning.js'
|
||||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||||
@@ -927,7 +927,7 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
const handleSaveEvent = async (e: React.FormEvent) => {
|
const handleSaveEvent = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (readOnly || !evTime) return
|
if (readOnly || !isValidTimeHHMM(evTime)) return
|
||||||
|
|
||||||
const eventData = buildEventFromForm()
|
const eventData = buildEventFromForm()
|
||||||
const isEdit = editingEventIndex !== null
|
const isEdit = editingEventIndex !== null
|
||||||
@@ -1369,13 +1369,11 @@ export default function LogEntryEditor({
|
|||||||
<Clock size={12} style={{ display: 'inline', marginRight: 4 }} />
|
<Clock size={12} style={{ display: 'inline', marginRight: 4 }} />
|
||||||
{t('logs.event_time')} *
|
{t('logs.event_time')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<EventTimeInput24h
|
||||||
type="time"
|
|
||||||
className="input-text"
|
|
||||||
lang={resolveIntlLocale(i18n.language)}
|
|
||||||
value={evTime}
|
value={evTime}
|
||||||
onChange={(e) => setEvTime(e.target.value)}
|
onChange={setEvTime}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
aria-label={t('logs.event_time')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1613,7 +1611,7 @@ export default function LogEntryEditor({
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn secondary"
|
className="btn secondary"
|
||||||
onClick={handleSaveEvent}
|
onClick={handleSaveEvent}
|
||||||
disabled={saving || !evTime}
|
disabled={saving || !isValidTimeHHMM(evTime)}
|
||||||
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
|
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
|
||||||
>
|
>
|
||||||
{editingEventIndex !== null ? <Save size={16} /> : <Plus size={16} />}
|
{editingEventIndex !== null ? <Save size={16} /> : <Plus size={16} />}
|
||||||
|
|||||||
@@ -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 {
|
export function resolveIntlLocale(language?: string): string {
|
||||||
const lng = (language ?? 'en').toLowerCase()
|
const lng = (language ?? 'en').toLowerCase()
|
||||||
return lng.startsWith('de') ? 'de-DE' : 'en-GB'
|
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 = {
|
const APP_DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
|
|||||||
@@ -17,13 +17,56 @@ export interface LogEventPayload {
|
|||||||
remarks: string
|
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 {
|
export function currentLocalTimeHHMM(date: Date = new Date()): string {
|
||||||
const hours = String(date.getHours()).padStart(2, '0')
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
return `${hours}:${minutes}`
|
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)[] = [
|
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
|
||||||
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
|
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
|
||||||
'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
|
'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 e = event as Record<string, unknown>
|
||||||
const timeRaw = String(e.time ?? '').trim()
|
const timeRaw = String(e.time ?? '').trim()
|
||||||
const normalized: LogEventPayload = {
|
const normalized: LogEventPayload = {
|
||||||
time: timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw,
|
time: parseTimeToHHMM(timeRaw) ?? (timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw),
|
||||||
mgk: '',
|
mgk: '',
|
||||||
rwk: '',
|
rwk: '',
|
||||||
windPressure: '',
|
windPressure: '',
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { i18n as I18nInstance } from 'i18next'
|
import type { i18n as I18nInstance } from 'i18next'
|
||||||
import { resolveDocumentLang } from './dateTimeFormat.js'
|
|
||||||
|
|
||||||
const SITE_ORIGIN = 'https://kapteins-daagbok.eu'
|
const SITE_ORIGIN = 'https://kapteins-daagbok.eu'
|
||||||
|
|
||||||
@@ -35,7 +34,7 @@ export function updatePageSeo(lng?: string) {
|
|||||||
if (!i18nRef?.isInitialized) return
|
if (!i18nRef?.isInitialized) return
|
||||||
|
|
||||||
const lang = normalizeSeoLang(lng ?? i18nRef.language)
|
const lang = normalizeSeoLang(lng ?? i18nRef.language)
|
||||||
document.documentElement.lang = resolveDocumentLang(lang)
|
document.documentElement.lang = lang
|
||||||
|
|
||||||
const title = i18nRef.t('seo.title')
|
const title = i18nRef.t('seo.title')
|
||||||
document.title = title
|
document.title = title
|
||||||
|
|||||||
Reference in New Issue
Block a user