| 1 |
/** |
| 2 |
* Composable function for time field logic |
| 3 |
*/ |
| 4 |
export function useTimeField() { |
| 5 |
function parseTimeString(str: string) { |
| 6 |
if (!str) return { hours: '', minutes: '', amPm: '' } |
| 7 |
const fullMatch = str.match(/^(\d{2}):?(\d{2})?(?:\s*(AM|PM))?$/) |
| 8 |
const minutesAmPmMatch = str.match(/^:(\d{2})\s*(AM|PM)$/) |
| 9 |
const hoursMatch = str.match(/^\d{2}$/) |
| 10 |
const minutesMatch = str.match(/^:(\d{2})$/) |
| 11 |
const ampmMatch = str.match(/^(AM|PM)$/) |
| 12 |
if (fullMatch) { |
| 13 |
return { |
| 14 |
hours: fullMatch[1] || '', |
| 15 |
minutes: fullMatch[2] || '', |
| 16 |
amPm: fullMatch[3] || '', |
| 17 |
} |
| 18 |
} |
| 19 |
if (minutesAmPmMatch) { |
| 20 |
return { |
| 21 |
hours: '', |
| 22 |
minutes: minutesAmPmMatch[1], |
| 23 |
amPm: minutesAmPmMatch[2], |
| 24 |
} |
| 25 |
} |
| 26 |
if (hoursMatch) { |
| 27 |
return { |
| 28 |
hours: str, |
| 29 |
minutes: '', |
| 30 |
amPm: '', |
| 31 |
} |
| 32 |
} |
| 33 |
if (minutesMatch) { |
| 34 |
return { |
| 35 |
hours: '', |
| 36 |
minutes: minutesMatch[1], |
| 37 |
amPm: '', |
| 38 |
} |
| 39 |
} |
| 40 |
if (ampmMatch) { |
| 41 |
return { |
| 42 |
hours: '', |
| 43 |
minutes: '', |
| 44 |
amPm: ampmMatch[1], |
| 45 |
} |
| 46 |
} |
| 47 |
return { hours: '', minutes: '', amPm: '' } |
| 48 |
} |
| 49 |
|
| 50 |
/** |
| 51 |
* Parse time string to total minutes since midnight |
| 52 |
* Handles both 24h and 12h (AM/PM) formats |
| 53 |
* Returns null if time string is malformed |
| 54 |
* |
| 55 |
* @param timeStr - Time string in format "HH:MM" or "HH:MM AM/PM" |
| 56 |
* @returns Total minutes since midnight, or null if parsing fails |
| 57 |
*/ |
| 58 |
function parseTimeToMinutes(timeStr: string): number | null { |
| 59 |
if (!timeStr || typeof timeStr !== 'string') { |
| 60 |
return null |
| 61 |
} |
| 62 |
|
| 63 |
let hours = 0 |
| 64 |
let minutes = 0 |
| 65 |
|
| 66 |
// Handle AM/PM format |
| 67 |
const ampmMatch = timeStr.trim().match(/^(\d{1,2}):(\d{2})\s?(AM|PM|am|pm)$/i) |
| 68 |
if (ampmMatch) { |
| 69 |
hours = parseInt(ampmMatch[1], 10) |
| 70 |
minutes = parseInt(ampmMatch[2], 10) |
| 71 |
const meridiem = ampmMatch[3].toUpperCase() |
| 72 |
|
| 73 |
// Validate hour range for 12h format |
| 74 |
if (hours < 1 || hours > 12) { |
| 75 |
return null |
| 76 |
} |
| 77 |
|
| 78 |
// Convert to 24h format |
| 79 |
if (meridiem === 'PM' && hours !== 12) { |
| 80 |
hours += 12 |
| 81 |
} else if (meridiem === 'AM' && hours === 12) { |
| 82 |
hours = 0 |
| 83 |
} |
| 84 |
} else { |
| 85 |
// Handle 24h format |
| 86 |
const match24h = timeStr.trim().match(/^(\d{1,2}):(\d{2})$/) |
| 87 |
if (match24h) { |
| 88 |
hours = parseInt(match24h[1], 10) |
| 89 |
minutes = parseInt(match24h[2], 10) |
| 90 |
} else { |
| 91 |
return null // Malformed time string |
| 92 |
} |
| 93 |
} |
| 94 |
|
| 95 |
// Validate hour and minute ranges |
| 96 |
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) { |
| 97 |
return null |
| 98 |
} |
| 99 |
|
| 100 |
return hours * 60 + minutes |
| 101 |
} |
| 102 |
|
| 103 |
/** |
| 104 |
* Validate time format based on time format and field type |
| 105 |
* Returns true if time matches the expected format |
| 106 |
*/ |
| 107 |
function isValidTimeFormat(timeStr: string, timeFormat: string, timeFieldType?: string): boolean { |
| 108 |
if (!timeStr || typeof timeStr !== 'string') { |
| 109 |
return false |
| 110 |
} |
| 111 |
|
| 112 |
const trimmed = timeStr.trim() |
| 113 |
|
| 114 |
// Dropdown mode already ensures format, so only validate non-dropdown |
| 115 |
if (timeFieldType === 'dropdown') { |
| 116 |
if (timeFormat === 'ampm') { |
| 117 |
return /^(0?[1-9]|1[0-2]):[0-5]\d ?([AP]M|[ap]m)$/.test(trimmed) |
| 118 |
} else { |
| 119 |
return /^(?:[01]?\d|2[0-3]):[0-5]\d$/.test(trimmed) |
| 120 |
} |
| 121 |
} |
| 122 |
|
| 123 |
// Time-picker mode |
| 124 |
if (timeFieldType === 'time-picker') { |
| 125 |
return /^(?:[01]\d|2[0-3]):[0-5]\d(?:\s?(?:AM|PM|am|pm))?$/.test(trimmed) |
| 126 |
} |
| 127 |
|
| 128 |
// Input mode |
| 129 |
if (timeFormat === 'ampm') { |
| 130 |
return /^(0?[1-9]|1[0-2]):[0-5]\d (AM|PM|am|pm)$/.test(trimmed) |
| 131 |
} else { |
| 132 |
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(trimmed) |
| 133 |
} |
| 134 |
} |
| 135 |
function isTimeInValidRange( |
| 136 |
timeStr: string, |
| 137 |
minTimeValue?: string | null, |
| 138 |
maxTimeValue?: string | null, |
| 139 |
): { isInRange: boolean; error?: string } { |
| 140 |
// Empty or partial values are allowed |
| 141 |
if (!timeStr || timeStr === '__PARTIAL__') { |
| 142 |
return { isInRange: true } |
| 143 |
} |
| 144 |
|
| 145 |
const currentTimeMinutes = parseTimeToMinutes(timeStr) |
| 146 |
if (currentTimeMinutes === null) { |
| 147 |
return { isInRange: false, error: 'Invalid time format' } |
| 148 |
} |
| 149 |
|
| 150 |
const hasMin = minTimeValue !== null && minTimeValue !== undefined && minTimeValue !== '' |
| 151 |
const hasMax = maxTimeValue !== null && maxTimeValue !== undefined && maxTimeValue !== '' |
| 152 |
|
| 153 |
if (!hasMin && !hasMax) { |
| 154 |
return { isInRange: true } |
| 155 |
} |
| 156 |
|
| 157 |
// Parse min time |
| 158 |
let minTimeMinutes: number | null = null |
| 159 |
if (hasMin) { |
| 160 |
minTimeMinutes = parseTimeToMinutes(minTimeValue!) |
| 161 |
if (minTimeMinutes === null) { |
| 162 |
return { isInRange: false, error: `Malformed minimum time value: ${minTimeValue}` } |
| 163 |
} |
| 164 |
} |
| 165 |
|
| 166 |
// Parse max time |
| 167 |
let maxTimeMinutes: number | null = null |
| 168 |
if (hasMax) { |
| 169 |
maxTimeMinutes = parseTimeToMinutes(maxTimeValue!) |
| 170 |
if (maxTimeMinutes === null) { |
| 171 |
return { isInRange: false, error: `Malformed maximum time value: ${maxTimeValue}` } |
| 172 |
} |
| 173 |
} |
| 174 |
|
| 175 |
// Check range |
| 176 |
if (minTimeMinutes !== null && currentTimeMinutes < minTimeMinutes) { |
| 177 |
return { isInRange: false, error: `Time must be at or after ${minTimeValue}` } |
| 178 |
} |
| 179 |
|
| 180 |
if (maxTimeMinutes !== null && currentTimeMinutes > maxTimeMinutes) { |
| 181 |
return { isInRange: false, error: `Time must be at or before ${maxTimeValue}` } |
| 182 |
} |
| 183 |
|
| 184 |
return { isInRange: true } |
| 185 |
} |
| 186 |
|
| 187 |
function formatTimeString(hours: string, minutes: string, amPm: string) { |
| 188 |
if (!hours && !minutes && amPm) return amPm |
| 189 |
if (!hours && minutes && amPm) return `:${minutes} ${amPm}` |
| 190 |
if (hours && minutes) return amPm ? `${hours}:${minutes} ${amPm}` : `${hours}:${minutes}` |
| 191 |
if (hours) return hours |
| 192 |
if (minutes) return `:${minutes}` |
| 193 |
return '' |
| 194 |
} |
| 195 |
|
| 196 |
function getAmPmFromHours(hours: string): 'AM' | 'PM' { |
| 197 |
const h = parseInt(hours, 10) |
| 198 |
return h >= 12 ? 'PM' : 'AM' |
| 199 |
} |
| 200 |
|
| 201 |
function to24hFormat(time: string): string { |
| 202 |
if (!time) return '' |
| 203 |
const match = time.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)?$/i) |
| 204 |
if (!match) return time |
| 205 |
let hours = parseInt(match[1], 10) |
| 206 |
const minutes = match[2] |
| 207 |
const ampm = match[3]?.toUpperCase() |
| 208 |
if (ampm === 'PM' && hours < 12) hours += 12 |
| 209 |
if (ampm === 'AM' && hours === 12) hours = 0 |
| 210 |
return `${hours.toString().padStart(2, '0')}:${minutes}` |
| 211 |
} |
| 212 |
|
| 213 |
return { |
| 214 |
parseTimeString, |
| 215 |
parseTimeToMinutes, |
| 216 |
isValidTimeFormat, |
| 217 |
isTimeInValidRange, |
| 218 |
formatTimeString, |
| 219 |
getAmPmFromHours, |
| 220 |
to24hFormat, |
| 221 |
} |
| 222 |
} |
| 223 |
|