TimeSpan.psm1

[CmdletBinding()]
param()
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath)
$script:PSModuleInfo = Test-ModuleManifest -Path "$PSScriptRoot\$baseName.psd1"
$script:PSModuleInfo | Format-List | Out-String -Stream | ForEach-Object { Write-Debug $_ }
$scriptName = $script:PSModuleInfo.Name
Write-Debug "[$scriptName] - Importing module"
#region [functions] - [private]
Write-Debug "[$scriptName] - [functions] - [private] - Processing folder"
#region [functions] - [private] - [Format-UnitValue]
Write-Debug "[$scriptName] - [functions] - [private] - [Format-UnitValue] - Importing"
function Format-UnitValue {
    <#
        .SYNOPSIS
        Formats a numerical value with its corresponding unit.

        .DESCRIPTION
        This function takes an integer value and a unit and returns a formatted string.
        If the -FullNames switch is specified, the function uses the singular or plural full unit name.
        Otherwise, it returns the value with the unit abbreviation.

        .EXAMPLE
        Format-UnitValue -Value 5 -Unit 'meter'

        Output:
        ```powershell
        5m
        ```

        Returns the formatted value with its abbreviation.

        .EXAMPLE
        Format-UnitValue -Value 1 -Unit 'meter' -FullNames

        Output:
        ```powershell
        1 meter
        ```

        Returns the formatted value with the full singular unit name.

        .EXAMPLE
        Format-UnitValue -Value 2 -Unit 'meter' -FullNames

        Output:
        ```powershell
        2 meters
        ```

        Returns the formatted value with the full plural unit name.

        .OUTPUTS
        string. A formatted string combining the value and its corresponding unit abbreviation or full name.

        .LINK
        https://psmodule.io/Format/Functions/Format-UnitValue/
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param(
        # The numerical value to be formatted with a unit.
        [Parameter(Mandatory)]
        [System.Int128] $Value,

        # The unit type to append to the value.
        [Parameter(Mandatory)]
        [string] $Unit,

        # Switch to use full unit names instead of abbreviations.
        [Parameter()]
        [switch] $FullNames
    )

    if ($FullNames) {
        # Choose singular or plural form based on the value.
        $unitName = if ($Value -eq 1) { $script:UnitMap[$Unit].Singular } else { $script:UnitMap[$Unit].Plural }
        return "$Value $unitName"
    }

    "$Value$($script:UnitMap[$Unit].Abbreviation)"
}
Write-Debug "[$scriptName] - [functions] - [private] - [Format-UnitValue] - Done"
#endregion [functions] - [private] - [Format-UnitValue]
Write-Debug "[$scriptName] - [functions] - [private] - Done"
#endregion [functions] - [private]
#region [functions] - [public]
Write-Debug "[$scriptName] - [functions] - [public] - Processing folder"
#region [functions] - [public] - [completer]
Write-Debug "[$scriptName] - [functions] - [public] - [completer] - Importing"
Register-ArgumentCompleter -CommandName Format-TimeSpan -ParameterName BaseUnit -ScriptBlock {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter)
    $null = $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter
    $($script:UnitMap.Keys) | Where-Object { $_ -like "$wordToComplete*" }
}
Write-Debug "[$scriptName] - [functions] - [public] - [completer] - Done"
#endregion [functions] - [public] - [completer]
#region [functions] - [public] - [Format-TimeSpan]
Write-Debug "[$scriptName] - [functions] - [public] - [Format-TimeSpan] - Importing"
function Format-TimeSpan {
    <#
        .SYNOPSIS
        Formats a TimeSpan object into a human-readable string.

        .DESCRIPTION
        This function converts a TimeSpan object into a formatted string based on a chosen unit or precision.
        It allows specifying a base unit, the number of precision levels, and whether to use full unit names.
        If the TimeSpan is negative, it is prefixed with a minus sign.

        .EXAMPLE
        New-TimeSpan -Minutes 90 | Format-TimeSpan

        Output:
        ```powershell
        2h
        ```

        Formats the given TimeSpan into a human-readable format using the most significant unit.

        .EXAMPLE
        [TimeSpan]::FromSeconds(3661) | Format-TimeSpan -Precision 2 -FullNames

        Output:
        ```powershell
        1 hour 1 minute
        ```

        Returns the TimeSpan formatted into multiple components based on the specified precision.

        .EXAMPLE
        [TimeSpan]::FromMilliseconds(500) | Format-TimeSpan -Precision 2 -FullNames

        Output:
        ```powershell
        500 milliseconds 0 microseconds
        ```

        Forces the output to be formatted in milliseconds, regardless of precision.

        .OUTPUTS
        System.String

        .NOTES
        The formatted string representation of the TimeSpan.

        .LINK
        https://psmodule.io/TimeSpan/Functions/Format-TimeSpan/
    #>

    [CmdletBinding()]
    param(
        # The TimeSpan object to format.
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [TimeSpan] $TimeSpan,

        # Specifies the number of precision levels to include in the output.
        [Parameter()]
        [int] $Precision = 1,

        # Specifies the base unit to use for formatting the TimeSpan.
        [Parameter()]
        [string] $BaseUnit,

        # If specified, outputs full unit names instead of abbreviations.
        [Parameter()]
        [switch] $FullNames
    )

    process {
        $isNegative = $TimeSpan.Ticks -lt 0
        if ($isNegative) {
            $TimeSpan = [System.TimeSpan]::FromTicks(-1 * $TimeSpan.Ticks)
        }
        $originalTicks = $TimeSpan.Ticks

        # Ordered list of units from most to least significant.
        $orderedUnits = @($script:UnitMap.Keys)

        if ($Precision -eq 1) {
            # For precision=1, use the "fractional" approach.
            if ($BaseUnit) {
                $chosenUnit = $BaseUnit
            } else {
                # Pick the most significant unit that fits (unless all are zero).
                $chosenUnit = $null
                foreach ($unit in $orderedUnits) {
                    if (($script:UnitMap.Keys -contains $unit) -and $originalTicks -ge $script:UnitMap[$unit].Ticks) {
                        $chosenUnit = $unit; break
                    }
                }
                if (-not $chosenUnit) { $chosenUnit = 'Microseconds' }
            }

            $fractionalValue = $originalTicks / $script:UnitMap[$chosenUnit].Ticks
            $roundedValue = [math]::Round($fractionalValue, 0, [System.MidpointRounding]::AwayFromZero)
            $formatted = Format-UnitValue -value $roundedValue -unit $chosenUnit -FullNames:$FullNames
            if ($isNegative) { $formatted = "-$formatted" }
            return $formatted
        } else {
            # For multi-component output, perform a sequential breakdown.
            if ($BaseUnit) {
                $startingIndex = $orderedUnits.IndexOf($BaseUnit)
                if ($startingIndex -lt 0) { throw "Invalid BaseUnit value: $BaseUnit" }
            } else {
                $startingIndex = 0
                foreach ($unit in $orderedUnits) {
                    if (($script:UnitMap.Keys -contains $unit) -and $originalTicks -ge $script:UnitMap[$unit].Ticks) { break }
                    $startingIndex++
                }
                if ($startingIndex -ge $orderedUnits.Count) { $startingIndex = $orderedUnits.Count - 1 }
            }

            $resultSegments = @()
            $remainder = $originalTicks
            $endIndex = [Math]::Min($startingIndex + $Precision - 1, $orderedUnits.Count - 1)
            for ($i = $startingIndex; $i -le $endIndex; $i++) {
                $unit = $orderedUnits[$i]
                $unitTicks = $script:UnitMap[$unit].Ticks
                if ($i -eq $endIndex) {
                    $value = [math]::Round($remainder / $unitTicks, 0, [System.MidpointRounding]::AwayFromZero)
                } else {
                    $value = [math]::Floor($remainder / $unitTicks)
                }
                $remainder = $remainder - ($value * $unitTicks)
                $resultSegments += Format-UnitValue -value $value -unit $unit -FullNames:$FullNames
            }
            $formatted = $resultSegments -join ' '
            if ($isNegative) { $formatted = "-$formatted" }
            return $formatted
        }
    }
}
Write-Debug "[$scriptName] - [functions] - [public] - [Format-TimeSpan] - Done"
#endregion [functions] - [public] - [Format-TimeSpan]
Write-Debug "[$scriptName] - [functions] - [public] - Done"
#endregion [functions] - [public]
#region [variables] - [private]
Write-Debug "[$scriptName] - [variables] - [private] - Processing folder"
#region [variables] - [private] - [UnitMap]
Write-Debug "[$scriptName] - [variables] - [private] - [UnitMap] - Importing"
$script:AverageDaysInMonth = 30.436875
$script:AverageDaysInYear = 365.2425
$script:DaysInWeek = 7
$script:HoursInDay = 24

