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",
|
"name": "netfelix-audio-fix",
|
||||||
"version": "2026.04.13.7",
|
"version": "2026.04.13.8",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
|
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
|
||||||
"dev:client": "vite",
|
"dev:client": "vite",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Alert } from "~/shared/components/ui/alert";
|
|||||||
import { Badge } from "~/shared/components/ui/badge";
|
import { Badge } from "~/shared/components/ui/badge";
|
||||||
import { Button } from "~/shared/components/ui/button";
|
import { Button } from "~/shared/components/ui/button";
|
||||||
import { api } from "~/shared/lib/api";
|
import { api } from "~/shared/lib/api";
|
||||||
|
import { formatThousands } from "~/shared/lib/utils";
|
||||||
|
|
||||||
interface ScanStatus {
|
interface ScanStatus {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
@@ -32,7 +33,7 @@ function StatCard({ label, value, danger }: { label: string; value: number; dang
|
|||||||
return (
|
return (
|
||||||
<div className="border border-gray-200 rounded-lg px-3.5 py-2.5">
|
<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" : ""}`}>
|
<div className={`text-[1.6rem] font-bold leading-[1.1] ${danger ? "text-red-600" : ""}`}>
|
||||||
{value.toLocaleString()}
|
{formatThousands(value)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[0.7rem] text-gray-500 mt-0.5">{label}</div>
|
<div className="text-[0.7rem] text-gray-500 mt-0.5">{label}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { Button } from "~/shared/components/ui/button";
|
import { Button } from "~/shared/components/ui/button";
|
||||||
import { Input } from "~/shared/components/ui/input";
|
import { Input } from "~/shared/components/ui/input";
|
||||||
import { Select } from "~/shared/components/ui/select";
|
import { Select } from "~/shared/components/ui/select";
|
||||||
|
import { TimeInput } from "~/shared/components/ui/time-input";
|
||||||
import { api } from "~/shared/lib/api";
|
import { api } from "~/shared/lib/api";
|
||||||
import { LANG_NAMES } from "~/shared/lib/lang";
|
import { LANG_NAMES } from "~/shared/lib/lang";
|
||||||
|
|
||||||
@@ -273,20 +274,10 @@ function WindowEditor({
|
|||||||
</label>
|
</label>
|
||||||
{window.enabled && (
|
{window.enabled && (
|
||||||
<div className="flex items-center gap-2 pl-5">
|
<div className="flex items-center gap-2 pl-5">
|
||||||
<Input
|
<TimeInput value={window.start} onChange={(next) => onChange({ ...window, start: next })} />
|
||||||
type="time"
|
|
||||||
value={window.start}
|
|
||||||
onChange={(e) => onChange({ ...window, start: e.target.value })}
|
|
||||||
className="w-28"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-gray-500">to</span>
|
<span className="text-xs text-gray-500">to</span>
|
||||||
<Input
|
<TimeInput value={window.end} onChange={(next) => onChange({ ...window, end: next })} />
|
||||||
type="time"
|
<span className="text-xs text-gray-500">(24h, overnight ranges wrap midnight)</span>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
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