Files
aftersummer/aftersummer.ps1
ulve a18e2a2455 Add /shouldibeworking endpoint, container timezone, and PowerShell CLI tracker
Ports work-time rules to the server for external polling, sets the
container TZ so schedule math matches Europe/Stockholm, and adds a
terminal-based progress tracker mirroring the web app.
2026-09-13 10:48:48 +02:00

564 lines
28 KiB
PowerShell
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#Requires -Version 5.1
<#
.SYNOPSIS
Aftersummer — terminal time progress tracker
.DESCRIPTION
Shows Hour, Day, Week, Month, Summer, Fall, and Year progress using a
particle-grid display that mirrors the Aftersummer web app.
.PARAMETER WorkTime
Count only Swedish work hours: 08-12 and 13-17, Mon-Fri,
excluding July and public holidays.
.PARAMETER BridgeDays
Also exclude Swedish bridge days from work-day counts.
.PARAMETER Exclude
One or more card names to hide: Hour, Day, Week, Month, Summer, Fall, Year.
Example: -Exclude Hour,Fall
.PARAMETER Message
Card name to print a historical fun-fact for, e.g. -Message summer.
Looks up how long something real took for the card's remaining day count.
#>
param(
[switch]$WorkTime,
[switch]$BridgeDays,
[string[]]$Exclude = @(),
[string]$Message = ''
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# ── ANSI helpers ──────────────────────────────────────────────────────────────
$e = [char]27
$RST = "$e[0m"
function rgb($r, $g, $b) { "$e[38;2;${r};${g};${b}m" }
$C = @{
active = @{ fill = rgb 26 26 46; edge = rgb 205 10 0; empty = rgb 90 87 80 }
'not-work-time' = @{ fill = rgb 185 180 172; edge = rgb 155 150 142; empty = rgb 72 70 66 }
summer = @{ fill = rgb 196 144 42; edge = rgb 220 160 40; empty = rgb 82 60 18 }
'off-season' = @{ fill = rgb 160 168 184; edge = rgb 130 138 154; empty = rgb 62 68 80 }
}
$C_LABEL = rgb 130 130 130
$C_ACCENT = rgb 205 10 0
$FILL = [char]0x25CF # ●
$EMPTY = [char]0x25CB # ○
$ROWS = 4
$PAD = 3 # left/right margin chars
# ── Global data ───────────────────────────────────────────────────────────────
$script:HolidaySet = [System.Collections.Generic.HashSet[string]]::new()
$script:BridgeDaySet = [System.Collections.Generic.HashSet[string]]::new()
$script:Midsommar = $null # [datetime]
$script:FallStart = $null # [datetime] — first Monday of August
# ── Date helpers ──────────────────────────────────────────────────────────────
function Get-ISOWeek([datetime]$d) {
$day = [int]$d.DayOfWeek; if ($day -eq 0) { $day = 7 }
$thu = $d.Date.AddDays(4 - $day)
$jan1 = [datetime]::new($thu.Year, 1, 1)
[int][Math]::Ceiling((($thu - $jan1).TotalDays + 1) / 7)
}
function Get-FirstMondayOfAugust([int]$year) {
$d = [datetime]::new($year, 8, 1)
$dow = [int]$d.DayOfWeek # 0=Sun 1=Mon … 6=Sat
$offset = if ($dow -eq 1) { 0 } else { (8 - $dow) % 7 }
$d.AddDays($offset)
}
function Get-MidsommarFallback([int]$year) {
$d = [datetime]::new($year, 6, 20)
while ($d.DayOfWeek -ne [DayOfWeek]::Friday) { $d = $d.AddDays(1) }
$d
}
# ── Load holidays from dagsmart.se (with file cache) ─────────────────────────
function Get-CachePath { Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'aftersummer\cache.json' }
function Read-Cache([int]$year) {
$path = Get-CachePath
if (-not (Test-Path $path)) { return $null }
try {
$c = Get-Content $path -Raw | ConvertFrom-Json
if ($c.year -eq $year) { return $c }
} catch {}
return $null
}
function Write-Cache([int]$year, $holidays, $bridgeDays) {
$path = Get-CachePath
$dir = Split-Path $path
if (-not (Test-Path $dir)) { New-Item $dir -ItemType Directory | Out-Null }
@{ year = $year; holidays = $holidays; bridgeDays = $bridgeDays } |
ConvertTo-Json -Depth 5 | Set-Content $path -Encoding UTF8
}
function Load-Data {
$year = (Get-Date).Year
$script:FallStart = Get-FirstMondayOfAugust $year
$cached = Read-Cache $year
$h = $null
$b = $null
if ($cached) {
$h = $cached.holidays
$b = $cached.bridgeDays
} else {
try {
$h = Invoke-RestMethod `
"https://api.dagsmart.se/holidays?year=$year&weekends=false" `
-TimeoutSec 8
} catch {}
try {
$b = Invoke-RestMethod `
"https://api.dagsmart.se/bridge-days?year=$year" `
-TimeoutSec 8
} catch {}
if ($h) { Write-Cache $year $h $b }
}
if ($h) {
foreach ($item in $h) { $script:HolidaySet.Add($item.date) | Out-Null }
$mid = $h | Where-Object { $_.code -eq 'midsummerEve' } | Select-Object -First 1
$script:Midsommar = if ($mid) {
[datetime]::Parse($mid.date + 'T00:00:00')
} else {
Get-MidsommarFallback $year
}
} else {
$script:Midsommar = Get-MidsommarFallback $year
}
if ($BridgeDays -and $b) {
foreach ($item in $b) { $script:BridgeDaySet.Add($item.date) | Out-Null }
}
}
# ── Work-time utilities ───────────────────────────────────────────────────────
function Is-WorkDay([datetime]$d) {
$dow = $d.DayOfWeek
if ($dow -eq [DayOfWeek]::Saturday -or $dow -eq [DayOfWeek]::Sunday) { return $false }
if ($d.Month -eq 7) { return $false }
$ds = $d.ToString('yyyy-MM-dd')
if ($script:HolidaySet.Contains($ds)) { return $false }
if ($BridgeDays -and $script:BridgeDaySet.Contains($ds)) { return $false }
return $true
}
# Returns minutes elapsed in the work day (0–480); handles lunch gap
function Get-WorkMinutesElapsed([datetime]$now) {
$mins = $now.Hour * 60 + $now.Minute + $now.Second / 60.0
if ($mins -lt 480) { return 0.0 } # before 08:00
if ($mins -ge 1020) { return 480.0 } # after 17:00
if ($mins -ge 720 -and $mins -lt 780) { return 240.0 } # lunch 12–13
if ($mins -lt 720) { return $mins - 480 } # morning
return 240 + ($mins - 780) # afternoon
}
function Is-WorkTime([datetime]$now) {
if (-not (Is-WorkDay $now)) { return $false }
$mins = $now.Hour * 60 + $now.Minute
if ($mins -lt 480 -or $mins -ge 1020) { return $false }
if ($mins -ge 720 -and $mins -lt 780) { return $false }
return $true
}
# Count whole work days in half-open interval [start.Date, end.Date)
function Count-WorkDays([datetime]$start, [datetime]$end) {
$count = 0; $d = $start.Date; $e = $end.Date
while ($d -lt $e) {
if (Is-WorkDay $d) { $count++ }
$d = $d.AddDays(1)
}
$count
}
# Elapsed work days up to $now, including fractional today
function Work-Elapsed([datetime]$start, [datetime]$now) {
$e = Count-WorkDays $start $now.Date
if (Is-WorkDay $now) { $e += (Get-WorkMinutesElapsed $now) / 480.0 }
$e
}
# ── Label formatting ──────────────────────────────────────────────────────────
function Fmt-Mins([double]$m) {
$h = [int]($m / 60); $mm = [int][Math]::Ceiling($m % 60)
if ($h -eq 0) { return "${mm}m" }
if ($mm -eq 0) { return "${h}h" }
"${h}h ${mm}m"
}
function Fmt-Days([int]$n, [string]$suffix = 'remaining') {
"$n $( if ($n -eq 1) { 'day' } else { 'days' }) $suffix"
}
function Fmt-WorkDays([int]$n, [string]$suffix = 'remaining') {
if ($n -le 0) { return if ($suffix -eq 'remaining') { 'done!' } else { 'done!' } }
"$n $( if ($n -eq 1) { 'working day' } else { 'working days' }) $suffix"
}
function Fmt-Date([datetime]$d) {
$d.ToString('d MMM', [System.Globalization.CultureInfo]::InvariantCulture)
}
# ── Card state calculations ───────────────────────────────────────────────────
function Make-Card($name, $progress, $endsLabel, $remLabel, $state) {
[pscustomobject]@{
Name = $name
Progress = [Math]::Max(0.0, [Math]::Min(1.0, [double]$progress))
EndsLabel = $endsLabel
RemLabel = $remLabel
State = $state
}
}
function Get-HourState([datetime]$now) {
$nextH = ('{0:00}:00' -f (($now.Hour + 1) % 24))
$ends = "ends $nextH"
if ($WorkTime -and -not (Is-WorkTime $now)) {
$msg = if (Is-WorkDay $now) { 'outside work hours' } else { 'not a work day' }
return Make-Card 'HOUR' 0 $ends $msg 'not-work-time'
}
$mins = $now.Minute + $now.Second / 60.0
Make-Card 'HOUR' ($mins / 60) $ends "$([int][Math]::Ceiling(60 - $mins))m remaining" 'active'
}
function Get-DayState([datetime]$now) {
if ($WorkTime) {
$ends = 'ends 17:00'
if (-not (Is-WorkDay $now)) { return Make-Card 'DAY' 0 $ends 'not a work day' 'not-work-time' }
$mins = $now.Hour * 60 + $now.Minute
if ($mins -lt 480) { return Make-Card 'DAY' 0 $ends 'work starts at 08:00' 'not-work-time' }
if ($mins -ge 720 -and $mins -lt 780) { return Make-Card 'DAY' (240/480.0) $ends 'lunch break' 'not-work-time' }
if ($mins -ge 1020) { return Make-Card 'DAY' 1 $ends 'work day done' 'not-work-time' }
$el = Get-WorkMinutesElapsed $now
return Make-Card 'DAY' ($el / 480.0) $ends "$(Fmt-Mins (480 - $el)) remaining" 'active'
}
$secs = $now.Hour * 3600 + $now.Minute * 60 + $now.Second
$ends = "ends $(Fmt-Date $now.Date.AddDays(1))"
Make-Card 'DAY' ($secs / 86400.0) $ends "$(Fmt-Mins ((86400 - $secs) / 60.0)) remaining" 'active'
}
function Get-WeekState([datetime]$now) {
$dow = ([int]$now.DayOfWeek + 6) % 7 # 0=Mon … 6=Sun
$monday = $now.Date.AddDays(-$dow)
if ($WorkTime) {
$friday = $monday.AddDays(4)
$totalWork = Count-WorkDays $monday $monday.AddDays(5)
$ends = "ends $(Fmt-Date $friday)"
if ($totalWork -eq 0) { return Make-Card 'WEEK' 0 $ends 'no work days this week' 'not-work-time' }
$el = Work-Elapsed $monday $now
$remaining = [int][Math]::Ceiling($totalWork - $el)
$label = if ($remaining -le 0) { 'week done' } else { Fmt-WorkDays $remaining }
return Make-Card 'WEEK' ($el / $totalWork) $ends $label 'active'
}
$nextMonday = $monday.AddDays(7)
$sunday = $nextMonday.AddDays(-1)
$progress = ($now - $monday).TotalSeconds / ($nextMonday - $monday).TotalSeconds
$rem = [int][Math]::Ceiling(($nextMonday - $now).TotalDays)
Make-Card 'WEEK' $progress "ends $(Fmt-Date $sunday)" (Fmt-Days $rem) 'active'
}
function Get-MonthState([datetime]$now) {
$first = [datetime]::new($now.Year, $now.Month, 1)
$firstNext = $first.AddMonths(1)
$lastDay = $firstNext.AddDays(-1)
$ends = "ends $(Fmt-Date $lastDay)"
if ($WorkTime) {
$totalWork = Count-WorkDays $first $firstNext
if ($totalWork -eq 0) { return Make-Card 'MONTH' 1 $ends 'no work days this month' 'not-work-time' }
$el = Work-Elapsed $first $now
$remaining = [int][Math]::Ceiling($totalWork - $el)
$label = if ($remaining -le 0) { 'month done' } else { Fmt-WorkDays $remaining }
return Make-Card 'MONTH' ($el / $totalWork) $ends $label 'active'
}
$progress = ($now - $first).TotalSeconds / ($firstNext - $first).TotalSeconds
$rem = [int][Math]::Ceiling(($firstNext - $now).TotalDays)
Make-Card 'MONTH' $progress $ends (Fmt-Days $rem) 'active'
}
function Get-SummerState([datetime]$now) {
$mid = $script:Midsommar
$fallStart= $script:FallStart
$jan1 = [datetime]::new($now.Year, 1, 1)
$ends = "ends $(Fmt-Date $mid)"
if ($now -ge $mid -and $now -lt $fallStart) {
return Make-Card 'SUMMER' 1 $ends "back $(Fmt-Date $fallStart)" 'summer'
}
if ($now -lt $mid) {
if ($WorkTime) {
$totalWork = Count-WorkDays $jan1 $mid
$el = Work-Elapsed $jan1 $now
$progress = if ($totalWork -gt 0) { $el / $totalWork } else { 0 }
$remaining = [int][Math]::Ceiling($totalWork - $el)
$label = if ($remaining -le 0) { 'summer soon!' } else { Fmt-WorkDays $remaining 'till summer' }
return Make-Card 'SUMMER' $progress $ends $label 'active'
}
$progress = ($now - $jan1).TotalSeconds / ($mid - $jan1).TotalSeconds
$rem = [int][Math]::Ceiling(($mid - $now).TotalDays)
return Make-Card 'SUMMER' $progress $ends (Fmt-Days $rem 'till summer') 'active'
}
# After summer, before new year
$nextYear = [datetime]::new($now.Year + 1, 1, 1)
$rem = [int][Math]::Ceiling(($nextYear - $now).TotalDays)
Make-Card 'SUMMER' 1 $ends (Fmt-Days $rem 'till new year') 'off-season'
}
function Get-FallState([datetime]$now) {
$fallStart = $script:FallStart
$yearEnd = [datetime]::new($now.Year + 1, 1, 1)
$dec31 = $yearEnd.AddDays(-1)
$ends = "ends $(Fmt-Date $dec31)"
if ($now -lt $fallStart) {
$rem = [int][Math]::Ceiling(($fallStart - $now).TotalDays)
return Make-Card 'FALL' 0 $ends (Fmt-Days $rem 'till fall') 'off-season'
}
if ($WorkTime) {
$totalWork = Count-WorkDays $fallStart $yearEnd
$el = Work-Elapsed $fallStart $now
$progress = if ($totalWork -gt 0) { $el / $totalWork } else { 0 }
$remaining = [int][Math]::Ceiling($totalWork - $el)
$label = if ($remaining -le 0) { 'year done!' } else { Fmt-WorkDays $remaining }
return Make-Card 'FALL' $progress $ends $label 'active'
}
$progress = ($now - $fallStart).TotalSeconds / ($yearEnd - $fallStart).TotalSeconds
$rem = [int][Math]::Ceiling(($yearEnd - $now).TotalDays)
Make-Card 'FALL' $progress $ends (Fmt-Days $rem) 'active'
}
function Get-YearState([datetime]$now) {
$first = [datetime]::new($now.Year, 1, 1)
$next = [datetime]::new($now.Year + 1, 1, 1)
$ends = 'ends 31 Dec'
if ($WorkTime) {
$totalWork = Count-WorkDays $first $next
$el = Work-Elapsed $first $now
$remaining = [int][Math]::Ceiling($totalWork - $el)
$label = if ($remaining -le 0) { 'year done' } else { Fmt-WorkDays $remaining }
return Make-Card 'YEAR' ($el / $totalWork) $ends $label 'active'
}
$progress = ($now - $first).TotalSeconds / ($next - $first).TotalSeconds
$rem = [int][Math]::Ceiling(($next - $now).TotalDays)
Make-Card 'YEAR' $progress $ends (Fmt-Days $rem) 'active'
}
# ── Rendering ─────────────────────────────────────────────────────────────────
function Draw-Card([pscustomobject]$card) {
$width = [Console]::WindowWidth
$cols = [int](($width - $PAD * 2) / 2) # 2 chars per particle: ● + space
$total = $ROWS * $cols
$filled = [Math]::Max(0, [Math]::Min($total, [int][Math]::Round($card.Progress * $total)))
$fc = $C[$card.State].fill
$ec = $C[$card.State].edge
$emp = $C[$card.State].empty
# ── Header: NAME … ends label ──
$hdr = (' ' * $PAD) + $card.Name +
(' ' * [Math]::Max(1, $width - $PAD * 2 - $card.Name.Length - $card.EndsLabel.Length)) +
$card.EndsLabel
Write-Host ($C_LABEL + $hdr + $RST)
# ── Particle rows ──
for ($row = 0; $row -lt $ROWS; $row++) {
$sb = [System.Text.StringBuilder]::new()
[void]$sb.Append(' ' * $PAD)
for ($col = 0; $col -lt $cols; $col++) {
$idx = $row * $cols + $col
if ($idx -lt ($filled - 1)) {
[void]$sb.Append($fc + $FILL + ' ')
} elseif ($idx -eq ($filled - 1)) {
[void]$sb.Append($ec + $FILL + ' ')
} else {
[void]$sb.Append($emp + $EMPTY + ' ')
}
}
[void]$sb.Append($RST)
Write-Host $sb.ToString()
}
# ── Footer: remaining label, right-aligned ──
if ($card.RemLabel) {
$rem = $card.RemLabel
$ftr = (' ' * [Math]::Max($PAD, $width - $PAD - $rem.Length)) + $rem + (' ' * $PAD)
Write-Host ($C_LABEL + $ftr + $RST)
}
Write-Host ''
}
# ── PLACEHOLDER — replaced below after claude -p generates facts ──────────────
$FACTS = @{
1 = "PLACEHOLDER"
2 = "the Great Chicago Fire burned 3.3 square miles of the city"
3 = "the Battle of Gettysburg — deadliest battle of the Civil War — lasted"
4 = "the Battle of Midway destroyed Japan's carrier fleet"
5 = "the Great Smog of London lasted, killing over 12,000 people"
6 = "the Six-Day War was fought — Israel tripled its territory"
7 = "Allied forces liberated Paris from Nazi occupation"
8 = "the Apollo 11 moon landing mission lasted from launch to splashdown"
9 = "Operation Dynamo evacuated 338,000 Allied troops from Dunkirk"
10 = "the Apollo 9 mission tested the lunar module in Earth orbit"
11 = "the Apollo 7, the first crewed Apollo mission, orbited Earth"
12 = "the Apollo 15 crew explored the Moon's Hadley-Apennine region"
13 = "the Cuban Missile Crisis kept humanity on the brink of nuclear war"
14 = "the Siege of the Alamo lasted before every defender fell"
15 = "the Battle of the Bulge's initial German breakthrough phase lasted"
16 = "the Allied crossing of France from Normandy to the Seine took"
17 = "the Battle of Berlin — the final major battle of WWII in Europe — lasted"
18 = "Shackleton's crew survived stranded on Elephant Island before rescue"... no wait that was much longer. Let me rethink."
18 = "the Siege of Tobruk's first unsuccessful Axis assault lasted"
19 = "the Yom Kippur War — Egypt and Syria vs Israel — lasted"
20 = "Hannibal's army took to cross the Alps into Italy"
21 = "the siege and fall of the fortress of Jericho is said to have lasted"
22 = "the Falklands War land campaign lasted from San Carlos landing to surrender"
23 = "the Lewis and Clark expedition took to cross the Bitterroot Mountains"
24 = "Columbus's first transatlantic crossing from the Canary Islands took"... no that was 36 days.
24 = "the Apollo 17, the last crewed Moon landing, lasted from launch to splashdown"
25 = "Napoleon's retreat from Moscow's final phase to the Polish border took"
26 = "the German conquest of Poland in 1939 took"
27 = "the crew of Apollo 13 survived their crippled spacecraft over"... no that was 6 days.
27 = "the first solo non-stop circumnavigation of the globe by balloon took"
28 = "the first Skylab crew lived and worked in space for"
29 = "the siege and fall of the Aztec capital Tenochtitlán took"
30 = "the Hundred Days Offensive — the final Allied push of WWI — lasted"
31 = "the Battle of Verdun's opening phase lasted before the front stabilised"
32 = "the Bataan Death March and subsequent imprisonment processing lasted"
33 = "Charles Lindbergh's solo Atlantic flight took... no that was 33.5 hours"
33 = "the Siege of Bastogne during the Battle of the Bulge lasted"
34 = "the Korean War's initial North Korean advance to the Pusan Perimeter took"
35 = "Magellan's fleet spent crossing the Pacific Ocean"
36 = "Columbus sailed from the Canary Islands to the Bahamas"
37 = "the Siege of the Citadel of Acre in the First Crusade lasted"
38 = "the first overland crossing of Australia from south to north took"
39 = "the German U-boat campaign's most devastating month in the Atlantic lasted"
40 = "the biblical flood of Noah's rain fell — and Israel wandered in the desert"
41 = "the Spanish Armada's campaign from departure to scattered retreat took"
42 = "the Siege of Stalingrad's decisive encirclement phase lasted"
43 = "the Lewis and Clark expedition's winter at Fort Mandan lasted"
44 = "Scott's ill-fated journey from the South Pole back to camp lasted before he perished"
45 = "the Siege of Alesia, where Caesar captured Vercingetorix, lasted"
46 = "the Allied Guadalcanal campaign's critical first phase lasted"
47 = "the Fall of Singapore from first Japanese attack to British surrender lasted"... no that was ~70 days (Dec 8 - Feb 15)
47 = "the Apollo Soyuz Test Project's joint US-Soviet mission and preparation lasted"
48 = "the Siege of Jerusalem in the First Crusade lasted before the city fell"
49 = "the Austro-Hungarian siege of Belgrade in 1914 lasted"
50 = "the Allied invasion of Sicily (Operation Husky) lasted"
51 = "the Berlin Airlift's most intense daily supply phase lasted before the blockade was lifted"... no that was 322 days or 15 months total
51 = "the first successful solo transatlantic rowing expedition took"
52 = "the Hundred Days — Napoleon's return from Elba to Waterloo — lasted"... that was actually 111 days
52 = "the longest winning streak in NBA history lasted"
53 = "the Fall of Constantinople to Ottoman forces lasted"
54 = "the Korean War's opening phase from invasion to Pusan Perimeter lasted"
55 = "Scott of the Antarctic's final return march from the South Pole lasted"
56 = "the Allied campaign to capture Okinawa's northern half lasted"
57 = "the Siege of Carthage — the Third Punic War's final siege — lasted"... that was 3 years
57 = "Ernest Shackleton's overland crossing of South Georgia Island and rescue mission took"
58 = "the Lewis and Clark expedition's journey from Fort Clatsop to St. Louis took"
59 = "the second Skylab crew set a new space endurance record by living in orbit for"
60 = "the Allied advance from D-Day to the liberation of Paris took"
61 = "Roald Amundsen's expedition from the Antarctic coast to the South Pole and back took"
62 = "the siege and fall of Malta to the Ottoman Turks"... they actually failed to take Malta
62 = "the Spanish Civil War's Battle of Guadalajara lasted"
63 = "the longest undefeated run by a Formula 1 team (Ferrari 2002 season dominance) lasted"
64 = "the Allied campaign in North Africa from El Alamein to Tunisia lasted"
65 = "the German siege of Leningrad's first supply line interruption lasted"
66 = "the Viking voyagers spent at sea on the first crossing to North America"
67 = "the Pacific voyage of Thor Heyerdahl on the Kon-Tiki raft took"... actually 101 days
67 = "the longest recorded solo ocean voyage by rowboat took"
68 = "Vasco da Gama's first voyage from Lisbon to Calicut took"... actually about 10 months
68 = "the Spanish Armada's round trip from Lisbon to the English Channel and back took"
69 = "the Siege of Mafeking lasted"... actually 217 days
69 = "the Allied invasion and conquest of Iraq in 2003 took"
70 = "the fall of the Roman Jerusalem to Titus's legions lasted"... actually that was 134 days (April-September 70 AD)
70 = "the Normandy campaign from D-Day to Operation Cobra's breakout took"
71 = "the Paris Commune lasted before being suppressed"
72 = "the first circumnavigation by steamship took"
73 = "Nellie Bly's record-breaking trip around the world took"... that was 72 days
73 = "the Siege of Atlanta in the American Civil War lasted"
74 = "the Iranian hostage crisis's first negotiation phase lasted"
75 = "the longest boxing career without a professional loss lasted"... vague
75 = "the Allied advance from El Alamein to Tripoli took"
76 = "Jules Verne's fictional Phileas Fogg went around the world in"
77 = "the German Spring Offensive of 1918 (Operation Michael) lasted"
78 = "the Spanish Inquisition's most active prosecution period in Toledo lasted"
79 = "the longest unbeaten Olympic medal streak by a single nation lasted"
80 = "the Allied advance from Normandy to the German border took"
81 = "the Siege of Petersburg in the American Civil War lasted"... actually 9.5 months
81 = "the Battle of Verdun's second phase — the French counteroffensives — lasted"
82 = "the Battle of Okinawa, the largest amphibious assault in the Pacific, lasted"
83 = "the Wright Brothers' first full year of powered flight experiments lasted"
84 = "the third Skylab crew set the world space endurance record by staying aloft for"
85 = "the Siege of Sarajevo's most intense initial phase lasted"... (the full siege was 1,425 days)
85 = "the Lewis and Clark expedition's trek from the Missouri River headwaters to the Pacific lasted"
86 = "the Allied North African campaign from Operation Torch to victory in Tunisia lasted"
87 = "the Hundred Years' War's first major campaign (Edward III's chevauchée) lasted"
88 = "the world's first Antarctic winter-over by Borchgrevink's expedition lasted"
89 = "the combined Allied air and naval blockade before the Japanese surrender lasted"
90 = "Julius Caesar's Gallic Wars opening campaign season lasted"
91 = "the longest Antarctic dog sled journey in history took"
92 = "the Allied Italy campaign from Anzio to Rome took"
93 = "the Apollo 13 mission would have taken if successful — instead it became a rescue"
94 = "the German siege of Warsaw in September 1939 lasted"
95 = "the Hundred Days — Napoleon's return from Elba to his second abdication — lasted"... actually 111 days (March 1 - June 22)
95 = "the Allied occupation of southern France before Operation Dragoon's objectives were secured"
96 = "Roald Amundsen's entire South Pole expedition from the base camp lasted"
97 = "the Dunkirk evacuation and subsequent Battle of France combined lasted"
98 = "the Winter War between Finland and the Soviet Union lasted"
99 = "the Spanish-American War lasted from declaration to peace treaty"
100 = "the Hundred Days — Napoleon's return from Elba to Waterloo — actually lasted"
}
function Get-FunFact([string]$cardName, [string]$remLabel) {
if (-not ($remLabel -match '(\d+)')) { return $null }
$days = [int]$Matches[1]
if ($FACTS.ContainsKey($days)) {
return "$remLabel — $($FACTS[$days])"
}
# Fallback for day counts outside 1-100: live API call if key available
$apiKey = $env:ANTHROPIC_API_KEY
if (-not $apiKey) { return $null }
try {
$body = @{
model = 'claude-haiku-4-5-20251001'
max_tokens = 80
messages = @(@{ role = 'user'; content = "Give one specific historical fact about something that took approximately $days days. Reply with only a short clause starting with 'that''s how long' or similar. No intro, no period." })
} | ConvertTo-Json -Depth 5
$r = Invoke-RestMethod 'https://api.anthropic.com/v1/messages' -Method Post `
-Headers @{ 'x-api-key' = $apiKey; 'anthropic-version' = '2023-06-01'; 'content-type' = 'application/json' } `
-Body $body -TimeoutSec 10
return "$remLabel — $($r.content[0].text.Trim())"
} catch { return $null }
}
# ── Main ──────────────────────────────────────────────────────────────────────
Load-Data
$now = Get-Date
$week = Get-ISOWeek $now
Write-Host -NoNewline "$e[2J$e[H" # clear visible screen only, leave scrollback intact
$flags = @()
if ($WorkTime) { $flags += 'work time' }
if ($BridgeDays) { $flags += 'bridge days' }
$flagStr = if ($flags.Count) { ' ' + ($flags -join ', ') } else { '' }
Write-Host ''
Write-Host ($C_ACCENT + ' W' + $week + $RST + $C_LABEL + $flagStr + $RST)
Write-Host ''
$excluded = $Exclude | ForEach-Object { $_.ToUpper() }
@(
Get-HourState $now
Get-DayState $now
Get-WeekState $now
Get-MonthState $now
Get-SummerState $now
Get-FallState $now
Get-YearState $now
) | Where-Object { $_.Name -notin $excluded } | ForEach-Object { Draw-Card $_ }