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>
This commit is contained in:
Dev
2026-04-07 15:16:16 +02:00
parent 666e918810
commit a63a698282
5 changed files with 1290 additions and 5 deletions

View File

@@ -93,7 +93,12 @@ Plans:
2. After a storage scan completes, a chart appears in the Storage Metrics tab showing space broken down by file type 2. After a storage scan completes, a chart appears in the Storage Metrics tab showing space broken down by file type
3. A toggle control switches the chart between pie/donut and bar chart representations without re-running the scan 3. A toggle control switches the chart between pie/donut and bar chart representations without re-running the scan
4. The chart updates automatically whenever a new storage scan finishes, without requiring manual refresh 4. The chart updates automatically whenever a new storage scan finishes, without requiring manual refresh
**Plans**: TBD **Plans:** 4 plans
Plans:
- [ ] 09-01-PLAN.md — LiveCharts2 NuGet + FileTypeMetric model + IStorageService extension (Wave 1)
- [ ] 09-02-PLAN.md — StorageService file-type enumeration implementation (Wave 2)
- [ ] 09-03-PLAN.md — ViewModel chart properties + View XAML + localization (Wave 3)
- [ ] 09-04-PLAN.md — Unit tests for chart ViewModel behavior (Wave 4)
## Progress ## Progress
@@ -104,7 +109,7 @@ Plans:
| 3. Storage and File Operations | v1.0 | 8/8 | Complete | 2026-04-02 | | 3. Storage and File Operations | v1.0 | 8/8 | Complete | 2026-04-02 |
| 4. Bulk Operations and Provisioning | v1.0 | 10/10 | Complete | 2026-04-03 | | 4. Bulk Operations and Provisioning | v1.0 | 10/10 | Complete | 2026-04-03 |
| 5. Distribution and Hardening | v1.0 | 3/3 | Complete | 2026-04-03 | | 5. Distribution and Hardening | v1.0 | 3/3 | Complete | 2026-04-03 |
| 6. Global Site Selection | 5/5 | Complete | 2026-04-07 | - | | 6. Global Site Selection | v1.1 | 5/5 | Complete | 2026-04-07 |
| 7. User Access Audit | 10/10 | Complete | 2026-04-07 | - | | 7. User Access Audit | v1.1 | 10/10 | Complete | 2026-04-07 |
| 8. Simplified Permissions | 6/6 | Complete | 2026-04-07 | - | | 8. Simplified Permissions | v1.1 | 6/6 | Complete | 2026-04-07 |
| 9. Storage Visualization | v1.1 | 0/? | Not started | - | | 9. Storage Visualization | v1.1 | 0/4 | Not started | - |

View File

