Skip to main content

dioxus_ui_system/molecules/
time_picker.rs

1//! Time Picker molecule component
2//!
3//! A time selection component with scrollable columns for hours, minutes,
4//! and optionally seconds. Supports 12h (AM/PM) and 24h formats.
5
6use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10/// Time picker properties
11#[derive(Props, Clone, PartialEq)]
12pub struct TimePickerProps {
13    /// Current time value (HH:MM or HH:MM:SS format)
14    #[props(default)]
15    pub value: Option<String>,
16    /// Change handler called when time changes
17    pub on_change: EventHandler<Option<String>>,
18    /// Use 24-hour format (default: true, false for AM/PM)
19    #[props(default = true)]
20    pub use_24h: bool,
21    /// Show seconds column
22    #[props(default = false)]
23    pub show_seconds: bool,
24    /// Disabled state
25    #[props(default = false)]
26    pub disabled: bool,
27    /// Minute step interval (default: 1, e.g., 15 for quarters)
28    #[props(default = 1)]
29    pub minute_step: u32,
30    /// Placeholder text
31    #[props(default)]
32    pub placeholder: Option<String>,
33    /// Label text
34    #[props(default)]
35    pub label: Option<String>,
36    /// Error message
37    #[props(default)]
38    pub error: Option<String>,
39    /// Additional CSS classes
40    #[props(default)]
41    pub class: Option<String>,
42}
43
44/// Parsed time components
45#[derive(Clone, PartialEq, Debug)]
46struct TimeValue {
47    hour: u32,
48    minute: u32,
49    second: u32,
50    is_pm: bool,
51}
52
53impl TimeValue {
54    fn from_string(value: &str, use_24h: bool) -> Option<Self> {
55        let parts: Vec<&str> = value.split(':').collect();
56        if parts.len() < 2 {
57            return None;
58        }
59
60        let hour = parts[0].parse().ok()?;
61        let minute = parts[1].parse().ok()?;
62        let second = if parts.len() > 2 {
63            parts[2].parse().ok()?
64        } else {
65            0
66        };
67
68        let (hour, is_pm) = if use_24h {
69            (hour, false)
70        } else {
71            // Convert from 24h to 12h
72            if hour == 0 {
73                (12, false) // 12 AM
74            } else if hour == 12 {
75                (12, true) // 12 PM
76            } else if hour > 12 {
77                (hour - 12, true)
78            } else {
79                (hour, false)
80            }
81        };
82
83        Some(TimeValue {
84            hour,
85            minute,
86            second,
87            is_pm,
88        })
89    }
90
91    fn to_string(&self, use_24h: bool, show_seconds: bool) -> String {
92        let hour = if use_24h {
93            if self.is_pm && self.hour != 12 {
94                self.hour + 12
95            } else if !self.is_pm && self.hour == 12 {
96                0
97            } else {
98                self.hour
99            }
100        } else {
101            self.hour
102        };
103
104        if show_seconds {
105            format!("{:02}:{:02}:{:02}", hour, self.minute, self.second)
106        } else {
107            format!("{:02}:{:02}", hour, self.minute)
108        }
109    }
110
111    fn now(use_24h: bool) -> Self {
112        // Get current time - in a real app, use system time
113        // For now, default to 12:00
114        TimeValue {
115            hour: if use_24h { 12 } else { 12 },
116            minute: 0,
117            second: 0,
118            is_pm: true,
119        }
120    }
121}
122
123/// Time picker molecule component
124///
125/// # Example
126/// ```rust,ignore
127/// use dioxus_ui_system::molecules::TimePicker;
128///
129/// let mut time = use_signal(|| None::<String>);
130///
131/// rsx! {
132///     TimePicker {
133///         value: time(),
134///         on_change: move |t| time.set(t),
135///         use_24h: true,
136///         show_seconds: false,
137///         minute_step: 15,
138///     }
139/// }
140/// ```
141#[component]
142pub fn TimePicker(props: TimePickerProps) -> Element {
143    let theme = use_theme();
144    let mut is_open = use_signal(|| false);
145
146    // Parse current value
147    let current_time = props
148        .value
149        .as_ref()
150        .and_then(|v| TimeValue::from_string(v, props.use_24h));
151
152    let class_css = props
153        .class
154        .as_ref()
155        .map(|c| format!(" {}", c))
156        .unwrap_or_default();
157
158    let border_color = if props.error.is_some() {
159        theme.tokens.read().colors.destructive.to_rgba()
160    } else if is_open() {
161        theme.tokens.read().colors.primary.to_rgba()
162    } else {
163        theme.tokens.read().colors.border.to_rgba()
164    };
165
166    // Display format
167    let display_value = current_time.as_ref().map(|t| {
168        if props.use_24h {
169            if props.show_seconds {
170                format!("{:02}:{:02}:{:02}", t.hour, t.minute, t.second)
171            } else {
172                format!("{:02}:{:02}", t.hour, t.minute)
173            }
174        } else {
175            let am_pm = if t.is_pm { "PM" } else { "AM" };
176            if props.show_seconds {
177                format!("{:02}:{:02}:{:02} {}", t.hour, t.minute, t.second, am_pm)
178            } else {
179                format!("{:02}:{:02} {}", t.hour, t.minute, am_pm)
180            }
181        }
182    });
183
184    let has_error = props.error.is_some();
185    let is_disabled = props.disabled;
186    let trigger_style = use_style(move |t| {
187        Style::new()
188            .w_full()
189            .h_px(40)
190            .px(&t.spacing, "md")
191            .rounded(&t.radius, "md")
192            .border(
193                1,
194                if has_error {
195                    &t.colors.destructive
196                } else {
197                    &t.colors.border
198                },
199            )
200            .bg(&t.colors.background)
201            .text_color(&t.colors.foreground)
202            .font_size(14)
203            .cursor(if is_disabled {
204                "not-allowed"
205            } else {
206                "pointer"
207            })
208            .inline_flex()
209            .items_center()
210            .justify_between()
211            .transition("all 150ms ease")
212            .build()
213    });
214
215    let on_select_time = {
216        let on_change = props.on_change.clone();
217        let use_24h = props.use_24h;
218        let show_seconds = props.show_seconds;
219        move |time: TimeValue| {
220            let value = time.to_string(use_24h, show_seconds);
221            on_change.call(Some(value));
222            is_open.set(false);
223        }
224    };
225
226    let on_clear = {
227        let on_change = props.on_change.clone();
228        move |e: Event<MouseData>| {
229            e.stop_propagation();
230            on_change.call(None);
231        }
232    };
233
234    let disabled_style = if props.disabled { "opacity: 0.5;" } else { "" };
235    let placeholder_text = props
236        .placeholder
237        .clone()
238        .unwrap_or_else(|| "Select time".to_string());
239
240    rsx! {
241        div {
242            class: "time-picker{class_css}",
243            style: "display: flex; flex-direction: column; gap: 6px; position: relative;",
244
245            if let Some(label) = props.label.clone() {
246                label {
247                    class: "time-picker-label",
248                    style: "font-size: 14px; font-weight: 500; color: {theme.tokens.read().colors.foreground.to_rgba()};",
249                    "{label}"
250                }
251            }
252
253            div {
254                style: "position: relative;",
255
256                button {
257                    type: "button",
258                    class: "time-picker-trigger",
259                    style: "{trigger_style} border-color: {border_color}; {disabled_style}",
260                    disabled: props.disabled,
261                    onclick: move |_| if !props.disabled { is_open.toggle() },
262
263                    if let Some(value) = display_value.clone() {
264                        span { "{value}" }
265                    } else {
266                        span {
267                            style: "color: {theme.tokens.read().colors.muted.to_rgba()};",
268                            "{placeholder_text}"
269                        }
270                    }
271
272                    div {
273                        style: "display: flex; align-items: center; gap: 8px;",
274
275                        if props.value.is_some() {
276                            button {
277                                type: "button",
278                                style: "background: none; border: none; cursor: pointer; font-size: 12px; color: {theme.tokens.read().colors.muted.to_rgba()}; padding: 2px; display: flex; align-items: center; justify-content: center;",
279                                onclick: on_clear,
280                                "✕"
281                            }
282                        }
283
284                        span {
285                            style: "font-size: 14px; color: {theme.tokens.read().colors.muted.to_rgba()}; transition: transform 0.2s;",
286                            style: if is_open() { "transform: rotate(180deg);" } else { "" },
287                            "▼"
288                        }
289                    }
290                }
291
292                if is_open() && !props.disabled {
293                    TimePickerDropdown {
294                        value: current_time.clone(),
295                        use_24h: props.use_24h,
296                        show_seconds: props.show_seconds,
297                        minute_step: props.minute_step,
298                        on_select: on_select_time,
299                        on_close: move || is_open.set(false),
300                    }
301                }
302            }
303
304            if let Some(error) = props.error.clone() {
305                span {
306                    class: "time-picker-error",
307                    style: "font-size: 12px; color: {theme.tokens.read().colors.destructive.to_rgba()};",
308                    "{error}"
309                }
310            }
311        }
312    }
313}
314
315/// Time picker dropdown component
316#[derive(Props, Clone, PartialEq)]
317struct TimePickerDropdownProps {
318    value: Option<TimeValue>,
319    use_24h: bool,
320    show_seconds: bool,
321    minute_step: u32,
322    on_select: EventHandler<TimeValue>,
323    on_close: EventHandler<()>,
324}
325
326#[component]
327fn TimePickerDropdown(props: TimePickerDropdownProps) -> Element {
328    let theme = use_theme();
329
330    // Use signals for each component
331    let initial = props.value.clone().unwrap_or_else(|| TimeValue {
332        hour: if props.use_24h { 12 } else { 12 },
333        minute: 0,
334        second: 0,
335        is_pm: false,
336    });
337
338    let mut hour = use_signal(|| initial.hour);
339    let mut minute = use_signal(|| initial.minute);
340    let mut second = use_signal(|| initial.second);
341    let mut is_pm = use_signal(|| initial.is_pm);
342
343    // Sync with props
344    use_effect(move || {
345        if let Some(v) = &props.value {
346            hour.set(v.hour);
347            minute.set(v.minute);
348            second.set(v.second);
349            is_pm.set(v.is_pm);
350        }
351    });
352
353    let dropdown_style = use_style(|t| {
354        Style::new()
355            .absolute()
356            .top("calc(100% + 4px)")
357            .left("0")
358            .rounded(&t.radius, "md")
359            .border(1, &t.colors.border)
360            .bg(&t.colors.popover)
361            .shadow(&t.shadows.lg)
362            .z_index(9999)
363            .p_px(12)
364            .build()
365    });
366
367    let columns_container_style = use_style(|_| Style::new().flex().gap_px(8).build());
368
369    let column_style = use_style(|_| {
370        Style::new()
371            .flex()
372            .flex_col()
373            .items_center()
374            .gap_px(4)
375            .build()
376    });
377
378    let header_style = use_style(|t| {
379        Style::new()
380            .font_size(11)
381            .font_weight(600)
382            .text_color(&t.colors.muted)
383            .pb_px(4)
384            .build()
385    });
386
387    let scroll_container_style = use_style(|t| {
388        Style::new()
389            .h_px(200)
390            .overflow_auto()
391            .flex()
392            .flex_col()
393            .gap_px(2)
394            .build()
395            + " scrollbar-width: thin; &::-webkit-scrollbar { width: 4px; } &::-webkit-scrollbar-thumb { background: "
396            + &t.colors.border.to_rgba()
397            + "; border-radius: 2px; }"
398    });
399
400    // Generate hour options
401    let hour_options: Vec<u32> = if props.use_24h {
402        (0..24).collect()
403    } else {
404        (1..13).collect()
405    };
406
407    // Generate minute options based on step
408    let minute_options: Vec<u32> = (0..60).step_by(props.minute_step as usize).collect();
409
410    // Generate second options
411    let second_options: Vec<u32> = (0..60).step_by(5).collect();
412
413    let on_select = {
414        let on_select = props.on_select.clone();
415        move || {
416            on_select.call(TimeValue {
417                hour: hour(),
418                minute: minute(),
419                second: second(),
420                is_pm: is_pm(),
421            });
422        }
423    };
424
425    let on_now = {
426        let mut hour = hour.clone();
427        let mut minute = minute.clone();
428        let mut second = second.clone();
429        let mut is_pm = is_pm.clone();
430        let on_select = props.on_select.clone();
431        let use_24h = props.use_24h;
432        move || {
433            let now = TimeValue::now(use_24h);
434            hour.set(now.hour);
435            minute.set(now.minute);
436            second.set(now.second);
437            is_pm.set(now.is_pm);
438            on_select.call(now);
439        }
440    };
441
442    rsx! {
443        // Backdrop to close on outside click - high z-index to capture clicks but below dropdown
444        div {
445            class: "time-picker-backdrop",
446            style: "position: fixed; inset: 0; z-index: 9998;",
447            onclick: move |_| props.on_close.call(()),
448        }
449
450        div {
451            class: "time-picker-dropdown",
452            style: "{dropdown_style}",
453            onclick: move |e: Event<MouseData>| e.stop_propagation(),
454
455            // Columns container
456            div {
457                style: "{columns_container_style}",
458
459                // Hours column
460                div {
461                    style: "{column_style}",
462
463                    span {
464                        style: "{header_style}",
465                        "HH"
466                    }
467
468                    div {
469                        style: "{scroll_container_style}",
470
471                        for h in hour_options {
472                            {
473                                let is_selected = hour() == h;
474                                let bg_color = if is_selected {
475                                    theme.tokens.read().colors.primary.to_rgba()
476                                } else {
477                                    "transparent".to_string()
478                                };
479                                let text_color = if is_selected {
480                                    "white".to_string()
481                                } else {
482                                    theme.tokens.read().colors.foreground.to_rgba()
483                                };
484
485                                rsx! {
486                                    button {
487                                        key: "hour-{h}",
488                                        type: "button",
489                                        style: "width: 48px; height: 32px; border: none; border-radius: 6px; background: {bg_color}; color: {text_color}; font-size: 14px; cursor: pointer; transition: all 100ms ease;",
490                                        onclick: move |_| {
491                                            hour.set(h);
492                                            on_select();
493                                        },
494                                        "{h:02}"
495                                    }
496                                }
497                            }
498                        }
499                    }
500                }
501
502                // Separator
503                div {
504                    style: "display: flex; flex-direction: column; justify-content: center; padding-top: 20px;",
505                    span {
506                        style: "font-size: 14px; font-weight: 600; color: {theme.tokens.read().colors.muted.to_rgba()};",
507                        ":"
508                    }
509                }
510
511                // Minutes column
512                div {
513                    style: "{column_style}",
514
515                    span {
516                        style: "{header_style}",
517                        "MM"
518                    }
519
520                    div {
521                        style: "{scroll_container_style}",
522
523                        for m in minute_options {
524                            {
525                                let is_selected = minute() == m;
526                                let bg_color = if is_selected {
527                                    theme.tokens.read().colors.primary.to_rgba()
528                                } else {
529                                    "transparent".to_string()
530                                };
531                                let text_color = if is_selected {
532                                    "white".to_string()
533                                } else {
534                                    theme.tokens.read().colors.foreground.to_rgba()
535                                };
536
537                                rsx! {
538                                    button {
539                                        key: "minute-{m}",
540                                        type: "button",
541                                        style: "width: 48px; height: 32px; border: none; border-radius: 6px; background: {bg_color}; color: {text_color}; font-size: 14px; cursor: pointer; transition: all 100ms ease;",
542                                        onclick: move |_| {
543                                            minute.set(m);
544                                            on_select();
545                                        },
546                                        "{m:02}"
547                                    }
548                                }
549                            }
550                        }
551                    }
552                }
553
554                // Seconds column (optional)
555                if props.show_seconds {
556                    // Separator
557                    div {
558                        style: "display: flex; flex-direction: column; justify-content: center; padding-top: 20px;",
559                        span {
560                            style: "font-size: 14px; font-weight: 600; color: {theme.tokens.read().colors.muted.to_rgba()};",
561                            ":"
562                        }
563                    }
564
565                    div {
566                        style: "{column_style}",
567
568                        span {
569                            style: "{header_style}",
570                            "SS"
571                        }
572
573                        div {
574                            style: "{scroll_container_style}",
575
576                            for s in second_options {
577                                {
578                                    let is_selected = second() == s;
579                                    let bg_color = if is_selected {
580                                        theme.tokens.read().colors.primary.to_rgba()
581                                    } else {
582                                        "transparent".to_string()
583                                    };
584                                    let text_color = if is_selected {
585                                        "white".to_string()
586                                    } else {
587                                        theme.tokens.read().colors.foreground.to_rgba()
588                                    };
589
590                                    rsx! {
591                                        button {
592                                            key: "second-{s}",
593                                            type: "button",
594                                            style: "width: 48px; height: 32px; border: none; border-radius: 6px; background: {bg_color}; color: {text_color}; font-size: 14px; cursor: pointer; transition: all 100ms ease;",
595                                            onclick: move |_| {
596                                                second.set(s);
597                                                on_select();
598                                            },
599                                            "{s:02}"
600                                        }
601                                    }
602                                }
603                            }
604                        }
605                    }
606                }
607
608                // AM/PM column (for 12h format)
609                if !props.use_24h {
610                    div {
611                        style: "{column_style} margin-left: 4px;",
612
613                        span {
614                            style: "{header_style}",
615                            ""
616                        }
617
618                        div {
619                            style: "{scroll_container_style}",
620
621                            for (label, value) in [("AM", false), ("PM", true)] {
622                                {
623                                    let is_selected = is_pm() == value;
624                                    let bg_color = if is_selected {
625                                        theme.tokens.read().colors.primary.to_rgba()
626                                    } else {
627                                        "transparent".to_string()
628                                    };
629                                    let text_color = if is_selected {
630                                        "white".to_string()
631                                    } else {
632                                        theme.tokens.read().colors.foreground.to_rgba()
633                                    };
634
635                                    rsx! {
636                                        button {
637                                            key: "ampm-{label}",
638                                            type: "button",
639                                            style: "width: 48px; height: 32px; border: none; border-radius: 6px; background: {bg_color}; color: {text_color}; font-size: 12px; cursor: pointer; transition: all 100ms ease; font-weight: 500;",
640                                            onclick: move |_| {
641                                                is_pm.set(value);
642                                                on_select();
643                                            },
644                                            "{label}"
645                                        }
646                                    }
647                                }
648                            }
649                        }
650                    }
651                }
652            }
653
654            // Footer with "Now" button
655            TimePickerNowButton {
656                on_click: on_now,
657            }
658        }
659    }
660}
661
662/// Now button component with hover state
663#[derive(Props, Clone, PartialEq)]
664struct TimePickerNowButtonProps {
665    on_click: EventHandler<()>,
666}
667
668#[component]
669fn TimePickerNowButton(props: TimePickerNowButtonProps) -> Element {
670    let theme = use_theme();
671    let mut is_hovered = use_signal(|| false);
672
673    let bg_color = if is_hovered() {
674        theme.tokens.read().colors.muted.to_rgba()
675    } else {
676        "transparent".to_string()
677    };
678
679    rsx! {
680        div {
681            style: "display: flex; justify-content: center; margin-top: 12px; padding-top: 12px; border-top: 1px solid {theme.tokens.read().colors.border.to_rgba()};",
682
683            button {
684                type: "button",
685                style: "font-size: 13px; font-weight: 500; color: {theme.tokens.read().colors.primary.to_rgba()}; background: {bg_color}; border: none; cursor: pointer; padding: 6px 12px; border-radius: 6px; transition: all 100ms ease;",
686                onmouseenter: move |_| is_hovered.set(true),
687                onmouseleave: move |_| is_hovered.set(false),
688                onclick: move |_| props.on_click.call(()),
689                "Now"
690            }
691        }
692    }
693}
694
695/// Simple time input component with mask
696#[derive(Props, Clone, PartialEq)]
697pub struct TimeInputProps {
698    /// Current time value (HH:MM or HH:MM:SS format)
699    #[props(default)]
700    pub value: Option<String>,
701    /// Change handler
702    pub on_change: EventHandler<Option<String>>,
703    /// Use 24-hour format
704    #[props(default = true)]
705    pub use_24h: bool,
706    /// Show seconds
707    #[props(default = false)]
708    pub show_seconds: bool,
709    /// Disabled state
710    #[props(default = false)]
711    pub disabled: bool,
712    /// Placeholder text
713    #[props(default)]
714    pub placeholder: Option<String>,
715    /// Additional CSS classes
716    #[props(default)]
717    pub class: Option<String>,
718}
719
720/// Time input component with manual text entry
721#[component]
722pub fn TimeInput(props: TimeInputProps) -> Element {
723    let _theme = use_theme();
724    let mut input_value = use_signal(|| props.value.clone().unwrap_or_default());
725
726    // Sync with props
727    use_effect(move || {
728        input_value.set(props.value.clone().unwrap_or_default());
729    });
730
731    let class_css = props
732        .class
733        .as_ref()
734        .map(|c| format!(" {}", c))
735        .unwrap_or_default();
736
737    let input_style = use_style(move |t| {
738        Style::new()
739            .w_full()
740            .h_px(40)
741            .px(&t.spacing, "md")
742            .rounded(&t.radius, "md")
743            .border(1, &t.colors.border)
744            .bg(&t.colors.background)
745            .text_color(&t.colors.foreground)
746            .font_size(14)
747            .cursor(if props.disabled {
748                "not-allowed"
749            } else {
750                "text"
751            })
752            .transition("all 150ms ease")
753            .outline("none")
754            .build()
755    });
756
757    let handle_input = move |e: Event<FormData>| {
758        let value = e.value();
759
760        // Basic validation - allow only digits and colons
761        let filtered: String = value
762            .chars()
763            .filter(|c| c.is_ascii_digit() || *c == ':')
764            .collect();
765
766        // Auto-format as user types
767        let formatted = format_time_input(&filtered, props.show_seconds);
768        input_value.set(formatted.clone());
769
770        // Validate and emit if valid
771        if is_valid_time(&formatted, props.show_seconds) {
772            props.on_change.call(Some(formatted));
773        }
774    };
775
776    let placeholder = if props.show_seconds {
777        props
778            .placeholder
779            .clone()
780            .unwrap_or_else(|| "HH:MM:SS".to_string())
781    } else {
782        props
783            .placeholder
784            .clone()
785            .unwrap_or_else(|| "HH:MM".to_string())
786    };
787
788    rsx! {
789        input {
790            r#type: "text",
791            class: "time-input{class_css}",
792            style: "{input_style}",
793            placeholder: "{placeholder}",
794            value: "{input_value}",
795            disabled: props.disabled,
796            oninput: handle_input,
797        }
798    }
799}
800
801/// Format time input as user types
802fn format_time_input(input: &str, show_seconds: bool) -> String {
803    let digits: String = input.chars().filter(|c| c.is_ascii_digit()).collect();
804
805    if show_seconds {
806        match digits.len() {
807            0..=2 => digits,
808            3..=4 => format!("{}:{}", &digits[..2], &digits[2..]),
809            _ => format!(
810                "{}:{}:{}",
811                &digits[..2],
812                &digits[2..4],
813                &digits[4..6.min(digits.len())]
814            ),
815        }
816    } else {
817        match digits.len() {
818            0..=2 => digits,
819            _ => format!("{}:{}", &digits[..2], &digits[2..4.min(digits.len())]),
820        }
821    }
822}
823
824/// Validate time string
825fn is_valid_time(input: &str, show_seconds: bool) -> bool {
826    let parts: Vec<&str> = input.split(':').collect();
827
828    if show_seconds {
829        if parts.len() != 3 {
830            return false;
831        }
832        if let (Ok(h), Ok(m), Ok(s)) = (
833            parts[0].parse::<u32>(),
834            parts[1].parse::<u32>(),
835            parts[2].parse::<u32>(),
836        ) {
837            h <= 23 && m <= 59 && s <= 59
838        } else {
839            false
840        }
841    } else {
842        if parts.len() != 2 {
843            return false;
844        }
845        if let (Ok(h), Ok(m)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
846            h <= 23 && m <= 59
847        } else {
848            false
849        }
850    }
851}
852
853#[cfg(test)]
854mod tests {
855    use super::*;
856
857    #[test]
858    fn test_time_value_parse() {
859        let time = TimeValue::from_string("14:30", true).unwrap();
860        assert_eq!(time.hour, 14);
861        assert_eq!(time.minute, 30);
862        assert_eq!(time.second, 0);
863        assert!(!time.is_pm);
864    }
865
866    #[test]
867    fn test_time_value_parse_12h() {
868        let time = TimeValue::from_string("14:30", false).unwrap();
869        assert_eq!(time.hour, 2);
870        assert_eq!(time.minute, 30);
871        assert!(time.is_pm);
872    }
873
874    #[test]
875    fn test_time_value_to_string() {
876        let time = TimeValue {
877            hour: 2,
878            minute: 30,
879            second: 0,
880            is_pm: true,
881        };
882        assert_eq!(time.to_string(true, false), "14:30");
883        assert_eq!(time.to_string(false, false), "02:30");
884    }
885
886    #[test]
887    fn test_format_time_input() {
888        assert_eq!(format_time_input("123", false), "12:3");
889        assert_eq!(format_time_input("1234", false), "12:34");
890        assert_eq!(format_time_input("123456", true), "12:34:56");
891    }
892
893    #[test]
894    fn test_is_valid_time() {
895        assert!(is_valid_time("12:30", false));
896        assert!(is_valid_time("23:59", false));
897        assert!(!is_valid_time("24:00", false));
898        assert!(!is_valid_time("12:60", false));
899        assert!(is_valid_time("12:30:45", true));
900        assert!(!is_valid_time("12:30", true)); // missing seconds
901    }
902}