Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd636bbe03 | ||
|
|
4ff73e5110 |
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user