---
phase: 16-report-consolidation-toggle
plan: "02"
type: execute
wave: 2
depends_on: ["16-01"]
files_modified:
  - SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs
  - SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
  - SharepointToolbox.Tests/Services/Export/UserAccessHtmlExportServiceTests.cs
autonomous: true
requirements: [RPT-03]

must_haves:
  truths:
    - "HTML export with mergePermissions=false produces byte-identical output to pre-Phase-16 behavior"
    - "HTML export with mergePermissions=true renders consolidated by-user rows with Sites column"
    - "Consolidated rows with 1 location show site title inline (no badge)"
    - "Consolidated rows with 2+ locations show clickable [N sites] badge that expands sub-list"
    - "By-site view toggle is omitted from HTML when consolidation is ON"
    - "ViewModel passes MergePermissions to HTML export service"
  artifacts:
    - path: "SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs"
      provides: "Consolidated HTML rendering with expandable location sub-lists"
      contains: "mergePermissions"
    - path: "SharepointToolbox.Tests/Services/Export/UserAccessHtmlExportServiceTests.cs"
      provides: "Tests for consolidated and non-consolidated HTML paths"
      contains: "mergePermissions"
  key_links:
    - from: "UserAccessAuditViewModel.ExportHtmlAsync"
      to: "UserAccessHtmlExportService.WriteAsync"
      via: "MergePermissions parameter passthrough"
      pattern: "WriteAsync.*MergePermissions"
    - from: "UserAccessHtmlExportService.BuildHtml"
      to: "PermissionConsolidator.Consolidate"
      via: "Early-return branch when mergePermissions=true"
      pattern: "PermissionConsolidator\\.Consolidate"
    - from: "Consolidated HTML"
      to: "toggleGroup JS"
      via: "data-group='loc{idx}' on location sub-rows"
      pattern: "data-group.*loc"
---

<objective>
Implement the consolidated HTML rendering path in UserAccessHtmlExportService — expandable location sub-lists using existing toggleGroup() JS, by-site view suppression, and wire the ViewModel HTML export call site.

Purpose: Completes the user-visible consolidation behavior for HTML reports — the primary export format.
Output: Working consolidated HTML export with expandable site lists, full test coverage.
</objective>

<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/16-report-consolidation-toggle/16-CONTEXT.md
@.planning/phases/16-report-consolidation-toggle/16-RESEARCH.md
@.planning/phases/16-report-consolidation-toggle/16-01-SUMMARY.md

<interfaces>
<!-- From Plan 01 — MergePermissions is now on the ViewModel -->

From SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs:
```csharp
[ObservableProperty]
private bool _mergePermissions;

// ExportHtmlAsync call site (line ~526):
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding);
// Must become:
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, MergePermissions, branding);
```

From SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs:
```csharp
public class UserAccessHtmlExportService
{
    public string BuildHtml(IReadOnlyList<UserAccessEntry> entries, ReportBranding? branding = null);
    public async Task WriteAsync(IReadOnlyList<UserAccessEntry> entries, string filePath, CancellationToken ct, ReportBranding? branding = null);
}
```

From SharepointToolbox/Core/Helpers/PermissionConsolidator.cs:
```csharp
public static IReadOnlyList<ConsolidatedPermissionEntry> Consolidate(IReadOnlyList<UserAccessEntry> entries);
```

From SharepointToolbox/Core/Models/ConsolidatedPermissionEntry.cs:
```csharp
public record ConsolidatedPermissionEntry(
    string UserDisplayName, string UserLogin, string PermissionLevel,
    string AccessType, string GrantedThrough,
    bool IsExternalUser, bool IsHighPrivilege,
    IReadOnlyList<LocationInfo> Locations)
{
    public int LocationCount => Locations.Count;
}
```

Existing toggleGroup JS (reuse as-is, already in BuildHtml inline JS):
```javascript
function toggleGroup(id) {
  var rows = document.querySelectorAll('tr[data-group="' + id + '"]');
  var isHidden = rows.length > 0 && rows[0].style.display === 'none';
  rows.forEach(function(r) { r.style.display = isHidden ? '' : 'none'; });
}
```

