diff --git a/shellhist/cli/time_analysis.py b/shellhist/cli/time_analysis.py new file mode 100644 index 0000000..2da44d1 --- /dev/null +++ b/shellhist/cli/time_analysis.py @@ -0,0 +1,263 @@ +"""Time-based analysis command.""" + +import os +from collections import defaultdict +from datetime import datetime, timedelta +from typing import Optional + +import click +from rich.console import Console +from rich.table import Table + +from shellhist.core import HistoryLoader +from shellhist.utils import format_timestamp + + +@click.command("analyze-time") +@click.option( + "--history", + "-H", + type=str, + help="Path to history file", +) +@click.option( + "--daily/--no-daily", + default=False, + help="Show commands run at similar times daily", +) +@click.option( + "--weekly/--no-weekly", + default=False, + help="Show commands run at similar times weekly", +) +@click.option( + "--time-range", + "-t", + type=str, + default="7d", + help="Time range filter (e.g., '7d', '24h', '30d')", +) +@click.option( + "--shell", + "-s", + type=click.Choice(["bash", "zsh"]), + help="Shell type for parsing", +) +@click.pass_context +def analyze_time_command( + ctx: click.Context, + history: Optional[str], + daily: bool, + weekly: bool, + time_range: str, + shell: Optional[str], +) -> None: + """Analyze time-based patterns in your shell history. + + Examples: + + \b + shellhist analyze-time --daily + shellhist analyze-time --weekly --time-range 30d + shellhist analyze-time --daily --time-range 7d + """ + console = Console() + + try: + loader = HistoryLoader(history_path=history) + store = loader.load() + + if not store.entries: + console.print("[yellow]No entries found in history.[/yellow]") + return + + entries_with_time = [ + e for e in store.entries + if e.timestamp is not None + ] + + if not entries_with_time: + console.print( + "[yellow]No timestamps found in history. " + "Ensure your history includes timestamps.[/yellow]" + ) + return + + cutoff = _parse_time_range(time_range) + recent_entries = [ + e for e in entries_with_time + if e.timestamp >= cutoff + ] + + if daily: + _analyze_daily_patterns(console, recent_entries) + elif weekly: + _analyze_weekly_patterns(console, recent_entries) + else: + _analyze_hourly_distribution(console, recent_entries) + + except FileNotFoundError as e: + console.print(f"[red]Error: {e}[/red]") + ctx.exit(1) + except Exception as e: + console.print(f"[red]Error analyzing time patterns: {e}[/red]") + ctx.exit(1) + + +def _parse_time_range(time_range: str) -> datetime: + """Parse time range string like '7d', '24h', '30d'.""" + value = int(time_range[:-1]) + unit = time_range[-1].lower() + + now = datetime.now() + + if unit == 'h': + return now - timedelta(hours=value) + elif unit == 'd': + return now - timedelta(days=value) + elif unit == 'w': + return now - timedelta(weeks=value) + else: + return now - timedelta(days=7) + + +def _analyze_hourly_distribution(console: Console, entries: list) -> None: + """Analyze command distribution by hour of day.""" + hourly = defaultdict(list) + + for entry in entries: + hour = entry.timestamp.hour + hourly[hour].append(entry.command) + + console.print("\n[bold cyan]Hourly Command Distribution[/bold cyan]") + + table = Table(show_header=True, header_style="bold magenta") + table.add_column("Hour", width=10) + table.add_column("Commands", width=10) + table.add_column("Top Commands", width=60) + + for hour in range(24): + cmds = hourly.get(hour, []) + if cmds: + top = defaultdict(int) + for cmd in cmds: + top[cmd] += 1 + top_sorted = sorted(top.items(), key=lambda x: x[1], reverse=True)[:2] + top_str = ", ".join(f"{c}({n})" for c, n in top_sorted) + else: + top_str = "-" + + hour_label = f"{hour:02d}:00" + table.add_row(hour_label, str(len(cmds)), top_str[:58]) + + console.print(table) + + +def _analyze_daily_patterns(console: Console, entries: list) -> None: + """Analyze commands run at similar times daily.""" + daily_patterns = defaultdict(list) + + for entry in entries: + hour = entry.timestamp.hour + key = (entry.timestamp.weekday(), hour) + daily_patterns[key].append(entry.command) + + console.print("\n[bold cyan]Daily Time Patterns[/bold cyan]") + + day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + + patterns_found = [] + for (weekday, hour), cmds in daily_patterns.items(): + if len(cmds) >= 2: + top = defaultdict(int) + for cmd in cmds: + top[cmd] += 1 + top_sorted = sorted(top.items(), key=lambda x: x[1], reverse=True)[:3] + patterns_found.append({ + "day": day_names[weekday], + "hour": hour, + "count": len(cmds), + "top": top_sorted, + }) + + if not patterns_found: + console.print("[yellow]No significant daily patterns found.[/yellow]") + return + + patterns_found.sort(key=lambda x: x["count"], reverse=True) + + table = Table(show_header=True, header_style="bold magenta") + table.add_column("Day", width=8) + table.add_column("Hour", width=8) + table.add_column("Times", width=8) + table.add_column("Common Commands", width=60) + + for p in patterns_found[:20]: + top_str = ", ".join(f"{c}({n})" for c, n in p["top"]) + table.add_row( + p["day"], + f"{p['hour']:02d}:00", + str(p["count"]), + top_str[:58], + ) + + console.print(table) + + +def _analyze_weekly_patterns(console: Console, entries: list) -> None: + """Analyze commands run at similar times weekly.""" + weekly_patterns = defaultdict(list) + + for entry in entries: + key = ( + entry.timestamp.isocalendar().year, + entry.timestamp.isocalendar().week, + entry.timestamp.weekday(), + entry.timestamp.hour, + ) + weekly_patterns[key].append(entry.command) + + console.print("\n[bold cyan]Weekly Time Patterns[/bold cyan]") + + day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + + patterns_found = [] + for key, cmds in weekly_patterns.items(): + year, week, weekday, hour = key + if len(cmds) >= 2: + top = defaultdict(int) + for cmd in cmds: + top[cmd] += 1 + top_sorted = sorted(top.items(), key=lambda x: x[1], reverse=True)[:3] + patterns_found.append({ + "week": week, + "day": day_names[weekday], + "hour": hour, + "count": len(cmds), + "top": top_sorted, + }) + + if not patterns_found: + console.print("[yellow]No significant weekly patterns found.[/yellow]") + return + + patterns_found.sort(key=lambda x: x["count"], reverse=True) + + table = Table(show_header=True, header_style="bold magenta") + table.add_column("Week", width=8) + table.add_column("Day", width=8) + table.add_column("Hour", width=8) + table.add_column("Times", width=8) + table.add_column("Common Commands", width=50) + + for p in patterns_found[:20]: + top_str = ", ".join(f"{c}({n})" for c, n in p["top"]) + table.add_row( + str(p["week"]), + p["day"], + f"{p['hour']:02d}:00", + str(p["count"]), + top_str[:48], + ) + + console.print(table)