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

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:
2026-04-13 15:29:53 +02:00
parent 6d8a8fa6d6
commit a1122d7666
5 changed files with 92 additions and 15 deletions

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>

View 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");
}

View File

@@ -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, ",");
}