mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 23:01:06 +02:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* Schedule type */}
|
||||
@@ -61,14 +161,58 @@ export function ScheduleOptionsPanel({ options, onChange }: ScheduleOptionsProps
|
||||
{options.scheduleType === 'scheduled' && (
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
|
||||
<div className="flex gap-2">
|
||||
{/* Date input */}
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
className={`w-full px-3 py-2 pr-10 bg-bambu-dark border rounded-lg text-white focus:outline-none ${
|
||||
isDateValid
|
||||
? 'border-bambu-dark-tertiary focus:border-bambu-green'
|
||||
: 'border-red-500'
|
||||
}`}
|
||||
value={dateValue}
|
||||
onChange={(e) => handleDateChange(e.target.value)}
|
||||
placeholder={getDatePlaceholder(dateFormat as DateFormat)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCalendar}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
|
||||
title="Open calendar"
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Time input */}
|
||||
<div className="w-32">
|
||||
<input
|
||||
type="text"
|
||||
className={`w-full px-3 py-2 bg-bambu-dark border rounded-lg text-white focus:outline-none ${
|
||||
isTimeValid
|
||||
? 'border-bambu-dark-tertiary focus:border-bambu-green'
|
||||
: 'border-red-500'
|
||||
}`}
|
||||
value={timeValue}
|
||||
onChange={(e) => handleTimeChange(e.target.value)}
|
||||
placeholder={getTimePlaceholder(timeFormat as TimeFormat)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Hidden datetime-local for calendar picker */}
|
||||
<input
|
||||
ref={hiddenInputRef}
|
||||
type="datetime-local"
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
className="sr-only"
|
||||
value={options.scheduledTime}
|
||||
onChange={(e) => onChange({ ...options, scheduledTime: e.target.value })}
|
||||
min={getMinDateTime()}
|
||||
required
|
||||
onChange={handleCalendarChange}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{(!isDateValid || !isTimeValid) && (
|
||||
<p className="mt-1 text-xs text-red-400">
|
||||
Please enter a valid date and time
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -688,7 +688,12 @@ export function PrintModal({
|
||||
|
||||
{/* Schedule options - only for queue modes */}
|
||||
{mode !== 'reprint' && (
|
||||
<ScheduleOptionsPanel options={scheduleOptions} onChange={setScheduleOptions} />
|
||||
<ScheduleOptionsPanel
|
||||
options={scheduleOptions}
|
||||
onChange={setScheduleOptions}
|
||||
dateFormat={settings?.date_format || 'system'}
|
||||
timeFormat={settings?.time_format || 'system'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -23,7 +23,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-POsspFOS.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-lqLnkE85.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CPqcJWwC.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user