kill AM/PM from the schedule picker, enforce iso 8601 24h everywhere
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m1s
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m1s
native <input type="time"> inherits the browser/OS locale; on chrome/macos in en-US that means AM/PM in the settings schedule editor. no attribute turns it off — lang="en-GB" is not honored. swap for a tiny headless TimeInput (two number fields, HH clamp 0..23, MM clamp 0..59) that always emits HH:MM. also drop toLocaleString() in ScanPage, which silently flips thousands separators in de-DE / fr-FR. add formatThousands() that always produces the 1,234,567 form regardless of locale. shared/components/ui/time-input.tsx + shared/lib/utils.ts#formatThousands are now the canonical helpers — every future time/number render must use them. captured as a forward-looking feedback memory so this doesn't regress. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "netfelix-audio-fix",
|
||||
"version": "2026.04.13.7",
|
||||
"version": "2026.04.13.8",
|
||||
"scripts": {
|
||||
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
|
||||
"dev:client": "vite",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Alert } from "~/shared/components/ui/alert";
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { formatThousands } from "~/shared/lib/utils";
|
||||
|
||||
interface ScanStatus {
|
||||
running: boolean;
|
||||
@@ -32,7 +33,7 @@ function StatCard({ label, value, danger }: { label: string; value: number; dang
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg px-3.5 py-2.5">
|
||||
<div className={`text-[1.6rem] font-bold leading-[1.1] ${danger ? "text-red-600" : ""}`}>
|
||||
{value.toLocaleString()}
|
||||
{formatThousands(value)}
|
||||
</div>
|
||||
<div className="text-[0.7rem] text-gray-500 mt-0.5">{label}</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { Input } from "~/shared/components/ui/input";
|
||||
import { Select } from "~/shared/components/ui/select";
|
||||
import { TimeInput } from "~/shared/components/ui/time-input";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { LANG_NAMES } from "~/shared/lib/lang";
|
||||
|
||||
@@ -273,20 +274,10 @@ function WindowEditor({
|
||||
</label>
|
||||
{window.enabled && (
|
||||
<div className="flex items-center gap-2 pl-5">
|
||||
<Input
|
||||
type="time"
|
||||
value={window.start}
|
||||
onChange={(e) => onChange({ ...window, start: e.target.value })}
|
||||
className="w-28"
|
||||
/>
|
||||
<TimeInput value={window.start} onChange={(next) => onChange({ ...window, start: next })} />
|
||||
<span className="text-xs text-gray-500">to</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={window.end}
|
||||
onChange={(e) => onChange({ ...window, end: e.target.value })}
|
||||
className="w-28"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">(overnight ranges wrap midnight)</span>
|
||||
<TimeInput value={window.end} onChange={(next) => onChange({ ...window, end: next })} />
|
||||
<span className="text-xs text-gray-500">(24h, overnight ranges wrap midnight)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
78
src/shared/components/ui/time-input.tsx
Normal file
78
src/shared/components/ui/time-input.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { ChangeEvent } from "react";
|
||||
import { cn } from "~/shared/lib/utils";
|
||||
|
||||
/**
|
||||
* ISO 8601 24-hour time input that never leaks AM/PM, never respects
|
||||
* browser/OS locale, and always emits `HH:MM`. Two plain number fields
|
||||
* with hard clamps — deliberately simpler than a real masked input so
|
||||
* there's nothing to go wrong in foreign locales.
|
||||
*/
|
||||
export function TimeInput({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
}: {
|
||||
/** "HH:MM" — always 24-hour. Empty string is allowed and rendered as blank. */
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const [rawH, rawM] = (value ?? "").split(":");
|
||||
const hh = rawH ?? "";
|
||||
const mm = rawM ?? "";
|
||||
|
||||
const emit = (nextH: string, nextM: string) => {
|
||||
// Clamp + pad on emit; empty segments become "00" so downstream schedulers
|
||||
// always see a parseable "HH:MM". Users can still type single digits while
|
||||
// editing — clamping happens onBlur, not per-keystroke.
|
||||
const h = clamp(nextH, 23);
|
||||
const m = clamp(nextM, 59);
|
||||
onChange(`${pad(h)}:${pad(m)}`);
|
||||
};
|
||||
|
||||
const onHourChange = (e: ChangeEvent<HTMLInputElement>) => emit(e.target.value, mm);
|
||||
const onMinuteChange = (e: ChangeEvent<HTMLInputElement>) => emit(hh, e.target.value);
|
||||
|
||||
return (
|
||||
<div className={cn("inline-flex items-center gap-1 font-mono text-sm", className)}>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={23}
|
||||
step={1}
|
||||
value={hh}
|
||||
disabled={disabled}
|
||||
onChange={onHourChange}
|
||||
aria-label="Hour"
|
||||
className="w-12 border border-gray-300 rounded px-2 py-1 text-center tabular-nums disabled:bg-gray-100 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<span className="text-gray-500 select-none">:</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={59}
|
||||
step={1}
|
||||
value={mm}
|
||||
disabled={disabled}
|
||||
onChange={onMinuteChange}
|
||||
aria-label="Minute"
|
||||
className="w-12 border border-gray-300 rounded px-2 py-1 text-center tabular-nums disabled:bg-gray-100 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clamp(raw: string, max: number): number {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
if (n > max) return max;
|
||||
return n;
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return n.toString().padStart(2, "0");
|
||||
}
|
||||
@@ -4,3 +4,10 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/** Locale-free thousands separator. Always emits the comma form (1,234,567) —
|
||||
* never defers to the browser's locale, which can silently flip to "1.234.567"
|
||||
* in de-DE or "1 234 567" in fr-FR and make numbers unreadable across users. */
|
||||
export function formatThousands(n: number): string {
|
||||
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user