fix: use explicit America/Chicago timezone for freeze/thaw scheduling
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m16s

The production container has ambiguous timezone config — /etc/localtime
points to Etc/UTC but date reports CST. The transaction freeze/thaw task
used datetime.now() (naive, relying on OS timezone), causing scheduling
to fire at unpredictable wall-clock times.

- Add utils/timezone.py with centralized Chicago timezone helpers
- Fix tasks/transaction_freeze.py to use now_chicago() for scheduling
- Fix utils/logging.py timestamp to use proper UTC-aware datetime
- Add 14 timezone utility tests
- Update freeze task tests to mock now_chicago instead of datetime

Closes #43

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-02-22 15:55:49 -06:00
parent a668a3505c
commit 8a1a957c2a
5 changed files with 514 additions and 254 deletions

View File

@ -8,6 +8,8 @@ Runs on a schedule to increment weeks and process contested transactions.
import asyncio import asyncio
import random import random
from datetime import datetime, UTC from datetime import datetime, UTC
from utils.timezone import now_chicago
from typing import Dict, List, Tuple, Set, Optional from typing import Dict, List, Tuple, Set, Optional
from dataclasses import dataclass from dataclasses import dataclass
@ -325,7 +327,7 @@ class TransactionFreezeTask:
self.logger.warning("Could not get current league state") self.logger.warning("Could not get current league state")
return return
now = datetime.now(UTC) now = now_chicago()
self.logger.info( self.logger.info(
f"Weekly loop check", f"Weekly loop check",
datetime=now.isoformat(), datetime=now.isoformat(),

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,129 @@
"""
Tests for timezone utility module.
Validates centralized timezone helpers that ensure scheduling logic uses
explicit America/Chicago timezone rather than relying on OS defaults.
"""
import pytest
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from utils.timezone import (
CHICAGO_TZ,
now_utc,
now_chicago,
to_chicago,
to_discord_timestamp,
)
class TestChicagoTZ:
"""Tests for the CHICAGO_TZ constant."""
def test_chicago_tz_is_zoneinfo(self):
"""CHICAGO_TZ should be a ZoneInfo instance for America/Chicago."""
assert isinstance(CHICAGO_TZ, ZoneInfo)
assert str(CHICAGO_TZ) == "America/Chicago"
class TestNowUtc:
"""Tests for now_utc()."""
def test_returns_aware_datetime(self):
"""now_utc() should return a timezone-aware datetime."""
result = now_utc()
assert result.tzinfo is not None
def test_returns_utc(self):
"""now_utc() should return a datetime in UTC."""
result = now_utc()
assert result.tzinfo == timezone.utc
class TestNowChicago:
"""Tests for now_chicago()."""
def test_returns_aware_datetime(self):
"""now_chicago() should return a timezone-aware datetime."""
result = now_chicago()
assert result.tzinfo is not None
def test_returns_chicago_tz(self):
"""now_chicago() should return a datetime in America/Chicago timezone."""
result = now_chicago()
assert result.tzinfo == CHICAGO_TZ
class TestToChicago:
"""Tests for to_chicago()."""
def test_converts_utc_datetime(self):
"""to_chicago() should correctly convert a UTC datetime to Chicago time."""
# 2024-07-15 18:00 UTC = 2024-07-15 13:00 CDT (UTC-5 during summer)
utc_dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc)
result = to_chicago(utc_dt)
assert result.hour == 13
assert result.tzinfo == CHICAGO_TZ
def test_handles_naive_datetime_assumes_utc(self):
"""to_chicago() should treat naive datetimes as UTC."""
naive_dt = datetime(2024, 7, 15, 18, 0, 0)
result = to_chicago(naive_dt)
# Same as converting from UTC
utc_dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc)
expected = to_chicago(utc_dt)
assert result.hour == expected.hour
assert result.tzinfo == CHICAGO_TZ
def test_preserves_already_chicago(self):
"""to_chicago() on an already-Chicago datetime should be a no-op."""
chicago_dt = datetime(2024, 7, 15, 13, 0, 0, tzinfo=CHICAGO_TZ)
result = to_chicago(chicago_dt)
assert result.hour == 13
assert result.tzinfo == CHICAGO_TZ
def test_winter_offset(self):
"""to_chicago() should use CST (UTC-6) during winter months."""
# 2024-01-15 18:00 UTC = 2024-01-15 12:00 CST (UTC-6 during winter)
utc_dt = datetime(2024, 1, 15, 18, 0, 0, tzinfo=timezone.utc)
result = to_chicago(utc_dt)
assert result.hour == 12
class TestToDiscordTimestamp:
"""Tests for to_discord_timestamp()."""
def test_default_style(self):
"""to_discord_timestamp() should use 'f' style by default."""
dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc)
result = to_discord_timestamp(dt)
unix_ts = int(dt.timestamp())
assert result == f"<t:{unix_ts}:f>"
def test_relative_style(self):
"""to_discord_timestamp() with style='R' should produce relative format."""
dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc)
result = to_discord_timestamp(dt, style="R")
unix_ts = int(dt.timestamp())
assert result == f"<t:{unix_ts}:R>"
def test_all_styles(self):
"""to_discord_timestamp() should support all Discord timestamp styles."""
dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc)
unix_ts = int(dt.timestamp())
for style in ("R", "f", "F", "t", "T", "d", "D"):
result = to_discord_timestamp(dt, style=style)
assert result == f"<t:{unix_ts}:{style}>"
def test_naive_datetime_assumes_utc(self):
"""to_discord_timestamp() should treat naive datetimes as UTC."""
naive_dt = datetime(2024, 7, 15, 18, 0, 0)
aware_dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc)
assert to_discord_timestamp(naive_dt) == to_discord_timestamp(aware_dt)
def test_chicago_datetime_same_instant(self):
"""to_discord_timestamp() should produce the same unix timestamp regardless of tz."""
utc_dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc)
chicago_dt = utc_dt.astimezone(CHICAGO_TZ)
assert to_discord_timestamp(utc_dt) == to_discord_timestamp(chicago_dt)

