--- phase: 09-storage-visualization plan: 03 type: execute wave: 3 depends_on: - "09-01" - "09-02" files_modified: - SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs - SharepointToolbox/Views/Tabs/StorageView.xaml - SharepointToolbox/Localization/Strings.resx - SharepointToolbox/Localization/Strings.fr.resx - SharepointToolbox/Views/Converters/BytesLabelConverter.cs autonomous: true requirements: - VIZZ-02 - VIZZ-03 must_haves: truths: - "After a storage scan completes, a chart appears showing space broken down by file type" - "A toggle control switches between pie/donut and bar chart views without re-running the scan" - "The chart updates automatically when a new storage scan finishes" - "Chart labels show file extension and human-readable size" artifacts: - path: "SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs" provides: "FileTypeMetrics collection, IsDonutChart toggle, chart series computation" contains: "FileTypeMetrics" - path: "SharepointToolbox/Views/Tabs/StorageView.xaml" provides: "Chart panel with PieChart and CartesianChart, toggle button" contains: "lvc:PieChart" - path: "SharepointToolbox/Views/Converters/BytesLabelConverter.cs" provides: "Converter for chart tooltip bytes formatting" contains: "class BytesLabelConverter" - path: "SharepointToolbox/Localization/Strings.resx" provides: "EN localization keys for chart UI" contains: "stor.chart" - path: "SharepointToolbox/Localization/Strings.fr.resx" provides: "FR localization keys for chart UI" contains: "stor.chart" key_links: - from: "SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs" to: "SharepointToolbox/Services/IStorageService.cs" via: "Calls CollectFileTypeMetricsAsync after CollectStorageAsync" pattern: "_storageService\\.CollectFileTypeMetricsAsync" - from: "SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs" to: "SharepointToolbox/Core/Models/FileTypeMetric.cs" via: "ObservableCollection property" pattern: "ObservableCollection" - from: "SharepointToolbox/Views/Tabs/StorageView.xaml" to: "SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs" via: "Binds PieSeries to PieChartSeries, ColumnSeries to BarChartSeries" pattern: "Binding.*ChartSeries" --- Extend StorageViewModel with chart data properties and toggle, update StorageView.xaml with LiveCharts2 chart controls (pie/donut + bar), add localization keys, and create a bytes label converter for chart tooltips. Purpose: Delivers the complete UI for VIZZ-02 (chart showing file type breakdown) and VIZZ-03 (toggle between pie/donut and bar). This is the plan that makes the feature visible to users. Output: Updated ViewModel, updated View XAML, localization keys, BytesLabelConverter @C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/dev/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/09-storage-visualization/09-01-SUMMARY.md @.planning/phases/09-storage-visualization/09-02-SUMMARY.md From SharepointToolbox/Core/Models/FileTypeMetric.cs: ```csharp public record FileTypeMetric(string Extension, long TotalSizeBytes, int FileCount) { public string DisplayLabel => string.IsNullOrEmpty(Extension) ? "No Extension" : Extension.TrimStart('.').ToUpperInvariant(); } ``` From SharepointToolbox/Services/IStorageService.cs: ```csharp public interface IStorageService { Task> CollectStorageAsync( ClientContext ctx, StorageScanOptions options, IProgress progress, CancellationToken ct); Task> CollectFileTypeMetricsAsync( ClientContext ctx, IProgress progress, CancellationToken ct); } ``` From SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs: ```csharp public partial class StorageViewModel : FeatureViewModelBase { // Fields: _storageService, _sessionManager, _csvExportService, _htmlExportService, _logger, _currentProfile, _hasLocalSiteOverride // Properties: SiteUrl, PerLibrary, IncludeSubsites, FolderDepth, IsMaxDepth, Results // Commands: RunCommand (base), CancelCommand (base), ExportCsvCommand, ExportHtmlCommand // RunOperationAsync: calls CollectStorageAsync, flattens tree, sets Results // Test constructor: internal StorageViewModel(IStorageService, ISessionManager, ILogger) } ``` From SharepointToolbox/Views/Tabs/StorageView.xaml: - DockPanel with left ScrollViewer (options) and right DataGrid (results) - Uses loc:TranslationSource.Instance for all labels - Uses StaticResource: InverseBoolConverter, IndentConverter, BytesConverter, RightAlignStyle From SharepointToolbox/Views/Converters/BytesConverter.cs: ```csharp // IValueConverter: long bytes -> "1.23 GB" human-readable string // Used in DataGrid column bindings ``` LiveChartsCore.SkiaSharpView.WPF: - PieChart control: Series property (IEnumerable) - CartesianChart control: Series, XAxes, YAxes properties - PieSeries: Values, Name, InnerRadius, DataLabelsPosition, DataLabelsFormatter - ColumnSeries: Values, Name, DataLabelsFormatter - Axis: Labels, LabelsRotation, Name - SolidColorPaint: for axis/label paint Task 1: Extend StorageViewModel with chart data and toggle SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs Add chart-related properties and logic to StorageViewModel. Read the current file first, then make these additions: **1. Add using statements** at the top (add to existing usings): ```csharp using LiveChartsCore; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using SkiaSharp; ``` **2. Add new observable properties** (after the existing `_folderDepth` field): ```csharp [ObservableProperty] private bool _isDonutChart = true; private ObservableCollection _fileTypeMetrics = new(); public ObservableCollection FileTypeMetrics { get => _fileTypeMetrics; private set { _fileTypeMetrics = value; OnPropertyChanged(); UpdateChartSeries(); } } public bool HasChartData => FileTypeMetrics.Count > 0; ``` **3. Add chart series properties** (after HasChartData): ```csharp private IEnumerable _pieChartSeries = Enumerable.Empty(); public IEnumerable PieChartSeries { get => _pieChartSeries; private set { _pieChartSeries = value; OnPropertyChanged(); } } private IEnumerable _barChartSeries = Enumerable.Empty(); public IEnumerable BarChartSeries { get => _barChartSeries; private set { _barChartSeries = value; OnPropertyChanged(); } } private Axis[] _barXAxes = Array.Empty(); public Axis[] BarXAxes { get => _barXAxes; private set { _barXAxes = value; OnPropertyChanged(); } } private Axis[] _barYAxes = Array.Empty(); public Axis[] BarYAxes { get => _barYAxes; private set { _barYAxes = value; OnPropertyChanged(); } } ``` **4. Add partial method** to react to IsDonutChart changes: ```csharp partial void OnIsDonutChartChanged(bool value) { UpdateChartSeries(); } ``` **5. Add UpdateChartSeries private method** (before the existing FlattenNode method): ```csharp private void UpdateChartSeries() { var metrics = FileTypeMetrics.ToList(); OnPropertyChanged(nameof(HasChartData)); if (metrics.Count == 0) { PieChartSeries = Enumerable.Empty(); BarChartSeries = Enumerable.Empty(); BarXAxes = Array.Empty(); BarYAxes = Array.Empty(); return; } // Take top 10 by size, aggregate the rest as "Other" var top = metrics.Take(10).ToList(); long otherSize = metrics.Skip(10).Sum(m => m.TotalSizeBytes); int otherCount = metrics.Skip(10).Sum(m => m.FileCount); var chartItems = new List(top); if (otherSize > 0) chartItems.Add(new FileTypeMetric("Other", otherSize, otherCount)); // Pie/Donut series double innerRadius = IsDonutChart ? 50 : 0; PieChartSeries = chartItems.Select(m => new PieSeries { Values = new[] { m.TotalSizeBytes }, Name = m.DisplayLabel, InnerRadius = innerRadius, DataLabelsPosition = LiveChartsCore.Measure.PolarLabelsPosition.Outer, DataLabelsFormatter = point => m.DisplayLabel, ToolTipLabelFormatter = point => $"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)" }).ToList(); // Bar chart series BarChartSeries = new ISeries[] { new ColumnSeries { Values = chartItems.Select(m => m.TotalSizeBytes).ToArray(), Name = "Size", DataLabelsFormatter = point => { int idx = (int)point.Index; return idx < chartItems.Count ? FormatBytes(chartItems[idx].TotalSizeBytes) : ""; }, ToolTipLabelFormatter = point => { int idx = (int)point.Index; if (idx >= chartItems.Count) return ""; var m = chartItems[idx]; return $"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)"; } } }; BarXAxes = new Axis[] { new Axis { Labels = chartItems.Select(m => m.DisplayLabel).ToArray(), LabelsRotation = -45 } }; BarYAxes = new Axis[] { new Axis { Labeler = value => FormatBytes((long)value) } }; } private static string FormatBytes(long bytes) { if (bytes < 1024) return $"{bytes} B"; if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB"; if (bytes < 1024 * 1024 * 1024) return $"{bytes / (1024.0 * 1024):F1} MB"; return $"{bytes / (1024.0 * 1024 * 1024):F2} GB"; } ``` **6. Update RunOperationAsync** to call CollectFileTypeMetricsAsync AFTER the existing storage scan. After the existing `Results = new ObservableCollection(flat);` block (both dispatcher and else branches), add: ```csharp // Collect file-type metrics for chart visualization progress.Report(OperationProgress.Indeterminate("Scanning file types for chart...")); var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct); if (Application.Current?.Dispatcher is { } chartDispatcher) { await chartDispatcher.InvokeAsync(() => { FileTypeMetrics = new ObservableCollection(typeMetrics); }); } else { FileTypeMetrics = new ObservableCollection(typeMetrics); } ``` **7. Update OnTenantSwitched** to clear chart data. Add after `Results = new ObservableCollection();`: ```csharp FileTypeMetrics = new ObservableCollection(); ``` **Important:** The `ctx` variable used by the new CollectFileTypeMetricsAsync call is the same `ctx` already obtained earlier in RunOperationAsync. The call goes after the Results assignment but BEFORE the method returns. cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5 StorageViewModel has IsDonutChart toggle, FileTypeMetrics collection, PieChartSeries, BarChartSeries, BarXAxes, BarYAxes properties. RunOperationAsync calls CollectFileTypeMetricsAsync after storage scan. UpdateChartSeries builds top-10 + Other aggregation. OnTenantSwitched clears chart data. Project compiles. Task 2: Update StorageView.xaml with chart panel, toggle, and localization SharepointToolbox/Views/Tabs/StorageView.xaml, SharepointToolbox/Views/Converters/BytesLabelConverter.cs, SharepointToolbox/Localization/Strings.resx, SharepointToolbox/Localization/Strings.fr.resx **Step 1: Add localization keys** to `SharepointToolbox/Localization/Strings.resx`. Add these entries before the closing `` tag (follow existing `stor.*` naming convention): ```xml Storage by File Type Donut Chart Bar Chart Chart View: Run a storage scan to see file type breakdown. ``` Add corresponding FR translations to `SharepointToolbox/Localization/Strings.fr.resx`: ```xml Stockage par type de fichier Graphique en anneau Graphique en barres Type de graphique : Exécutez une analyse pour voir la répartition par type de fichier. ``` Note: Use XML entities for accented chars (`é` for e-acute) matching existing resx convention per Phase 08 decision. **Step 2: Create BytesLabelConverter** at `SharepointToolbox/Views/Converters/BytesLabelConverter.cs`: ```csharp using System.Globalization; using System.Windows.Data; namespace SharepointToolbox.Views.Converters; /// /// Converts a long byte value to a human-readable label for chart axes and tooltips. /// Similar to BytesConverter but implements IValueConverter for XAML binding. /// public class BytesLabelConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { if (value is not long bytes) return value?.ToString() ?? ""; if (bytes < 1024) return $"{bytes} B"; if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB"; if (bytes < 1024 * 1024 * 1024) return $"{bytes / (1024.0 * 1024):F1} MB"; return $"{bytes / (1024.0 * 1024 * 1024):F2} GB"; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotSupportedException(); } ``` **Step 3: Update StorageView.xaml** to add the chart panel. Replace the entire file content with the updated layout: The key structural change: Replace the simple `DockPanel` with left options + right content split. The right content area becomes a `Grid` with two rows -- top row for DataGrid, bottom row for chart panel. The chart panel has a toggle and two chart controls (one visible based on IsDonutChart). Read the current StorageView.xaml first, then replace with: ```xml