From 49daba0579df32dd03283b1f415e8d277d384e58 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 3 Feb 2026 07:47:22 +0100 Subject: [PATCH] Fix date picker format issues Issue #233: Respect user date/time format in queue scheduler - Replace native datetime-local input with custom text inputs - Add date parsing/formatting utilities for US, EU, ISO, and system formats - Add calendar button that opens native picker for convenience - Pass dateFormat and timeFormat settings to ScheduleOptionsPanel - Show format-appropriate placeholders (MM/DD/YYYY, DD/MM/YYYY, etc.) Closes #233 --- CHANGELOG.md | 8 + .../components/PrintModal/ScheduleOptions.tsx | 156 ++++++++++++- frontend/src/components/PrintModal/index.tsx | 7 +- frontend/src/components/PrintModal/types.ts | 4 + frontend/src/utils/date.ts | 208 ++++++++++++++++++ 5 files changed, 376 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b9338bb0..21581c67e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,14 @@ All notable changes to Bambuddy will be documented in this file. - Fixed statistics export failing with authentication enabled - Fixed printer file ZIP download failing with authentication enabled - Root cause: These endpoints used raw `fetch()` without Authorization header +- **Queue Schedule Date Picker Ignores User Format Settings** (Issue #233): + - Replaced native datetime picker with custom date/time inputs respecting user settings + - Date input shows in user's format (DD/MM/YYYY for EU, MM/DD/YYYY for US, YYYY-MM-DD for ISO) + - Time input shows in user's format (24H or 12H with AM/PM) + - Calendar button opens native picker for convenience; selection is formatted to user's preference + - Placeholder text shows expected format (e.g., "DD/MM/YYYY" or "HH:MM AM/PM") + - Added date utilities: `formatDateInput`, `parseDateInput`, `getDatePlaceholder` + - Added time utilities: `formatTimeInput`, `parseTimeInput`, `getTimePlaceholder` ## [0.1.6.2] - 2026-02-02 diff --git a/frontend/src/components/PrintModal/ScheduleOptions.tsx b/frontend/src/components/PrintModal/ScheduleOptions.tsx index 56f80c5a6..84fb1f116 100644 --- a/frontend/src/components/PrintModal/ScheduleOptions.tsx +++ b/frontend/src/components/PrintModal/ScheduleOptions.tsx @@ -1,17 +1,117 @@ +import { useState, useEffect, useRef } from 'react'; import { Calendar, Clock, Hand, Power } from 'lucide-react'; -import { getMinDateTime } from '../../utils/amsHelpers'; import type { ScheduleOptionsProps, ScheduleType } from './types'; +import { + formatDateInput, + formatTimeInput, + parseDateInput, + parseTimeInput, + getDatePlaceholder, + getTimePlaceholder, + toDateTimeLocalValue, + type DateFormat, + type TimeFormat, +} from '../../utils/date'; /** * Schedule options component for queue items. * Includes schedule type (ASAP/Scheduled/Queue Only), datetime picker, * and options for require previous success and auto power off. */ -export function ScheduleOptionsPanel({ options, onChange }: ScheduleOptionsProps) { +export function ScheduleOptionsPanel({ + options, + onChange, + dateFormat = 'system', + timeFormat = 'system', +}: ScheduleOptionsProps) { + const [dateValue, setDateValue] = useState(''); + const [timeValue, setTimeValue] = useState(''); + const [isDateValid, setIsDateValid] = useState(true); + const [isTimeValid, setIsTimeValid] = useState(true); + const hiddenInputRef = useRef(null); + const isInitializedRef = useRef(false); + + // Initialize or sync from options.scheduledTime + useEffect(() => { + if (options.scheduleType !== 'scheduled') { + isInitializedRef.current = false; + return; + } + + // Initialize with default time (now + 1 hour) or from existing value + if (!isInitializedRef.current) { + isInitializedRef.current = true; + let date: Date; + + if (options.scheduledTime) { + date = new Date(options.scheduledTime); + if (isNaN(date.getTime())) { + date = new Date(); + date.setHours(date.getHours() + 1, 0, 0, 0); + } + } else { + date = new Date(); + date.setHours(date.getHours() + 1, 0, 0, 0); + // Set initial value + onChange({ ...options, scheduledTime: toDateTimeLocalValue(date) }); + } + + setDateValue(formatDateInput(date, dateFormat as DateFormat)); + setTimeValue(formatTimeInput(date, timeFormat as TimeFormat)); + setIsDateValid(true); + setIsTimeValid(true); + } + }, [options.scheduleType, options.scheduledTime, dateFormat, timeFormat, onChange, options]); + const handleScheduleTypeChange = (scheduleType: ScheduleType) => { onChange({ ...options, scheduleType }); }; + const updateScheduledTime = (newDateValue: string, newTimeValue: string) => { + const parsedDate = parseDateInput(newDateValue, dateFormat as DateFormat); + const parsedTime = parseTimeInput(newTimeValue); + + setIsDateValid(!!parsedDate); + setIsTimeValid(!!parsedTime); + + if (parsedDate && parsedTime) { + parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0); + const now = new Date(); + if (parsedDate > now) { + onChange({ ...options, scheduledTime: toDateTimeLocalValue(parsedDate) }); + } + } + }; + + const handleDateChange = (value: string) => { + setDateValue(value); + updateScheduledTime(value, timeValue); + }; + + const handleTimeChange = (value: string) => { + setTimeValue(value); + updateScheduledTime(dateValue, value); + }; + + // Handle calendar picker selection + const handleCalendarChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (value) { + const date = new Date(value); + if (!isNaN(date.getTime())) { + setDateValue(formatDateInput(date, dateFormat as DateFormat)); + setTimeValue(formatTimeInput(date, timeFormat as TimeFormat)); + setIsDateValid(true); + setIsTimeValid(true); + onChange({ ...options, scheduledTime: value }); + } + } + }; + + const openCalendar = () => { + hiddenInputRef.current?.showPicker(); + }; + return (
{/* Schedule type */} @@ -61,14 +161,58 @@ export function ScheduleOptionsPanel({ options, onChange }: ScheduleOptionsProps {options.scheduleType === 'scheduled' && (
+
+ {/* Date input */} +
+ handleDateChange(e.target.value)} + placeholder={getDatePlaceholder(dateFormat as DateFormat)} + /> + +
+ {/* Time input */} +
+ handleTimeChange(e.target.value)} + placeholder={getTimePlaceholder(timeFormat as TimeFormat)} + /> +
+
+ {/* Hidden datetime-local for calendar picker */} onChange({ ...options, scheduledTime: e.target.value })} - min={getMinDateTime()} - required + onChange={handleCalendarChange} + tabIndex={-1} /> + {(!isDateValid || !isTimeValid) && ( +

+ Please enter a valid date and time +

+ )}
)} diff --git a/frontend/src/components/PrintModal/index.tsx b/frontend/src/components/PrintModal/index.tsx index 6590282c5..2b3878dd1 100644 --- a/frontend/src/components/PrintModal/index.tsx +++ b/frontend/src/components/PrintModal/index.tsx @@ -688,7 +688,12 @@ export function PrintModal({ {/* Schedule options - only for queue modes */} {mode !== 'reprint' && ( - + )} {/* Error message */} diff --git a/frontend/src/components/PrintModal/types.ts b/frontend/src/components/PrintModal/types.ts index b607f9f51..daa3f3a99 100644 --- a/frontend/src/components/PrintModal/types.ts +++ b/frontend/src/components/PrintModal/types.ts @@ -187,4 +187,8 @@ export interface PrintOptionsProps { export interface ScheduleOptionsProps { options: ScheduleOptions; onChange: (options: ScheduleOptions) => void; + /** Date format setting from user preferences */ + dateFormat?: 'system' | 'us' | 'eu' | 'iso'; + /** Time format setting from user preferences */ + timeFormat?: 'system' | '12h' | '24h'; } diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts index f98ed541d..b7f0cedc8 100644 --- a/frontend/src/utils/date.ts +++ b/frontend/src/utils/date.ts @@ -7,6 +7,214 @@ */ export type TimeFormat = 'system' | '12h' | '24h'; +export type DateFormat = 'system' | 'us' | 'eu' | 'iso'; + +/** + * Get the date input placeholder based on format setting. + */ +export function getDatePlaceholder(dateFormat: DateFormat = 'system'): string { + switch (dateFormat) { + case 'us': + return 'MM/DD/YYYY'; + case 'eu': + return 'DD/MM/YYYY'; + case 'iso': + return 'YYYY-MM-DD'; + case 'system': + default: { + // Try to detect system format + const testDate = new Date(2000, 11, 31); // Dec 31, 2000 + const formatted = testDate.toLocaleDateString(); + if (formatted.startsWith('12')) return 'MM/DD/YYYY'; + if (formatted.startsWith('31')) return 'DD/MM/YYYY'; + return 'YYYY-MM-DD'; + } + } +} + +/** + * Get the time input placeholder based on format setting. + */ +export function getTimePlaceholder(timeFormat: TimeFormat = 'system'): string { + switch (timeFormat) { + case '12h': + return 'HH:MM AM/PM'; + case '24h': + return 'HH:MM'; + case 'system': + default: { + // Try to detect system format + const testDate = new Date(2000, 0, 1, 14, 30); + const formatted = testDate.toLocaleTimeString(); + if (formatted.includes('PM') || formatted.includes('AM')) return 'HH:MM AM/PM'; + return 'HH:MM'; + } + } +} + +/** + * Format a Date object to a date string based on format setting. + */ +export function formatDateInput(date: Date, dateFormat: DateFormat = 'system'): string { + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + + switch (dateFormat) { + case 'us': + return `${month}/${day}/${year}`; + case 'eu': + return `${day}/${month}/${year}`; + case 'iso': + return `${year}-${month}-${day}`; + case 'system': + default: + return date.toLocaleDateString(); + } +} + +/** + * Format a Date object to a time string based on format setting. + */ +export function formatTimeInput(date: Date, timeFormat: TimeFormat = 'system'): string { + const hours24 = date.getHours(); + const minutes = String(date.getMinutes()).padStart(2, '0'); + + switch (timeFormat) { + case '12h': { + const hours12 = hours24 % 12 || 12; + const ampm = hours24 < 12 ? 'AM' : 'PM'; + return `${hours12}:${minutes} ${ampm}`; + } + case '24h': + return `${String(hours24).padStart(2, '0')}:${minutes}`; + case 'system': + default: + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } +} + +/** + * Parse a date string based on format setting. + * Returns null if parsing fails. + */ +export function parseDateInput(value: string, dateFormat: DateFormat = 'system'): Date | null { + if (!value) return null; + + let day: number, month: number, year: number; + + try { + switch (dateFormat) { + case 'us': { + // MM/DD/YYYY + const parts = value.split('/'); + if (parts.length !== 3) return null; + month = parseInt(parts[0], 10); + day = parseInt(parts[1], 10); + year = parseInt(parts[2], 10); + break; + } + case 'eu': { + // DD/MM/YYYY + const parts = value.split('/'); + if (parts.length !== 3) return null; + day = parseInt(parts[0], 10); + month = parseInt(parts[1], 10); + year = parseInt(parts[2], 10); + break; + } + case 'iso': { + // YYYY-MM-DD + const parts = value.split('-'); + if (parts.length !== 3) return null; + year = parseInt(parts[0], 10); + month = parseInt(parts[1], 10); + day = parseInt(parts[2], 10); + break; + } + case 'system': + default: { + // Try common formats + const date = new Date(value); + if (!isNaN(date.getTime())) return date; + // Try EU format + const euParts = value.split('/'); + if (euParts.length === 3) { + day = parseInt(euParts[0], 10); + month = parseInt(euParts[1], 10); + year = parseInt(euParts[2], 10); + break; + } + return null; + } + } + + if (isNaN(day) || isNaN(month) || isNaN(year)) return null; + if (month < 1 || month > 12) return null; + if (day < 1 || day > 31) return null; + if (year < 1900 || year > 2100) return null; + + return new Date(year, month - 1, day); + } catch { + return null; + } +} + +/** + * Parse a time string. Handles both 12h (with AM/PM) and 24h formats. + * Returns { hours, minutes } or null if parsing fails. + */ +export function parseTimeInput(value: string): { hours: number; minutes: number } | null { + if (!value) return null; + + try { + const trimmed = value.trim().toUpperCase(); + + // Check for 12h format with AM/PM + const ampmMatch = trimmed.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)?$/i); + if (ampmMatch) { + let hours = parseInt(ampmMatch[1], 10); + const minutes = parseInt(ampmMatch[2], 10); + const ampm = ampmMatch[3]?.toUpperCase(); + + if (ampm === 'PM' && hours < 12) hours += 12; + if (ampm === 'AM' && hours === 12) hours = 0; + + if (hours < 0 || hours > 23) return null; + if (minutes < 0 || minutes > 59) return null; + + return { hours, minutes }; + } + + // Try 24h format HH:MM + const match24 = trimmed.match(/^(\d{1,2}):(\d{2})$/); + if (match24) { + const hours = parseInt(match24[1], 10); + const minutes = parseInt(match24[2], 10); + + if (hours < 0 || hours > 23) return null; + if (minutes < 0 || minutes > 59) return null; + + return { hours, minutes }; + } + + return null; + } catch { + return null; + } +} + +/** + * Convert a Date object to datetime-local input value (ISO format). + */ +export function toDateTimeLocalValue(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; +} /** * Apply time format setting to Intl.DateTimeFormatOptions.