Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Run WPF app (Windows-only): `dotnet run --project src/AIUsageMonitor.WPF`
- Run tests: `dotnet test AIUsageMonitor.slnx`
- Single test: `dotnet test tests/AIUsageMonitor.Core.Tests --filter "FullyQualifiedName~MethodName"`
- Tests live only under `tests/AIUsageMonitor.Core.Tests`, mirroring Core's `Analytics/` and `Providers/Claude/` folders — there are no Cli or WPF test projects.
- Versioning is via MinVer, driven by `v*` git tags (prefix `v`); no manual version bumps in project files.

## Architecture
Expand All @@ -30,7 +31,7 @@ Provider-specific code lives under `Providers/<Name>/` and implements `Providers

`Analytics/UsageAnalyzer` computes daily/period/model-distribution/hourly/session summaries from an `IUsageProvider`'s `StatsCache`, using `Analytics/CostCalculator` for token cost estimation → `Services/DataService` is the single facade over all of this, consumed by both Cli and WPF. `StatsCache` is currently Claude's own cache-file shape (`Providers/Claude/Models`); adding a second provider will require either normalizing its output to that shape or generalizing `UsageAnalyzer`'s input type.

`DataService` caches the parsed `StatsCache` for 30 seconds and invalidates early via a `FileSystemWatcher` on `stats-cache.json` (Claude-provider-specific, via a type check in `DataService`'s constructor). Session-level summaries (`GetSessionSummaries`) are read fresh from the raw session files rather than the cache.
`DataService` caches the parsed `StatsCache` for 30 seconds and invalidates early via a `FileSystemWatcher` on `stats-cache.json` (Claude-provider-specific, via a type check in `DataService`'s constructor). Session-level summaries (`GetSessionSummaries`) are read fresh from the raw session files rather than the cache. Long-running `DataService` reads accept an optional `IProgress<int>`, which the Cli surfaces as a Spectre.Console progress bar.

DI is wired through `ServiceCollectionExtensions.AddClaudeUsageCore()`, which registers the Claude provider's locator/parsers, binds it as the singleton `IUsageProvider`, and registers `CostCalculator`, `UsageAnalyzer`, and `DataService`.

Expand Down
20 changes: 5 additions & 15 deletions src/AIUsageMonitor.Cli/Commands/TodayCommand.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using AIUsageMonitor.Cli.Rendering;
using AIUsageMonitor.Core.Models;
using AIUsageMonitor.Core.Services;
using Spectre.Console;
using System.CommandLine;
Expand All @@ -18,26 +19,15 @@ public static class TodayCommand
public static Command Create(DataService dataService)
{
var command = new Command("today", "Show today's usage summary");
var recentHoursOption = new Option<int>("--recent-hours")
{
Description = "Trailing window (in hours) for the recent activity block",
DefaultValueFactory = _ => 6
};
command.Options.Add(recentHoursOption);

command.SetAction(parseResult =>
command.SetAction(_ =>
{
var summary = ProgressReporter.Run("Loading usage data...",
p => dataService.GetDailySummary(DateOnly.FromDateTime(DateTime.Today), p));
if (summary is null)
{
AnsiConsole.MarkupLine("[yellow]No data for today.[/]");
return 0;
}
p => dataService.GetDailySummary(DateOnly.FromDateTime(DateTime.Today), p))
?? DailySummary.Empty(DateOnly.FromDateTime(DateTime.Today));

var recentHours = Math.Max(1, parseResult.GetValue(recentHoursOption));
var recent = ProgressReporter.Run("Loading recent activity...",
p => dataService.GetRecentActivity(TimeSpan.FromHours(recentHours), p));
p => dataService.GetRecentActivity(DateTimeOffset.Now - DateTimeOffset.Now.Date, p));

AnsiConsole.Write(new Rows(
SpectreRenderer.BuildDailySummary(summary),
Expand Down
20 changes: 7 additions & 13 deletions src/AIUsageMonitor.Cli/Commands/WatchCommand.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using AIUsageMonitor.Cli.Rendering;
using AIUsageMonitor.Core.Models;
using AIUsageMonitor.Core.Services;
using Spectre.Console;
using Spectre.Console.Rendering;
Expand Down Expand Up @@ -29,29 +30,22 @@ public static Command Create(DataService dataService)
Description = "Refresh interval in seconds",
DefaultValueFactory = _ => 2
};
var recentHoursOption = new Option<int>("--recent-hours")
{
Description = "Trailing window (in hours) for the hourly token chart shown in the today view",
DefaultValueFactory = _ => 6
};
command.Options.Add(viewOption);
command.Options.Add(intervalOption);
command.Options.Add(recentHoursOption);

command.SetAction(async (parseResult, ct) =>
{
var view = parseResult.GetValue(viewOption)!;
var interval = Math.Max(1, parseResult.GetValue(intervalOption));
var recentHours = Math.Max(1, parseResult.GetValue(recentHoursOption));

IRenderable BuildCurrent(IProgress<int>? progress = null) => view switch
{
"today" => dataService.GetDailySummary(DateOnly.FromDateTime(DateTime.Today), progress) is { } d
? new Rows(
SpectreRenderer.BuildDailySummary(d),
new Rule().RuleStyle("grey"),
SpectreRenderer.BuildHourlyTokenChart(dataService.GetRecentActivity(TimeSpan.FromHours(recentHours), progress).HourlyTrend))
: new Markup("[yellow]No data for today.[/]"),
"today" => new Rows(
SpectreRenderer.BuildDailySummary(
dataService.GetDailySummary(DateOnly.FromDateTime(DateTime.Today), progress)
?? DailySummary.Empty(DateOnly.FromDateTime(DateTime.Today))),
new Rule().RuleStyle("grey"),
SpectreRenderer.BuildHourlyTokenChart(dataService.GetRecentActivity(DateTimeOffset.Now - DateTimeOffset.Now.Date, progress).HourlyTrend)),
"week" => SpectreRenderer.BuildPeriodSummary(
dataService.GetPeriodSummary(DateOnly.FromDateTime(DateTime.Today).AddDays(-6), DateOnly.FromDateTime(DateTime.Today), progress)),
"models" => SpectreRenderer.BuildModelDistribution(dataService.GetModelDistribution(progress)),
Expand Down
10 changes: 9 additions & 1 deletion src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,15 @@ public sealed record DailySummary(
int ToolCalls,
long TotalTokens,
Dictionary<string, long> TokensByModel,
decimal EstimatedCost);
decimal EstimatedCost)
{
/// <summary>
/// Creates a zero-valued <see cref="DailySummary"/> for a date with no recorded activity.
/// </summary>
/// <param name="date">The calendar date the summary covers.</param>
/// <returns>A <see cref="DailySummary"/> with all counters at zero.</returns>
public static DailySummary Empty(DateOnly date) => new(date, 0, 0, 0, 0, [], 0m);
}

/// <summary>
/// Represents an aggregated summary of usage activity over a date range.
Expand Down
Loading