-
-
Notifications
You must be signed in to change notification settings - Fork 296
feat: Add historical data storage for daily and monthly usage tracking #145
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 8 commits
874df4c
1521544
18abfa4
0edcb94
364f572
4fb6aa5
8da35a4
10df28e
fac3903
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,7 @@ | |
| import logging | ||
| from collections import defaultdict | ||
| from dataclasses import dataclass, field | ||
| from datetime import datetime | ||
| from datetime import datetime, timedelta | ||
| from typing import Any, Callable, Dict, List, Optional | ||
|
|
||
| from claude_monitor.core.models import SessionBlock, UsageEntry, normalize_model_name | ||
|
|
@@ -105,7 +105,9 @@ def __init__( | |
| self.data_path = data_path | ||
| self.aggregation_mode = aggregation_mode | ||
| self.timezone = timezone | ||
| self.timezone_handler = TimezoneHandler() | ||
| # Initialize handler with the user-selected timezone so subsequent | ||
| # conversions and localizations use it consistently. | ||
| self.timezone_handler = TimezoneHandler(timezone) | ||
|
|
||
| def _aggregate_by_period( | ||
| self, | ||
|
|
@@ -121,23 +123,48 @@ def _aggregate_by_period( | |
| entries: List of usage entries | ||
| period_key_func: Function to extract period key from timestamp | ||
| period_type: Type of period ('date' or 'month') | ||
| start_date: Optional start date filter | ||
| end_date: Optional end date filter | ||
| start_date: Optional start date filter (inclusive) | ||
| end_date: Optional end date filter (inclusive - includes the whole day) | ||
|
|
||
| Returns: | ||
| List of aggregated data dictionaries | ||
|
|
||
| Note: | ||
| Both start_date and end_date are inclusive. If end_date is provided, | ||
| all entries from that entire day are included (up to 23:59:59.999999). | ||
| """ | ||
| period_data: Dict[str, AggregatedPeriod] = {} | ||
|
|
||
| # Normalize filter boundaries into the configured timezone for | ||
| # consistent, intuitive "whole-day inclusive" semantics. | ||
| norm_start = ( | ||
| self.timezone_handler.to_timezone(start_date, self.timezone) | ||
| if start_date | ||
| else None | ||
| ) | ||
| norm_end = ( | ||
| self.timezone_handler.to_timezone(end_date, self.timezone) | ||
| if end_date | ||
| else None | ||
| ) | ||
|
|
||
| for entry in entries: | ||
| # Apply date filters | ||
| if start_date and entry.timestamp < start_date: | ||
| continue | ||
| if end_date and entry.timestamp > end_date: | ||
| # Convert entry timestamp to the configured timezone for filtering | ||
| # and period-key extraction. | ||
| ts_local = self.timezone_handler.to_timezone(entry.timestamp, self.timezone) | ||
|
|
||
| # Apply date filters (inclusive boundaries in local timezone) | ||
| if norm_start and ts_local < norm_start: | ||
| continue | ||
| # For end_date, include all entries up to the end of that day. | ||
| # Exclude entries >= next day's midnight in local timezone. | ||
| if norm_end: | ||
| next_day = norm_end + timedelta(days=1) | ||
| if ts_local >= next_day: | ||
| continue | ||
|
|
||
|
Comment on lines
+140
to
167
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: end_date with time-of-day over-includes the next day. Normalize to day-start before +1 day. Currently next_day = norm_end + 1 day. If norm_end is 23:59:59, you include the entire following day until 23:59:59. Normalize start/end to the start of their days, then use an exclusive upper bound = end_day_start + 1 day. - norm_start = (
- self.timezone_handler.to_timezone(start_date, self.timezone)
- if start_date
- else None
- )
- norm_end = (
- self.timezone_handler.to_timezone(end_date, self.timezone)
- if end_date
- else None
- )
+ norm_start = (
+ self.timezone_handler.to_timezone(start_date, self.timezone)
+ if start_date
+ else None
+ )
+ norm_end = (
+ self.timezone_handler.to_timezone(end_date, self.timezone)
+ if end_date
+ else None
+ )
+ # Day-normalized inclusive bounds in local time
+ start_bound = (
+ norm_start.replace(hour=0, minute=0, second=0, microsecond=0)
+ if norm_start
+ else None
+ )
+ end_exclusive = (
+ norm_end.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
+ if norm_end
+ else None
+ )
@@
- # Apply date filters (inclusive boundaries in local timezone)
- if norm_start and ts_local < norm_start:
+ # Apply date filters (inclusive by calendar day)
+ if start_bound and ts_local < start_bound:
continue
- # For end_date, include all entries up to the end of that day.
- # Exclude entries >= next day's midnight in local timezone.
- if norm_end:
- next_day = norm_end + timedelta(days=1)
- if ts_local >= next_day:
- continue
+ # Exclude entries on or after start_of_day(end_date + 1)
+ if end_exclusive and ts_local >= end_exclusive:
+ continue#!/bin/bash
# Expect to see only two days (2024-01-15 and 2024-01-31) in tests;
# grep current logic and test inputs to confirm potential overshoot.
rg -n -C2 'next_day\s*=\s*norm_end\s*\+\s*timedelta\(days=1\)' src/claude_monitor/data/aggregator.py
rg -n -C2 'aggregate_daily\(.+end_date' src/tests | sed -n '1,120p' |
||
| # Get period key | ||
| period_key = period_key_func(entry.timestamp) | ||
| # Get period key using local time | ||
| period_key = period_key_func(ts_local) | ||
|
|
||
| # Get or create period aggregate | ||
| if period_key not in period_data: | ||
|
|
@@ -266,9 +293,15 @@ def calculate_totals(self, aggregated_data: List[Dict[str, Any]]) -> Dict[str, A | |
| "entries_count": total_stats.count, | ||
| } | ||
|
|
||
| def aggregate(self) -> List[Dict[str, Any]]: | ||
| def aggregate( | ||
| self, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None | ||
| ) -> List[Dict[str, Any]]: | ||
| """Main aggregation method that reads data and returns aggregated results. | ||
|
|
||
| Args: | ||
| start_date: Optional start date filter | ||
| end_date: Optional end date filter | ||
|
|
||
| Returns: | ||
| List of aggregated data based on aggregation_mode | ||
| """ | ||
|
|
@@ -288,10 +321,10 @@ def aggregate(self) -> List[Dict[str, Any]]: | |
| if entry.timestamp.tzinfo is None: | ||
| entry.timestamp = self.timezone_handler.ensure_timezone(entry.timestamp) | ||
|
|
||
| # Aggregate based on mode | ||
| # Aggregate based on mode with date filters | ||
| if self.aggregation_mode == "daily": | ||
| return self.aggregate_daily(entries) | ||
| return self.aggregate_daily(entries, start_date, end_date) | ||
| elif self.aggregation_mode == "monthly": | ||
| return self.aggregate_monthly(entries) | ||
| return self.aggregate_monthly(entries, start_date, end_date) | ||
| else: | ||
| raise ValueError(f"Invalid aggregation mode: {self.aggregation_mode}") | ||
Uh oh!
There was an error while loading. Please reload this page.