Datepicker redesign + DB connection fixes for Prod

This commit is contained in:
AG
2025-12-18 20:49:34 +02:00
parent 3a8f132b91
commit b6cb3059af
10 changed files with 472 additions and 78 deletions

View File

@@ -321,6 +321,7 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
value={birthDate}
onChange={(val) => setBirthDate(val)}
testId="profile-birth-date"
maxDate={new Date()}
/>
</div>
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect, useId } from 'react';
import { createPortal } from 'react-dom';
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from 'lucide-react';
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
import { Button } from './Button';
import { Ripple } from './Ripple';
@@ -12,6 +12,7 @@ interface DatePickerProps {
icon?: React.ReactNode;
disabled?: boolean;
testId?: string;
maxDate?: Date; // Optional maximum date constraint
}
export const DatePicker: React.FC<DatePickerProps> = ({
@@ -21,7 +22,8 @@ export const DatePicker: React.FC<DatePickerProps> = ({
placeholder = 'Select date',
icon = <CalendarIcon size={16} />,
disabled = false,
testId
testId,
maxDate
}) => {
const id = useId();
const [isOpen, setIsOpen] = useState(false);
@@ -30,6 +32,11 @@ export const DatePicker: React.FC<DatePickerProps> = ({
const containerRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
// MD3 Enhancement: Calendar view and text input states
const [calendarView, setCalendarView] = useState<'days' | 'months' | 'years'>('days');
const [textInputValue, setTextInputValue] = useState('');
const [textInputError, setTextInputError] = useState('');
// Update popover position when opening or when window resizes
const updatePosition = () => {
if (containerRef.current) {
@@ -100,6 +107,10 @@ export const DatePicker: React.FC<DatePickerProps> = ({
const nextOpen = !isOpen;
if (nextOpen) {
updatePosition();
// MD3 Enhancement: Reset to days view when opening
setCalendarView('days');
setTextInputValue('');
setTextInputError('');
}
setIsOpen(nextOpen);
}
@@ -122,57 +133,257 @@ export const DatePicker: React.FC<DatePickerProps> = ({
setIsOpen(false);
};
// MD3 Enhancement: Year selection handler
const handleYearSelect = (year: number) => {
setViewDate(new Date(year, viewDate.getMonth(), 1));
setCalendarView('days');
};
// MD3 Enhancement: Text input validation and handling
const validateDateInput = (input: string): boolean => {
// Check format YYYY-MM-DD
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(input)) {
setTextInputError('Format: YYYY-MM-DD');
return false;
}
// Check if date is valid
const date = new Date(input);
if (isNaN(date.getTime())) {
setTextInputError('Invalid date');
return false;
}
// Check if the input matches the parsed date (catches invalid dates like 2023-02-30)
const [year, month, day] = input.split('-').map(Number);
if (date.getFullYear() !== year || date.getMonth() + 1 !== month || date.getDate() !== day) {
setTextInputError('Invalid date');
return false;
}
// Check against maxDate constraint
if (maxDate && date > maxDate) {
setTextInputError('Date cannot be in the future');
return false;
}
setTextInputError('');
return true;
};
const handleTextInputSubmit = () => {
if (validateDateInput(textInputValue)) {
onChange(textInputValue);
setIsOpen(false);
setTextInputValue('');
}
};
const daysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
const firstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
const renderCalendar = () => {
const year = viewDate.getFullYear();
const month = viewDate.getMonth();
const daysCount = daysInMonth(year, month);
const startingDay = firstDayOfMonth(year, month);
const monthName = viewDate.toLocaleString('default', { month: 'long' });
const days = [];
for (let i = 0; i < startingDay; i++) {
days.push(<div key={`empty-${i}`} className="w-10 h-10" />);
}
const monthName = viewDate.toLocaleString('en-US', { month: 'long' });
const shortMonthName = viewDate.toLocaleString('en-US', { month: 'short' });
// Format selected date for header display
const selectedDate = value ? new Date(value) : null;
const isSelected = (d: number) => {
return selectedDate &&
selectedDate.getFullYear() === year &&
selectedDate.getMonth() === month &&
selectedDate.getDate() === d;
};
const headerDateText = selectedDate
? selectedDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
: 'Select date';
const today = new Date();
const isToday = (d: number) => {
return today.getFullYear() === year &&
today.getMonth() === month &&
today.getDate() === d;
};
// Render year selection view
const renderYearsView = () => {
const currentYear = new Date().getFullYear();
const maxYear = maxDate ? maxDate.getFullYear() : currentYear + 100;
const years = [];
// Generate years from current year going backwards (recent first)
for (let y = maxYear; y >= currentYear - 100; y--) {
years.push(y);
}
for (let d = 1; d <= daysCount; d++) {
days.push(
<button
key={d}
type="button"
onClick={() => handleDateSelect(d)}
className={`
relative w-10 h-10 rounded-full flex items-center justify-center text-sm transition-colors
${isSelected(d)
? 'bg-primary text-on-primary font-bold'
: isToday(d)
? 'text-primary border border-primary font-medium'
: 'text-on-surface hover:bg-surface-container-high'
}
`}
>
<Ripple color={isSelected(d) ? 'rgba(255,255,255,0.3)' : 'rgba(var(--primary-rgb), 0.1)'} />
{d}
</button>
const selectedYear = selectedDate?.getFullYear();
return (
<div className="h-64 overflow-y-auto">
<div className="grid grid-cols-3 gap-2 p-2">
{years.map(y => (
<button
key={y}
type="button"
onClick={() => handleYearSelect(y)}
className={`
relative py-3 rounded-lg text-sm transition-colors
${y === year
? 'bg-primary text-on-primary font-bold'
: y === currentYear
? 'text-primary border border-primary font-medium'
: 'text-on-surface hover:bg-surface-container-high'
}
`}
>
<Ripple color={y === year ? 'rgba(255,255,255,0.3)' : 'rgba(var(--primary-rgb), 0.1)'} />
{y}
</button>
))}
</div>
</div>
);
}
};
// Render month selection view
const renderMonthsView = () => {
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
return (
<div className="h-64 overflow-y-auto">
<div className="space-y-1">
{months.map((monthLabel, i) => (
<button
key={i}
type="button"
onClick={() => {
setViewDate(new Date(year, i, 1));
setCalendarView('days');
}}
className={`
relative w-full text-left px-4 py-3 rounded-lg text-sm transition-colors flex items-center gap-2
${i === month
? 'bg-secondary-container text-on-secondary-container font-medium'
: 'text-on-surface hover:bg-surface-container-high'
}
`}
>
{i === month && (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
<span className={i === month ? '' : 'ml-7'}>{monthLabel}</span>
</button>
))}
</div>
</div>
);
};
// Render days view
const renderDaysView = () => {
const days = [];
for (let i = 0; i < startingDay; i++) {
days.push(<div key={`empty-${i}`} className="w-10 h-10" />);
}
const isSelected = (d: number) => {
return selectedDate &&
selectedDate.getFullYear() === year &&
selectedDate.getMonth() === month &&
selectedDate.getDate() === d;
};
const today = new Date();
const isToday = (d: number) => {
return today.getFullYear() === year &&
today.getMonth() === month &&
today.getDate() === d;
};
// Check if a date is disabled (after maxDate)
const isDisabled = (d: number) => {
if (!maxDate) return false;
const checkDate = new Date(year, month, d);
return checkDate > maxDate;
};
for (let d = 1; d <= daysCount; d++) {
const disabled = isDisabled(d);
days.push(
<button
key={d}
type="button"
onClick={() => !disabled && handleDateSelect(d)}
disabled={disabled}
className={`
relative w-10 h-10 rounded-full flex items-center justify-center text-sm transition-colors
${disabled
? 'text-on-surface/30 cursor-not-allowed'
: isSelected(d)
? 'bg-primary text-on-primary font-bold'
: isToday(d)
? 'text-primary border border-primary font-medium'
: 'text-on-surface hover:bg-surface-container-high'
}
`}
>
{!disabled && <Ripple color={isSelected(d) ? 'rgba(255,255,255,0.3)' : 'rgba(var(--primary-rgb), 0.1)'} />}
{d}
</button>
);
}
return (
<>
<div className="flex items-center justify-between mb-4">
{/* Month navigation */}
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={handlePrevMonth} className="h-8 w-8">
<ChevronLeft size={18} />
</Button>
<button
type="button"
onClick={() => setCalendarView('months')}
className="flex items-center gap-1 hover:bg-surface-container rounded px-2 py-1 transition-colors text-sm font-medium text-on-surface"
>
{shortMonthName}
<ChevronDown size={14} />
</button>
<Button variant="ghost" size="icon" onClick={handleNextMonth} className="h-8 w-8">
<ChevronRight size={18} />
</Button>
</div>
{/* Year navigation */}
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={() => setViewDate(new Date(viewDate.getFullYear() - 1, viewDate.getMonth(), 1))} className="h-8 w-8">
<ChevronLeft size={18} />
</Button>
<button
type="button"
onClick={() => setCalendarView('years')}
className="flex items-center gap-1 hover:bg-surface-container rounded px-2 py-1 transition-colors text-sm font-medium text-on-surface"
>
{year}
<ChevronDown size={14} />
</button>
<Button variant="ghost" size="icon" onClick={() => setViewDate(new Date(viewDate.getFullYear() + 1, viewDate.getMonth(), 1))} className="h-8 w-8">
<ChevronRight size={18} />
</Button>
</div>
</div>
<div className="grid grid-cols-7 gap-1 mb-2">
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => (
<div key={i} className="text-center text-[10px] font-bold text-on-surface-variant h-8 flex items-center justify-center uppercase tracking-wider">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{days}
</div>
</>
);
};
const calendarContent = (
<div
@@ -185,32 +396,16 @@ export const DatePicker: React.FC<DatePickerProps> = ({
}}
className="p-4 w-[320px] bg-surface-container-high rounded-2xl shadow-xl border border-outline-variant animate-in fade-in zoom-in duration-200 origin-top"
>
<div className="flex items-center justify-between mb-4">
<div className="flex flex-col">
<span className="text-sm font-medium text-on-surface">{monthName} {year}</span>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="icon" onClick={handlePrevMonth} className="h-8 w-8">
<ChevronLeft size={18} />
</Button>
<Button variant="ghost" size="icon" onClick={handleNextMonth} className="h-8 w-8">
<ChevronRight size={18} />
</Button>
</div>
</div>
<div className="grid grid-cols-7 gap-1 mb-2">
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => (
<div key={i} className="text-center text-[10px] font-bold text-on-surface-variant h-8 flex items-center justify-center uppercase tracking-wider">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{days}
</div>
{/* Calendar content based on view */}
{calendarView === 'years' ? (
renderYearsView()
) : calendarView === 'months' ? (
renderMonthsView()
) : (
renderDaysView()
)}
{/* Footer with Cancel button */}
<div className="mt-4 pt-2 border-t border-outline-variant flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setIsOpen(false)}>
Cancel
@@ -222,17 +417,17 @@ export const DatePicker: React.FC<DatePickerProps> = ({
return createPortal(calendarContent, document.body);
};
const formattedValue = value ? new Date(value).toLocaleDateString() : '';
// Display value in same format as editing (YYYY-MM-DD)
const formattedValue = value || '';
return (
<div className="relative w-full" ref={containerRef}>
<div
className={`
relative group bg-surface-container-high rounded-t-[4px] border-b transition-all min-h-[56px] cursor-pointer
relative group bg-surface-container-high rounded-t-[4px] border-b transition-all min-h-[56px]
${isOpen ? 'border-primary border-b-2' : 'border-on-surface-variant hover:bg-on-surface/10'}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
`}
onClick={handleInputClick}
>
<label className={`
absolute top-2 left-4 text-label-sm font-medium transition-colors flex items-center gap-1
@@ -241,13 +436,60 @@ export const DatePicker: React.FC<DatePickerProps> = ({
{icon} {label}
</label>
<div className="w-full h-[56px] pt-5 pb-1 pl-4 pr-10 text-body-lg text-on-surface flex items-center">
{formattedValue || <span className="text-on-surface-variant/50">{placeholder}</span>}
</div>
<input
type="text"
value={textInputValue || formattedValue}
onChange={(e) => {
setTextInputValue(e.target.value);
setTextInputError('');
}}
onFocus={() => {
// Initialize text input with current value and open calendar
setTextInputValue(value || '');
if (!disabled && !isOpen) {
updatePosition();
setCalendarView('days');
setIsOpen(true);
}
}}
onBlur={() => {
// Validate and apply on blur
if (textInputValue && textInputValue !== value) {
if (validateDateInput(textInputValue)) {
onChange(textInputValue);
setTextInputValue('');
}
} else if (!textInputValue) {
setTextInputValue('');
setTextInputError('');
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (textInputValue && validateDateInput(textInputValue)) {
onChange(textInputValue);
setTextInputValue('');
(e.target as HTMLInputElement).blur();
}
}
}}
placeholder={placeholder}
disabled={disabled}
className="w-full h-[56px] pt-5 pb-1 pl-4 pr-10 text-body-lg text-on-surface bg-transparent focus:outline-none"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-on-surface-variant">
{textInputError && (
<span className="absolute bottom-[-18px] left-4 text-xs text-error">{textInputError}</span>
)}
<button
type="button"
onClick={handleInputClick}
disabled={disabled}
className="absolute right-3 top-1/2 -translate-y-1/2 text-on-surface-variant hover:text-primary p-1 rounded-full transition-colors"
>
<CalendarIcon size={20} />
</div>
</button>
</div>