$script:UnitMap = [ordered]@{
    'Millennia'    = @{
        Singular     = 'millennium'
        Plural       = 'millennia'
        Abbreviation = 'mil'
        Ticks        = [System.TimeSpan]::TicksPerDay * $script:AverageDaysInYear * 1000
    }
    'Centuries'    = @{
        Singular     = 'century'
        Plural       = 'centuries'
        Abbreviation = 'cent'
        Ticks        = [System.TimeSpan]::TicksPerDay * $script:AverageDaysInYear * 100
    }
    'Decades'      = @{
        Singular     = 'decade'
        Plural       = 'decades'
        Abbreviation = 'dec'
        Ticks        = [System.TimeSpan]::TicksPerDay * $script:AverageDaysInYear * 10
    }
    'Years'        = @{
        Singular     = 'year'
        Plural       = 'years'
        Abbreviation = 'y'
        Ticks        = [System.TimeSpan]::TicksPerDay * $script:AverageDaysInYear
    }
    'Months'       = @{
        Singular     = 'month'
        Plural       = 'months'
        Abbreviation = 'mo'
        Ticks        = [System.TimeSpan]::TicksPerDay * $script:AverageDaysInMonth
    }
    'Weeks'        = @{
        Singular     = 'week'
        Plural       = 'weeks'
        Abbreviation = 'w'
        Ticks        = [System.TimeSpan]::TicksPerDay * $script:DaysInWeek
    }
    'Days'         = @{
        Singular     = 'day'
        Plural       = 'days'
        Abbreviation = 'd'
        Ticks        = [System.TimeSpan]::TicksPerDay
    }
    'Hours'        = @{
        Singular     = 'hour'
        Plural       = 'hours'
        Abbreviation = 'h'
        Ticks        = [System.TimeSpan]::TicksPerHour
    }
    'Minutes'      = @{
        Singular     = 'minute'
        Plural       = 'minutes'
        Abbreviation = 'min'
        Ticks        = [System.TimeSpan]::TicksPerMinute
    }
    'Seconds'      = @{
        Singular     = 'second'
        Plural       = 'seconds'
        Abbreviation = 's'
        Ticks        = [System.TimeSpan]::TicksPerSecond
    }
    'Milliseconds' = @{
        Singular     = 'millisecond'
        Plural       = 'milliseconds'
        Abbreviation = 'ms'
        Ticks        = [System.TimeSpan]::TicksPerMillisecond
    }
    'Microseconds' = @{
        Singular     = 'microsecond'
        Plural       = 'microseconds'
        Abbreviation = "$([char]0x00B5)s"
        Ticks        = 10
    }
}
Write-Debug "[$scriptName] - [variables] - [private] - [UnitMap] - Done"
#endregion [variables] - [private] - [UnitMap]
Write-Debug "[$scriptName] - [variables] - [private] - Done"
#endregion [variables] - [private]

#region Member exporter
$exports = @{
    Alias    = '*'
    Cmdlet   = ''
    Function = 'Format-TimeSpan'
    Variable = ''
}
Export-ModuleMember @exports
#endregion Member exporter