feat(central-ui): add Min time between runs field to the script form

The template script editor had no input for MinTimeBetweenRuns, so a
WhileTrue trigger configured through the UI always saved a null interval
and degraded to a single edge fire. The Add/Edit Script modal now has a
"Min time between runs" number+unit (ms/sec/min) field.

- Visible only for ValueChange / Conditional / Expression triggers — the
  auto-firing triggers MinTimeBetweenRuns throttles. Hidden for Interval
  (its own period is the cadence), Call (invoked explicitly, never
  throttled), and None.
- For a WhileTrue Conditional/Expression trigger the field is labelled as
  the re-fire interval and shows a warning while it is blank.
- Wired through the new-script and edit-script save paths (edit previously
  only preserved the existing value, never let the user change it).

New DurationInput helper does the TimeSpan <-> number+unit conversion;
ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns classifies trigger
types. Both TDD'd — 21 new tests. CentralUI suite 316 green; verified
end-to-end in the browser (visibility per trigger type, WhileTrue warning,
save/reload round-trip).
This commit is contained in:
Joseph Doherty
2026-05-18 16:44:15 -04:00
parent 437fe154e7
commit 01509a045f
5 changed files with 241 additions and 2 deletions
@@ -0,0 +1,50 @@
using System.Globalization;
namespace ScadaLink.CentralUI.Components.Shared;
/// <summary>
/// Converts a <see cref="TimeSpan"/> to and from the number+unit pair behind a
/// duration input (milliseconds / seconds / minutes). A blank or non-positive
/// number represents "unset" (a <c>null</c> duration).
/// </summary>
internal static class DurationInput
{
/// <summary>The unit tokens a duration input offers, smallest first.</summary>
internal static readonly string[] Units = { "ms", "sec", "min" };
/// <summary>
/// Splits a duration into the largest whole unit that represents it exactly.
/// A null or non-positive duration yields a blank value and the default
/// <c>sec</c> unit.
/// </summary>
internal static (string? Value, string Unit) Split(TimeSpan? duration)
{
if (duration is not { } d || d <= TimeSpan.Zero) return (null, "sec");
var ms = (long)d.TotalMilliseconds;
if (ms % 60000 == 0) return ((ms / 60000).ToString(CultureInfo.InvariantCulture), "min");
if (ms % 1000 == 0) return ((ms / 1000).ToString(CultureInfo.InvariantCulture), "sec");
return (ms.ToString(CultureInfo.InvariantCulture), "ms");
}
/// <summary>
/// Composes a number+unit pair into a duration. A blank, unparseable, or
/// non-positive value yields <c>null</c> (unset).
/// </summary>
internal static TimeSpan? Compose(string? value, string unit)
{
if (!long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)
|| n <= 0)
{
return null;
}
var factorMs = unit switch
{
"min" => 60000L,
"ms" => 1L,
_ => 1000L,
};
return TimeSpan.FromMilliseconds(n * factorMs);
}
}
@@ -81,6 +81,17 @@ internal static class ScriptTriggerConfigCodec
};
}
/// <summary>
/// Whether a trigger type honours the script's <c>MinTimeBetweenRuns</c>.
/// True for the auto-firing triggers it throttles — ValueChange, Conditional,
/// Expression. False for Interval (its own period is the cadence), Call
/// (invoked explicitly, never throttled), and None/Unknown.
/// </summary>
internal static bool SupportsMinTimeBetweenRuns(string? triggerType) =>
ParseKind(triggerType) is ScriptTriggerKind.ValueChange
or ScriptTriggerKind.Conditional
or ScriptTriggerKind.Expression;
/// <summary>Canonical <c>TriggerType</c> string for a kind; null for None/Unknown.</summary>
internal static string? KindToString(ScriptTriggerKind kind) => kind switch
{