From a18e2a24555c8ed1ce1b7320930ff4bdd24588c0 Mon Sep 17 00:00:00 2001 From: ulve Date: Sun, 13 Sep 2026 10:48:48 +0200 Subject: [PATCH] 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. --- Dockerfile | 3 + aftersummer.ps1 | 563 ++++++++++++++++++++++++++++++++++++++++++++++++ server.js | 111 ++++++++++ 3 files changed, 677 insertions(+) create mode 100644 aftersummer.ps1 diff --git a/Dockerfile b/Dockerfile index d9f4480..db8d0fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ FROM node:22-alpine +RUN apk add --no-cache tzdata +ENV TZ=Europe/Stockholm + WORKDIR /app COPY package.json package-lock.json* ./ diff --git a/aftersummer.ps1 b/aftersummer.ps1 new file mode 100644 index 0000000..1cbcfc4 --- /dev/null +++ b/aftersummer.ps1 @@ -0,0 +1,563 @@ +#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 $_ } diff --git a/server.js b/server.js index 339948e..affcdf5 100644 --- a/server.js +++ b/server.js @@ -39,5 +39,116 @@ app.get('/api/bridge-days', async (req, res) => { } }); +// ───────────────────────────────────────────────────────────────────────────── +// /shouldibeworking — same work-time rules as public/app.js (see isWorkDay / +// isCurrentlyWorkTime there), ported so it can be evaluated server-side. +// ───────────────────────────────────────────────────────────────────────────── + +const WORK_START = 8 * 60; // 08:00 +const LUNCH_START = 12 * 60; // 12:00 +const LUNCH_END = 13 * 60; // 13:00 +const WORK_END = 17 * 60; // 17:00 + +function formatDate(date) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function isWeekend(date) { + const d = date.getDay(); + return d === 0 || d === 6; +} + +// Matches app.js: all of July counts as non-work (Swedish summer holidays). +function isJuly(date) { + return date.getMonth() === 6; +} + +async function isWorkDay(date, includeBridgeDays) { + if (isWeekend(date)) return false; + if (isJuly(date)) return false; + const year = date.getFullYear(); + const ds = formatDate(date); + const holidays = await cachedFetch(`https://api.dagsmart.se/holidays?year=${year}&weekends=false`); + if (holidays.some(h => h.date === ds)) return false; + if (includeBridgeDays) { + const bridgeDays = await cachedFetch(`https://api.dagsmart.se/bridge-days?year=${year}`); + if (bridgeDays.some(h => h.date === ds)) return false; + } + return true; +} + +async function isCurrentlyWorkTime(date, includeBridgeDays) { + if (!(await isWorkDay(date, includeBridgeDays))) return false; + const mins = date.getHours() * 60 + date.getMinutes(); + if (mins < WORK_START || mins >= WORK_END) return false; + if (mins >= LUNCH_START && mins < LUNCH_END) return false; + return true; +} + +// Minutes from `now` until the working/not-working state next flips — +// the next of WORK_START/LUNCH_START/LUNCH_END/WORK_END today, or WORK_START +// on the next work day if none of those are left today. Rounded up, so it +// never reads 0 while a change is still seconds away. +async function minutesUntilNextShift(now, includeBridgeDays) { + const nowMins = now.getHours() * 60 + now.getMinutes() + now.getSeconds() / 60; + + if (await isWorkDay(now, includeBridgeDays)) { + for (const boundary of [WORK_START, LUNCH_START, LUNCH_END, WORK_END]) { + if (boundary > nowMins) return Math.ceil(boundary - nowMins); + } + } + + const cursor = new Date(now); + cursor.setHours(0, 0, 0, 0); + cursor.setDate(cursor.getDate() + 1); + for (let i = 0; i < 60; i++) { + if (await isWorkDay(cursor, includeBridgeDays)) { + const target = new Date(cursor); + target.setMinutes(WORK_START); + return Math.ceil((target - now) / 60000); + } + cursor.setDate(cursor.getDate() + 1); + } + return null; // no work day found within 60 days — shouldn't happen +} + +// Fixed-window rate limiter: N requests per IP per window, no dependencies. +function rateLimit({ windowMs, max }) { + const hits = new Map(); // ip -> { count, resetAt } + return (req, res, next) => { + const now = Date.now(); + const entry = hits.get(req.ip); + if (!entry || now > entry.resetAt) { + hits.set(req.ip, { count: 1, resetAt: now + windowMs }); + return next(); + } + if (entry.count >= max) { + res.set('Retry-After', String(Math.ceil((entry.resetAt - now) / 1000))); + return res.status(429).json({ error: 'rate limited' }); + } + entry.count++; + next(); + }; +} + +app.get( + '/shouldibeworking', + rateLimit({ windowMs: 60 * 1000, max: 20 }), + async (req, res) => { + try { + const includeBridgeDays = req.query.bridgeDays === 'true'; + const now = new Date(); + const working = await isCurrentlyWorkTime(now, includeBridgeDays); + const minutesUntilChange = await minutesUntilNextShift(now, includeBridgeDays); + res.json({ working, includeBridgeDays, minutesUntilChange }); + } catch (err) { + res.status(500).json({ error: String(err) }); + } + } +); + const PORT = process.env.PORT || 3000; app.listen(PORT, () => console.log(`Aftersummer → http://localhost:${PORT}`));