Browse Source

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
maziggy 3 months ago
parent
commit
7e298d8b85

+ 8 - 0
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 statistics export failing with authentication enabled
   - Fixed printer file ZIP download failing with authentication enabled
   - Fixed printer file ZIP download failing with authentication enabled
   - Root cause: These endpoints used raw `fetch()` without Authorization header
   - 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
 ## [0.1.6.2] - 2026-02-02
 
 

+ 150 - 6
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 { Calendar, Clock, Hand, Power } from 'lucide-react';
-import { getMinDateTime } from '../../utils/amsHelpers';
 import type { ScheduleOptionsProps, ScheduleType } from './types';
 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.
  * Schedule options component for queue items.
  * Includes schedule type (ASAP/Scheduled/Queue Only), datetime picker,
  * Includes schedule type (ASAP/Scheduled/Queue Only), datetime picker,
  * and options for require previous success and auto power off.
  * 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) => {
   const handleScheduleTypeChange = (scheduleType: ScheduleType) => {
     onChange({ ...options, 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 (
   return (
     <div className="space-y-4">
     <div className="space-y-4">
       {/* Schedule type */}
       {/* Schedule type */}
@@ -61,14 +161,58 @@ export function ScheduleOptionsPanel({ options, onChange }: ScheduleOptionsProps
       {options.scheduleType === 'scheduled' && (
       {options.scheduleType === 'scheduled' && (
         <div>
         <div>
           <label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
           <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
           <input
+            ref={hiddenInputRef}
             type="datetime-local"
             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}
             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>
         </div>
       )}
       )}
 
 

+ 6 - 1
frontend/src/components/PrintModal/index.tsx

@@ -688,7 +688,12 @@ export function PrintModal({
 
 
             {/* Schedule options - only for queue modes */}
             {/* Schedule options - only for queue modes */}
             {mode !== 'reprint' && (
             {mode !== 'reprint' && (
-              <ScheduleOptionsPanel options={scheduleOptions} onChange={setScheduleOptions} />
+              <ScheduleOptionsPanel
+                options={scheduleOptions}
+                onChange={setScheduleOptions}
+                dateFormat={settings?.date_format || 'system'}
+                timeFormat={settings?.time_format || 'system'}
+              />
             )}
             )}
 
 
             {/* Error message */}
             {/* Error message */}

+ 4 - 0
frontend/src/components/PrintModal/types.ts

@@ -187,4 +187,8 @@ export interface PrintOptionsProps {
 export interface ScheduleOptionsProps {
 export interface ScheduleOptionsProps {
   options: ScheduleOptions;
   options: ScheduleOptions;
   onChange: (options: ScheduleOptions) => void;
   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';
 }
 }

+ 208 - 0
frontend/src/utils/date.ts

@@ -7,6 +7,214 @@
  */
  */
 
 
 export type TimeFormat = 'system' | '12h' | '24h';
 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.
  * Apply time format setting to Intl.DateTimeFormatOptions.

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-lqLnkE85.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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">
     <link rel="stylesheet" crossorigin href="/assets/index-CPqcJWwC.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff