From 63d6124ce3bac85fcf52b2c25b8a42566d3f7cb7 Mon Sep 17 00:00:00 2001 From: Andreas Weyer Date: Wed, 3 Dec 2025 22:38:59 +0000 Subject: [PATCH] Update input field error/warning behavior and time picker handling --- src/components/day-schedule.tsx | 5 + src/components/ui/form-numeric-input.tsx | 36 +++-- src/components/ui/form-time-input.tsx | 160 +++++++++++++++-------- src/locales/de.ts | 8 +- src/locales/en.ts | 10 +- src/styles/global.css | 6 +- 6 files changed, 154 insertions(+), 71 deletions(-) diff --git a/src/components/day-schedule.tsx b/src/components/day-schedule.tsx index a8f23ca..00bb654 100644 --- a/src/components/day-schedule.tsx +++ b/src/components/day-schedule.tsx @@ -93,6 +93,9 @@ const DaySchedule: React.FC = ({ const duplicateTimeCount = day.doses.filter(d => d.time === dose.time).length; const hasDuplicateTime = duplicateTimeCount > 1; + // Check for zero dose + const isZeroDose = dose.ldx === '0' || dose.ldx === '0.0'; + return (
= ({ min={0} unit="mg" required={true} + warning={isZeroDose} errorMessage={t('errorNumberRequired')} + warningMessage={t('warningZeroDose')} />
{unit && {unit}} - {hasError && showError && errorMessage && ( + {hasError && isFocused && errorMessage && (
{errorMessage}
)} - {hasWarning && showWarning && warningMessage && ( -
+ {hasWarning && isFocused && warningMessage && ( +
{warningMessage}
)} diff --git a/src/components/ui/form-time-input.tsx b/src/components/ui/form-time-input.tsx index 5deaee0..2841364 100644 --- a/src/components/ui/form-time-input.tsx +++ b/src/components/ui/form-time-input.tsx @@ -14,6 +14,7 @@ import { Button } from "./button" import { Input } from "./input" import { Popover, PopoverContent, PopoverTrigger } from "./popover" import { cn } from "../../lib/utils" +import { useTranslation } from "react-i18next" interface TimeInputProps extends Omit, 'onChange' | 'value'> { value: string @@ -41,13 +42,22 @@ const FormTimeInput = React.forwardRef( className, ...props }, ref) => { + const { t } = useTranslation() const [displayValue, setDisplayValue] = React.useState(value) const [isPickerOpen, setIsPickerOpen] = React.useState(false) - const [showError, setShowError] = React.useState(false) - const [showWarning, setShowWarning] = React.useState(false) + const [, setShowError] = React.useState(false) + const [, setShowWarning] = React.useState(false) + const [touched, setTouched] = React.useState(false) + const [isFocused, setIsFocused] = React.useState(false) const containerRef = React.useRef(null) + + // Current committed value parsed from prop const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number) + // Staged selections (pending confirmation) + const [stagedHour, setStagedHour] = React.useState(null) + const [stagedMinute, setStagedMinute] = React.useState(null) + // Check if value is invalid (check validity regardless of touch state) const isInvalid = required && (!value || value.trim() === '') const hasError = error || isInvalid @@ -57,9 +67,21 @@ const FormTimeInput = React.forwardRef( setDisplayValue(value) }, [value]) + // Align error bubble behavior with numeric input: show when invalid after first blur + React.useEffect(() => { + if (isInvalid && touched) { + setShowError(true) + } else if (!isInvalid) { + setShowError(false) + } + }, [isInvalid, touched]) + const handleBlur = (e: React.FocusEvent) => { const inputValue = e.target.value.trim() + setTouched(true) + setIsFocused(false) setShowError(false) + setShowWarning(false) if (inputValue === '') { // Update parent with empty value so validation works @@ -98,21 +120,41 @@ const FormTimeInput = React.forwardRef( } const handleFocus = () => { + setIsFocused(true) setShowError(hasError) setShowWarning(hasWarning) } - const handlePickerChange = (part: 'h' | 'm', val: number) => { - let newHours = pickerHours, newMinutes = pickerMinutes - if (part === 'h') { - newHours = val - } else { - newMinutes = val + const handlePickerOpen = (open: boolean) => { + setIsPickerOpen(open) + if (open) { + // Reset staging when opening picker + setStagedHour(null) + setStagedMinute(null) } - const formattedTime = `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}` - onChange(formattedTime) } + const handleHourClick = (hour: number) => { + setStagedHour(hour) + } + + const handleMinuteClick = (minute: number) => { + setStagedMinute(minute) + } + + const handleApply = () => { + // Use staged values if selected, otherwise keep current values + const finalHour = stagedHour !== null ? stagedHour : pickerHours + const finalMinute = stagedMinute !== null ? stagedMinute : pickerMinutes + const formattedTime = `${String(finalHour).padStart(2, '0')}:${String(finalMinute).padStart(2, '0')}` + onChange(formattedTime) + setIsPickerOpen(false) + } + + // Apply button is enabled when both hour and minute have valid values (either staged or from current value) + const canApply = (stagedHour !== null || pickerHours !== undefined) && + (stagedMinute !== null || pickerMinutes !== undefined) + const getAlignmentClass = () => { switch (align) { case 'left': return 'text-left' @@ -142,7 +184,7 @@ const FormTimeInput = React.forwardRef( )} {...props} /> - + -
-
-
Hour
-
- {Array.from({ length: 24 }, (_, i) => ( - - ))} +
+
+
+
{t('timePickerHour')}
+
+ {Array.from({ length: 24 }, (_, i) => { + const isCurrentValue = pickerHours === i && stagedHour === null + const isStaged = stagedHour === i + return ( + + ) + })} +
+
+
+
{t('timePickerMinute')}
+
+ {Array.from({ length: 12 }, (_, i) => i * 5).map(minute => { + const isCurrentValue = pickerMinutes === minute && stagedMinute === null + const isStaged = stagedMinute === minute + return ( + + ) + })} +
-
-
Min
-
- {Array.from({ length: 12 }, (_, i) => i * 5).map(minute => ( - - ))} -
+
+
{unit && {unit}} - {hasError && showError && errorMessage && ( + {hasError && isFocused && errorMessage && (
{errorMessage}
)} - {hasWarning && showWarning && warningMessage && ( -
+ {hasWarning && isFocused && warningMessage && ( +
{warningMessage}
)} diff --git a/src/locales/de.ts b/src/locales/de.ts index 50f34e3..d10061b 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -92,6 +92,7 @@ export const de = { errorNumberRequired: "Bitte gib eine gültige Zahl ein.", errorTimeRequired: "Bitte gib eine gültige Zeitangabe ein.", warningDuplicateTime: "Mehrere Dosen zur gleichen Zeit.", + warningZeroDose: "Nulldosis hat keine Auswirkung auf die Simulation.", // Day-based schedule regularPlan: "Regulärer Plan", @@ -112,7 +113,12 @@ export const de = { viewingSharedPlan: "Du siehst einen geteilten Plan", saveAsMyPlan: "Als meinen Plan speichern", discardSharedPlan: "Verwerfen", - planCopiedToClipboard: "Plan-Link in Zwischenablage kopiert!" + planCopiedToClipboard: "Plan-Link in Zwischenablage kopiert!", + + // Time picker + timePickerHour: "Stunde", + timePickerMinute: "Minute", + timePickerApply: "Übernehmen" }; export default de; diff --git a/src/locales/en.ts b/src/locales/en.ts index 3ba9af8..cee2e93 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -92,6 +92,12 @@ export const en = { errorNumberRequired: "Please enter a valid number.", errorTimeRequired: "Please enter a valid time.", warningDuplicateTime: "Multiple doses at same time.", + warningZeroDose: "Zero dose has no effect on simulation.", + + // Time picker + timePickerHour: "Hour", + timePickerMinute: "Minute", + timePickerApply: "Apply", // Day-based schedule regularPlan: "Regular Plan", @@ -109,10 +115,10 @@ export const en = { // URL sharing sharePlan: "Share Plan", - viewingSharedPlan: "You are viewing a shared plan", + viewingSharedPlan: "Viewing shared plan", saveAsMyPlan: "Save as My Plan", discardSharedPlan: "Discard", - planCopiedToClipboard: "Plan link copied to clipboard!", + planCopiedToClipboard: "Plan link copied to clipboard!" }; export default en; diff --git a/src/styles/global.css b/src/styles/global.css index 377386f..73ea027 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -10,9 +10,9 @@ --card-foreground: 0 0% 10%; --popover: 0 0% 100%; --popover-foreground: 0 0% 10%; - --primary: 0 0% 15%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 94%; + --primary: 217 91% 60%; + --primary-foreground: 0 0% 100%; + --secondary: 220 15% 88%; --secondary-foreground: 0 0% 15%; --muted: 220 10% 95%; --muted-foreground: 0 0% 45%;