brueckentage-gen.ps1


<#PSScriptInfo

.VERSION 1.0

.GUID 61c8cca7-5460-47b4-b1f3-6ffb2b5afc1e

.AUTHOR Lukas Möller

.COMPANYNAME

.COPYRIGHT

.TAGS brueckentage

.LICENSEURI

.PROJECTURI

.ICONURI

.EXTERNALMODULEDEPENDENCIES

.REQUIREDSCRIPTS

.EXTERNALSCRIPTDEPENDENCIES

.RELEASENOTES

.PRIVATEDATA

#>


<#

.DESCRIPTION
This PowerShell script calculates potential "bridge days" (Brueckentage) for a given year and state in Germany. Bridge days are workdays that can be taken off to create a longer holiday period by combining weekends and public holidays.
#>

Param(
    [Parameter(Mandatory = $true)]
    [int]$Year,

    [Parameter(Mandatory = $true)]
    [ValidateSet("BW", "BY", "BE", "BB", "HB", "HH", "HE", "MV", "NI", "NW", "RP", "SL", "SN", "ST", "SH", "TH")]
    [string]$State,

    [int[]]$WeekendDays = @(0, 6), # Sunday=0, Saturday=6

    [string[]]$VacationDays = @(),

    [int]$Range = 20
)

function Get-Holidays {
    param (
        [int]$Year,
        [string]$State
    )
    $url = "https://feiertage-api.de/api/?jahr=$Year&nur_land=$State"
    try {
        return Invoke-RestMethod -Uri $url -Method Get
    } catch {
        Write-Error "ERROR: Error fetching holidays: $_"
        return @{}
    }
}

function ConvertTo-DayOfYearMap {
    param ($holidays)
    $map = @{}
    foreach ($holiday in $holidays.PSObject.Properties) {
        $date = Get-Date $holiday.Value.datum
        $map[$date.DayOfYear] = $holiday.Name
    }
    return $map
}

function Is-FreeDay {
    param ($dayIndex, $holidayMap, $vacationSet)
    $date = (Get-Date -Year $Year -Month 1 -Day 1).AddDays($dayIndex - 1)
    return ($WeekendDays -contains [int]$date.DayOfWeek) -or
            ($holidayMap.ContainsKey($dayIndex)) -or
            ($vacationSet.Contains($date.ToString("yyyy-MM-dd")))
}

function Get-FreeBlock {
    param ($startDay, $blockSize, $holidayMap, $vacationSet)
    $first = $startDay
    $last = $startDay + $blockSize - 1

    # Expand backward
    for ($i = 1; $i -lt 30; $i++) {
        if (-not (Is-FreeDay -dayIndex ($first - $i) -holidayMap $holidayMap -vacationSet $vacationSet)) {
            $first = $first - $i + 1
            break
        }
    }

    # Expand forward
    for ($i = 1; $i -lt 30; $i++) {
        if (-not (Is-FreeDay -dayIndex ($last + $i) -holidayMap $holidayMap -vacationSet $vacationSet)) {
            $last = $last + $i - 1
            break
        }
    }

    # Count free days
    $daysAlreadyFree = 0
    for ($i = $first; $i -le $last; $i++) {
        if (Is-FreeDay -dayIndex $i -holidayMap $holidayMap -vacationSet $vacationSet) {
            $daysAlreadyFree++
        }
    }

    $freeDays = $last - $first + 1
    $bridgeDays = $freeDays - $daysAlreadyFree

    return @{
        First = $first
        Last = $last
        BridgeDays = $bridgeDays
        TotalDays = $freeDays
    }
}

$holidays = Get-Holidays -Year $Year -State $State
$holidayMap = ConvertTo-DayOfYearMap -holidays $holidays
$vacationSet = [System.Collections.Generic.HashSet[string]]::new()
$VacationDays | ForEach-Object { [void]$vacationSet.Add($_) }

$results = @()
$uniqueBlocks = [System.Collections.Generic.HashSet[string]]::new()
$daysInYear = [DateTime]::IsLeapYear($Year) ? 366 : 365

for ($d = 1; $d -le $daysInYear; $d++) {
    for ($r = 1; $r -le $Range; $r++) {
        if ($d + $r - 1 -gt $daysInYear) {
            continue
        }

        if (Is-FreeDay -dayIndex $d -holidayMap $holidayMap -vacationSet $vacationSet) {
            continue
        }

        $block = Get-FreeBlock -startDay $d -blockSize $r -holidayMap $holidayMap -vacationSet $vacationSet
        if ($block.BridgeDays -le 0) {
            continue
        }

        $startDate = (Get-Date -Year $Year -Month 1 -Day 1).AddDays($block.First - 1).ToString("yyyy-MM-dd")
        $endDate = (Get-Date -Year $Year -Month 1 -Day 1).AddDays($block.Last - 1).ToString("yyyy-MM-dd")
        $blockIdentifier = "$startDate-$endDate"

        if ($uniqueBlocks.Add($blockIdentifier)) {
            $score = if ($block.BridgeDays -gt 0) {
                $block.TotalDays / $block.BridgeDays
            } else {
                -1
            }

            # Prioritize blocks starting or ending with a free day and having more free days than half the range
            $startsWithFreeDay = Is-FreeDay -dayIndex $block.First -holidayMap $holidayMap -vacationSet $vacationSet
            $endsWithFreeDay = Is-FreeDay -dayIndex $block.Last -holidayMap $holidayMap -vacationSet $vacationSet
            $moreThanHalfFree = $block.TotalDays / 2 -lt $daysAlreadyFree

            if (($startsWithFreeDay -or $endsWithFreeDay) -and $moreThanHalfFree) {
                $score += 1 # Boost score for priority
            }

            $results += [PSCustomObject]@{
                Start = $startDate
                End = $endDate
                BridgeDays = $block.BridgeDays
                TotalFreeDays = $block.TotalDays
                Score = [math]::Round($score, 2)
            }
        }
    }
}

$results |
        Group-Object { (Get-Date $_.Start).Month } |
        ForEach-Object {
            $monthName = (Get-Culture).DateTimeFormat.GetMonthName($_.Name)
            Write-Output "# $($monthName):"
            $_.Group | Where-Object { $_.Score -ge 1.8 } | Sort-Object -Property Score -Descending | Select-Object -First 5 | Format-Table -AutoSize
        }