fix(scheduler): address review findings
- JobScheduler: a stored job whose zone lookup throws no longer fails StartAsync; timer re-armed under the lock; a delivery cut off by shutdown isn't recorded (one-offs fire again after restart); CancelJobAsync in one step - Schedules.ParseAt: ISO 8601 with a date only (no machine-clock date for a bare 09:00) - core: a FollowUp prompt that loses the race to a starting run joins it as a follow-up, not steering - Telegram: TimeZoneFor tolerates non-numeric targets; RunPromptAsync waits only when queued; one agent dir - ScriptRunner: bad workspace paths are Failed, not exceptions; zone id helpers share TimeZoneIds.ToIana's rule - tests: no wall-clock sleeps in scheduler tests; transcript-entry, disabled-scheduler and failing-prompt coverage - docs: spec notes for the above; README row for the scheduler
This commit is contained in:
@@ -161,13 +161,17 @@ public static class ClaudeArgs
|
||||
return env;
|
||||
}
|
||||
|
||||
/// <summary>The IANA id for <c>TZ</c>: the zone's own id, a converted Windows id, or null for an unnamed <c>Local</c> zone.</summary>
|
||||
/// <summary>
|
||||
/// The IANA id for <c>TZ</c>: the zone's own id or a converted Windows id, or null when there is none that resolves
|
||||
/// back to a system zone (an unnamed <c>Local</c> zone). Mirrors the core's <c>TimeZoneIds.ToIana</c> (the extension
|
||||
/// references only the Abstractions); keep the two in step.
|
||||
/// </summary>
|
||||
public static string? IanaZoneId(TimeZoneInfo zone)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zone);
|
||||
if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
if (zone.HasIanaId) return zone.Id;
|
||||
return TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
|
||||
var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
|
||||
return id is not null && TimeZoneInfo.TryFindSystemTimeZoneById(id, out _) ? id : null;
|
||||
}
|
||||
|
||||
/// <summary>The allow-listed environment without the denial check (used by diagnostics that only probe the CLI).</summary>
|
||||
|
||||
@@ -27,9 +27,6 @@ public static class JobFormat
|
||||
|
||||
public static string ZoneName(TimeZoneInfo zone) => ZoneNames.Name(zone);
|
||||
|
||||
/// <summary><c>UTC+02:00</c> at <paramref name="at"/>.</summary>
|
||||
public static string Offset(TimeZoneInfo zone, DateTimeOffset at) => ZoneNames.Offset(zone.GetUtcOffset(at));
|
||||
|
||||
/// <summary><c>exit 1</c>, <c>exit 1, timed out</c>, <c>timed out</c>, or <c>did not start</c>.</summary>
|
||||
public static string FailureReason(JobResult result) => result switch
|
||||
{
|
||||
|
||||
@@ -33,6 +33,7 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
private DateTimeOffset? _wakeAt;
|
||||
private bool _stopped;
|
||||
private Task _pendingTick = Task.CompletedTask;
|
||||
private int _firesStarted;
|
||||
|
||||
public JobScheduler(SchedulerOptions options, IScriptRunner runner, ISecretRedactor redactor, TimeProvider time, ILogger<JobScheduler> logger)
|
||||
{
|
||||
@@ -53,6 +54,12 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
/// <summary>When the timer fires next (tests).</summary>
|
||||
internal DateTimeOffset? NextWakeAt { get { lock (_lock) return _wakeAt; } }
|
||||
|
||||
/// <summary>How many fires have been started (tests: a skipped occurrence doesn't count).</summary>
|
||||
internal int FiresStarted { get { lock (_lock) return _firesStarted; } }
|
||||
|
||||
/// <summary>The latest queued tick, without the fires it started (tests; unlike <see cref="IdleAsync"/> it doesn't wait for held fires).</summary>
|
||||
internal Task PendingTick { get { lock (_lock) return _pendingTick; } }
|
||||
|
||||
/// <summary>Load the store, fire overdue one-offs (late), compute cron occurrences strictly after now (no catch-up), arm the timer.</summary>
|
||||
public async Task StartAsync(IJobDelivery delivery, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -69,10 +76,19 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
if (job.Schedule.At is { } at) entry.Next = at;
|
||||
else
|
||||
{
|
||||
entry.Next = NextCron(job, now);
|
||||
var missed = Schedules.NextCron(job.Schedule.Cron!, job.LastRunAt ?? job.CreatedAt, delivery.TimeZoneFor(job.Target));
|
||||
if (missed is { } m && m < now)
|
||||
_logger.LogInformation("Job {Id} ({Name}): occurrences missed while the bot was down are skipped; next {Next}", job.Id, job.Name, entry.Next);
|
||||
try
|
||||
{
|
||||
entry.Next = NextCron(job, now);
|
||||
var missed = Schedules.NextCron(job.Schedule.Cron!, job.LastRunAt ?? job.CreatedAt, delivery.TimeZoneFor(job.Target));
|
||||
if (missed is { } m && m < now)
|
||||
_logger.LogInformation("Job {Id} ({Name}): occurrences missed while the bot was down are skipped; next {Next}", job.Id, job.Name, entry.Next);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// One bad entry never keeps the host down: it stays listed but unarmed until a zone change recomputes it.
|
||||
entry.Next = null;
|
||||
_logger.LogError(ex, "Job {Id} ({Name}): computing the next occurrence on start failed; the job isn't armed", job.Id, job.Name);
|
||||
}
|
||||
}
|
||||
_jobs[job.Id] = entry;
|
||||
}
|
||||
@@ -148,19 +164,25 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Remove a job of <paramref name="target"/>. A running watcher is killed and its outcome dropped; a prompt run in progress isn't aborted. False: unknown, or another target's.</summary>
|
||||
public async Task<bool> CancelAsync(string target, string id, CancellationToken cancellationToken)
|
||||
public async Task<bool> CancelAsync(string target, string id, CancellationToken cancellationToken) =>
|
||||
await CancelJobAsync(target, id, cancellationToken).ConfigureAwait(false) is not null;
|
||||
|
||||
/// <summary><see cref="CancelAsync"/> returning the removed job (null: unknown, or another target's); looked up and removed in one step.</summary>
|
||||
public async Task<ScheduledJob?> CancelJobAsync(string target, string id, CancellationToken cancellationToken)
|
||||
{
|
||||
Fire? running;
|
||||
ScheduledJob removed;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_jobs.TryGetValue(id, out var entry) || entry.Job.Target != target) return false;
|
||||
if (!_jobs.TryGetValue(id, out var entry) || entry.Job.Target != target) return null;
|
||||
removed = entry.Job;
|
||||
_jobs.Remove(id);
|
||||
_inFlight.TryGetValue(id, out running);
|
||||
}
|
||||
if (running is not null) await TryCancelAsync(running.Cancel).ConfigureAwait(false);
|
||||
await PersistAsync(cancellationToken).ConfigureAwait(false);
|
||||
Arm();
|
||||
return true;
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>The target's zone changed: cron jobs get their next occurrence in the new zone (strictly after now), one-offs keep their instant.</summary>
|
||||
@@ -250,10 +272,13 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the wake time and applies it to the timer under the lock, so two concurrent arms can't apply an older
|
||||
/// wake after a newer one. A synchronous callback from <c>Change</c> (zero delay) is safe: <see cref="Lock"/> is
|
||||
/// re-entrant and <see cref="OnTimer"/> only queues a tick.
|
||||
/// </summary>
|
||||
private void Arm()
|
||||
{
|
||||
ITimer timer;
|
||||
TimeSpan delay;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_stopped || _timer is null) return;
|
||||
@@ -262,16 +287,14 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
var wake = soonest is { } s && s - now < MaxTimerDelay ? s : now + MaxTimerDelay;
|
||||
if (wake < now) wake = now;
|
||||
_wakeAt = wake;
|
||||
timer = _timer;
|
||||
delay = wake - now;
|
||||
}
|
||||
try
|
||||
{
|
||||
timer.Change(delay, Timeout.InfiniteTimeSpan); // outside the lock: a zero delay may call back synchronously
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Stopped meanwhile.
|
||||
try
|
||||
{
|
||||
_timer.Change(wake - now, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Stopped meanwhile.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +311,7 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var task = RunFireAsync(gate.Task, job, dueAt, now, cancel);
|
||||
_inFlight[job.Id] = new Fire(task, cancel);
|
||||
_firesStarted++;
|
||||
gate.SetResult();
|
||||
}
|
||||
|
||||
@@ -324,12 +348,14 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
switch (job.Kind)
|
||||
{
|
||||
case JobKind.Reminder:
|
||||
await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Reminder(job, lateDue, zone), ct)).ConfigureAwait(false);
|
||||
result = new JobResult(true, null, false, null, _time.GetUtcNow());
|
||||
result = await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Reminder(job, lateDue, zone), ct)).ConfigureAwait(false)
|
||||
? new JobResult(true, null, false, null, _time.GetUtcNow())
|
||||
: null;
|
||||
break;
|
||||
case JobKind.Prompt:
|
||||
await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.Prompt(job, lateDue, zone), ct)).ConfigureAwait(false);
|
||||
result = new JobResult(true, null, false, null, _time.GetUtcNow());
|
||||
result = await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.Prompt(job, lateDue, zone), ct)).ConfigureAwait(false)
|
||||
? new JobResult(true, null, false, null, _time.GetUtcNow())
|
||||
: null;
|
||||
break;
|
||||
default:
|
||||
result = await RunWatcherAsync(job, delivery, zone, scriptToken).ConfigureAwait(false);
|
||||
@@ -338,7 +364,7 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
if (result is not null) await RecordAsync(job.Id, result).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Null when the run was cancelled (job cancelled or scheduler stopping): the outcome is dropped.</summary>
|
||||
/// <summary>Null when the run or its delivery was cancelled (job cancelled or scheduler stopping): the outcome is dropped.</summary>
|
||||
private async Task<JobResult?> RunWatcherAsync(ScheduledJob job, IJobDelivery delivery, TimeZoneInfo zone, CancellationToken scriptToken)
|
||||
{
|
||||
var timeout = job.TimeoutSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : _options.DefaultScriptTimeout;
|
||||
@@ -359,12 +385,14 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
{
|
||||
case ScriptOutcome.Output output:
|
||||
// The condition is met: exactly one agent prompt. Redacted: watcher stdout isn't a tool result.
|
||||
await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.WatcherMatch(job, _redactor.Redact(output.Text)), ct)).ConfigureAwait(false);
|
||||
return new JobResult(true, 0, false, null, at);
|
||||
return await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.WatcherMatch(job, _redactor.Redact(output.Text)), ct)).ConfigureAwait(false)
|
||||
? new JobResult(true, 0, false, null, at)
|
||||
: null;
|
||||
case ScriptOutcome.Failed failed:
|
||||
var result = new JobResult(false, failed.ExitCode, failed.TimedOut, _redactor.Redact(failed.StderrTail), at);
|
||||
if (!IsRepeat(LastResult(job.Id), result))
|
||||
await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Failure(job, result), ct)).ConfigureAwait(false);
|
||||
if (!IsRepeat(LastResult(job.Id), result)
|
||||
&& !await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Failure(job, result), ct)).ConfigureAwait(false))
|
||||
return null; // the alert was cut off by shutdown: not recorded, so the next failure alerts again
|
||||
return result;
|
||||
default:
|
||||
return new JobResult(true, 0, false, null, at); // silent: nothing but LastRunAt/LastResult
|
||||
@@ -375,17 +403,27 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
private static bool IsRepeat(JobResult? previous, JobResult current) =>
|
||||
previous is { Ok: false } p && p.ExitCode == current.ExitCode && p.TimedOut == current.TimedOut;
|
||||
|
||||
/// <summary>A delivery exception is logged and swallowed; the job's state still advances (no redelivery queue).</summary>
|
||||
private async Task DeliverAsync(ScheduledJob job, Func<CancellationToken, Task> deliver)
|
||||
/// <summary>
|
||||
/// A delivery exception is logged and swallowed; the job's state still advances (no redelivery queue). False only
|
||||
/// when shutdown interrupted the delivery: the fire then isn't recorded, so a one-off stays stored and fires again
|
||||
/// after the next start (at-least-once).
|
||||
/// </summary>
|
||||
private async Task<bool> DeliverAsync(ScheduledJob job, Func<CancellationToken, Task> deliver)
|
||||
{
|
||||
try
|
||||
{
|
||||
await deliver(_stopping.Token).ConfigureAwait(false); // a prompt run isn't aborted by a cancel, only by shutdown
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException) when (_stopping.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Job {Id} ({Name}): delivery interrupted by shutdown; kept for the next start", job.Id, job.Name);
|
||||
return false;
|
||||
}
|
||||
catch (OperationCanceledException) when (_stopping.IsCancellationRequested) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Job {Id} ({Name}): delivery to {Target} failed", job.Id, job.Name, job.Target);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,9 +88,8 @@ public sealed class ScheduleTool(JobScheduler scheduler, string target) : Tool<S
|
||||
{
|
||||
var key = id?.Trim().ToLowerInvariant() ?? "";
|
||||
if (key.Length == 0) throw Error("cancel needs id (see action=list).");
|
||||
var job = scheduler.List(target).FirstOrDefault(v => v.Job.Id == key)?.Job;
|
||||
if (job is null || !await scheduler.CancelAsync(target, key, cancellationToken).ConfigureAwait(false))
|
||||
throw Error($"No job {key} in this chat.");
|
||||
var job = await scheduler.CancelJobAsync(target, key, cancellationToken).ConfigureAwait(false)
|
||||
?? throw Error($"No job {key} in this chat.");
|
||||
return ToolResult.Text($"Cancelled {job.Id} ({job.Name}).");
|
||||
}
|
||||
|
||||
|
||||
@@ -38,19 +38,36 @@ public static partial class Schedules
|
||||
public static DateTimeOffset? NextCron(string cron, DateTimeOffset after, TimeZoneInfo zone) =>
|
||||
ParseCron(cron).GetNextOccurrence(after, zone, inclusive: false);
|
||||
|
||||
/// <summary>ISO 8601. With an offset (or <c>Z</c>) it's used as given; without one it's wall-clock time in <paramref name="zone"/>.</summary>
|
||||
private static readonly string[] WallFormats = ["yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF"];
|
||||
|
||||
/// <summary>
|
||||
/// ISO 8601 only, always with a date: <c>yyyy-MM-dd</c> (local midnight) or <c>yyyy-MM-dd[T| ]HH:mm[:ss[.fff]]</c>, with
|
||||
/// an optional <c>Z</c> or offset. With an offset it's used as given; without one it's wall-clock time in
|
||||
/// <paramref name="zone"/>. A time without a date (<c>09:00</c>) and culture formats (<c>09/10/2026</c>) are rejected:
|
||||
/// they'd be resolved against the machine's clock, not the chat's.
|
||||
/// </summary>
|
||||
public static DateTimeOffset ParseAt(string text, TimeZoneInfo zone)
|
||||
{
|
||||
var trimmed = text?.Trim() ?? "";
|
||||
var hint = $"at \"{trimmed}\" isn't a date-time. Use ISO 8601, e.g. 2026-09-27T09:00 (read in the chat's time zone) or 2026-09-27T09:00+02:00.";
|
||||
if (HasOffset().IsMatch(trimmed))
|
||||
{
|
||||
return DateTimeOffset.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var absolute)
|
||||
? absolute
|
||||
: throw new JobValidationException(hint);
|
||||
}
|
||||
if (!DateTime.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var wall)) throw new JobValidationException(hint);
|
||||
var hint = $"at \"{trimmed}\" isn't a date-time. Use ISO 8601 with the date, e.g. 2026-09-27T09:00 (read in the chat's time zone) or 2026-09-27T09:00+02:00.";
|
||||
var match = IsoAt().Match(trimmed);
|
||||
if (!match.Success) throw new JobValidationException(hint);
|
||||
var local = match.Groups["date"].Value + (match.Groups["time"].Success ? "T" + match.Groups["time"].Value : "T00:00");
|
||||
if (!DateTime.TryParseExact(local, WallFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var wall))
|
||||
throw new JobValidationException(hint);
|
||||
wall = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
|
||||
|
||||
if (match.Groups["offset"] is { Success: true } group)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new DateTimeOffset(wall, ParseOffset(group.Value));
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
throw new JobValidationException(hint); // an offset beyond ±14:00
|
||||
}
|
||||
}
|
||||
// A time skipped by a spring-forward change doesn't exist: move it past the gap (Europe/US gaps are one hour).
|
||||
if (zone.IsInvalidTime(wall)) wall = wall.AddHours(1);
|
||||
// An ambiguous fall-back time is read as the first of the two (the larger offset).
|
||||
@@ -58,7 +75,19 @@ public static partial class Schedules
|
||||
return new DateTimeOffset(wall, offset);
|
||||
}
|
||||
|
||||
/// <summary>A time of day followed by <c>Z</c> or an offset. A bare date such as <c>2026-09-27</c> must not match (its <c>-27</c> isn't an offset).</summary>
|
||||
[GeneratedRegex(@"\d{1,2}:\d{2}(:\d{2}(\.\d+)?)?\s*(Z|[+-]\d{2}(:?\d{2})?)$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex HasOffset();
|
||||
/// <summary><c>Z</c>, <c>+02</c>, <c>+0200</c> or <c>+02:00</c>.</summary>
|
||||
private static TimeSpan ParseOffset(string text)
|
||||
{
|
||||
if (text.Equals("Z", StringComparison.OrdinalIgnoreCase)) return TimeSpan.Zero;
|
||||
var digits = text[1..].Replace(":", "", StringComparison.Ordinal);
|
||||
var hours = int.Parse(digits[..2], NumberStyles.None, CultureInfo.InvariantCulture);
|
||||
var minutes = digits.Length > 2 ? int.Parse(digits[2..], NumberStyles.None, CultureInfo.InvariantCulture) : 0;
|
||||
if (minutes > 59) throw new ArgumentException("minutes", nameof(text));
|
||||
var offset = new TimeSpan(hours, minutes, 0);
|
||||
return text[0] == '-' ? -offset : offset;
|
||||
}
|
||||
|
||||
/// <summary>A date, optionally a time (after <c>T</c> or a space) and then optionally <c>Z</c> or an offset.</summary>
|
||||
[GeneratedRegex(@"^(?<date>\d{4}-\d{2}-\d{2})(?:[T ](?<time>\d{2}:\d{2}(?::\d{2}(?:\.\d{1,7})?)?)\s*(?<offset>Z|[+-]\d{2}(?::?\d{2})?)?)?$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex IsoAt();
|
||||
}
|
||||
|
||||
@@ -75,7 +75,8 @@ public sealed class ScriptRunner(SchedulerOptions options, IProcessSpawnPolicy p
|
||||
Directory.CreateDirectory(spawn.Cwd);
|
||||
process.Start();
|
||||
}
|
||||
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException or UnauthorizedAccessException)
|
||||
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException or UnauthorizedAccessException
|
||||
or ArgumentException or NotSupportedException) // e.g. a workspace path that can't be created
|
||||
{
|
||||
return new ScriptOutcome.Failed(null, $"failed to start {psi.FileName}: {ex.Message}", false);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
/// <summary>Zone ids and offsets for display and <c>TZ</c> (the extension can't reference the core's <c>TimeZoneIds</c>).</summary>
|
||||
/// <summary>
|
||||
/// Zone ids for display and <c>TZ</c>. Mirrors the core's <c>Volcanic.AgenticCore.Hosting.TimeZoneIds</c> (the extension
|
||||
/// references only the Abstractions); keep the two in step.
|
||||
/// </summary>
|
||||
internal static class ZoneNames
|
||||
{
|
||||
/// <summary>The IANA id (converting a Windows id), or null for an unnamed <c>Local</c> zone.</summary>
|
||||
/// <summary>
|
||||
/// The IANA id (converting a Windows id), or null when there is none that resolves back to a system zone (an unnamed
|
||||
/// <c>Local</c> zone: <c>TZ=Local</c> would run the script in UTC). Same rule as <c>TimeZoneIds.ToIana</c>.
|
||||
/// </summary>
|
||||
public static string? Iana(TimeZoneInfo zone)
|
||||
{
|
||||
if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
if (zone.HasIanaId) return zone.Id;
|
||||
return TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
|
||||
var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
|
||||
return id is not null && TimeZoneInfo.TryFindSystemTimeZoneById(id, out _) ? id : null;
|
||||
}
|
||||
|
||||
/// <summary>The IANA id when there is one, else the zone's own id.</summary>
|
||||
/// <summary>The IANA id when there is one, else the zone's own id (<c>TimeZoneIds.Name</c>).</summary>
|
||||
public static string Name(TimeZoneInfo zone) => Iana(zone) ?? zone.Id;
|
||||
|
||||
/// <summary><c>UTC+09:00</c>.</summary>
|
||||
public static string Offset(TimeSpan offset) =>
|
||||
(offset < TimeSpan.Zero ? "UTC-" : "UTC+") + offset.Duration().ToString(@"hh\:mm", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user