Skip to content

Commit 9813737

Browse files
Add utc
1 parent edc5a1c commit 9813737

File tree

6 files changed

+320
-30
lines changed

6 files changed

+320
-30
lines changed

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,27 @@
22

33
## [2.0.0] - 2025-06-25
44

5+
### Added (Post-release update - 2025-06-26)
6+
- **🌐 Enhanced UTC-First Timezone Architecture**: Robust timezone handling improvements
7+
- New `timezone_utils.py` module with comprehensive timezone utilities
8+
- Robust timestamp parsing supporting multiple formats (ISO, Unix timestamps, timezone-naive)
9+
- Timezone validation pipeline with caching for performance
10+
- Automatic zoneinfo support for Python 3.9+ with pytz fallback
11+
- Cross-platform timezone detection (Windows, macOS, Linux)
12+
- Improved error handling and logging throughout timezone operations
13+
- **🔧 Modern Python Compatibility**: Future-proof timezone handling
14+
- Conditional import of zoneinfo (Python 3.9+) or pytz (Python 3.8)
15+
- Windows timezone data support via tzdata package
16+
- Backwards compatibility maintained for all Python versions ≥3.8
17+
18+
### Changed (Post-release update - 2025-06-26)
19+
- Enhanced `data_loader.py` with robust timestamp parsing using TimezoneHandler
20+
- Improved `json_formatter.py` timezone handling to maintain timezone awareness
21+
- Updated `claude_monitor.py` with better timezone error handling and validation
22+
- Added `tzdata` dependency for Windows systems to support zoneinfo
23+
24+
## [2.0.0] - 2025-06-25
25+
526
### Added
627
- **🎨 Smart Theme System**: Automatic light/dark theme detection for optimal terminal appearance
728
- Intelligent theme detection based on terminal environment, system settings, and background color

claude_monitor.py

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
import sys
77
import threading
88
from datetime import datetime, timedelta
9+
import logging
910

1011
import pytz
1112

1213
from usage_analyzer.api import analyze_usage
1314
from usage_analyzer.themes import get_themed_console, print_themed, ThemeType
15+
from usage_analyzer.utils.timezone_utils import TimezoneHandler, safe_timezone_conversion
1416

1517
# All internal calculations use UTC, display timezone is configurable
1618
UTC_TZ = pytz.UTC
@@ -154,13 +156,14 @@ def calculate_hourly_burn_rate(blocks, current_time):
154156
if not start_time_str:
155157
continue
156158

157-
# Parse start time - data from usage_analyzer is in UTC
158-
start_time = datetime.fromisoformat(start_time_str.replace("Z", "+00:00"))
159-
# Ensure it's in UTC for calculations
160-
if start_time.tzinfo is None:
161-
start_time = UTC_TZ.localize(start_time)
162-
else:
163-
start_time = start_time.astimezone(UTC_TZ)
159+
# Parse start time using robust timestamp parsing
160+
tz_handler = TimezoneHandler()
161+
try:
162+
start_time = tz_handler.parse_timestamp(start_time_str)
163+
start_time = tz_handler.ensure_utc(start_time)
164+
except Exception as e:
165+
logging.debug(f"Failed to parse start time '{start_time_str}': {e}")
166+
continue
164167

165168
# Skip gaps
166169
if block.get("isGap", False):
@@ -174,14 +177,12 @@ def calculate_hourly_burn_rate(blocks, current_time):
174177
# For completed sessions, use actualEndTime or current time
175178
actual_end_str = block.get("actualEndTime")
176179
if actual_end_str:
177-
session_actual_end = datetime.fromisoformat(
178-
actual_end_str.replace("Z", "+00:00")
179-
)
180-
# Ensure it's in UTC for calculations
181-
if session_actual_end.tzinfo is None:
182-
session_actual_end = UTC_TZ.localize(session_actual_end)
183-
else:
184-
session_actual_end = session_actual_end.astimezone(UTC_TZ)
180+
try:
181+
session_actual_end = tz_handler.parse_timestamp(actual_end_str)
182+
session_actual_end = tz_handler.ensure_utc(session_actual_end)
183+
except Exception as e:
184+
logging.debug(f"Failed to parse actual end time '{actual_end_str}': {e}")
185+
session_actual_end = current_time
185186
else:
186187
session_actual_end = current_time
187188

@@ -530,12 +531,15 @@ def main():
530531
screen_buffer.append("")
531532

532533
# Predictions - convert to configured timezone for display
533-
try:
534-
local_tz = pytz.timezone(args.timezone)
535-
except pytz.exceptions.UnknownTimeZoneError:
536-
local_tz = pytz.timezone("Europe/Warsaw")
537-
predicted_end_local = predicted_end_time.astimezone(local_tz)
538-
reset_time_local = reset_time.astimezone(local_tz)
534+
tz_handler = TimezoneHandler(default_tz="Europe/Warsaw")
535+
if not tz_handler.validate_timezone(args.timezone):
536+
print_themed(f"Invalid timezone '{args.timezone}', using Europe/Warsaw", style="warning")
537+
timezone_to_use = "Europe/Warsaw"
538+
else:
539+
timezone_to_use = args.timezone
540+
541+
predicted_end_local = tz_handler.convert_to_timezone(predicted_end_time, timezone_to_use)
542+
reset_time_local = tz_handler.convert_to_timezone(reset_time, timezone_to_use)
539543