Consolidated HTML column layout (from RESEARCH.md Pattern 4):
| Column | Source Field |
|--------|-------------|
| User | UserDisplayName (+ Guest badge if IsExternalUser) |
| Permission Level | PermissionLevel (+ high-priv icon if IsHighPrivilege) |
| Access Type | badge from AccessType |
| Granted Through | GrantedThrough |
| Sites | inline title OR [N sites] badge + expandable sub-list |
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Implement consolidated HTML rendering path in BuildHtml and wire WriteAsync</name>
  <files>
    SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs,
    SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs,
    SharepointToolbox.Tests/Services/Export/UserAccessHtmlExportServiceTests.cs
  </files>
  <behavior>
    - RPT-03-b: `BuildHtml(entries, mergePermissions: false)` output is byte-identical to current `BuildHtml(entries)` output
    - RPT-03-c: `BuildHtml(entries, mergePermissions: true)` includes consolidated rows and a "Sites" column header
    - RPT-03-d: When a consolidated entry has LocationCount >= 2, the HTML contains an `[N sites]` badge with `onclick="toggleGroup('loc...')"` and hidden sub-rows with `data-group="loc..."`
    - RPT-03-e: When mergePermissions=true, the HTML does NOT contain the "By Site" button or `view-site` div
    - Edge: Single-location consolidated entry renders site title inline (no badge, no expandable rows)
  </behavior>
  <action>
    1. Change `BuildHtml` signature to add `bool mergePermissions = false` as the second parameter (before `branding`):
       ```csharp
       public string BuildHtml(IReadOnlyList<UserAccessEntry> entries, bool mergePermissions = false, ReportBranding? branding = null)
       ```

    2. At the very beginning of `BuildHtml`, after the stats computation block, add an early-return branch:
       ```csharp
       if (mergePermissions)
       {
           var consolidated = PermissionConsolidator.Consolidate(entries);
           return BuildConsolidatedHtml(consolidated, entries, branding);
       }
       ```
       Leave the ENTIRE existing code path below this branch COMPLETELY UNTOUCHED. Not a single character change.

    3. Create a private `BuildConsolidatedHtml` method that builds the consolidated HTML report:
       - Reuse the same HTML shell (DOCTYPE, head, CSS, header, stats section, user summary cards) from the existing `BuildHtml` — extract the stats from `entries` (the original flat list) for accurate counts.
       - Include the existing `toggleGroup()` and `toggleView()` JS functions (copy from existing inline JS).
       - **OMIT the "By Site" button** from the view toggle bar — only render the "By User" button (or omit the view toggle entirely since there's only one view).
       - **OMIT the `view-site` div** and its by-site table entirely.
       - Render a single by-user table with columns: User, Permission Level, Access Type, Granted Through, Sites.
       - Group consolidated entries by UserLogin (similar to existing user grouping pattern with `ugrp{n}` group headers).
       - For each `ConsolidatedPermissionEntry` row:
         - **Sites column — 1 location:** Render `entry.Locations[0].SiteTitle` as plain text.
         - **Sites column — 2+ locations:** Render `<span class="badge" onclick="toggleGroup('loc{locIdx}')" style="cursor:pointer">{entry.LocationCount} sites</span>`. Immediately after the main `<tr>`, emit hidden sub-rows:
           ```html
           <tr data-group="loc{locIdx}" style="display:none">
             <td colspan="5" style="padding-left:2em">
               <a href="{loc.SiteUrl}">{loc.SiteTitle}</a>
             </td>
           </tr>
           ```
       - Use a SEPARATE counter `int locIdx = 0` for location group IDs, distinct from user group counter `int grpIdx = 0` (RESEARCH.md Pitfall 2).
       - Apply the same CSS classes: `.guest-badge` for external users, `.high-priv` for high-privilege rows, `.badge` for access type badges.
       - Include the same search/filter JS if present in the existing template.

    4. Change `WriteAsync` signature to add `bool mergePermissions = false` after `ct` and before `branding`:
       ```csharp
       public async Task WriteAsync(IReadOnlyList<UserAccessEntry> entries, string filePath, CancellationToken ct, bool mergePermissions = false, ReportBranding? branding = null)
       ```
       Pass `mergePermissions` through to `BuildHtml`:
       ```csharp
       var html = BuildHtml(entries, mergePermissions, branding);
       ```

    5. In `UserAccessAuditViewModel.cs`, update the `ExportHtmlAsync` call site (line ~526):
       Change: `await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding);`
       To: `await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, MergePermissions, branding);`

    6. Add test methods in `UserAccessHtmlExportServiceTests.cs`:
       - **RPT-03-b test:** Capture `BuildHtml(testEntries)` output (old signature), then verify `BuildHtml(testEntries, mergePermissions: false)` produces identical string.
       - **RPT-03-c test:** `BuildHtml(testEntries, mergePermissions: true)` contains "Sites" column header and consolidated row content.
       - **RPT-03-d test:** Create test data with 2+ entries sharing the same consolidation key. Verify output contains `onclick="toggleGroup('loc` and `data-group="loc` patterns.
       - **RPT-03-e test:** `BuildHtml(testEntries, mergePermissions: true)` does NOT contain `btn-site` or `view-site`.
       - Use 3-4 UserAccessEntry test rows where 2 share the same key (same UserLogin+PermissionLevel+AccessType+GrantedThrough but different sites).

    CRITICAL ANTI-PATTERNS:
    - Do NOT modify any line of the existing non-consolidated code path in BuildHtml. The early-return branch guarantees isolation.
    - Do NOT reuse the `ugrp` counter for location groups — use `loc{locIdx}` with its own counter.
    - Do NOT forget to pass mergePermissions through WriteAsync to BuildHtml (Pitfall 3).
  </action>
  <verify>
    <automated>cd "C:/Users/dev/Documents/projets/Sharepoint" && dotnet test --filter "FullyQualifiedName~UserAccessHtmlExportServiceTests" --no-restore -v q 2>&1 | tail -10</automated>
  </verify>
  <done>HTML export with mergePermissions=false is byte-identical to pre-toggle output. HTML export with mergePermissions=true renders consolidated rows with Sites column, expandable location sub-lists for 2+ locations, and omits the by-site view. All tests pass.</done>
</task>

<task type="auto">
  <name>Task 2: Full solution build and test suite verification</name>
  <files></files>
  <action>
    1. Run `dotnet build` on the entire solution to verify zero errors and zero warnings.
    2. Run `dotnet test` to verify the full test suite passes — all existing tests plus new Phase 16 tests.
    3. Verify test count has increased by at least 4 (RPT-03-b through RPT-03-g from Plans 01 and 02).
    4. If any test fails, diagnose and fix. Common issues:
       - Existing tests calling `BuildHtml(entries)` still work because `mergePermissions` defaults to `false`.
       - Existing tests calling `WriteAsync(entries, path, ct, branding)` — verify the parameter order change doesn't break existing callers. Since `mergePermissions` is now between `ct` and `branding`, check that no existing call site passes `branding` positionally without the new parameter. If so, fix the call site.
       - Existing tests calling `WriteSingleFileAsync(entries, path, ct)` still work because `mergePermissions` defaults to `false`.
  </action>
  <verify>
    <automated>cd "C:/Users/dev/Documents/projets/Sharepoint" && dotnet test -v q 2>&1 | tail -15</automated>
  </verify>
  <done>Full solution builds with 0 errors, 0 warnings. All tests pass (existing + new). No regressions.</done>
</task>

</tasks>

<verification>
1. `dotnet build` — 0 errors, 0 warnings
2. `dotnet test --filter "FullyQualifiedName~UserAccessHtmlExportServiceTests"` — all HTML export tests pass
3. `dotnet test` — full suite green, no regressions
4. Manual spot-check: `BuildHtml(entries, false)` output matches `BuildHtml(entries)` character-for-character
</verification>

<success_criteria>
- HTML consolidated path renders expandable [N sites] badges with toggleGroup integration
- HTML non-consolidated path is byte-identical to pre-Phase-16 output
- By-site view is suppressed when consolidation is ON
- ViewModel wiring passes MergePermissions to WriteAsync
- Full test suite passes with no regressions
</success_criteria>

<output>
After completion, create `.planning/phases/16-report-consolidation-toggle/16-02-SUMMARY.md`
</output>
