namespace JSMR.Infrastructure.Common.Time; public interface ITimeProvider { DateTimeOffset Now(); DateTimeOffset Local(int year, int month, int day, int hour); DateTimeOffset Local(DateTimeOffset offset); } public abstract class TimeProvider : ITimeProvider { protected abstract string Id { get; } protected abstract string[] TimeZoneIds { get; } private readonly IClock _clock; private readonly TimeZoneInfo _timeZone; public TimeProvider(IClock clock) { _clock = clock; _timeZone = ResolveTimeZone(); } private TimeZoneInfo ResolveTimeZone() { foreach (string timeZoneId in TimeZoneIds) { if (TimeZoneInfo.TryFindSystemTimeZoneById(timeZoneId, out TimeZoneInfo? timeZoneInfo)) return timeZoneInfo; } throw new TimeZoneNotFoundException($"Unable to resolve time zone for: {Id} ({string.Join(" / ", TimeZoneIds)})"); } public DateTimeOffset Now() => TimeZoneInfo.ConvertTime(_clock.UtcNow, _timeZone); public DateTimeOffset Local(DateTimeOffset offset) => TimeZoneInfo.ConvertTime(offset, _timeZone); public DateTimeOffset Local(int year, int month, int day, int hour) { DateTime local = new(year, month, day, hour, 0, 0, DateTimeKind.Unspecified); TimeSpan offset = _timeZone.GetUtcOffset(local); return new DateTimeOffset(local, offset); } public DateTimeOffset CurrentScanAnchor() { DateTimeOffset now = Now(); DateTimeOffset midnight = Local(now.Year, now.Month, now.Day, 0); DateTimeOffset fourPm = Local(now.Year, now.Month, now.Day, 16); return now >= fourPm ? fourPm : midnight; } public DateTimeOffset PreviousScanAnchor(DateTimeOffset scanAnchorTokyo) { // Normalize to Tokyo (no-op if already) var a = TimeZoneInfo.ConvertTime(scanAnchorTokyo, _timeZone); return a.Hour == 16 ? Local(a.Year, a.Month, a.Day, 0) : Local(a.AddDays(-1).Year, a.AddDays(-1).Month, a.AddDays(-1).Day, 16); } } public class TokyoTimeProvider(IClock clock) : TimeProvider(clock) { protected override string Id => "Tokyo Standard Time"; protected override string[] TimeZoneIds => ["Tokyo Standard Time", "Asia/Tokyo"]; }