Public/Get-GarminBadge.ps1

<#
.SYNOPSIS
Fetches earned, available or non-completed Garmin Connect badges and summarizes them by year, month or name.
 
.DESCRIPTION
`Get-GarminBadge` calls the Garmin Connect API directly from PowerShell. Authentication
uses the DI OAuth2 token store written by `Get-GarminToken` (`gctoken.json`). An access
token that expires within 15 minutes is refreshed automatically and saved back.
 
`-Type` selects the badges:
- `Earned` (default): Badges earned by the user (`/badge-service/badge/earned`).
- `Available`: Badges not earned yet (`/badge-service/badge/available`, `earnedByMe = false`).
- `NonCompleted`: Joined badge challenges that are not completed yet
                       (`/badgechallenge-service/badgeChallenge/non-completed`, `badgeEarnedDate` empty).
                       All pages of the endpoint are fetched.
 
Before returning, the function writes a console summary. For `Earned`: total number of
badges, total points (badge points multiplied by the number of times each badge was
earned), the current Garmin level (1-10), the next level threshold, and the points still
missing. For `Available` and `NonCompleted`: number of badges and the points that can
still be earned.
 
Without `-GroupBy`, the badge objects are returned. With `-GroupBy Year` or
`-GroupBy Month`, the badges are aggregated by date and summary objects are returned
instead. The date is the earned date (`Earned`), the badge end date (`Available`) or the
challenge end date (`NonCompleted`). The first summary row always has `Year = 'Total'`;
the following rows are sorted chronologically and contain a running `TotalPoints` value.
Badges without a date are summarized in a last row with `Year = 'None'`.
 
Summary object properties:
- Year: 'Total', the four-digit year, or 'None'.
- Month: Two-digit month (only with `-GroupBy Month`; empty for the 'Total' and 'None' rows).
- BadgeCount: Number of badges in the period.
- Points: Points in the period.
- TotalPoints: Cumulative points up to and including the period.
 
With `-CustomSelection`, the Where-Object filter and the Select-Object properties for the
selected type are read from `$env:USERPROFILE\GCCare\Config\GCCare.json`
(section `GarminConnectApi > CustomSelection > Badge > <Type>`; the file is copied
from the module on import if it does not exist yet). `-Filter` and `-Property` override
these values for one call, `-SaveCustomSelection` stores them in `GCCare.json`.
Together with `-GroupBy`, only the filter is applied before grouping; the properties are
ignored.
 
Because the result consists of objects, it can be filtered and formatted with
standard cmdlets such as `Where-Object`, `Sort-Object`, and `Format-Table`.
Note that `Year` and `Month` are strings.
 
.PARAMETER Type
Badges to fetch. Valid values: `Earned` (default), `Available`, `NonCompleted`.
 
.PARAMETER GroupBy
Optional summary interval. Valid values: `Year`, `Month`, `Name`.
- `Year`: One row per year plus a 'Total' row, with badge count, points, and cumulative points.
- `Month`: One row per year and month plus a 'Total' row, with badge count, points, and cumulative points.
- `Name`: One row per badge (Name, EarnedCount, BadgePoints, Points, LastEarned), sorted by points.
           Only with `-Type Earned`.
 
If omitted, the individual badges are returned.
 
.PARAMETER CustomSelection
Applies the Where-Object filter and the Select-Object properties stored in `GCCare.json`
for the selected type.
 
.PARAMETER Filter
Where-Object filter for this call, e.g. `{ $_.badgePoints -ge 2 }`. Overrides the value
from `GCCare.json` and implies `-CustomSelection`.
 
.PARAMETER Property
Select-Object properties for this call, e.g. `badgeName, badgePoints`. Overrides the
value from `GCCare.json` and implies `-CustomSelection`.
 
.PARAMETER SaveCustomSelection
Saves `-Filter` and/or `-Property` for the selected type in
`$env:USERPROFILE\GCCare\Config\GCCare.json`. Requires `-Filter` or `-Property`.
 
.PARAMETER TokenStore
Optional path to the Garmin Connect token store directory or `gctoken.json` file.
Defaults to `$env:GARMINTOKENS`, then `~\.garminconnect`.
 
.PARAMETER EnableLogging
Optional switch to enable module logging behavior (if supported by module logging helpers).
 
.OUTPUTS
System.Object[]
Returns the badges, or summary objects when `-GroupBy` is specified.
 
.NOTES
- Requires helper functions in module scope:
  `Get-FunctionName`, `Write-Log`, `Invoke-Output`, `Get-RunTime`, `Invoke-GarminConnectApi`,
  `Get-GarminEarnedBadgeList`, `Get-GarminAvailableBadgeList`, `Get-GarminNonCompletedBadgeList`,
  `Get-GCCareCustomSelection`, `Save-GCCareCustomSelection`.
- Requires a token file created by `Get-GarminToken`.
- The Where-Object value in `GCCare.json` is executed as PowerShell code. Only store
  filters you trust.
- Alias: `Get-GarminBadges`.
 