540544
predicted_end_str = predicted_end_local.strftime("%H:%M")
541545
reset_time_str = reset_time_local.strftime("%H:%M")

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ classifiers = [
3535
dependencies = [
3636
"pytz",
3737
"rich>=13.0.0",
38+
"tzdata; sys_platform == 'win32'", # Required for zoneinfo on Windows
3839
]
3940

4041
[project.optional-dependencies]

usage_analyzer/core/data_loader.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
from pathlib import Path
99
from typing import List, Optional
1010
import json
11+
import logging
1112

1213
from usage_analyzer.utils.path_discovery import discover_claude_data_paths
1314
from usage_analyzer.utils.pricing_fetcher import ClaudePricingFetcher
15+
from usage_analyzer.utils.timezone_utils import TimezoneHandler
1416
from usage_analyzer.models.data_structures import UsageEntry, CostMode
1517

1618

@@ -27,6 +29,8 @@ def __init__(self, data_path: Optional[str] = None):
2729
self.data_path = Path(data_path).expanduser()
2830

2931
self.pricing_fetcher = ClaudePricingFetcher()
32+
self.timezone_handler = TimezoneHandler()
33+
self.logger = logging.getLogger(__name__)
3034

3135
def load_usage_data(self, mode: CostMode = CostMode.AUTO) -> List[UsageEntry]:
3236
"""Load and process all usage data."""
@@ -142,7 +146,14 @@ def _convert_to_usage_entry(self, data: dict, mode: CostMode) -> Optional[UsageE
142146
if 'timestamp' not in data:
143147
return None
144148

145-
timestamp = datetime.fromisoformat(data['timestamp'].replace('Z', '+00:00'))
149+
# Use robust timestamp parsing with error handling
150+
try:
151+
timestamp = self.timezone_handler.parse_timestamp(data['timestamp'])
152+
# Ensure timestamp is in UTC for internal processing
153+
timestamp = self.timezone_handler.ensure_utc(timestamp)
154+
except Exception as e:
155+
self.logger.debug(f"Failed to parse timestamp '{data.get('timestamp')}': {e}")
156+
return None
146157

147158
# Handle both nested and flat usage data
148159
usage = data.get('usage', {})

usage_analyzer/output/json_formatter.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77

88
import json
99
from typing import Any, Dict, List
10+
from datetime import datetime
1011

1112
from usage_analyzer.models.data_structures import SessionBlock
1213
from usage_analyzer.core.calculator import BurnRateCalculator
1314
from usage_analyzer.utils.pricing_fetcher import ClaudePricingFetcher
15+
from usage_analyzer.utils.timezone_utils import TimezoneHandler
1416
from usage_analyzer.themes import print_themed, get_themed_console
1517

1618

@@ -21,6 +23,7 @@ def __init__(self):
2123
"""Initialize formatter."""
2224
self.calculator = BurnRateCalculator()
2325
self.pricing_fetcher = ClaudePricingFetcher()
26+
self.timezone_handler = TimezoneHandler()
2427

2528
def print_summary(self, blocks: List[SessionBlock]) -> None:
2629
"""Print a themed summary of session blocks."""
@@ -168,17 +171,18 @@ def _format_per_model_stats(self, per_model_stats: Dict[str, Dict[str, Any]], pe
168171

169172
return formatted_stats
170173

171-
def _format_timestamp(self, timestamp) -> str:
174+
def _format_timestamp(self, timestamp: datetime) -> str:
172175
"""Format datetime to match format with milliseconds precision."""
173176
if timestamp is None:
174177
return None
175-
# Convert to UTC if needed
176-
if timestamp.tzinfo is not None:
177-
from datetime import timezone
178-
utc_timestamp = timestamp.astimezone(timezone.utc).replace(tzinfo=None)
179-
else:
180-
utc_timestamp = timestamp
178+
179+
# Ensure timestamp is in UTC using our robust handler
180+
utc_timestamp = self.timezone_handler.ensure_utc(timestamp)
181181

182182
# Format with milliseconds precision (.XXXZ)
183+
# Keep timezone information by formatting properly
183184
milliseconds = utc_timestamp.microsecond // 1000
184-
return utc_timestamp.strftime(f'%Y-%m-%dT%H:%M:%S.{milliseconds:03d}Z')
185+
# Use ISO format with explicit UTC 'Z' suffix
186+
formatted = utc_timestamp.strftime(f'%Y-%m-%dT%H:%M:%S.{milliseconds:03d}')
187+
# Add 'Z' suffix to indicate UTC (maintaining timezone awareness)
188+
return f"{formatted}Z"

0 commit comments

Comments
 (0)