@@ -0,0 +1,209 @@
---
phase: 09-storage-visualization
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- SharepointToolbox/SharepointToolbox.csproj
- SharepointToolbox/Core/Models/FileTypeMetric.cs
- SharepointToolbox/Services/IStorageService.cs
autonomous: true
requirements:
- VIZZ-01
must_haves:
truths:
- "LiveCharts2 SkiaSharp WPF package is a NuGet dependency and the project compiles"
- "FileTypeMetric record models file extension, total size, and file count"
- "IStorageService declares CollectFileTypeMetricsAsync without breaking existing CollectStorageAsync"
artifacts:
- path: "SharepointToolbox/SharepointToolbox.csproj"
provides: "LiveChartsCore.SkiaSharpView.WPF package reference"
contains: "LiveChartsCore.SkiaSharpView.WPF"
- path: "SharepointToolbox/Core/Models/FileTypeMetric.cs"
provides: "Data model for file type breakdown"
contains: "record FileTypeMetric"
- path: "SharepointToolbox/Services/IStorageService.cs"
provides: "Extended interface with file type metrics method"
contains: "CollectFileTypeMetricsAsync"
key_links:
- from: "SharepointToolbox/Services/IStorageService.cs"
to: "SharepointToolbox/Core/Models/FileTypeMetric.cs"
via: "Return type of CollectFileTypeMetricsAsync"
pattern: "IReadOnlyList<FileTypeMetric>"
---
<objective>
Add LiveCharts2 NuGet dependency, create the FileTypeMetric data model, and extend IStorageService with a file-type metrics collection method signature.
Purpose: Establishes the charting library dependency (VIZZ-01) and the data contracts that all subsequent plans depend on. No implementation yet -- just the NuGet, the model, and the interface.
Output: Updated csproj, FileTypeMetric.cs, updated IStorageService.cs
</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
<interfaces>
<!-- Existing IStorageService -- we ADD a method, do not change existing signature -->
From SharepointToolbox/Services/IStorageService.cs:
```csharp
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public interface IStorageService
{
Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx,
StorageScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
}
```
From SharepointToolbox/Core/Models/StorageScanOptions.cs:
```csharp
public record StorageScanOptions(bool PerLibrary = true, bool IncludeSubsites = false, int FolderDepth = 0);
```
From SharepointToolbox/Core/Models/OperationProgress.cs:
```csharp
public record OperationProgress(int Current, int Total, string Message)
{
public static OperationProgress Indeterminate(string message) => new(0, 0, message);
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add LiveCharts2 NuGet and create FileTypeMetric model</name>
<files>SharepointToolbox/SharepointToolbox.csproj, SharepointToolbox/Core/Models/FileTypeMetric.cs</files>
<action>
**Step 1:** Add LiveCharts2 WPF NuGet package:
```bash
cd "C:\Users\dev\Documents\projets\Sharepoint"
dotnet add SharepointToolbox/SharepointToolbox.csproj package LiveChartsCore.SkiaSharpView.WPF --version 2.0.0-rc5.4
```
This will add the package reference to the csproj. The `--version 2.0.0-rc5.4` is a pre-release RC, so the command may need `--prerelease` flag if it fails. Try with explicit version first; if that fails, use:
```bash
dotnet add SharepointToolbox/SharepointToolbox.csproj package LiveChartsCore.SkiaSharpView.WPF --prerelease
```
**Step 2:** Create `SharepointToolbox/Core/Models/FileTypeMetric.cs`:
```csharp
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Represents storage consumption for a single file extension across all scanned libraries.
/// Produced by IStorageService.CollectFileTypeMetricsAsync and consumed by chart bindings.
/// </summary>
public record FileTypeMetric(
/// <summary>File extension including dot, e.g. ".docx", ".pdf". Empty string for extensionless files.</summary>
string Extension,
/// <summary>Total size in bytes of all files with this extension.</summary>
long TotalSizeBytes,
/// <summary>Number of files with this extension.</summary>
int FileCount)
{
/// <summary>
/// Human-friendly display label: ".docx" becomes "DOCX", empty becomes "No Extension".
/// </summary>
public string DisplayLabel => string.IsNullOrEmpty(Extension)
? "No Extension"
: Extension.TrimStart('.').ToUpperInvariant();
}
```
Design notes:
- Record type for value semantics (same as StorageScanOptions, PermissionSummary patterns)
- Extension stored with dot prefix for consistency with Path.GetExtension
- DisplayLabel computed property for chart label binding
- TotalSizeBytes is long to match StorageNode.TotalSizeBytes type
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>LiveChartsCore.SkiaSharpView.WPF appears in csproj PackageReference. FileTypeMetric.cs exists with Extension, TotalSizeBytes, FileCount properties and DisplayLabel computed property. Project compiles with 0 errors.</done>
</task>
<task type="auto">
<name>Task 2: Extend IStorageService with CollectFileTypeMetricsAsync</name>
<files>SharepointToolbox/Services/IStorageService.cs</files>
<action>
Update `SharepointToolbox/Services/IStorageService.cs` to add a second method for file-type metrics collection. Do NOT modify the existing CollectStorageAsync signature.
Replace the file contents with:
```csharp
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public interface IStorageService
{
/// <summary>
/// Collects storage metrics per library/folder using SharePoint StorageMetrics API.
/// Returns a tree of StorageNode objects with aggregate size data.
/// </summary>
Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx,
StorageScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
/// <summary>
/// Enumerates files across all non-hidden document libraries in the site
/// and aggregates storage consumption grouped by file extension.
/// Uses CSOM CamlQuery to retrieve FileLeafRef and File_x0020_Size per file.
/// This is a separate operation from CollectStorageAsync -- it provides
/// file-type breakdown data for chart visualization.
/// </summary>
Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
ClientContext ctx,
IProgress<OperationProgress> progress,
CancellationToken ct);
}
```
Design notes:
- CollectFileTypeMetricsAsync does NOT take StorageScanOptions because file-type enumeration scans ALL non-hidden doc libraries (no per-library/subfolder filtering needed for chart aggregation)
- Returns IReadOnlyList<FileTypeMetric> sorted by TotalSizeBytes descending (convention -- implementation will handle sorting)
- Separate from CollectStorageAsync so existing storage scan flow is untouched
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>IStorageService.cs declares both CollectStorageAsync (unchanged) and CollectFileTypeMetricsAsync (new). Build fails with CS0535 in StorageService.cs (expected -- Plan 09-02 implements the method). If build succeeds, even better. Interface contract is established.</done>
</task>
</tasks>
<verification>
- `dotnet restore SharepointToolbox/SharepointToolbox.csproj` succeeds and LiveChartsCore.SkiaSharpView.WPF is resolved
- FileTypeMetric.cs exists in Core/Models with record definition
- IStorageService.cs has both method signatures
- Existing CollectStorageAsync signature is byte-identical to original
</verification>
<success_criteria>
LiveCharts2 is a project dependency. FileTypeMetric data model is defined. IStorageService has the new CollectFileTypeMetricsAsync method signature. The project compiles (or fails only because StorageService doesn't implement the new method yet -- that is acceptable and expected).
</success_criteria>
<output>
After completion, create `.planning/phases/09-storage-visualization/09-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,242 @@
---
phase: 09-storage-visualization
plan: 02
type: execute
wave: 2
depends_on:
- "09-01"
files_modified:
- SharepointToolbox/Services/StorageService.cs
autonomous: true
requirements:
- VIZZ-02
must_haves:
truths:
- "CollectFileTypeMetricsAsync enumerates files from all non-hidden document libraries"
- "Files are grouped by extension with summed size and count"
- "Results are sorted by TotalSizeBytes descending"
- "Existing CollectStorageAsync method is not modified"
artifacts:
- path: "SharepointToolbox/Services/StorageService.cs"
provides: "Implementation of CollectFileTypeMetricsAsync"
contains: "CollectFileTypeMetricsAsync"
key_links:
- from: "SharepointToolbox/Services/StorageService.cs"
to: "SharepointToolbox/Core/Models/FileTypeMetric.cs"
via: "Groups CSOM file data into FileTypeMetric records"
pattern: "new FileTypeMetric"
- from: "SharepointToolbox/Services/StorageService.cs"
to: "SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs"
via: "Throttle-safe query execution"
pattern: "ExecuteQueryRetryHelper\\.ExecuteQueryRetryAsync"
---
<objective>
Implement CollectFileTypeMetricsAsync in StorageService -- enumerate files across all non-hidden document libraries using CSOM CamlQuery, aggregate by file extension, and return sorted FileTypeMetric list.
Purpose: Provides the data layer for chart visualization (VIZZ-02). The ViewModel will call this after the main storage scan completes.
Output: Updated StorageService.cs with CollectFileTypeMetricsAsync implementation
</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
<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);
}
```
From SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs:
```csharp
public static class ExecuteQueryRetryHelper
{
public static async Task ExecuteQueryRetryAsync(
ClientContext ctx,
IProgress<OperationProgress>? progress = null,
CancellationToken ct = default);
}
```
<!-- Existing StorageService structure (DO NOT modify existing methods) -->
From SharepointToolbox/Services/StorageService.cs:
```csharp
public class StorageService : IStorageService
{
public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync(...) { ... }
private static async Task<StorageNode> LoadFolderNodeAsync(...) { ... }
private static async Task CollectSubfoldersAsync(...) { ... }
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Implement CollectFileTypeMetricsAsync in StorageService</name>
<files>SharepointToolbox/Services/StorageService.cs</files>
<action>
Add the `CollectFileTypeMetricsAsync` method to the existing `StorageService` class. Do NOT modify any existing methods (CollectStorageAsync, LoadFolderNodeAsync, CollectSubfoldersAsync). Add the new method after the existing `CollectStorageAsync` method.
Add this method to the `StorageService` class:
```csharp
public async Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
ClientContext ctx,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
// Load all non-hidden document libraries
ctx.Load(ctx.Web,
w => w.Lists.Include(
l => l.Title,
l => l.Hidden,
l => l.BaseType,
l => l.ItemCount));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var libs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList();
// Accumulate file sizes by extension across all libraries
var extensionMap = new Dictionary<string, (long totalSize, int count)>(StringComparer.OrdinalIgnoreCase);
int libIdx = 0;
foreach (var lib in libs)
{
ct.ThrowIfCancellationRequested();
libIdx++;
progress.Report(new OperationProgress(libIdx, libs.Count,
$"Scanning files by type: {lib.Title} ({libIdx}/{libs.Count})"));
// Use CamlQuery to enumerate all files in the library
// Paginate with 500 items per batch to avoid list view threshold issues
var query = new CamlQuery
{
ViewXml = @"<View Scope='RecursiveAll'>
<Query>
<Where>
<Eq>
<FieldRef Name='FSObjType' />
<Value Type='Integer'>0</Value>
</Eq>
</Where>
</Query>
<ViewFields>
<FieldRef Name='FileLeafRef' />
<FieldRef Name='File_x0020_Size' />
</ViewFields>
<RowLimit Paged='TRUE'>500</RowLimit>
</View>"
};
ListItemCollection items;
do
{
ct.ThrowIfCancellationRequested();
items = lib.GetItems(query);
ctx.Load(items, ic => ic.ListItemCollectionPosition,
ic => ic.Include(
i => i["FileLeafRef"],
i => i["File_x0020_Size"]));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
foreach (var item in items)
{
string fileName = item["FileLeafRef"]?.ToString() ?? string.Empty;
string sizeStr = item["File_x0020_Size"]?.ToString() ?? "0";
if (!long.TryParse(sizeStr, out long fileSize))
fileSize = 0;
string ext = Path.GetExtension(fileName).ToLowerInvariant();
// ext is "" for extensionless files, ".docx" etc. for others
if (extensionMap.TryGetValue(ext, out var existing))
extensionMap[ext] = (existing.totalSize + fileSize, existing.count + 1);
else
extensionMap[ext] = (fileSize, 1);
}
// Move to next page
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
}
while (items.ListItemCollectionPosition != null);
}
// Convert to FileTypeMetric list, sorted by size descending
return extensionMap
.Select(kvp => new FileTypeMetric(kvp.Key, kvp.Value.totalSize, kvp.Value.count))
.OrderByDescending(m => m.TotalSizeBytes)
.ToList();
}
```
Make sure to add `using System.IO;` at the top of the file if not already present (for `Path.GetExtension`).
Design notes:
- Uses `Scope='RecursiveAll'` in CamlQuery to get files from all subfolders without explicit recursion
- `FSObjType=0` filter ensures only files (not folders) are returned
- Paged query with 500-item batches avoids list view threshold (5000 default) issues
- File_x0020_Size is the internal name for file size in SharePoint
- Extensions normalized to lowercase for consistent grouping (".DOCX" and ".docx" merge)
- Dictionary uses OrdinalIgnoreCase comparer as extra safety
- Existing methods (CollectStorageAsync, LoadFolderNodeAsync, CollectSubfoldersAsync) are NOT touched
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>StorageService.cs implements CollectFileTypeMetricsAsync. Method enumerates files via CamlQuery with paging, groups by extension, returns IReadOnlyList&lt;FileTypeMetric&gt; sorted by TotalSizeBytes descending. Existing CollectStorageAsync is unchanged. Project compiles with 0 errors.</done>
</task>
</tasks>
<verification>
- `dotnet build SharepointToolbox/SharepointToolbox.csproj` succeeds with 0 errors
- StorageService now implements both IStorageService methods
- CollectFileTypeMetricsAsync uses paginated CamlQuery (RowLimit 500, Paged=TRUE)
- Extensions normalized to lowercase
- Results sorted by TotalSizeBytes descending
- No modifications to CollectStorageAsync, LoadFolderNodeAsync, or CollectSubfoldersAsync
</verification>
<success_criteria>
StorageService fully implements IStorageService. CollectFileTypeMetricsAsync can enumerate files by extension from any SharePoint site. The project compiles cleanly and existing storage scan behavior is unaffected.
</success_criteria>
<output>
After completion, create `.planning/phases/09-storage-visualization/09-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,634 @@
---
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>

View File

@@ -0,0 +1,195 @@
---
phase: 09-storage-visualization
plan: 04
type: execute
wave: 4
depends_on:
- "09-03"
files_modified:
- SharepointToolbox.Tests/ViewModels/StorageViewModelChartTests.cs
autonomous: true
requirements:
- VIZZ-01
- VIZZ-02
- VIZZ-03
must_haves:
truths:
- "Unit tests verify chart series are computed from FileTypeMetric data"
- "Unit tests verify donut/bar toggle changes series without re-scanning"
- "Unit tests verify top-10 + Other aggregation logic"
- "Unit tests verify chart data clears on tenant switch"
artifacts:
- path: "SharepointToolbox.Tests/ViewModels/StorageViewModelChartTests.cs"
provides: "Chart-specific unit tests for StorageViewModel"
contains: "class StorageViewModelChartTests"
key_links:
- from: "SharepointToolbox.Tests/ViewModels/StorageViewModelChartTests.cs"
to: "SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs"
via: "Tests chart properties and UpdateChartSeries behavior"
pattern: "StorageViewModel"
---
<objective>
Create unit tests for StorageViewModel chart functionality: FileTypeMetric aggregation into chart series, donut/bar toggle behavior, top-10 + Other logic, and tenant switch cleanup.
Purpose: Validates VIZZ-01 (charting library integration via series creation), VIZZ-02 (chart data from file types), and VIZZ-03 (toggle behavior) at the ViewModel level without requiring a live SharePoint connection.
Output: StorageViewModelChartTests.cs
</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
@.planning/phases/09-storage-visualization/09-03-SUMMARY.md
<interfaces>
<!-- From Plan 09-03: StorageViewModel chart properties -->
From SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs (chart additions):
```csharp
// New observable properties:
[ObservableProperty] private bool _isDonutChart = true;
public ObservableCollection<FileTypeMetric> FileTypeMetrics { get; private set; }
public bool HasChartData => FileTypeMetrics.Count > 0;
public IEnumerable<ISeries> PieChartSeries { get; private set; }
public IEnumerable<ISeries> BarChartSeries { get; private set; }
public Axis[] BarXAxes { get; private set; }
public Axis[] BarYAxes { get; private set; }
// Existing test constructor:
internal StorageViewModel(IStorageService, ISessionManager, ILogger<FeatureViewModelBase>)
// Existing test helper:
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
// Existing setup helper:
internal void SetCurrentProfile(TenantProfile profile)
```
From SharepointToolbox/Core/Models/FileTypeMetric.cs:
```csharp
public record FileTypeMetric(string Extension, long TotalSizeBytes, int FileCount)
{
public string DisplayLabel => ...;
}
```
From SharepointToolbox/Services/IStorageService.cs:
```csharp
public interface IStorageService
{
Task<IReadOnlyList<StorageNode>> CollectStorageAsync(...);
Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
ClientContext ctx, IProgress<OperationProgress> progress, CancellationToken ct);
}
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create StorageViewModel chart unit tests</name>
<files>SharepointToolbox.Tests/ViewModels/StorageViewModelChartTests.cs</files>
<behavior>
- Test 1: After RunOperationAsync with mock returning FileTypeMetrics, HasChartData is true and PieChartSeries has entries
- Test 2: After RunOperationAsync, BarChartSeries has exactly 1 ColumnSeries with values matching metric count
- Test 3: Toggle IsDonutChart from true to false updates PieChartSeries (InnerRadius changes) without calling service again
- Test 4: When mock returns >10 file types, chart series has 11 entries (10 + Other)
- Test 5: When mock returns <=10 file types, no "Other" entry is added
- Test 6: OnTenantSwitched clears FileTypeMetrics and HasChartData becomes false
- Test 7: When mock returns empty file type list, HasChartData is false and series are empty
</behavior>
<action>
Create `SharepointToolbox.Tests/ViewModels/StorageViewModelChartTests.cs`.
First, check the existing test project structure for patterns:
```bash
ls SharepointToolbox.Tests/ViewModels/
```
and read an existing ViewModel test to understand mock patterns (likely uses Moq or NSubstitute).
Also check the test project csproj for testing frameworks:
```bash
cat SharepointToolbox.Tests/SharepointToolbox.Tests.csproj
```
Create the test file following existing patterns. The tests should:
1. Use the internal test constructor: `new StorageViewModel(mockStorageService, mockSessionManager, mockLogger)`
2. Mock `IStorageService` to return predetermined `FileTypeMetric` lists from `CollectFileTypeMetricsAsync`
3. Mock `IStorageService.CollectStorageAsync` to return empty list (we only care about chart data)
4. Mock `ISessionManager.GetOrCreateContextAsync` -- this is tricky since it returns `ClientContext` which is hard to mock. Follow existing test patterns. If existing tests use reflection or a different approach, follow that.
5. Call `vm.SetCurrentProfile(new TenantProfile { TenantUrl = "https://test.sharepoint.com", ClientId = "test", Name = "Test" })`
6. Set `vm.SiteUrl = "https://test.sharepoint.com/sites/test"`
7. Call `await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>(_ => {}))`
8. Assert chart properties
**Test structure:**
```csharp
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.Messaging;
using LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq; // or NSubstitute -- check existing test patterns
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.ViewModels.Tabs;
namespace SharepointToolbox.Tests.ViewModels;
public class StorageViewModelChartTests
{
// Helper to create ViewModel with mocked services
// Helper to create sample FileTypeMetric lists
// 7 test methods as described in behavior block
}
```
**Critical note on ClientContext mocking:** ClientContext is a sealed CSOM class that cannot be directly mocked with Moq. Check how existing StorageService tests or StorageViewModel tests handle this. If there are no existing ViewModel tests that call TestRunOperationAsync (check existing test files), you may need to:
- Skip the full RunOperationAsync flow and instead directly set FileTypeMetrics via reflection
- OR mock ISessionManager to return null/throw and test a different path
- OR create tests that only verify the UpdateChartSeries logic by setting FileTypeMetrics directly
The SAFEST approach if ClientContext cannot be mocked: Make `UpdateChartSeries` and `FileTypeMetrics` setter accessible for testing. Since FileTypeMetrics has a private setter, you can set it via reflection in tests:
```csharp
var metricsProperty = typeof(StorageViewModel).GetProperty("FileTypeMetrics");
metricsProperty!.SetValue(vm, new ObservableCollection<FileTypeMetric>(testMetrics));
```
This tests the chart logic without needing a real SharePoint connection.
**Alternative approach:** If the project already has patterns for testing RunOperationAsync (check Phase 7 UserAccessAuditViewModel tests for TestRunOperationAsync usage), follow that pattern exactly.
Remember to add `WeakReferenceMessenger.Default.Reset()` in test constructor to prevent cross-test contamination (Phase 7 convention).
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~StorageViewModelChartTests" --no-build 2>&1 | tail -15</automated>
</verify>
<done>StorageViewModelChartTests.cs has 7 passing tests covering: chart series from metrics, bar series structure, toggle behavior, top-10+Other aggregation, no-Other for <=10 items, tenant switch cleanup, empty data handling. All tests pass. No existing tests are broken.</done>
</task>
</tasks>
<verification>
- `dotnet test SharepointToolbox.Tests/ --filter "StorageViewModelChartTests"` -- all tests pass
- `dotnet test SharepointToolbox.Tests/` -- all existing tests still pass (no regressions)
- Tests cover all 3 VIZZ requirements at the ViewModel level
</verification>
<success_criteria>
All 7 chart-related unit tests pass. No regression in existing test suite. Tests verify chart data computation, toggle behavior, aggregation logic, and cleanup -- all without requiring a live SharePoint connection.
</success_criteria>
<output>
After completion, create `.planning/phases/09-storage-visualization/09-04-SUMMARY.md`
</output>