Files
Sharepoint-Toolbox/.planning/phases/09-storage-visualization/09-03-PLAN.md
Dev a63a698282 docs(09-storage-visualization): create phase plan — 4 plans in 4 waves
Wave 1: LiveCharts2 NuGet + FileTypeMetric model + IStorageService extension
Wave 2: StorageService file-type enumeration implementation
Wave 3: ViewModel chart properties + View XAML + localization
Wave 4: Unit tests for chart ViewModel behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:16:16 +02:00

635 lines
32 KiB
Markdown

---
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<FileTypeMetric> property"
pattern: "ObservableCollection<FileTypeMetric>"
- from: "SharepointToolbox/Views/Tabs/StorageView.xaml"
to: "SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs"
via: "Binds PieSeries to PieChartSeries, ColumnSeries to BarChartSeries"
pattern: "Binding.*ChartSeries"
---
<objective>
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
</objective>
<execution_context>
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- From Plan 09-01 -->
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<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx, StorageScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct);
Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
ClientContext ctx,
IProgress<OperationProgress> progress,
CancellationToken ct);
}
```
<!-- Existing ViewModel structure -->
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)
}
```
<!-- Existing View structure -->
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
<!-- Existing converters -->
From SharepointToolbox/Views/Converters/BytesConverter.cs:
```csharp
// IValueConverter: long bytes -> "1.23 GB" human-readable string
// Used in DataGrid column bindings
```
<!-- LiveCharts2 key APIs -->
LiveChartsCore.SkiaSharpView.WPF:
- PieChart control: Series property (IEnumerable<ISeries>)
- CartesianChart control: Series, XAxes, YAxes properties
- PieSeries<T>: Values, Name, InnerRadius, DataLabelsPosition, DataLabelsFormatter
- ColumnSeries<T>: Values, Name, DataLabelsFormatter
- Axis: Labels, LabelsRotation, Name
- SolidColorPaint: for axis/label paint
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Extend StorageViewModel with chart data and toggle</name>
<files>SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs</files>
<action>
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<FileTypeMetric> _fileTypeMetrics = new();
public ObservableCollection<FileTypeMetric> 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<ISeries> _pieChartSeries = Enumerable.Empty<ISeries>();
public IEnumerable<ISeries> PieChartSeries
{
get => _pieChartSeries;
private set { _pieChartSeries = value; OnPropertyChanged(); }
}
private IEnumerable<ISeries> _barChartSeries = Enumerable.Empty<ISeries>();
public IEnumerable<ISeries> BarChartSeries
{
get => _barChartSeries;
private set { _barChartSeries = value; OnPropertyChanged(); }
}
private Axis[] _barXAxes = Array.Empty<Axis>();
public Axis[] BarXAxes
{
get => _barXAxes;
private set { _barXAxes = value; OnPropertyChanged(); }
}
private Axis[] _barYAxes = Array.Empty<Axis>();
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<ISeries>();
BarChartSeries = Enumerable.Empty<ISeries>();
BarXAxes = Array.Empty<Axis>();
BarYAxes = Array.Empty<Axis>();
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<FileTypeMetric>(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<long>
{
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<long>
{
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<StorageNode>(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<FileTypeMetric>(typeMetrics);
});
}
else
{
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(typeMetrics);
}
```
**7. Update OnTenantSwitched** to clear chart data. Add after `Results = new ObservableCollection<StorageNode>();`:
```csharp
FileTypeMetrics = new ObservableCollection<FileTypeMetric>();
```
**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.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Update StorageView.xaml with chart panel, toggle, and localization</name>
<files>SharepointToolbox/Views/Tabs/StorageView.xaml, SharepointToolbox/Views/Converters/BytesLabelConverter.cs, SharepointToolbox/Localization/Strings.resx, SharepointToolbox/Localization/Strings.fr.resx</files>
<action>
**Step 1: Add localization keys** to `SharepointToolbox/Localization/Strings.resx`. Add these entries before the closing `</root>` tag (follow existing `stor.*` naming convention):
```xml
<data name="stor.chart.title" xml:space="preserve"><value>Storage by File Type</value></data>
<data name="stor.chart.donut" xml:space="preserve"><value>Donut Chart</value></data>
<data name="stor.chart.bar" xml:space="preserve"><value>Bar Chart</value></data>
<data name="stor.chart.toggle" xml:space="preserve"><value>Chart View:</value></data>
<data name="stor.chart.nodata" xml:space="preserve"><value>Run a storage scan to see file type breakdown.</value></data>
```
Add corresponding FR translations to `SharepointToolbox/Localization/Strings.fr.resx`:
```xml
<data name="stor.chart.title" xml:space="preserve"><value>Stockage par type de fichier</value></data>
<data name="stor.chart.donut" xml:space="preserve"><value>Graphique en anneau</value></data>
<data name="stor.chart.bar" xml:space="preserve"><value>Graphique en barres</value></data>
<data name="stor.chart.toggle" xml:space="preserve"><value>Type de graphique :</value></data>
<data name="stor.chart.nodata" xml:space="preserve"><value>Ex&#233;cutez une analyse pour voir la r&#233;partition par type de fichier.</value></data>
```
Note: Use XML entities for accented chars (`&#233;` 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;
/// <summary>
/// Converts a long byte value to a human-readable label for chart axes and tooltips.
/// Similar to BytesConverter but implements IValueConverter for XAML binding.
/// </summary>
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
<UserControl x:Class="SharepointToolbox.Views.Tabs.StorageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters"
xmlns:coreconv="clr-namespace:SharepointToolbox.Core.Converters"
xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.WPF;assembly=LiveChartsCore.SkiaSharpView.WPF">
<DockPanel LastChildFill="True">
<!-- Options panel (unchanged) -->
<ScrollViewer DockPanel.Dock="Left" Width="240" VerticalScrollBarVisibility="Auto"
Margin="8,8,4,8">
<StackPanel>
<!-- Site URL -->
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.site.url]}" />
<TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}"
Height="26" Margin="0,0,0,8"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.site.url]}" />
<!-- Scan options group -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.opts]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.per.lib]}"
IsChecked="{Binding PerLibrary}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.subsites]}"
IsChecked="{Binding IncludeSubsites}" Margin="0,2" />
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.folder.depth]}"
VerticalAlignment="Center" Padding="0,0,4,0" />
<TextBox Text="{Binding FolderDepth, UpdateSourceTrigger=PropertyChanged}"
Width="40" Height="22" VerticalAlignment="Center"
IsEnabled="{Binding IsMaxDepth, Converter={StaticResource InverseBoolConverter}}" />
</StackPanel>
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.max.depth]}"
IsChecked="{Binding IsMaxDepth}" Margin="0,2" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.note]}"
TextWrapping="Wrap" FontSize="11" Foreground="#888"
Margin="0,6,0,0" />
</StackPanel>
</GroupBox>
<!-- Action buttons -->
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.gen.storage]}"
Command="{Binding RunCommand}"
Height="28" Margin="0,0,0,4" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Command="{Binding CancelCommand}"
Height="28" Margin="0,0,0,8" />
<!-- Export group -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.export.fmt]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.rad.csv]}"
Command="{Binding ExportCsvCommand}"
Height="26" Margin="0,2" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.rad.html]}"
Command="{Binding ExportHtmlCommand}"
Height="26" Margin="0,2" />
</StackPanel>
</GroupBox>
<!-- Chart view toggle (in left panel for easy access) -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.chart.toggle]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.chart.donut]}"
IsChecked="{Binding IsDonutChart}" Margin="0,2" />
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.chart.bar]}"
IsChecked="{Binding IsDonutChart, Converter={StaticResource InverseBoolConverter}}"
Margin="0,2" />
</StackPanel>
</GroupBox>
<!-- Status -->
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap"
FontSize="11" Foreground="#555" Margin="0,4" />
</StackPanel>
</ScrollViewer>
<!-- Right content area: DataGrid on top, Chart on bottom -->
<Grid Margin="4,8,8,8">
<Grid.RowDefinitions>
<RowDefinition Height="*" MinHeight="150" />
<RowDefinition Height="Auto" />
<RowDefinition Height="300" MinHeight="200" />
</Grid.RowDefinitions>
<!-- Results DataGrid -->
<DataGrid x:Name="ResultsGrid"
Grid.Row="0"
ItemsSource="{Binding Results}"
IsReadOnly="True"
AutoGenerateColumns="False"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling">
<DataGrid.Columns>
<DataGridTemplateColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.library]}"
Width="*" MinWidth="160">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}"
Margin="{Binding IndentLevel, Converter={StaticResource IndentConverter}}"
VerticalAlignment="Center" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.site]}"
Binding="{Binding SiteTitle}" Width="140" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.files]}"
Binding="{Binding TotalFileCount, StringFormat=N0}"
Width="70" ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.size]}"
Binding="{Binding TotalSizeBytes, Converter={StaticResource BytesConverter}}"
Width="100" ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.versions]}"
Binding="{Binding VersionSizeBytes, Converter={StaticResource BytesConverter}}"
Width="110" ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.lastmod]}"
Binding="{Binding LastModified, StringFormat=yyyy-MM-dd}"
Width="110" />
</DataGrid.Columns>
</DataGrid>
<!-- Splitter between DataGrid and Chart -->
<GridSplitter Grid.Row="1" Height="5" HorizontalAlignment="Stretch"
Background="#DDD" ResizeDirection="Rows" />
<!-- Chart panel -->
<Border Grid.Row="2" BorderBrush="#DDD" BorderThickness="1" CornerRadius="4"
Padding="8" Background="White">
<Grid>
<!-- Chart title -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.chart.title]}"
FontWeight="SemiBold" FontSize="14" VerticalAlignment="Top"
HorizontalAlignment="Left" Margin="4,0,0,0" />
<!-- No data placeholder -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.chart.nodata]}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="#888" FontSize="12"
Visibility="{Binding HasChartData, Converter={StaticResource InverseBoolConverter}, ConverterParameter=Visibility}" />
<!-- Pie/Donut chart (visible when IsDonutChart=true) -->
<lvc:PieChart Series="{Binding PieChartSeries}"
Margin="4,24,4,4"
LegendPosition="Right">
<lvc:PieChart.Style>
<Style TargetType="lvc:PieChart">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsDonutChart}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
<DataTrigger Binding="{Binding HasChartData}" Value="False">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</lvc:PieChart.Style>
</lvc:PieChart>
<!-- Bar chart (visible when IsDonutChart=false) -->
<lvc:CartesianChart Series="{Binding BarChartSeries}"
XAxes="{Binding BarXAxes}"
YAxes="{Binding BarYAxes}"
Margin="4,24,4,4"
LegendPosition="Hidden">
<lvc:CartesianChart.Style>
<Style TargetType="lvc:CartesianChart">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsDonutChart}" Value="False">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
<DataTrigger Binding="{Binding HasChartData}" Value="False">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</lvc:CartesianChart.Style>
</lvc:CartesianChart>
</Grid>
</Border>
</Grid>
</DockPanel>
</UserControl>
```
**IMPORTANT NOTES for the executor:**
1. The `InverseBoolConverter` with `ConverterParameter=Visibility` for the "no data" placeholder: Check how the existing InverseBoolConverter works. If it only returns bool (not Visibility), you may need to use a `BooleanToVisibilityConverter` with an `InverseBoolConverter` chain, OR simply use a DataTrigger on a TextBlock. The simplest approach is to use a `Style` with DataTrigger on the TextBlock itself:
```xml
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasChartData}" Value="False">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
```
Use whichever approach compiles. The DataTrigger approach is more reliable.
2. The LiveCharts2 PieChart DataTrigger approach with dual triggers (IsDonutChart AND HasChartData) needs MultiDataTrigger if both conditions must be true simultaneously. However, the simpler approach works: set default to Collapsed, show on IsDonutChart=True. When HasChartData is false, PieChartSeries is empty so the chart renders nothing anyway. So you can simplify to just the IsDonutChart trigger. Use your judgment on what compiles.
3. The `coreconv` xmlns is included in case you need InvertBoolConverter from Core/Converters (Phase 8). Only use it if needed.
4. If `lvc:PieChart` has `LegendPosition` as an enum, use `LiveChartsCore.Measure.LegendPosition.Right`. If it's a direct string property, use "Right". Adapt to what compiles.
5. The `Style` approach on chart controls may not work if LiveCharts controls don't support WPF style setters for Visibility. Alternative: wrap each chart in a `Border` or `Grid` and set Visibility on the wrapper via DataTrigger. This is more reliable.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -10</automated>
</verify>
<done>StorageView.xaml shows DataGrid on top, chart panel on bottom with GridSplitter. Radio buttons toggle between donut and bar views. PieChart and CartesianChart bind to ViewModel series properties. Localization keys exist in both EN and FR resx files. Project compiles with 0 errors.</done>
</task>
</tasks>
<verification>
- `dotnet build SharepointToolbox/SharepointToolbox.csproj` succeeds with 0 errors
- StorageViewModel has IsDonutChart, FileTypeMetrics, PieChartSeries, BarChartSeries, BarXAxes, BarYAxes
- RunOperationAsync calls CollectFileTypeMetricsAsync after CollectStorageAsync
- StorageView.xaml has lvc:PieChart and lvc:CartesianChart controls
- Radio buttons bind to IsDonutChart
- Strings.resx and Strings.fr.resx have stor.chart.* keys
- No data placeholder shown when HasChartData is false
</verification>
<success_criteria>
The Storage Metrics tab displays a chart panel below the DataGrid after a scan completes. Users can toggle between donut and bar chart views via radio buttons in the left panel. Charts show top 10 file types by size with "Other" aggregation. Switching chart view does not re-run the scan. Chart updates automatically when a new scan finishes. All labels are localized in EN and FR.
</success_criteria>
<output>
After completion, create `.planning/phases/09-storage-visualization/09-03-SUMMARY.md`
</output>