493 lines
20 KiB
C#
493 lines
20 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.ComponentModel;
|
|
using System.IO;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Windows.Media;
|
|
using AxCopilot.Models;
|
|
using AxCopilot.Views;
|
|
using AxCopilot.Services;
|
|
|
|
namespace AxCopilot.ViewModels;
|
|
|
|
/// <summary>하루 사용 통계를 막대 차트 아이템으로 표현</summary>
|
|
public class DayBarItem
|
|
{
|
|
public string DateLabel { get; init; } = ""; // "3/26" 형태
|
|
public string DayLabel { get; init; } = ""; // "월" 형태
|
|
public int Value { get; init; }
|
|
public double BarHeight { get; init; } // 0~66 (픽셀)
|
|
public string ValueLabel { get; init; } = "";
|
|
public string ToolTipText { get; init; } = ""; // 마우스 호버 시 표시
|
|
public bool IsToday { get; init; }
|
|
public bool HasData => Value > 0;
|
|
}
|
|
|
|
/// <summary>인기 명령어 순위 아이템</summary>
|
|
public class CommandStatItem
|
|
{
|
|
public int Rank { get; init; }
|
|
public string Command { get; init; } = "";
|
|
public int Count { get; init; }
|
|
public double BarWidth { get; init; } // 0~200 (픽셀)
|
|
}
|
|
|
|
/// <summary>즐겨찾기 항목 (통계 화면에 표시)</summary>
|
|
public class FavoriteStatItem
|
|
{
|
|
public int Rank { get; init; }
|
|
public string Name { get; init; } = "";
|
|
public string Path { get; init; } = "";
|
|
public string Icon { get; init; } = "\uE8B7"; // 기본: Folder
|
|
public bool Exists { get; init; }
|
|
}
|
|
|
|
/// <summary>탭별 대화 비율 아이템</summary>
|
|
public class TabRatioItem
|
|
{
|
|
public string TabName { get; init; } = "";
|
|
public int Count { get; init; }
|
|
public double Percentage { get; init; } // 0~100
|
|
public double BarWidth { get; init; } // 0~300 (픽셀)
|
|
public string PercentLabel { get; init; } = "";
|
|
public Brush Color { get; init; } = Brushes.MediumSlateBlue;
|
|
}
|
|
|
|
public class StatisticsViewModel : INotifyPropertyChanged
|
|
{
|
|
private const double MaxBarHeight = 66.0;
|
|
private const double MaxBarWidth = 200.0;
|
|
|
|
// ─── 차트 데이터 ─────────────────────────────────────────────────────────
|
|
public ObservableCollection<DayBarItem> LauncherOpenBars { get; } = new();
|
|
public ObservableCollection<DayBarItem> ActiveTimeBars { get; } = new();
|
|
public ObservableCollection<CommandStatItem> TopCommands { get; } = new();
|
|
public ObservableCollection<FavoriteStatItem> TopFavorites { get; } = new();
|
|
|
|
// AX Agent 통계
|
|
public ObservableCollection<DayBarItem> ChatCountBars { get; } = new(); // 대화 빈도
|
|
public ObservableCollection<DayBarItem> TokenUsageBars { get; } = new(); // 토큰 사용량
|
|
|
|
// 추가 차트
|
|
public ObservableCollection<DayBarItem> WeekdayAvgBars { get; } = new(); // 요일별 평균 호출
|
|
public ObservableCollection<TabRatioItem> TabChatRatios { get; } = new(); // 탭별 대화 비율
|
|
|
|
private bool _hasFavorites;
|
|
public bool HasFavorites { get => _hasFavorites; private set { _hasFavorites = value; OnPropertyChanged(); } }
|
|
|
|
// ─── 요약 카드 ───────────────────────────────────────────────────────────
|
|
private string _todayDate = "";
|
|
private string _todaySummary = "";
|
|
private string _peakDate = "";
|
|
private string _peakDaySummary = "";
|
|
private string _weekSummary = "";
|
|
private string _totalOpensSummary = "";
|
|
|
|
public string TodayDate { get => _todayDate; set { _todayDate = value; OnPropertyChanged(); } }
|
|
public string TodaySummary { get => _todaySummary; set { _todaySummary = value; OnPropertyChanged(); } }
|
|
public string PeakDate { get => _peakDate; set { _peakDate = value; OnPropertyChanged(); } }
|
|
public string PeakDaySummary { get => _peakDaySummary; set { _peakDaySummary = value; OnPropertyChanged(); } }
|
|
public string WeekSummary { get => _weekSummary; set { _weekSummary = value; OnPropertyChanged(); } }
|
|
public string TotalOpensSummary { get => _totalOpensSummary; set { _totalOpensSummary = value; OnPropertyChanged(); } }
|
|
|
|
private string _agentSummary = "";
|
|
public string AgentSummary { get => _agentSummary; set { _agentSummary = value; OnPropertyChanged(); } }
|
|
|
|
// 토큰 비율
|
|
private double _promptBarWidth;
|
|
public double PromptBarWidth { get => _promptBarWidth; set { _promptBarWidth = value; OnPropertyChanged(); } }
|
|
private double _completionBarWidth;
|
|
public double CompletionBarWidth { get => _completionBarWidth; set { _completionBarWidth = value; OnPropertyChanged(); } }
|
|
private string _promptTokenLabel = "";
|
|
public string PromptTokenLabel { get => _promptTokenLabel; set { _promptTokenLabel = value; OnPropertyChanged(); } }
|
|
private string _completionTokenLabel = "";
|
|
public string CompletionTokenLabel { get => _completionTokenLabel; set { _completionTokenLabel = value; OnPropertyChanged(); } }
|
|
private string _tokenRatioSummary = "";
|
|
public string TokenRatioSummary { get => _tokenRatioSummary; set { _tokenRatioSummary = value; OnPropertyChanged(); } }
|
|
|
|
public StatisticsViewModel()
|
|
{
|
|
Refresh();
|
|
}
|
|
|
|
public void Refresh()
|
|
{
|
|
var stats = UsageStatisticsService.GetStats(30);
|
|
var today = DateTime.Today.ToString("yyyy-MM-dd");
|
|
|
|
BuildLauncherOpenChart(stats, today);
|
|
BuildActiveTimeChart(stats, today);
|
|
BuildChatCountChart(stats, today);
|
|
BuildTokenUsageChart(stats, today);
|
|
BuildTopCommands(stats);
|
|
BuildWeekdayAvgChart(stats);
|
|
BuildTabChatRatios(stats);
|
|
BuildTokenRatio(stats);
|
|
BuildSummaryCards(stats, today);
|
|
BuildTopFavorites();
|
|
}
|
|
|
|
// ─── 차트 빌더 ───────────────────────────────────────────────────────────
|
|
|
|
private void BuildLauncherOpenChart(List<DailyUsageStats> stats, string today)
|
|
{
|
|
// 최근 14일만 표시
|
|
var recent = stats.TakeLast(14).ToList();
|
|
int maxVal = recent.Max(s => s.LauncherOpens);
|
|
if (maxVal == 0) maxVal = 1;
|
|
|
|
LauncherOpenBars.Clear();
|
|
foreach (var s in recent)
|
|
{
|
|
var date = DateTime.TryParse(s.Date, out var d) ? d : DateTime.MinValue;
|
|
LauncherOpenBars.Add(new DayBarItem
|
|
{
|
|
DateLabel = date != DateTime.MinValue ? $"{date.Month}/{date.Day}" : "",
|
|
DayLabel = date != DateTime.MinValue ? GetKoreanDayOfWeek(date.DayOfWeek) : "",
|
|
Value = s.LauncherOpens,
|
|
BarHeight = MaxBarHeight * s.LauncherOpens / maxVal,
|
|
ValueLabel = s.LauncherOpens > 0 ? s.LauncherOpens.ToString() : "",
|
|
ToolTipText = date != DateTime.MinValue
|
|
? $"{date:yyyy-MM-dd} ({GetKoreanDayOfWeek(date.DayOfWeek)})\n호출 {s.LauncherOpens}회"
|
|
: "",
|
|
IsToday = s.Date == today
|
|
});
|
|
}
|
|
}
|
|
|
|
private void BuildActiveTimeChart(List<DailyUsageStats> stats, string today)
|
|
{
|
|
var recent = stats.TakeLast(14).ToList();
|
|
int maxVal = recent.Max(s => s.ActiveSeconds);
|
|
if (maxVal == 0) maxVal = 1;
|
|
|
|
ActiveTimeBars.Clear();
|
|
foreach (var s in recent)
|
|
{
|
|
var date = DateTime.TryParse(s.Date, out var d) ? d : DateTime.MinValue;
|
|
ActiveTimeBars.Add(new DayBarItem
|
|
{
|
|
DateLabel = date != DateTime.MinValue ? $"{date.Month}/{date.Day}" : "",
|
|
DayLabel = date != DateTime.MinValue ? GetKoreanDayOfWeek(date.DayOfWeek) : "",
|
|
Value = s.ActiveSeconds,
|
|
BarHeight = MaxBarHeight * s.ActiveSeconds / maxVal,
|
|
ValueLabel = FormatActiveTime(s.ActiveSeconds),
|
|
ToolTipText = date != DateTime.MinValue
|
|
? $"{date:yyyy-MM-dd} ({GetKoreanDayOfWeek(date.DayOfWeek)})\n활성 {FormatActiveTime(s.ActiveSeconds)}"
|
|
: "",
|
|
IsToday = s.Date == today
|
|
});
|
|
}
|
|
}
|
|
|
|
private void BuildChatCountChart(List<DailyUsageStats> stats, string today)
|
|
{
|
|
var recent = stats.TakeLast(14).ToList();
|
|
// 탭별 합산
|
|
int maxVal = recent.Max(s =>
|
|
{
|
|
int sum = 0;
|
|
foreach (var kv in s.ChatCounts) sum += kv.Value;
|
|
return sum;
|
|
});
|
|
if (maxVal == 0) maxVal = 1;
|
|
|
|
ChatCountBars.Clear();
|
|
foreach (var s in recent)
|
|
{
|
|
var dt = DateTime.TryParse(s.Date, out var d) ? d : DateTime.MinValue;
|
|
int chat = s.ChatCounts.GetValueOrDefault("Chat");
|
|
int cowork = s.ChatCounts.GetValueOrDefault("Cowork");
|
|
int code = s.ChatCounts.GetValueOrDefault("Code");
|
|
int total = chat + cowork + code;
|
|
var isToday = s.Date == today;
|
|
|
|
ChatCountBars.Add(new DayBarItem
|
|
{
|
|
DateLabel = dt.ToString("M/d"),
|
|
DayLabel = dt.ToString("ddd")[..1],
|
|
Value = total,
|
|
BarHeight = total > 0 ? total * MaxBarHeight / maxVal : 2,
|
|
ValueLabel = total > 0 ? total.ToString() : "",
|
|
ToolTipText = $"{s.Date} ({dt:ddd})\nChat: {chat}회 · Cowork: {cowork}회 · Code: {code}회",
|
|
IsToday = isToday,
|
|
});
|
|
}
|
|
}
|
|
|
|
private void BuildTokenUsageChart(List<DailyUsageStats> stats, string today)
|
|
{
|
|
var recent = stats.TakeLast(14).ToList();
|
|
long maxVal = recent.Max(s => s.TotalTokens);
|
|
if (maxVal == 0) maxVal = 1;
|
|
|
|
TokenUsageBars.Clear();
|
|
foreach (var s in recent)
|
|
{
|
|
var dt = DateTime.TryParse(s.Date, out var d) ? d : DateTime.MinValue;
|
|
var isToday = s.Date == today;
|
|
var tokens = s.TotalTokens;
|
|
|
|
TokenUsageBars.Add(new DayBarItem
|
|
{
|
|
DateLabel = dt.ToString("M/d"),
|
|
DayLabel = dt.ToString("ddd")[..1],
|
|
Value = (int)Math.Min(tokens, int.MaxValue),
|
|
BarHeight = tokens > 0 ? (double)tokens * MaxBarHeight / maxVal : 2,
|
|
ValueLabel = tokens > 0 ? FormatTokens(tokens) : "",
|
|
ToolTipText = $"{s.Date} ({dt:ddd})\n프롬프트: {s.PromptTokens:N0} · 완료: {s.CompletionTokens:N0} · 합계: {s.TotalTokens:N0}",
|
|
IsToday = isToday,
|
|
});
|
|
}
|
|
}
|
|
|
|
private static string FormatTokens(long tokens) => tokens switch
|
|
{
|
|
>= 1_000_000 => $"{tokens / 1_000_000.0:F1}M",
|
|
>= 1_000 => $"{tokens / 1_000.0:F1}K",
|
|
_ => tokens.ToString(),
|
|
};
|
|
|
|
private void BuildWeekdayAvgChart(List<DailyUsageStats> stats)
|
|
{
|
|
// 요일별 평균 호출 (30일 데이터 → 요일 그룹)
|
|
var groups = new Dictionary<DayOfWeek, List<int>>();
|
|
foreach (DayOfWeek dow in Enum.GetValues<DayOfWeek>())
|
|
groups[dow] = new();
|
|
|
|
foreach (var s in stats)
|
|
{
|
|
if (DateTime.TryParse(s.Date, out var d))
|
|
groups[d.DayOfWeek].Add(s.LauncherOpens);
|
|
}
|
|
|
|
// 월~일 순서
|
|
var ordered = new[] { DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday,
|
|
DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday };
|
|
double maxAvg = ordered.Max(dow => groups[dow].Count > 0 ? groups[dow].Average() : 0);
|
|
if (maxAvg < 1) maxAvg = 1;
|
|
|
|
WeekdayAvgBars.Clear();
|
|
foreach (var dow in ordered)
|
|
{
|
|
var list = groups[dow];
|
|
var avg = list.Count > 0 ? list.Average() : 0;
|
|
WeekdayAvgBars.Add(new DayBarItem
|
|
{
|
|
DateLabel = "",
|
|
DayLabel = GetKoreanDayOfWeek(dow),
|
|
Value = (int)Math.Round(avg),
|
|
BarHeight = avg > 0 ? avg * MaxBarHeight / maxAvg : 2,
|
|
ValueLabel = avg > 0 ? avg.ToString("F1") : "",
|
|
ToolTipText = $"{GetKoreanDayOfWeek(dow)}요일 평균 {avg:F1}회 ({list.Count}일 데이터)",
|
|
IsToday = DateTime.Today.DayOfWeek == dow,
|
|
});
|
|
}
|
|
}
|
|
|
|
private void BuildTabChatRatios(List<DailyUsageStats> stats)
|
|
{
|
|
// 탭별 대화 비율 (30일 합계)
|
|
int chat = 0, cowork = 0, code = 0;
|
|
foreach (var s in stats)
|
|
{
|
|
chat += s.ChatCounts.GetValueOrDefault("Chat");
|
|
cowork += s.ChatCounts.GetValueOrDefault("Cowork");
|
|
code += s.ChatCounts.GetValueOrDefault("Code");
|
|
}
|
|
int total = chat + cowork + code;
|
|
if (total == 0) total = 1;
|
|
|
|
const double MaxWidth = 300.0;
|
|
TabChatRatios.Clear();
|
|
TabChatRatios.Add(new TabRatioItem
|
|
{
|
|
TabName = "Chat", Count = chat,
|
|
Percentage = 100.0 * chat / total,
|
|
BarWidth = MaxWidth * chat / total,
|
|
PercentLabel = $"{100.0 * chat / total:F0}% ({chat}회)",
|
|
Color = ThemeResourceHelper.HexBrush("#818CF8")
|
|
});
|
|
TabChatRatios.Add(new TabRatioItem
|
|
{
|
|
TabName = "Cowork", Count = cowork,
|
|
Percentage = 100.0 * cowork / total,
|
|
BarWidth = MaxWidth * cowork / total,
|
|
PercentLabel = $"{100.0 * cowork / total:F0}% ({cowork}회)",
|
|
Color = ThemeResourceHelper.HexBrush("#10B981")
|
|
});
|
|
TabChatRatios.Add(new TabRatioItem
|
|
{
|
|
TabName = "Code", Count = code,
|
|
Percentage = 100.0 * code / total,
|
|
BarWidth = MaxWidth * code / total,
|
|
PercentLabel = $"{100.0 * code / total:F0}% ({code}회)",
|
|
Color = ThemeResourceHelper.HexBrush("#F59E0B")
|
|
});
|
|
}
|
|
|
|
private void BuildTokenRatio(List<DailyUsageStats> stats)
|
|
{
|
|
long prompt = stats.Sum(s => s.PromptTokens);
|
|
long completion = stats.Sum(s => s.CompletionTokens);
|
|
long total = prompt + completion;
|
|
if (total == 0) total = 1;
|
|
|
|
const double TotalWidth = 400.0;
|
|
PromptBarWidth = TotalWidth * prompt / total;
|
|
CompletionBarWidth = TotalWidth * completion / total;
|
|
PromptTokenLabel = $"입력 {FormatTokens(prompt)} ({100.0 * prompt / total:F0}%)";
|
|
CompletionTokenLabel = $"출력 {FormatTokens(completion)} ({100.0 * completion / total:F0}%)";
|
|
TokenRatioSummary = $"30일 합계 입력 {FormatTokens(prompt)} + 출력 {FormatTokens(completion)} = {FormatTokens(prompt + completion)}";
|
|
}
|
|
|
|
private void BuildTopCommands(List<DailyUsageStats> stats)
|
|
{
|
|
// 30일 집계
|
|
var aggregate = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var s in stats)
|
|
foreach (var (key, count) in s.CommandUsage)
|
|
{
|
|
aggregate.TryGetValue(key, out var cur);
|
|
aggregate[key] = cur + count;
|
|
}
|
|
|
|
var top = aggregate
|
|
.OrderByDescending(kv => kv.Value)
|
|
.Take(10)
|
|
.ToList();
|
|
|
|
int maxCount = top.Count > 0 ? top[0].Value : 1;
|
|
|
|
TopCommands.Clear();
|
|
for (int i = 0; i < top.Count; i++)
|
|
{
|
|
TopCommands.Add(new CommandStatItem
|
|
{
|
|
Rank = i + 1,
|
|
Command = top[i].Key,
|
|
Count = top[i].Value,
|
|
BarWidth = MaxBarWidth * top[i].Value / maxCount
|
|
});
|
|
}
|
|
}
|
|
|
|
private void BuildSummaryCards(List<DailyUsageStats> stats, string today)
|
|
{
|
|
// 오늘 날짜 표시
|
|
TodayDate = DateTime.Today.ToString("yyyy년 M월 d일");
|
|
|
|
// 오늘 통계
|
|
var todayStat = stats.FirstOrDefault(s => s.Date == today);
|
|
TodaySummary = todayStat != null
|
|
? $"{todayStat.LauncherOpens}회 호출 · {FormatActiveTime(todayStat.ActiveSeconds)}"
|
|
: "데이터 없음";
|
|
|
|
// 역대 최고 호출일
|
|
var peakDay = stats.OrderByDescending(s => s.LauncherOpens).FirstOrDefault();
|
|
if (peakDay != null && peakDay.LauncherOpens > 0)
|
|
{
|
|
PeakDate = DateTime.TryParse(peakDay.Date, out var pd)
|
|
? pd.ToString("yyyy년 M월 d일")
|
|
: peakDay.Date;
|
|
PeakDaySummary = $"{peakDay.LauncherOpens}회 호출";
|
|
}
|
|
else
|
|
{
|
|
PeakDate = "";
|
|
PeakDaySummary = "기록 없음";
|
|
}
|
|
|
|
// 이번 주 합계
|
|
var weekStart = DateTime.Today.AddDays(-(int)DateTime.Today.DayOfWeek);
|
|
var weekTotal = stats
|
|
.Where(s => DateTime.TryParse(s.Date, out var d) && d >= weekStart)
|
|
.Sum(s => s.LauncherOpens);
|
|
WeekSummary = $"이번 주 {weekTotal}회";
|
|
|
|
// 30일 총 호출
|
|
var totalOpens = stats.Sum(s => s.LauncherOpens);
|
|
TotalOpensSummary = $"30일 합계 {totalOpens}회";
|
|
|
|
// AX Agent 요약
|
|
var totalChats = stats.Sum(s => s.ChatCounts.Values.Sum());
|
|
var totalTokens = stats.Sum(s => s.TotalTokens);
|
|
var todayChats = todayStat?.ChatCounts.Values.Sum() ?? 0;
|
|
var todayTokens = todayStat?.TotalTokens ?? 0;
|
|
AgentSummary = $"오늘 {todayChats}회 대화 · {FormatTokens(todayTokens)} 토큰 | 30일 합계 {totalChats}회 · {FormatTokens(totalTokens)} 토큰";
|
|
}
|
|
|
|
private void BuildTopFavorites()
|
|
{
|
|
TopFavorites.Clear();
|
|
var favFile = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"AxCopilot", "favorites.json");
|
|
|
|
if (!File.Exists(favFile)) { HasFavorites = false; return; }
|
|
|
|
try
|
|
{
|
|
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
|
var list = JsonSerializer.Deserialize<List<FavFileEntry>>(File.ReadAllText(favFile), opts) ?? new();
|
|
|
|
var top = list.Take(6).ToList();
|
|
if (top.Count == 0) { HasFavorites = false; return; }
|
|
|
|
for (int i = 0; i < top.Count; i++)
|
|
{
|
|
var entry = top[i];
|
|
var expanded = Environment.ExpandEnvironmentVariables(entry.Path);
|
|
var isDir = Directory.Exists(expanded);
|
|
var exists = isDir || File.Exists(expanded);
|
|
TopFavorites.Add(new FavoriteStatItem
|
|
{
|
|
Rank = i + 1,
|
|
Name = entry.Name,
|
|
Path = entry.Path,
|
|
Icon = isDir ? "\uE8B7" : "\uE8A5", // Folder / Page
|
|
Exists = exists
|
|
});
|
|
}
|
|
HasFavorites = TopFavorites.Count > 0;
|
|
}
|
|
catch (Exception) { HasFavorites = false; }
|
|
}
|
|
|
|
// favorites.json 역직렬화용 내부 레코드
|
|
private class FavFileEntry
|
|
{
|
|
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
|
[JsonPropertyName("path")] public string Path { get; set; } = "";
|
|
}
|
|
|
|
// ─── 헬퍼 ────────────────────────────────────────────────────────────────
|
|
|
|
private static string GetKoreanDayOfWeek(DayOfWeek dow) => dow switch
|
|
{
|
|
DayOfWeek.Monday => "월",
|
|
DayOfWeek.Tuesday => "화",
|
|
DayOfWeek.Wednesday => "수",
|
|
DayOfWeek.Thursday => "목",
|
|
DayOfWeek.Friday => "금",
|
|
DayOfWeek.Saturday => "토",
|
|
_ => "일"
|
|
};
|
|
|
|
private static string FormatActiveTime(int totalSeconds)
|
|
{
|
|
if (totalSeconds <= 0) return "";
|
|
int h = totalSeconds / 3600;
|
|
int m = (totalSeconds % 3600) / 60;
|
|
if (h > 0 && m > 0) return $"{h}시간 {m}분";
|
|
if (h > 0) return $"{h}시간";
|
|
if (m > 0) return $"{m}분";
|
|
return $"{totalSeconds}초";
|
|
}
|
|
|
|
public event PropertyChangedEventHandler? PropertyChanged;
|
|
protected void OnPropertyChanged([CallerMemberName] string? n = null)
|
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
|
|
}
|