.EXAMPLE
Get-GarminBadge
 
Returns the earned Garmin Connect badges and shows the level summary in the console.
 
.EXAMPLE
Get-GarminBadge -TokenStore "C:\Temp\.garminconnect"
 
Fetches earned badges using a custom token store.
 
.EXAMPLE
Get-GarminBadge -GroupBy Year | Format-Table
 
Shows badge count, points, and cumulative points per year, including a 'Total' row.
 
.EXAMPLE
Get-GarminBadge -GroupBy Month | Where-Object { $_.Year -eq 2025 } | Format-Table
 
Shows the monthly badge count, points, and cumulative points for 2025 only.
 
.EXAMPLE
Get-GarminBadge -GroupBy Name | Select-Object -First 10 | Format-Table
 
Shows the ten badges that earned the most points.
 
.EXAMPLE
Get-GarminBadge -Type Available -CustomSelection | Format-Table
 
Returns the badges not earned yet with the filter and properties from `GCCare.json`.
 
.EXAMPLE
Get-GarminBadge -Type Available -GroupBy Month | Format-Table
 
Shows per month how many available badges end and how many points they are worth.
 
.EXAMPLE
Get-GarminBadge -Type NonCompleted -CustomSelection | Format-Table
 
Returns the joined, not completed challenges with name, end date, target and progress.
 
.EXAMPLE
Get-GarminBadge -Type NonCompleted -Property badgeChallengeName, endDate, badgeProgressValue, badgeTargetValue -SaveCustomSelection
 
Saves the properties for `NonCompleted` in `GCCare.json` and returns the challenges with them.
#>

