diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..53966cf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,312 @@ +{ + "workbench.colorCustomizations": { + "editor.background": "#edf2ef", + "editor.lineHighlightBackground": "#e1eae4", + "editorGutter.background": "#edf2ef", + "minimap.background": "#edf2ef", + "terminal.background": "#edf2ef", + "sideBar.background": "#dee4d7", + "panel.background": "#e3e8de", + "titleBar.activeBackground": "#773a78", + "titleBar.inactiveBackground": "#934794", + "statusBar.background": "#475b85", + "statusBar.noFolderBackground": "#506695", + "activityBar.background": "#cad4bf", + "activityBar.activeBackground": "#c2ceb6", + "editorGroupHeader.tabsBackground": "#e6eae1", + "tab.activeBackground": "#edf2ef", + "tab.inactiveBackground": "#ebeee7", + "tab.hoverBackground": "#f0f3ed", + "editorWidget.background": "#dee7e2", + "editorWidget.border": "#c1d2c8", + "breadcrumb.background": "#e4ece7", + "editor.selectionBackground": "#e4cde4", + "editor.selectionHighlightBackground": "#d9dee8", + "editor.findMatchBackground": "#c4e3da", + "editor.findMatchHighlightBackground": "#d6e6e1", + "panel.border": "#c0cbb3", + "sideBar.border": "#c0cbb3", + "editorGroup.border": "#c5cfb9", + "editor.foreground": "#6f5874", + "sideBar.foreground": "#6f5973", + "panel.foreground": "#6f5973", + "terminal.foreground": "#6f5874", + "titleBar.activeForeground": "#ffffff", + "titleBar.inactiveForeground": "#e6e6e6", + "statusBar.foreground": "#e6e6e6", + "activityBar.foreground": "#595959", + "activityBar.inactiveForeground": "#595959", + "tab.activeForeground": "#6f5874", + "tab.inactiveForeground": "#675f6d", + "editorGroupHeader.tabsForeground": "#6f5973", + "editorWidget.foreground": "#6f5874", + "breadcrumb.foreground": "#6f5973", + "editorCursor.foreground": "#76dc2e", + "button.foreground": "#e6e6e6", + "button.background": "#7b177d", + "button.hoverBackground": "#911b93", + "textLink.foreground": "#d027d3", + "textLink.activeForeground": "#a61fa8", + "editorLineNumber.foreground": "#9a92a0", + "editorLineNumber.activeForeground": "#7d6482", + "gitDecoration.addedResourceForeground": "#00996b", + "gitDecoration.modifiedResourceForeground": "#00b339", + "gitDecoration.deletedResourceForeground": "#76dc2e", + "gitDecoration.untrackedResourceForeground": "#642aa2", + "errorForeground": "#76dc2e", + "warningForeground": "#2eca16", + "infoForeground": "#d027d3", + "focusBorder": "#bb23be", + "list.hoverBackground": "#ebeee7", + "list.activeSelectionBackground": "#e064e3", + "list.activeSelectionForeground": "#000000" + }, + "editor.tokenColorCustomizations": { + "textMateRules": [ + { + "name": "Comment", + "scope": ["comment", "punctuation.definition.comment"], + "settings": { + "fontStyle": "italic", + "foreground": "#9a92a0" + } + }, + { + "name": "String", + "scope": ["string", "punctuation.definition.string"], + "settings": { + "foreground": "#642aa2" + } + }, + { + "name": "Number", + "scope": ["constant.numeric"], + "settings": { + "foreground": "#76dc2e" + } + }, + { + "name": "Built-in constant", + "scope": ["constant.language"], + "settings": { + "foreground": "#76dc2e" + } + }, + { + "name": "User-defined constant", + "scope": ["constant.character", "constant.other"], + "settings": { + "foreground": "#76dc2e" + } + }, + { + "name": "Variable", + "scope": ["variable"], + "settings": { + "foreground": "#d027d3" + } + }, + { + "name": "Keyword", + "scope": ["keyword"], + "settings": { + "foreground": "#00996b" + } + }, + { + "name": "Storage", + "scope": ["storage"], + "settings": { + "foreground": "#00996b" + } + }, + { + "name": "Storage type", + "scope": ["storage.type"], + "settings": { + "fontStyle": "italic", + "foreground": "#d027d3" + } + }, + { + "name": "Class name", + "scope": ["entity.name.class"], + "settings": { + "foreground": "#d027d3" + } + }, + { + "name": "Function name", + "scope": ["entity.name.function"], + "settings": { + "foreground": "#d027d3" + } + }, + { + "name": "Function argument", + "scope": ["variable.parameter"], + "settings": { + "fontStyle": "italic", + "foreground": "#2eca16" + } + }, + { + "name": "Tag name", + "scope": ["entity.name.tag"], + "settings": { + "foreground": "#d027d3" + } + }, + { + "name": "Tag attribute", + "scope": ["entity.other.attribute-name"], + "settings": { + "foreground": "#00996b" + } + }, + { + "name": "Library function", + "scope": ["support.function"], + "settings": { + "foreground": "#d027d3" + } + }, + { + "name": "Library constant", + "scope": ["support.constant"], + "settings": { + "foreground": "#76dc2e" + } + }, + { + "name": "Library class/type", + "scope": ["support.type", "support.class"], + "settings": { + "fontStyle": "italic", + "foreground": "#d027d3" + } + }, + { + "name": "Library variable", + "scope": ["support.other.variable"], + "settings": { + "foreground": "#d027d3" + } + }, + { + "name": "Invalid", + "scope": ["invalid"], + "settings": { + "background": "#76dc2e", + "foreground": "#e2fdea" + } + }, + { + "name": "Invalid deprecated", + "scope": ["invalid.deprecated"], + "settings": { + "background": "#2eca16", + "foreground": "#e2fdea" + } + }, + { + "name": "JSON String", + "scope": ["meta.structure.dictionary.json string.quoted.double.json"], + "settings": { + "foreground": "#00b339" + } + }, + { + "name": "diff.header", + "scope": ["meta.diff", "meta.diff.header"], + "settings": { + "foreground": "#9a92a0" + } + }, + { + "name": "diff.deleted", + "scope": ["markup.deleted"], + "settings": { + "foreground": "#76dc2e" + } + }, + { + "name": "diff.inserted", + "scope": ["markup.inserted"], + "settings": { + "foreground": "#00996b" + } + }, + { + "name": "diff.changed", + "scope": ["markup.changed"], + "settings": { + "foreground": "#00b339" + } + }, + { + "name": "CSS Classes", + "scope": ["entity.other.attribute-name.class.css"], + "settings": { + "foreground": "#00b339" + } + }, + { + "name": "CSS IDs", + "scope": ["entity.other.attribute-name.id.css"], + "settings": { + "foreground": "#00b339" + } + }, + { + "name": "CSS Properties", + "scope": ["support.type.property-name.css"], + "settings": { + "foreground": "#d027d3" + } + }, + { + "name": "CSS Property Values", + "scope": [ + "support.constant.property-value.css", + "constant.numeric.css" + ], + "settings": { + "foreground": "#642aa2" + } + }, + { + "name": "Markdown Headings", + "scope": ["markup.heading"], + "settings": { + "fontStyle": "bold", + "foreground": "#d027d3" + } + }, + { + "name": "Markdown Links", + "scope": ["markup.underline.link"], + "settings": { + "foreground": "#642aa2" + } + }, + { + "name": "Markdown Bold", + "scope": ["markup.bold"], + "settings": { + "fontStyle": "bold", + "foreground": "#2eca16" + } + }, + { + "name": "Markdown Italic", + "scope": ["markup.italic"], + "settings": { + "fontStyle": "italic", + "foreground": "#2eca16" + } + } + ] + }, + "window.title": "♣ CLAUDE-CODE-USAGE-MONITOR - ${activeEditorShort}" +} diff --git a/src/claude_monitor/cli/main.py b/src/claude_monitor/cli/main.py index 3669423..4b63855 100644 --- a/src/claude_monitor/cli/main.py +++ b/src/claude_monitor/cli/main.py @@ -410,6 +410,8 @@ def _run_table_view( timezone=args.timezone, plan=args.plan, token_limit=_get_initial_token_limit(args, data_path), + date_format=getattr(args, "date_format", None), + abbreviate_tokens=getattr(args, "abbreviate_tokens", False), ) # Wait for user to press Ctrl+C diff --git a/src/claude_monitor/core/settings.py b/src/claude_monitor/core/settings.py index 14aec1b..cc52c82 100644 --- a/src/claude_monitor/core/settings.py +++ b/src/claude_monitor/core/settings.py @@ -161,6 +161,16 @@ def _get_system_time_format() -> str: log_file: Optional[Path] = Field(default=None, description="Log file path") + date_format: Optional[str] = Field( + default=None, + description="Date format string (strftime format) for monthly/daily views. Example: '%d %b - %a' for '07 Nov - Fri'", + ) + + abbreviate_tokens: bool = Field( + default=False, + description="Abbreviate token counts with 'k' suffix (e.g., '273k' instead of '273,155')", + ) + debug: bool = Field( default=False, description="Enable debug logging (equivalent to --log-level DEBUG)", @@ -349,6 +359,8 @@ def to_namespace(self) -> argparse.Namespace: args.time_format = self.time_format args.log_level = self.log_level args.log_file = str(self.log_file) if self.log_file else None + args.date_format = self.date_format + args.abbreviate_tokens = self.abbreviate_tokens args.version = self.version return args diff --git a/src/claude_monitor/ui/progress_bars.py b/src/claude_monitor/ui/progress_bars.py index db14e11..f72b86a 100644 --- a/src/claude_monitor/ui/progress_bars.py +++ b/src/claude_monitor/ui/progress_bars.py @@ -1,6 +1,7 @@ """Progress bar components for Claude Monitor. Provides token usage, time progress, and model usage progress bars. +Also provides sparkline utilities for table visualizations. """ from __future__ import annotations @@ -331,3 +332,89 @@ def render(self, per_model_stats: dict[str, Any]) -> str: summary = f"Other {other_percentage:.1f}%" return f"šŸ¤– [{bar_display}] {summary}" + + +def create_sparkline( + value: int | float, + max_value: int | float, + width: int = 12, + filled_char: str = "ā–ˆ", + empty_char: str = "ā–‘", +) -> str: + """Create a plaintext sparkline bar for table visualizations. + + The bar width is fixed, and the fill is proportional to value/max_value. + This provides a visual comparison across rows in tables. + + Args: + value: Current value to visualize + max_value: Maximum value (determines fill proportion) + width: Width of the sparkline bar in characters (default: 12) + filled_char: Character for filled segments (default: "ā–ˆ") + empty_char: Character for empty segments (default: "ā–‘") + + Returns: + Plaintext sparkline bar string (e.g., "ā–ˆā–ˆā–ˆā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘") + + Examples: + >>> create_sparkline(500, 1000, width=10) + 'ā–ˆā–ˆā–ˆā–ˆā–ˆā–‘ā–‘ā–‘ā–‘ā–‘' + >>> create_sparkline(273155, 710891658, width=12) + 'ā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘' + """ + if max_value <= 0: + return empty_char * width + + # Calculate percentage and ensure it's between 0 and 100 + percentage_value = min(100.0, max(0.0, (value / max_value) * 100.0)) + + # Calculate filled segments + filled = int(width * percentage_value / 100.0) + + # Render the bar + filled_bar = filled_char * filled + empty_bar = empty_char * (width - filled) + + return f"{filled_bar}{empty_bar}" + + +def create_dot_sparkline( + value: int | float, + max_value: int | float, + width: int = 12, + dot_char: str = "ā—", + line_char: str = "─", +) -> str: + """Create a Tufte-style dot sparkline showing value position on a scale. + + Instead of filling a bar, shows the value as a dot position on a fixed scale. + This allows better comparison across rows and columns since all use the same scale. + + Args: + value: Current value to visualize + max_value: Maximum value (determines dot position) + width: Width of the sparkline in characters (default: 12) + dot_char: Character for the value marker (default: "ā—") + line_char: Character for the scale line (default: "─") + + Returns: + Plaintext dot sparkline string (e.g., "ā”€ā”€ā”€ā”€ā”€ā—ā”€ā”€ā”€ā”€ā”€ā”€ā”€") + + Examples: + >>> create_dot_sparkline(500, 1000, width=10) + 'ā”€ā”€ā”€ā”€ā”€ā—ā”€ā”€ā”€ā”€ā”€' + >>> create_dot_sparkline(273155, 710891658, width=12) + 'ā”€ā—ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€' + """ + if max_value <= 0: + return line_char * width + + # Calculate position (0 to width-1) + percentage_value = min(100.0, max(0.0, (value / max_value) * 100.0)) + position = int((width - 1) * percentage_value / 100.0) + + # Create the sparkline with dot at position + sparkline = [line_char] * width + sparkline[position] = dot_char + + return "".join(sparkline) diff --git a/src/claude_monitor/ui/table_views.py b/src/claude_monitor/ui/table_views.py index f964fe8..ec458ed 100644 --- a/src/claude_monitor/ui/table_views.py +++ b/src/claude_monitor/ui/table_views.py @@ -5,8 +5,10 @@ """ import logging +from datetime import datetime from typing import Any, Dict, List, Optional, Union +import pytz from rich.align import Align from rich.console import Console from rich.panel import Panel @@ -14,7 +16,13 @@ from rich.text import Text # Removed theme import - using direct styles -from claude_monitor.utils.formatting import format_currency, format_number +from claude_monitor.ui.progress_bars import create_dot_sparkline +from claude_monitor.utils.formatting import ( + format_currency, + format_number, + format_number_abbreviated, +) +from claude_monitor.utils.model_utils import get_model_display_name logger = logging.getLogger(__name__) @@ -84,8 +92,67 @@ def _create_base_table( return table + def _format_period_value( + self, + period_value: str, + period_key: str, + date_format: Optional[str] = None, + timezone: str = "UTC", + ) -> str: + """Format period value (date or month) using optional date_format. + + Args: + period_value: The period value string (e.g., "2024-01" or "2024-01-15") + period_key: The period key type ('date' or 'month') + date_format: Optional strftime format string + timezone: Timezone for date conversion + + Returns: + Formatted period string + """ + if not date_format: + return period_value + + try: + # Parse the period value based on its format + if period_key == "month": + # Format: "YYYY-MM" - parse as first day of month + year, month = period_value.split("-") + dt = datetime(int(year), int(month), 1) + elif period_key == "date": + # Format: "YYYY-MM-DD" + dt = datetime.strptime(period_value, "%Y-%m-%d") + else: + return period_value + + # Convert to specified timezone + try: + tz = pytz.timezone(timezone) + # For naive datetime, localize to target timezone directly + # This ensures the date represents the correct day in that timezone + if dt.tzinfo is None: + dt = tz.localize(dt) + else: + dt = dt.astimezone(tz) + except Exception as e: + # If timezone conversion fails, use naive datetime + logger.debug(f"Timezone conversion failed: {e}") + pass + + # Format using strftime + return dt.strftime(date_format) + except Exception as e: + logger.debug(f"Failed to format period value '{period_value}': {e}") + return period_value + def _add_data_rows( - self, table: Table, data_list: List[Dict[str, Any]], period_key: str + self, + table: Table, + data_list: List[Dict[str, Any]], + period_key: str, + date_format: Optional[str] = None, + timezone: str = "UTC", + abbreviate_tokens: bool = False, ) -> None: """Add data rows to the table. @@ -93,7 +160,25 @@ def _add_data_rows( table: Table to add rows to data_list: List of data dictionaries period_key: Key to use for period column ('date' or 'month') + date_format: Optional strftime format string for period display + timezone: Timezone for date formatting + abbreviate_tokens: Whether to abbreviate token counts with 'k' suffix """ + # Choose formatting function based on abbreviate_tokens flag + format_func = format_number_abbreviated if abbreviate_tokens else format_number + + # Calculate max TOTAL tokens across all rows for universal sparkline scaling + # This allows comparison across rows AND columns (Tufte principle: same scale) + max_total_tokens = max( + ( + d["input_tokens"] + + d["output_tokens"] + + d["cache_creation_tokens"] + + d["cache_read_tokens"] + ) + for d in data_list + ) if data_list else 1 + for data in data_list: models_text = self._format_models(data["models_used"]) total_tokens = ( @@ -103,24 +188,64 @@ def _add_data_rows( + data["cache_read_tokens"] ) + # Format period value if date_format is provided + period_display = self._format_period_value( + data[period_key], period_key, date_format, timezone + ) + + # Create dot sparklines using universal scale (max_total_tokens) + # This allows comparing Input vs Output within a row AND across rows + input_sparkline = create_dot_sparkline( + data["input_tokens"], max_total_tokens, width=12 + ) + output_sparkline = create_dot_sparkline( + data["output_tokens"], max_total_tokens, width=12 + ) + cache_create_sparkline = create_dot_sparkline( + data["cache_creation_tokens"], max_total_tokens, width=12 + ) + cache_read_sparkline = create_dot_sparkline( + data["cache_read_tokens"], max_total_tokens, width=12 + ) + total_sparkline = create_dot_sparkline( + total_tokens, max_total_tokens, width=12 + ) + + # Combine formatted number with sparkline on new line + input_display = f"{format_func(data['input_tokens'])}\n{input_sparkline}" + output_display = f"{format_func(data['output_tokens'])}\n{output_sparkline}" + cache_create_display = ( + f"{format_func(data['cache_creation_tokens'])}\n{cache_create_sparkline}" + ) + cache_read_display = ( + f"{format_func(data['cache_read_tokens'])}\n{cache_read_sparkline}" + ) + total_display = f"{format_func(total_tokens)}\n{total_sparkline}" + table.add_row( - data[period_key], + period_display, models_text, - format_number(data["input_tokens"]), - format_number(data["output_tokens"]), - format_number(data["cache_creation_tokens"]), - format_number(data["cache_read_tokens"]), - format_number(total_tokens), + input_display, + output_display, + cache_create_display, + cache_read_display, + total_display, format_currency(data["total_cost"]), ) - def _add_totals_row(self, table: Table, totals: Dict[str, Any]) -> None: + def _add_totals_row( + self, table: Table, totals: Dict[str, Any], abbreviate_tokens: bool = False + ) -> None: """Add totals row to the table. Args: table: Table to add totals to totals: Dictionary with total statistics + abbreviate_tokens: Whether to abbreviate token counts with 'k' suffix """ + # Choose formatting function based on abbreviate_tokens flag + format_func = format_number_abbreviated if abbreviate_tokens else format_number + # Add separator table.add_row("", "", "", "", "", "", "", "") @@ -128,13 +253,11 @@ def _add_totals_row(self, table: Table, totals: Dict[str, Any]) -> None: table.add_row( Text("Total", style=self.accent_style), "", - Text(format_number(totals["input_tokens"]), style=self.accent_style), - Text(format_number(totals["output_tokens"]), style=self.accent_style), - Text( - format_number(totals["cache_creation_tokens"]), style=self.accent_style - ), - Text(format_number(totals["cache_read_tokens"]), style=self.accent_style), - Text(format_number(totals["total_tokens"]), style=self.accent_style), + Text(format_func(totals["input_tokens"]), style=self.accent_style), + Text(format_func(totals["output_tokens"]), style=self.accent_style), + Text(format_func(totals["cache_creation_tokens"]), style=self.accent_style), + Text(format_func(totals["cache_read_tokens"]), style=self.accent_style), + Text(format_func(totals["total_tokens"]), style=self.accent_style), Text(format_currency(totals["total_cost"]), style=self.success_style), ) @@ -143,6 +266,8 @@ def create_daily_table( daily_data: List[Dict[str, Any]], totals: Dict[str, Any], timezone: str = "UTC", + date_format: Optional[str] = None, + abbreviate_tokens: bool = False, ) -> Table: """Create a daily statistics table. @@ -150,6 +275,8 @@ def create_daily_table( daily_data: List of daily aggregated data totals: Total statistics timezone: Timezone for display + date_format: Optional strftime format string for date display + abbreviate_tokens: Whether to abbreviate token counts with 'k' suffix Returns: Rich Table object @@ -162,10 +289,12 @@ def create_daily_table( ) # Add data rows - self._add_data_rows(table, daily_data, "date") + self._add_data_rows( + table, daily_data, "date", date_format, timezone, abbreviate_tokens + ) # Add totals - self._add_totals_row(table, totals) + self._add_totals_row(table, totals, abbreviate_tokens) return table @@ -174,6 +303,8 @@ def create_monthly_table( monthly_data: List[Dict[str, Any]], totals: Dict[str, Any], timezone: str = "UTC", + date_format: Optional[str] = None, + abbreviate_tokens: bool = False, ) -> Table: """Create a monthly statistics table. @@ -181,22 +312,28 @@ def create_monthly_table( monthly_data: List of monthly aggregated data totals: Total statistics timezone: Timezone for display + date_format: Optional strftime format string for month display + abbreviate_tokens: Whether to abbreviate token counts with 'k' suffix Returns: Rich Table object """ # Create base table + # Use smaller width for date column (12 chars fits "01 Oct - Wed") + period_width = 12 if date_format else 10 table = self._create_base_table( title=f"Claude Code Token Usage Report - Monthly ({timezone})", period_column_name="Month", - period_column_width=10, + period_column_width=period_width, ) # Add data rows - self._add_data_rows(table, monthly_data, "month") + self._add_data_rows( + table, monthly_data, "month", date_format, timezone, abbreviate_tokens + ) # Add totals - self._add_totals_row(table, totals) + self._add_totals_row(table, totals, abbreviate_tokens) return table @@ -237,28 +374,40 @@ def create_summary_panel( return panel def _format_models(self, models: List[str]) -> str: - """Format model names for display. + """Format model names for display using normalized display names. Args: - models: List of model names + models: List of model names (can be full model strings) Returns: - Formatted string of model names + Formatted string of model names using display-friendly names """ if not models: return "No models" - # Create bullet list - if len(models) == 1: - return models[0] - elif len(models) <= 3: - return "\n".join([f"• {model}" for model in models]) + # Convert to display names and remove duplicates + display_names = [] + seen = set() + for model in models: + display_name = get_model_display_name(model) + if display_name and display_name not in seen: + display_names.append(display_name) + seen.add(display_name) + + if not display_names: + return "No models" + + # Create bullet list with display names + if len(display_names) == 1: + return display_names[0] + elif len(display_names) <= 3: + return "\n".join([f"• {name}" for name in display_names]) else: - # Truncate long lists - first_two = models[:2] - remaining_count = len(models) - 2 - formatted = "\n".join([f"• {model}" for model in first_two]) - formatted += f"\n• ...and {remaining_count} more" + # Show first two and count of remaining + first_two = display_names[:2] + remaining_count = len(display_names) - 2 + formatted = "\n".join([f"• {name}" for name in first_two]) + formatted += f"\n• +{remaining_count} more" return formatted def create_no_data_display(self, view_type: str) -> Panel: @@ -293,6 +442,8 @@ def create_aggregate_table( totals: Dict[str, Any], view_type: str, timezone: str = "UTC", + date_format: Optional[str] = None, + abbreviate_tokens: bool = False, ) -> Table: """Create a table for either daily or monthly aggregated data. @@ -301,6 +452,8 @@ def create_aggregate_table( totals: Total statistics view_type: Type of view ('daily' or 'monthly') timezone: Timezone for display + date_format: Optional strftime format string for period display + abbreviate_tokens: Whether to abbreviate token counts with 'k' suffix Returns: Rich Table object @@ -309,9 +462,13 @@ def create_aggregate_table( ValueError: If view_type is not 'daily' or 'monthly' """ if view_type == "daily": - return self.create_daily_table(aggregate_data, totals, timezone) + return self.create_daily_table( + aggregate_data, totals, timezone, date_format, abbreviate_tokens + ) elif view_type == "monthly": - return self.create_monthly_table(aggregate_data, totals, timezone) + return self.create_monthly_table( + aggregate_data, totals, timezone, date_format, abbreviate_tokens + ) else: raise ValueError(f"Invalid view type: {view_type}") @@ -323,6 +480,8 @@ def display_aggregated_view( plan: str, token_limit: int, console: Optional[Console] = None, + date_format: Optional[str] = None, + abbreviate_tokens: bool = False, ) -> None: """Display aggregated view with table and summary. @@ -333,6 +492,8 @@ def display_aggregated_view( plan: Plan type token_limit: Token limit for the plan console: Optional Console instance + date_format: Optional strftime format string for period display + abbreviate_tokens: Whether to abbreviate token counts with 'k' suffix """ if not data: no_data_display = self.create_no_data_display(view_mode) @@ -369,7 +530,9 @@ def display_aggregated_view( summary_panel = self.create_summary_panel(view_mode, totals, period) # Create and display table - table = self.create_aggregate_table(data, totals, view_mode, timezone) + table = self.create_aggregate_table( + data, totals, view_mode, timezone, date_format, abbreviate_tokens + ) # Display using console if provided if console: diff --git a/src/claude_monitor/utils/formatting.py b/src/claude_monitor/utils/formatting.py index 8f30a68..7718820 100644 --- a/src/claude_monitor/utils/formatting.py +++ b/src/claude_monitor/utils/formatting.py @@ -28,6 +28,37 @@ def format_number(value: Union[int, float], decimals: int = 0) -> str: return f"{int(value):,}" +def format_number_abbreviated(value: Union[int, float]) -> str: + """Format number with 'k' suffix for thousands. + + Examples: + 273155 -> "273k" + 48875924 -> "48,875k" + 1000 -> "1k" + 500 -> "500" + + Args: + value: Number to format + + Returns: + Formatted number string with 'k' suffix for values >= 1000 + """ + int_value = int(value) + + if int_value < 1000: + return str(int_value) + + # Divide by 1000 and format with commas, then add 'k' + thousands = int_value / 1000 + # Format the thousands part with commas if needed + if thousands >= 1000: + # For values like 1,234,567 -> "1,234k" + return f"{int(thousands):,}k" + else: + # For values like 273 -> "273k" + return f"{int(thousands)}k" + + def format_currency(amount: float, currency: str = "USD") -> str: """Format currency amount with appropriate symbol and formatting. diff --git a/src/tests/test_formatting.py b/src/tests/test_formatting.py index c42f587..783d08f 100644 --- a/src/tests/test_formatting.py +++ b/src/tests/test_formatting.py @@ -6,6 +6,7 @@ from claude_monitor.utils.formatting import ( format_currency, format_display_time, + format_number_abbreviated, format_time, get_time_format_preference, ) @@ -330,6 +331,39 @@ def test_internal_get_pref_function(self) -> None: mock_pref.assert_called_once_with(mock_args) +class TestFormatNumberAbbreviated: + """Test cases for format_number_abbreviated function.""" + + def test_format_number_abbreviated_small_values(self) -> None: + """Test format_number_abbreviated with values less than 1000.""" + # Values less than 1000 should not be abbreviated + assert format_number_abbreviated(0) == "0" + assert format_number_abbreviated(500) == "500" + assert format_number_abbreviated(999) == "999" + + def test_format_number_abbreviated_thousands(self) -> None: + """Test format_number_abbreviated with thousands.""" + # Values >= 1000 should be abbreviated with 'k' + assert format_number_abbreviated(1000) == "1k" + assert format_number_abbreviated(273155) == "273k" + assert format_number_abbreviated(999999) == "999k" + + def test_format_number_abbreviated_millions(self) -> None: + """Test format_number_abbreviated with millions.""" + # Large values should use comma separators + assert format_number_abbreviated(48875924) == "48,875k" + assert format_number_abbreviated(1234567) == "1,234k" + assert format_number_abbreviated(1000000) == "1,000k" + assert format_number_abbreviated(12345678) == "12,345k" + + def test_format_number_abbreviated_float_values(self) -> None: + """Test format_number_abbreviated with float values.""" + # Float values should be converted to int + assert format_number_abbreviated(273155.7) == "273k" + assert format_number_abbreviated(1000.9) == "1k" + assert format_number_abbreviated(999.9) == "999" + + class TestFormattingErrorHandling: """Test error handling in formatting utilities.""" diff --git a/src/tests/test_table_views.py b/src/tests/test_table_views.py index 6249f5a..6725cbe 100644 --- a/src/tests/test_table_views.py +++ b/src/tests/test_table_views.py @@ -6,6 +6,7 @@ from rich.panel import Panel from rich.table import Table +from claude_monitor.ui.progress_bars import create_dot_sparkline from claude_monitor.ui.table_views import TableViewsController @@ -267,14 +268,14 @@ def test_create_summary_panel( def test_format_models_single(self, controller: TableViewsController) -> None: """Test formatting single model.""" result = controller._format_models(["claude-3-haiku"]) - assert result == "claude-3-haiku" + assert result == "Claude 3 Haiku" def test_format_models_multiple(self, controller: TableViewsController) -> None: """Test formatting multiple models.""" result = controller._format_models( ["claude-3-haiku", "claude-3-sonnet", "claude-3-opus"] ) - expected = "• claude-3-haiku\n• claude-3-sonnet\n• claude-3-opus" + expected = "• Claude 3 Haiku\n• Claude 3 Sonnet\n• Claude 3 Opus" assert result == expected def test_format_models_empty(self, controller: TableViewsController) -> None: @@ -479,3 +480,130 @@ def test_empty_data_lists(self, controller: TableViewsController) -> None: # Monthly table with empty data monthly_table = controller.create_monthly_table([], empty_totals, "UTC") assert monthly_table.row_count == 2 # Separator + totals + + def test_format_period_value_month(self, controller: TableViewsController) -> None: + """Test formatting month period value with date_format.""" + # Test without date_format (should return original) + result = controller._format_period_value("2024-01", "month") + assert result == "2024-01" + + # Test with date_format + result = controller._format_period_value( + "2024-01", "month", date_format="%d %b - %a", timezone="UTC" + ) + # January 1, 2024 is a Monday + assert result == "01 Jan - Mon" + + # Test with different format + result = controller._format_period_value( + "2024-11", "month", date_format="%d %b - %a", timezone="UTC" + ) + # November 1, 2024 is a Friday + assert result == "01 Nov - Fri" + + def test_format_period_value_date(self, controller: TableViewsController) -> None: + """Test formatting date period value with date_format.""" + # Test without date_format (should return original) + result = controller._format_period_value("2024-01-15", "date") + assert result == "2024-01-15" + + # Test with date_format + result = controller._format_period_value( + "2024-01-15", "date", date_format="%d %b - %a", timezone="UTC" + ) + # January 15, 2024 is a Monday + assert result == "15 Jan - Mon" + + def test_monthly_table_with_date_format( + self, + controller: TableViewsController, + sample_monthly_data: List[Dict[str, Any]], + sample_totals: Dict[str, Any], + ) -> None: + """Test monthly table with date_format parameter.""" + table = controller.create_monthly_table( + sample_monthly_data, + sample_totals, + "UTC", + date_format="%d %b - %a", + ) + + assert isinstance(table, Table) + # Column width should be adjusted when date_format is provided (reduced to 12) + assert table.columns[0].width == 12 + + def test_daily_table_with_date_format( + self, + controller: TableViewsController, + sample_daily_data: List[Dict[str, Any]], + sample_totals: Dict[str, Any], + ) -> None: + """Test daily table with date_format parameter.""" + table = controller.create_daily_table( + sample_daily_data, sample_totals, "UTC", date_format="%d %b - %a" + ) + + assert isinstance(table, Table) + assert table.title == "Claude Code Token Usage Report - Daily (UTC)" + + def test_create_aggregate_table_with_date_format( + self, + controller: TableViewsController, + sample_monthly_data: List[Dict[str, Any]], + sample_totals: Dict[str, Any], + ) -> None: + """Test create_aggregate_table with date_format.""" + table = controller.create_aggregate_table( + sample_monthly_data, + sample_totals, + "monthly", + "UTC", + date_format="%d %b - %a", + ) + + assert isinstance(table, Table) + assert table.title == "Claude Code Token Usage Report - Monthly (UTC)" + + def test_sparklines_in_table_rows( + self, + controller: TableViewsController, + sample_monthly_data: List[Dict[str, Any]], + sample_totals: Dict[str, Any], + ) -> None: + """Test that sparklines are included in table rows.""" + table = controller.create_monthly_table( + sample_monthly_data, sample_totals, "UTC", abbreviate_tokens=False + ) + + # Check that sparklines are present in the table + # The table should have rows with sparklines (dot sparklines) + assert isinstance(table, Table) + # Verify table was created successfully with sparklines + assert table.row_count >= 3 # At least data rows + separator + totals + + def test_create_dot_sparkline(self) -> None: + """Test create_dot_sparkline function.""" + # Test with zero max value + result = create_dot_sparkline(100, 0, width=10) + assert result == "──────────" + + # Test with value at start + result = create_dot_sparkline(0, 1000, width=10) + assert result[0] == "ā—" + assert result.count("─") == 9 + + # Test with value at middle + result = create_dot_sparkline(500, 1000, width=10) + # Should be approximately at position 4-5 (middle) + assert "ā—" in result + assert result.count("─") == 9 + + # Test with value at end + result = create_dot_sparkline(1000, 1000, width=10) + assert result[-1] == "ā—" or result[-2] == "ā—" # Last or second-to-last position + assert result.count("─") == 9 + + # Test with very small value + result = create_dot_sparkline(10, 1000, width=12) + assert "ā—" in result + assert len(result) == 12