Private/FSvc.Dates.ps1

# Date and timezone helpers. Instants are absolute; wall-clock maths happens in
# the target zone/offset, so the host machine's timezone never changes results.

# Converts an API timestamp (string, DateTime or DateTimeOffset) to a
# DateTimeOffset that preserves the account's UTC offset. $null when absent or
# unparseable.
function ConvertTo-FSDateTimeOffset {
    param([AllowNull()]$Value)
    if ($null -eq $Value) { return $null }
    if ($Value -is [datetimeoffset]) { return $Value }
    if ($Value -is [datetime]) { return [datetimeoffset]$Value }
    $parsed = [datetimeoffset]::MinValue
    if ([datetimeoffset]::TryParse([string]$Value, [ref]$parsed)) { return $parsed }
    return $null
}

# Renders a DateTimeOffset as RFC 3339, using Z for a zero offset.
function Format-Iso8601 {
    param([datetimeoffset]$Value)
    if ($Value.Offset -eq [timespan]::Zero) {
        return $Value.ToString("yyyy-MM-ddTHH:mm:ss") + "Z"
    }
    return $Value.ToString("yyyy-MM-ddTHH:mm:sszzz")
}

# Adds n weekdays, skipping weekends.
function Add-FSvcBusinessDays {
    param([datetimeoffset]$Start, [int]$Days)
    $t = $Start
    $added = 0
    while ($added -lt $Days) {
        $t = $t.AddDays(1)
        if ($t.DayOfWeek -ne [System.DayOfWeek]::Saturday -and $t.DayOfWeek -ne [System.DayOfWeek]::Sunday) {
            $added++
        }
    }
    return $t
}

# Rounds up to the next quarter hour, keeping the timestamp's own offset.
function Round-FSvcQuarterHour {
    param([datetimeoffset]$Value)
    $total = $Value.Hour * 60 + $Value.Minute
    if ($Value.Second -ne 0 -or $Value.Millisecond -ne 0 -or ($total % 15) -ne 0) {
        $total = [math]::Floor($total / 15) * 15 + 15
    }
    $base = [datetimeoffset]::new($Value.Year, $Value.Month, $Value.Day, 0, 0, 0, $Value.Offset)
    return $base.AddMinutes($total)
}

# Business days between two instants, weekends skipped, partial days counting
# fractionally. Both are normalised to one offset so the result is host
# timezone independent.
function Get-FSvcBusinessDaysBetween {
    param([datetimeoffset]$From, [datetimeoffset]$To)
    $from = $From.ToOffset($From.Offset)
    $to = $To.ToOffset($From.Offset)
    if ($to -lt $from) { $swap = $from; $from = $to; $to = $swap }
    $start = [datetimeoffset]::new($from.Year, $from.Month, $from.Day, 0, 0, 0, $from.Offset)
    $end = [datetimeoffset]::new($to.Year, $to.Month, $to.Day, 0, 0, 0, $to.Offset)
    $full = 0.0
    for ($d = $start; $d -lt $end; $d = $d.AddDays(1)) {
        if ($d.DayOfWeek -ne [System.DayOfWeek]::Saturday -and $d.DayOfWeek -ne [System.DayOfWeek]::Sunday) { $full++ }
    }
    $fracFrom = 0.0
    if ($from.DayOfWeek -ne [System.DayOfWeek]::Saturday -and $from.DayOfWeek -ne [System.DayOfWeek]::Sunday) {
        $fracFrom = ($from - $start).TotalMinutes / 1440
    }
    $fracTo = 0.0
    if ($to.DayOfWeek -ne [System.DayOfWeek]::Saturday -and $to.DayOfWeek -ne [System.DayOfWeek]::Sunday) {
        $fracTo = ($to - $end).TotalMinutes / 1440
    }
    return $full - $fracFrom + $fracTo
}

# Resolves a Windows or IANA timezone id to a TimeZoneInfo. $null when empty.
function Resolve-FSvcTimeZone {
    param([string]$Id)
    if (-not $Id) { return $null }
    try { return [System.TimeZoneInfo]::FindSystemTimeZoneById($Id) }
    catch { throw ("Unknown TimeZoneId '{0}'. Use a Windows id (e.g. 'Arabian Standard Time') or IANA id (e.g. 'Asia/Dubai')." -f $Id) }
}

# Parses a "+04:00" style UTC offset. $null when empty.
function ConvertTo-FSvcUtcOffset {
    param([string]$Value)
    if (-not $Value) { return $null }
    try { return [datetimeoffset]::Parse("2000-01-01T00:00:00" + $Value).Offset }
    catch { throw ("Invalid UtcOffset '{0}'. Use a value like '+04:00'." -f $Value) }
}

# Converts an instant to the target timezone (by id) or fixed UTC offset.
function ConvertTo-FSvcTargetZone {
    param([datetimeoffset]$Value, [AllowNull()]$Zone, [AllowNull()]$Offset)
    if ($null -ne $Zone) { return [System.TimeZoneInfo]::ConvertTime($Value, $Zone) }
    if ($null -ne $Offset) { return $Value.ToOffset([timespan]$Offset) }
    return $Value
}

# Computes a planned_end_date from a base instant: convert to the target
# zone/offset, add business days, set the hour, round up, and (when -Now is
# supplied) clamp so the result is always in the future.
function Get-FSvcTargetEndDate {
    param(
        [datetimeoffset]$Base,
        [int]$Days,
        [int]$Hour,
        [AllowNull()]$Zone,
        [AllowNull()]$Offset,
        [AllowNull()]$Now
    )
    $b = ConvertTo-FSvcTargetZone -Value $Base -Zone $Zone -Offset $Offset
    $t = Add-FSvcBusinessDays -Start $b -Days $Days
    $t = [datetimeoffset]::new($t.Year, $t.Month, $t.Day, $Hour, 0, 0, $t.Offset)
    $t = Round-FSvcQuarterHour $t

    if ($null -ne $Now) {
        $n = ConvertTo-FSvcTargetZone -Value $Now -Zone $Zone -Offset $Offset
        if ($t -le $n) {
            $slot = [datetimeoffset]::new($n.Year, $n.Month, $n.Day, $Hour, 0, 0, $n.Offset)
            if ($slot -le $n) { $slot = Add-FSvcBusinessDays -Start $slot -Days 1 }
            $t = $slot
        }
    }
    return $t
}

# True when a ticket's date differs from the computed target (instant compare,
# so equal moments in different offsets do not count as a change).
function Test-FSvcEndDateNeedsUpdate {
    param([AllowNull()]$PlannedEndDate, [datetimeoffset]$Target)
    $cur = ConvertTo-FSDateTimeOffset $PlannedEndDate
    if ($null -eq $cur) { return $true }
    return ($cur.UtcDateTime -ne $Target.UtcDateTime)
}

# True when planned_start_date is unset and created_at can supply it.
function Test-FSvcFillStart {
    param([AllowNull()][string]$PlannedStartDate, [AllowNull()][string]$CreatedAt)
    if ($PlannedStartDate) { return $false }
    if (-not $CreatedAt) { return $false }
    return $true
}