▍ humdrum codex / soft

Settings: refresh interval as value + unit (min/hr) (issue 5)

e54e9567ab7730b34fa6405b45431924db865158
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-09 12:32

parent aa2ed843

Settings: refresh interval as value + unit (min/hr) (issue 5)

Replace the raw-seconds number input with RefreshControl: a number plus
a min/hr unit dropdown. The store still holds refreshSeconds — convert
seconds → friendliest unit on load (whole hours as hr, else min) and
value+unit → seconds on save. Switching unit preserves the real
duration. Editing floors at 15 min / 1 hr; existing sub-15-min values
display truthfully until edited (no silent bumping).

Closes the last open item in ISSUES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

2 files changed

ISSUES.md +8 −6
@@ -48,13 +48,15 @@ - Added a one-line hint under the Sources list explaining this.
 - `components/SettingsView.tsx`. (`addSource`/`removeSource` handlers dropped; the
   `POST /api/sources` + `DELETE /api/sources/[id]` routes remain but are now unused by the UI.)
 
-## 5. Refresh interval — minutes/hours, not just seconds
+## 5. Refresh interval — minutes/hours, not just seconds ✅ done 2026-06-09
 
-The per-source refresh control is raw seconds. Want a value + unit (minutes / hours).
-- `components/SettingsView.tsx` → `SourceRow` (the `refreshSeconds` number input).
-- Store still keeps `refreshSeconds`; convert in the UI (e.g. number + unit dropdown →
-  seconds on save, seconds → friendliest unit on load).
+- ✅ New `RefreshControl` (`components/SettingsView.tsx`): number + unit (min / hr) dropdown.
+- ✅ Store still keeps raw `refreshSeconds`; converts seconds → friendliest unit on load
+  (whole hours show as `hr`, else `min`) and value+unit → seconds on save.
+- ✅ Switching unit preserves the real duration (rounded into the new unit).
+- ✅ Editing floors at 15 min / 1 hr, but existing sub-15-min values display truthfully
+  until the user actually edits them (no silent bumping).
 
 ---
 
-_Logged 2026-05-21. Fix as time allows._
+_Logged 2026-05-21. All five resolved by 2026-06-09._
components/SettingsView.tsx +63 −12
@@ -272,18 +272,10 @@         <span className="flex-1 text-sm font-medium">{source.label}</span>
         <span className="text-xs" style={{ color: "var(--text-faint)" }}>
           {state.hasModule ? (state.configured ? "ready" : "needs config") : "local"}
         </span>
-        <label className="text-xs" style={{ color: "var(--text-muted)" }}>
-          every
-          <input
-            type="number"
-            min={30}
-            defaultValue={source.refreshSeconds}
-            onBlur={(e) => onPatch({ refreshSeconds: Number(e.target.value) })}
-            className="w-16 mx-1 px-1 py-0.5 text-xs"
-            style={inputStyle}
-          />
-          s
-        </label>
+        <RefreshControl
+          seconds={source.refreshSeconds}
+          onChange={(s) => onPatch({ refreshSeconds: s })}
+        />
         <select
           value={source.size}
           onChange={(e) => onPatch({ size: e.target.value })}
@@ -316,6 +308,65 @@         )}
       </div>
       {open && configurable && <SourceConfig state={state} onPatch={onPatch} />}
     </div>
+  );
+}
+
+// Refresh interval as a friendly value + unit (minutes / hours). The store still
+// holds raw refreshSeconds; we convert on load and on save. Floors at 15 min / 1 hr.
+type RefreshUnit = "min" | "hr";
+
+function RefreshControl({
+  seconds,
+  onChange,
+}: {
+  seconds: number;
+  onChange: (seconds: number) => void;
+}) {
+  // A whole number of hours shows as hours; everything else as minutes.
+  const derivedUnit: RefreshUnit = seconds % 3600 === 0 && seconds >= 3600 ? "hr" : "min";
+  const [unit, setUnit] = useState<RefreshUnit>(derivedUnit);
+
+  const floor = (u: RefreshUnit) => (u === "hr" ? 1 : 15);
+  // Display the true stored value (never lie about it); only the floor below
+  // applies when the user actually edits/commits a new interval.
+  const toValue = (s: number, u: RefreshUnit) =>
+    Math.max(1, Math.round(u === "hr" ? s / 3600 : s / 60));
+  const toSeconds = (v: number, u: RefreshUnit) =>
+    Math.max(floor(u), Math.round(v || 0)) * (u === "hr" ? 3600 : 60);
+
+  const [draft, setDraft] = useState(String(toValue(seconds, unit)));
+
+  // Re-sync the draft whenever the stored value or unit changes externally.
+  useEffect(() => setDraft(String(toValue(seconds, unit))), [seconds, unit]);
+
+  function changeUnit(u: RefreshUnit) {
+    setUnit(u); // keep the same real duration, re-expressed in the new unit
+    onChange(toSeconds(toValue(seconds, u), u));
+  }
+
+  return (
+    <label className="text-xs flex items-center gap-1" style={{ color: "var(--text-muted)" }}>
+      every
+      <input
+        type="number"
+        min={floor(unit)}
+        value={draft}
+        onChange={(e) => setDraft(e.target.value)}
+        onBlur={() => onChange(toSeconds(Number(draft), unit))}
+        className="w-14 px-1 py-0.5 text-xs"
+        style={inputStyle}
+      />
+      <select
+        value={unit}
+        onChange={(e) => changeUnit(e.target.value as RefreshUnit)}
+        className="text-xs px-1 py-0.5"
+        style={inputStyle}
+        aria-label="Refresh unit"
+      >
+        <option value="min">min</option>
+        <option value="hr">hr</option>
+      </select>
+    </label>
   );
 }