View File

@ -10,7 +10,7 @@ import json
import logging import logging
import time import time
import uuid import uuid
from datetime import datetime from datetime import datetime, UTC
from typing import Dict, Any, Optional, Union from typing import Dict, Any, Optional, Union
# Context variable for request tracking across async calls # Context variable for request tracking across async calls
@ -32,7 +32,7 @@ class JSONFormatter(logging.Formatter):
"""Format log record as JSON with context information.""" """Format log record as JSON with context information."""
# Base log object # Base log object
log_obj: dict[str, JSONValue] = { log_obj: dict[str, JSONValue] = {
"timestamp": datetime.now().isoformat() + "Z", "timestamp": datetime.now(UTC).isoformat(),
"level": record.levelname, "level": record.levelname,
"logger": record.name, "logger": record.name,
"message": record.getMessage(), "message": record.getMessage(),

54
utils/timezone.py Normal file
View File

@ -0,0 +1,54 @@
"""
Timezone Utilities
Centralized timezone handling for the Discord bot. The SBA league operates
in America/Chicago time, but the production container may have ambiguous
timezone config. These helpers ensure scheduling logic uses explicit timezones
rather than relying on the OS default.
- Internal storage/logging: UTC
- Scheduling checks (freeze/thaw): America/Chicago
"""
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
# League timezone — all scheduling decisions use this
CHICAGO_TZ = ZoneInfo("America/Chicago")
def now_utc() -> datetime:
"""Return the current time as a timezone-aware UTC datetime."""
return datetime.now(timezone.utc)
def now_chicago() -> datetime:
"""Return the current time as a timezone-aware America/Chicago datetime."""
return datetime.now(CHICAGO_TZ)
def to_chicago(dt: datetime) -> datetime:
"""Convert a datetime to America/Chicago.
If *dt* is naive (no tzinfo), it is assumed to be UTC.
"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(CHICAGO_TZ)
def to_discord_timestamp(dt: datetime, style: str = "f") -> str:
"""Format a datetime as a Discord dynamic timestamp.
Args:
dt: A datetime (naive datetimes are assumed UTC).
style: Discord timestamp style letter.
R = relative, f = long date/short time, F = long date/time,
t = short time, T = long time, d = short date, D = long date.
Returns:
A string like ``<t:1234567890:f>``.
"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return f"<t:{int(dt.timestamp())}:{style}>"