Function Get-GarminBadge {

    [CmdletBinding()]
    [Alias('Get-GarminBadges')]
    param(
        [Parameter(Position = 0)]
        [ValidateSet('Earned', 'Available', 'NonCompleted')]
        [string]$Type = 'Earned',
        [ValidateSet('Year', 'Month', 'Name')]
        [string]$GroupBy,
        [switch]$CustomSelection,
        [scriptblock]$Filter,
        [string[]]$Property,
        [switch]$SaveCustomSelection,
        [string]$TokenStore,
        [switch]$EnableLogging
    )

    $CurrentFunction = Get-FunctionName
    Write-Log -Message "### Start Function $CurrentFunction ###"
    $StartRunTime = (Get-Date).ToString($Script:DateFormatLog)
    #################### main code | out- host #####################

    if ($GroupBy -eq 'Name' -and $Type -ne 'Earned') {
        throw "-GroupBy Name is only supported for -Type Earned."
    }
    if ($SaveCustomSelection -and -not ($PSBoundParameters.ContainsKey('Filter') -or $PSBoundParameters.ContainsKey('Property'))) {
        throw "-SaveCustomSelection requires -Filter and/or -Property."
    }
    $useCustomSelection = $CustomSelection -or $PSBoundParameters.ContainsKey('Filter') -or $PSBoundParameters.ContainsKey('Property')

    # Minimum points required for Garmin levels 1-10 (index 0 = level 1)
    $LevelThresholds = @(0, 20, 60, 140, 300, 620, 1260, 2540, 5100, 10220)

    # Earned: points x times earned; Available/NonCompleted: points that can be earned
    function Get-BadgePoints($BadgeList) {
        $sum = (@($BadgeList) | ForEach-Object {
                if ($Type -eq 'Earned') { [double]$_.badgePoints * [double]$_.badgeEarnedNumber } else { [double]$_.badgePoints }
            } | Measure-Object -Sum).Sum
        if ($null -eq $sum) { 0 } else { $sum }
    }

    function ConvertTo-BadgeDate($Value) {
        if ($Value -is [datetime]) { $Value } else { [datetime]::Parse([string]$Value, [Globalization.CultureInfo]::InvariantCulture) }
    }

    $typeText = @{ Earned = 'earned'; Available = 'available'; NonCompleted = 'non-completed' }[$Type]
    $dateProperty = @{ Earned = 'badgeEarnedDate'; Available = 'badgeEndDate'; NonCompleted = 'endDate' }[$Type]

    Invoke-Output -Type Header -Message "Fetching $typeText Garmin Connect badges..."

    $allBadges = switch ($Type) {
        'Earned' { Get-GarminEarnedBadgeList -TokenStore $TokenStore }
        'Available' { Get-GarminAvailableBadgeList -TokenStore $TokenStore }
        'NonCompleted' { Get-GarminNonCompletedBadgeList -TokenStore $TokenStore }
    }
    $allBadges = @($allBadges)

    # Console summary
    $totalPoints = Get-BadgePoints $allBadges
    Write-Host ""
    Invoke-Output -Type Bullet -Message "Total Badges: " -TextMaker $allBadges.Count -NoExtraLines

    if ($Type -eq 'Earned') {
        $CurrentLevel = @($LevelThresholds | Where-Object { $totalPoints -ge $_ }).Count
        if ($CurrentLevel -lt 10) {
            $NextLevel = $CurrentLevel + 1
            $NextPoints = $LevelThresholds[$NextLevel - 1]
            $Missing = $NextPoints - $totalPoints

            Invoke-Output -Type Bullet -Message "Total Points: " -TextMaker $totalPoints -NoExtraLines
            Invoke-Output -Type Bullet -Message "Current Level: " -TextMaker $CurrentLevel -NoExtraLines
            Invoke-Output -Type Bullet -Message "Next Level: " -TextMaker "$NextLevel ($NextPoints Points)"
            Invoke-Output -Type Bullet -Message "Points Missing:" -TextMaker $Missing
        }
        else {
            Invoke-Output -Type Bullet -Message "Total Points: " -TextMaker $totalPoints -NoExtraLines
            Invoke-Output -Type TextMaker -Message "Level 10 reached." -TextMaker "(highest)"
        }
    }
    else {
        Invoke-Output -Type Bullet -Message "Possible Points:" -TextMaker $totalPoints
    }

    # Custom selection: GCCare.json < -Filter / -Property
    $selectProperties = @()
    if ($useCustomSelection) {
        if ($SaveCustomSelection) {
            $saveParams = @{ Section = 'Badge'; Name = $Type }
            if ($PSBoundParameters.ContainsKey('Filter')) { $saveParams.Filter = $Filter }
            if ($PSBoundParameters.ContainsKey('Property')) { $saveParams.Property = $Property }
            Save-GCCareCustomSelection @saveParams
        }

        $selection = Get-GCCareCustomSelection -Section 'Badge' -Name $Type
        $whereFilter = if ($PSBoundParameters.ContainsKey('Filter')) { $Filter } else { $selection.Filter }
        $selectProperties = if ($PSBoundParameters.ContainsKey('Property')) { @($Property) } else { @($selection.Property) }

        if ($whereFilter) {
            $allBadges = @($allBadges | Where-Object -FilterScript $whereFilter)
            Write-Log -Message " >> Filter {$whereFilter}: $($allBadges.Count) badges left"
        }
    }

    $result = $allBadges

    if ($GroupBy -in 'Year', 'Month') {
        $byMonth = $GroupBy -eq 'Month'
        $groupPoints = Get-BadgePoints $allBadges

        $totalRow = [ordered]@{ Year = 'Total' }
        if ($byMonth) { $totalRow.Month = '' }
        $totalRow.BadgeCount = $allBadges.Count
        $totalRow.Points = $groupPoints
        $totalRow.TotalPoints = $groupPoints
        $badgeSummary = @([pscustomobject]$totalRow)

        # Group keys 'yyyy' or 'yyyy-MM' sort chronologically as plain strings
        $groups = $allBadges |
            Where-Object { $_.$dateProperty } |
            Group-Object -Property { (ConvertTo-BadgeDate $_.$dateProperty).ToString($(if ($byMonth) { 'yyyy-MM' } else { 'yyyy' })) } |
            Sort-Object -Property Name

        $runningTotalPoints = 0
        foreach ($group in $groups) {
            $points = Get-BadgePoints $group.Group
            $runningTotalPoints += $points

            $row = [ordered]@{ Year = $group.Name.Substring(0, 4) }
            if ($byMonth) { $row.Month = $group.Name.Substring(5, 2) }
            $row.BadgeCount = $group.Count
            $row.Points = $points
            $row.TotalPoints = $runningTotalPoints
            $badgeSummary += [pscustomobject]$row
        }

        # Badges without a date (e.g. available badges without end date)
        $undated = @($allBadges | Where-Object { -not $_.$dateProperty })
        if ($undated.Count -gt 0) {
            $points = Get-BadgePoints $undated
            $runningTotalPoints += $points

            $row = [ordered]@{ Year = 'None' }
            if ($byMonth) { $row.Month = '' }
            $row.BadgeCount = $undated.Count
            $row.Points = $points
            $row.TotalPoints = $runningTotalPoints
            $badgeSummary += [pscustomobject]$row
        }

        $result = $badgeSummary
    }
    elseif ($GroupBy -eq 'Name') {
        $result = $allBadges | ForEach-Object {
            [pscustomobject]@{
                Name        = $_.badgeName
                EarnedCount = [int]$_.badgeEarnedNumber
                BadgePoints = [double]$_.badgePoints
                Points      = [double]$_.badgePoints * [double]$_.badgeEarnedNumber
                LastEarned  = if ($_.badgeEarnedDate) { ConvertTo-BadgeDate $_.badgeEarnedDate } else { $null }
            }
        } | Sort-Object -Property @{ Expression = 'Points'; Descending = $true }, Name
    }
    elseif ($selectProperties.Count -gt 0) {
        $result = $allBadges | Select-Object -Property $selectProperties
    }

    Invoke-Output -Type Success -Message "Garmin Connect badges fetched successfully."
    ######################## main code ############################
    $runtime = Get-RunTime -StartRunTime $StartRunTime
    Write-Log -Message " Run Time: $runtime [h] ###"
    Write-Log -Message "### End Function $CurrentFunction ###"

    return $result
}