import { useEffect, useMemo, useRef, useState } from "react"; import { Group, NumberInput, Select, Stack } from "@mantine/core"; export type DurationUnit = "minutes" | "hours" | "days"; const UNIT_MINUTES: Record = { minutes: 1, hours: 60, days: 1440, }; const UNIT_OPTIONS: { value: DurationUnit; label: string }[] = [ { value: "minutes", label: "min" }, { value: "hours", label: "hr" }, { value: "days", label: "day" }, ]; /** Convert a value expressed in `from` units to `to` units. */ function convert(value: number, from: DurationUnit, to: DurationUnit): number { return (value * UNIT_MINUTES[from]) / UNIT_MINUTES[to]; } /** Pick the largest unit that keeps a value a clean-ish whole number, so a * stored 0.0667h loads back as "4 min" rather than "0.0667 hr". */ function bestDisplayUnit(minutes: number): DurationUnit { if (minutes <= 0) return "minutes"; if (minutes % 1440 === 0) return "days"; if (minutes % 60 === 0) return "hours"; return "minutes"; } export interface DurationFieldProps { label: string; description?: string; /** Current value, expressed in `nativeUnit` (what the API/DB stores). */ value: number | string; /** The unit the parent stores/sends. The field converts to this on change. */ nativeUnit: DurationUnit; /** Called with the value converted back to `nativeUnit` (or "" when blank). */ onChange: (nativeValue: number | "") => void; /** Smallest allowed value, in `nativeUnit`. */ min?: number; disabled?: boolean; } export default function DurationField({ label, description, value, nativeUnit, onChange, min, disabled, }: DurationFieldProps) { const nativeMinutes = useMemo(() => { const num = value === "" || value == null ? NaN : Number(value); return Number.isFinite(num) ? num * UNIT_MINUTES[nativeUnit] : NaN; }, [value, nativeUnit]); // Display unit is user-driven; seed it from the incoming value once. const [unit, setUnit] = useState(() => Number.isFinite(nativeMinutes) ? bestDisplayUnit(nativeMinutes) : nativeUnit, ); // The value usually arrives async (after the initial "" render), so the // useState seed above runs before it exists. Re-pick the friendliest display // unit the first time a real value shows up — but never again, so the user's // manual unit choice sticks. const seeded = useRef(false); useEffect(() => { if (!seeded.current && Number.isFinite(nativeMinutes)) { seeded.current = true; setUnit(bestDisplayUnit(nativeMinutes)); } }, [nativeMinutes]); const displayValue: number | "" = Number.isFinite(nativeMinutes) ? Number(convert(nativeMinutes, "minutes", unit).toFixed(4)) : ""; const emitNative = (display: number | "", displayUnit: DurationUnit) => { if (display === "" || !Number.isFinite(Number(display))) { onChange(""); return; } const native = convert(Number(display), displayUnit, nativeUnit); onChange(Number(native.toFixed(6))); }; return ( emitNative(v === "" ? "" : Number(v), unit) } clampBehavior="none" allowNegative={false} allowDecimal min={min != null ? convert(min, nativeUnit, unit) : 0} disabled={disabled} style={{ flex: 1 }} />