Compare commits

...
2 Commits
Author SHA1 Message Date
tajniak81andClaude Opus 5 dd636bbe03 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>
2026-09-03 21:13:42 +02:00
tajniak81andClaude Opus 5 4ff73e5110 Brightness and the solar floor become sliders too
Both are a place on a short, known range, which is what the slider row
added for the current limit is for. Typing 70 into a box that only accepts
tens was the worse way to say it.

The solar minimum gets no floor note under it. The current limit's says
that below six amps the charger pauses rather than charging slowly, which
is a sentence about a ceiling; this is the least a solar charge will
draw, and the same words would be wrong about it.

Main breaker limit stays a box. Ten to five hundred amps is too wide a
range to aim at with a slider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:01:01 +02:00
3 changed files with 172 additions and 12 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.
+10 -9
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";
@@ -654,7 +655,10 @@ const MQTT_SETTING_BLOCKS = [
fields: [
{ key: "solarBalancing", at: "solarBalancing", label: "solarBalancing", type: "switch" },
{ key: "solarChargeMode", at: "settings.solarChargeMode", label: "solarChargeMode", type: "option", enum: "solarMode", values: [0, 1] },
{ key: "solarMinCurrentA", at: "settings.solarMinCurrentA", label: "solarMinCurrent", type: "number", min: LIMIT_FLOOR, max: 32, step: 1, unit: "A" },
// A slider, like the current limit it shares a floor with. No floor note
// under it though: this is the least a solar charge will draw, not a
// ceiling, so the limit slider's hint would be saying the wrong thing.
{ key: "solarMinCurrentA", at: "settings.solarMinCurrentA", label: "solarMinCurrent", type: "slider", min: LIMIT_FLOOR, max: 32, step: 1, unit: "A" },
// This command offers automatic and single-phase only. The three-phase
// setting is a Modbus register, and offering it here would be offering a
// write that comes back refused.
@@ -666,7 +670,9 @@ const MQTT_SETTING_BLOCKS = [
id: "panel",
title: "blockPanel",
fields: [
{ key: "ledBrightness", at: "ledBrightness", label: "ledBrightness", type: "number", min: 0, max: 100, step: 10, unit: "%" },
// A slider, like the current limit: a brightness is a place on a range,
// and typing 70 into a box that only takes tens is a worse way to say it.
{ key: "ledBrightness", at: "ledBrightness", label: "ledBrightness", type: "slider", min: 0, max: 100, step: 10, unit: "%" },
{ key: "lightOffSchedule", at: "settings.lightOffSchedule", label: "lightOff", type: "switch" },
{ type: "window", label: "lightOffWindow", from: "lightOffStart", to: "lightOffEnd", at: ["settings.lightOffStart", "settings.lightOffEnd"] },
{ key: "swipeUpMode", at: "swipeUpMode", label: "swipeUp", type: "option", enum: "gesture", values: [0, 1, 2, 3] },
@@ -2587,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>