A time box that reads on the clock the user chose

The schedule windows met this account with "12:00 AM", clipped to
"12:00 A!" by a box too narrow for it, while the card above them printed
00:00. Both halves of that are the native control: <input type="time">
renders in the browser's locale, and its am/pm did not fit.

Nothing on the page moves it. lang= was measured rather than assumed —
five inputs set to en-US, en-GB, da-DK, pl-PL and nothing came out
identically wide, because the control follows navigator.languages and not
the document. That is the wall DateField hit for dates, so the answer has
its shape: the typing half is ours, the value stays 24-hour "HH:MM", and
what the box shows follows the setting.

Four digits, the colon inserted as they are typed, the meridiem its own
control rather than something to spell. No picker button: a calendar
earns one, four digits do not, and the browser's popup would have brought
the 12-hour reading back in with it. A half-typed or impossible time
emits nothing rather than the part of it that parses, so a block's Apply
leaves that field out instead of writing a time nobody meant.

format.js exports which clock is in force now, so what prints a time and
what accepts one cannot disagree about it.

The box is 80px because 12:30 measures 66 with its padding and the first
attempt at 64 clipped — which was the complaint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-03 21:13:42 +02:00
co-authored by Claude Opus 5
parent 4ff73e5110
commit dd636bbe03
3 changed files with 165 additions and 10 deletions
+147
View File
@@ -0,0 +1,147 @@
<script setup>
// A time box that reads on the clock the user chose.
//
// The same problem DateField solves for dates, and the same shape of answer.
// `<input type="time">` renders in the *browser's* locale and nothing on the
// page moves it — `lang` included, which was measured rather than assumed: the
// control comes out identically wide whatever it is set to, because it follows
// navigator.languages and not the document. So a Settings → Time format of
// 24-hour still met this account with "12:00 AM" in every schedule field,
// disagreeing with the 00:00 the card beside it printed, and the browser's am/pm
// did not fit the box either.
//
// So the typing half is ours: four digits, masked into the clock in force, with
// the meridiem as its own control rather than something to be spelled. There is
// no picker half — a calendar earns its button, four digits do not, and the
// browser's time popup would have brought the same 12-hour reading back with it.
//
// The value in and out is always 24-hour "HH:MM", which is what the charger's
// schedule commands take and what every caller already had.
import { computed, ref, watch } from "vue";
import { clockIsTwelveHour } from "../lib/format.js";
const props = defineProps({
modelValue: { type: String, default: "" },
disabled: { type: Boolean, default: false },
ariaLabel: { type: String, default: "" },
});
const emit = defineEmits(["update:modelValue"]);
const twelve = computed(() => clockIsTwelveHour());
const pad = (n) => String(n).padStart(2, "0");
// "HH:MM" → its two numbers, or null for anything that is not a time of day.
function parse(value) {
const m = /^(\d{1,2}):(\d{2})$/.exec(String(value || "").trim());
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
return h > 23 || min > 59 ? null : { h, min };
}
// The digits the box shows: the hour as this clock writes it, and the minute.
function toText(value) {
const p = parse(value);
if (!p) return "";
return `${pad(twelve.value ? p.h % 12 || 12 : p.h)}:${pad(p.min)}`;
}
// Whether the value sits in the afternoon. Only consulted on a 12-hour clock,
// where the box cannot say it and the select has to.
function toPm(value) {
const p = parse(value);
return !!p && p.h >= 12;
}
// What the box and the select hold → "HH:MM", or "" while it is still half
// typed. A half-typed time is not a time, and emitting the part of it that
// parses would set the charger's schedule to whatever was passed through on the
// way to the value somebody meant.
function toValue(text, pm) {
const digits = String(text).replace(/\D/g, "");
if (digits.length !== 4) return "";
let h = Number(digits.slice(0, 2));
const min = Number(digits.slice(2));
if (min > 59) return "";
if (twelve.value) {
if (h < 1 || h > 12) return "";
h = (h % 12) + (pm ? 12 : 0);
} else if (h > 23) {
return "";
}
return `${pad(h)}:${pad(min)}`;
}
// Digits regrouped as hh:mm. No trailing colon: it appears with the next digit,
// and adding it early only gives backspace something to fight with.
function mask(text) {
const digits = String(text).replace(/\D/g, "").slice(0, 4);
return digits.length > 2 ? `${digits.slice(0, 2)}:${digits.slice(2)}` : digits;
}
const text = ref(toText(props.modelValue));
const pm = ref(toPm(props.modelValue));
// Only re-render the box when the value it is showing is genuinely a different
// time. Half-typed input emits "" — there is no time yet — and reacting to that
// would wipe the very digits being typed.
watch(
() => props.modelValue,
(value) => {
if (toValue(text.value, pm.value) === (value || "")) return;
text.value = toText(value);
pm.value = toPm(value);
}
);
// Switching the setting in another tab re-lays out what is already in the box,
// rather than leaving one field on the old clock.
watch(twelve, () => {
text.value = toText(props.modelValue);
pm.value = toPm(props.modelValue);
});
function onInput(event) {
const el = event.target;
const masked = mask(el.value);
text.value = masked;
// Written straight to the DOM: Vue skips the patch when the bound value is
// unchanged from the last render, which would leave a stray character the
// user just typed sitting in the box.
el.value = masked;
emit("update:modelValue", toValue(masked, pm.value));
}
function onMeridiem(event) {
pm.value = event.target.value === "pm";
emit("update:modelValue", toValue(text.value, pm.value));
}
</script>
<template>
<span class="flex items-center gap-1">
<input
type="text"
inputmode="numeric"
autocomplete="off"
class="dh-input data w-20 shrink-0 text-center"
:value="text"
:disabled="disabled"
:aria-label="ariaLabel"
placeholder="--:--"
maxlength="5"
@input="onInput"
/>
<select
v-if="twelve"
class="dh-input w-16 shrink-0"
:value="pm ? 'pm' : 'am'"
:disabled="disabled"
:aria-label="ariaLabel"
@change="onMeridiem"
>
<option value="am">am</option>
<option value="pm">pm</option>
</select>
</span>
</template>
+15 -3
View File
@@ -109,13 +109,25 @@ export function formatTime(value) {
const pad = (n) => String(n).padStart(2, "0");
const mm = pad(d.getMinutes());
const mode = prefs.timeFormat;
const twelve = mode === "12" || (mode !== "24" && regionReadsTwelveHour());
if (!twelve) return `${pad(h)}:${mm}`;
if (!clockIsTwelveHour()) return `${pad(h)}:${mm}`;
// 12 for both noon and midnight, and midnight is the am one.
return `${pad(h % 12 || 12)}:${mm} ${h < 12 ? "am" : "pm"}`;
}
// Whether times are written on a 12-hour clock right now: what the setting says
// outright, or what the region says when it is left on auto.
//
// Exported because printing a time is not the only thing that has to know. A
// control that lets somebody *enter* one has to offer the same clock, and a box
// that reads 13:45 beside a picker that says 01:45 PM is the disagreement this
// setting exists to end (see components/TimeField.vue).
export function clockIsTwelveHour() {
const mode = prefs.timeFormat;
if (mode === "12") return true;
if (mode === "24") return false;
return regionReadsTwelveHour();
}
// Whether the chosen region tells the time on a 12-hour clock — the one question
// "auto" asks it. Cached because this is asked once per timestamp on a page that
// can hold a great many, and the answer only changes when the region does.
+3 -7
View File
@@ -5,6 +5,7 @@ import { prefs } from "../prefs";
import { askConfirm } from "../lib/confirm.js";
import { api } from "../api";
import { formatDateTime } from "../lib/format.js";
import TimeField from "../components/TimeField.vue";
import { CHARGING_TABS, defaultTabFor } from "../lib/tabs.js";
import ChargerImportModal from "../components/ChargerImportModal.vue";
@@ -2592,18 +2593,13 @@ onMounted(async () => {
</span>
<span v-else class="flex shrink-0 items-center gap-1">
<input
:id="`set-${f.from}`"
<TimeField
v-model="mqttDraft[f.from]"
type="time"
class="dh-input w-28"
:aria-label="`${t(`charging.modbus.${f.label}`)} — ${t('charging.modbus.windowStart')}`"
/>
<span class="text-[11px] text-muted"></span>
<input
<TimeField
v-model="mqttDraft[f.to]"
type="time"
class="dh-input w-28"
:aria-label="`${t(`charging.modbus.${f.label}`)} — ${t('charging.modbus.windowEnd')}`"
/>
</span>