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.
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache tzdata
|
||||||
|
ENV TZ=Europe/Stockholm
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|||||||
+563
@@ -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 $_ }
|
||||||
@@ -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;
|
const PORT = process.env.PORT || 3000;
|
||||||
app.listen(PORT, () => console.log(`Aftersummer → http://localhost:${PORT}`));
|
app.listen(PORT, () => console.log(`Aftersummer → http://localhost:${PORT}`));
|
||||||
|
|||||||
Reference in New Issue
Block a user