# Per-playout Reshuffle + shuffle-state surfacing (#71) — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a per-playout "Reshuffle" action (rolls a new random play order + rebuilds) and surface the play-order seed in the SPA.

**Architecture:** A new `POST /api/v1/playouts/{id}/reshuffle` endpoint delegates to a fire-and-forget MediatR command that enqueues `BuildPlayout(id, PlayoutBuildMode.Reset)` — the existing `Reset` build mode already reseeds `Playout.Seed` and rebuilds. The seed is projected onto the playout list/detail DTOs so the SPA can display it (visible confirmation the reshuffle worked). SPA adds a "Reshuffle" button (mirroring the existing erase/reset per-playout actions) plus a seed display line.

**Tech Stack:** C#/.NET 10, MediatR CQRS, EF Core, ChicoryTV React SPA (Vite + TypeScript), NUnit + Shouldly + NSubstitute (backend), Vitest + Testing Library (SPA).

## Global Constraints

- Test framework is **NUnit + Shouldly + NSubstitute** (backend) and **Vitest** (SPA). Never xUnit.
- New `/api/*` controllers/DTOs require OpenAPI regen: `dotnet build ErsatzTV.sln` → `./scripts/update-openapi.sh` → `cd web && npm run generate:api`, then commit `ErsatzTV/wwwroot/openapi/v1.json`, `docs/endpoint-index.md`, and `web/src/api/generated/v1.d.ts`. A blocking CI `api-docs` job fires on `ErsatzTV/Controllers/Api/**` and `ErsatzTV.Core/Api/**` changes.
- Every controller action needs `[Tags("Playouts")]`, `[EndpointSummary(...)]`, `[EndpointGroupName("general")]` (REQUIRED or it's dropped from OpenAPI), and a `[ProducesResponseType]` per status code.
- Success paths that only *queue* a build return **202 Accepted**, not 200.
- Backend tests run timezone-pinned: `TZ=UTC dotnet test ErsatzTV.Tests`. SPA: `npm test` in `web/`.
- Format touched C# files (de-BOM per the #311 formatting gate: `charset=utf-8`, no BOM on files you touch). Worktree hooks are flaky → commit with `--no-verify` and verify manually (`dotnet build`, `dotnet test`, `dotnet format whitespace <project>` on staged files only).
- Commit trailer on every commit: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
- Work only in this worktree: `/Users/timothy/ersatztv/.worktrees/71-reshuffle` (branch `feat/71-reshuffle-playout`, off `origin/main`).

---

### Task 1: `ReshufflePlayout` command + handler

**Files:**
- Create: `ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs`
- Create: `ErsatzTV.Application/Playouts/Commands/ReshufflePlayoutHandler.cs`
- Test: `ErsatzTV.Tests/Application/Playouts/ReshufflePlayoutHandlerTests.cs`

**Interfaces:**
- Consumes: `BuildPlayout(int playoutId, PlayoutBuildMode mode)` (ctor), `PlayoutBuildMode.Reset` (`ErsatzTV.Core.Scheduling`), `IBackgroundServiceRequest` (`ErsatzTV.Application`, parent namespace — no `using` needed), `IDbContextFactory<TvContext>`, the `InMemoryTvContext` test harness (`ErsatzTV.Tests.Support`).
- Produces: `public record ReshufflePlayout(int PlayoutId) : IRequest;` and `ReshufflePlayoutHandler` — later relied on by the controller (Task 2), which sends `new ReshufflePlayout(id)`.

- [ ] **Step 1: Write the failing handler test**

Create `ErsatzTV.Tests/Application/Playouts/ReshufflePlayoutHandlerTests.cs` (mirrors `ResetAllPlayoutsHandlerTests.cs`; the handler enqueues a `Reset` build for a resettable kind and skips unsupported kinds):

```csharp
using System.Collections.Generic;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Tests.Support;
using NUnit.Framework;
using Shouldly;
using Channel = System.Threading.Channels.Channel;

namespace ErsatzTV.Tests.Application.Playouts;

[TestFixture]
public class ReshufflePlayoutHandlerTests
{
    private InMemoryTvContext _db = null!;
    private Channel<IBackgroundServiceRequest> _worker = null!;

    [SetUp]
    public async Task SetUp()
    {
        _db = await InMemoryTvContext.CreateAsync();
        _worker = Channel.CreateUnbounded<IBackgroundServiceRequest>();
    }

    [TearDown]
    public async Task TearDown() => await _db.DisposeAsync();

    private ReshufflePlayoutHandler CreateHandler() => new(_worker.Writer, _db.Factory);

    private async Task<int> SeedPlayout(PlayoutScheduleKind kind)
    {
        await using TvContext context = _db.CreateContext();
        var playout = new Playout { ChannelId = 0, ScheduleKind = kind };
        context.Playouts.Add(playout);
        await context.SaveChangesAsync();
        return playout.Id;
    }

    [TestCase(PlayoutScheduleKind.Classic)]
    [TestCase(PlayoutScheduleKind.Block)]
    [TestCase(PlayoutScheduleKind.Sequential)]
    [TestCase(PlayoutScheduleKind.Scripted)]
    public async Task Handle_Should_Enqueue_Reset_Build_For_Supported_Kind(PlayoutScheduleKind kind)
    {
        int id = await SeedPlayout(kind);

        await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);

        _worker.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
        var build = request.ShouldBeOfType<BuildPlayout>();
        build.PlayoutId.ShouldBe(id);
        build.Mode.ShouldBe(PlayoutBuildMode.Reset);
        _worker.Reader.TryRead(out _).ShouldBeFalse();
    }

    [TestCase(PlayoutScheduleKind.ExternalJson)]
    [TestCase(PlayoutScheduleKind.None)]
    public async Task Handle_Should_Not_Enqueue_For_Unsupported_Kind(PlayoutScheduleKind kind)
    {
        int id = await SeedPlayout(kind);

        await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);

        _worker.Reader.TryRead(out _).ShouldBeFalse();
    }
}
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ReshufflePlayoutHandlerTests`
Expected: FAIL — `ReshufflePlayout` / `ReshufflePlayoutHandler` do not exist (compile error).

- [ ] **Step 3: Create the command**

Create `ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs`:

```csharp
namespace ErsatzTV.Application.Playouts;

public record ReshufflePlayout(int PlayoutId) : IRequest;
```

- [ ] **Step 4: Create the handler**

Create `ErsatzTV.Application/Playouts/Commands/ReshufflePlayoutHandler.cs`. The `Reset` build mode already reseeds + clears state + rebuilds; the handler only enqueues, and keeps the resettable-kind filter (defense-in-depth, matching `ErasePlayoutItemsHandler`):

```csharp
using System.Threading.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;

namespace ErsatzTV.Application.Playouts;

public class ReshufflePlayoutHandler(
    ChannelWriter<IBackgroundServiceRequest> channel,
    IDbContextFactory<TvContext> dbContextFactory)
    : IRequestHandler<ReshufflePlayout>
{
    public async Task Handle(ReshufflePlayout request, CancellationToken cancellationToken)
    {
        await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);

        Option<Playout> maybePlayout = await dbContext.Playouts
            .AsNoTracking()
            .Where(p => p.ScheduleKind == PlayoutScheduleKind.Classic ||
                        p.ScheduleKind == PlayoutScheduleKind.Block ||
                        p.ScheduleKind == PlayoutScheduleKind.Sequential ||
                        p.ScheduleKind == PlayoutScheduleKind.Scripted)
            .SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken);

        foreach (Playout playout in maybePlayout)
        {
            await channel.WriteAsync(
                new BuildPlayout(playout.Id, PlayoutBuildMode.Reset),
                cancellationToken);
        }
    }
}
```

> Note: `SingleOrDefaultAsync` returns `Playout?`; the codebase's global usings (LanguageExt) implicitly convert to `Option<Playout>` as in `ErasePlayoutItemsHandler`. If the implicit conversion does not compile, wrap with `Optional(...)`.

- [ ] **Step 5: Run the test to verify it passes**

Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ReshufflePlayoutHandlerTests`
Expected: PASS (6 cases).

- [ ] **Step 6: Format + commit**

```bash
dotnet format whitespace ErsatzTV.Application ErsatzTV.Tests --folder --include ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs ErsatzTV.Application/Playouts/Commands/ReshufflePlayoutHandler.cs ErsatzTV.Tests/Application/Playouts/ReshufflePlayoutHandlerTests.cs || true
git add ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs ErsatzTV.Application/Playouts/Commands/ReshufflePlayoutHandler.cs ErsatzTV.Tests/Application/Playouts/ReshufflePlayoutHandlerTests.cs
git commit --no-verify -m "feat(71): ReshufflePlayout command + handler (enqueue per-playout Reset build)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

> If `dotnet format --include` no-ops on this Mac (known issue), instead run `dotnet format whitespace ErsatzTV.Application` and `... ErsatzTV.Tests`, then `git add` only the three files above.

---

### Task 2: Reshuffle controller action (202 / 404 / 409 / 422)

**Files:**
- Modify: `ErsatzTV/Controllers/Api/PlayoutController.cs` (add the `Reshuffle` action after `EraseItemsAndHistory`, ~line 685)
- Test: `ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs`

**Interfaces:**
- Consumes: `ReshufflePlayout` (Task 1), `GetPlayoutById`, `entityLocker.IsPlayoutLocked(id)`, `PlayoutLockedProblem()`, `ApiResults.NotFoundProblem()`, `BaseError.New(...).ToErrorResult()`.
- Produces: action `Task<IActionResult> Reshuffle(int id, CancellationToken)` at `POST /api/v1/playouts/{id:int}/reshuffle`.

- [ ] **Step 1: Write the failing controller tests**

Add to `ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs` (mirrors the `EraseItems_*` tests). Also extend the existing `Controller_Should_Expose_Idiomatic_Rest_Routes` test with the new route:

```csharp
    [Test]
    public async Task Reshuffle_Should_Return_409_When_Playout_Locked()
    {
        _entityLocker.IsPlayoutLocked(9).Returns(true);

        IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);

        result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
        await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
        await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
    }

    [Test]
    public async Task Reshuffle_Should_Return_404_When_Playout_Missing()
    {
        _mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
            .Returns(Option<PlayoutNameViewModel>.None);

        IActionResult result = await _controller.Reshuffle(404, CancellationToken.None);

        result.ShouldBeOfType<NotFoundObjectResult>();
        await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
    }

    [TestCase(PlayoutScheduleKind.ExternalJson)]
    [TestCase(PlayoutScheduleKind.None)]
    public async Task Reshuffle_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind)
    {
        _mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
            .Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));

        IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);

        result.ShouldBeOfType<UnprocessableEntityObjectResult>();
        await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
    }

    [TestCase(PlayoutScheduleKind.Classic)]
    [TestCase(PlayoutScheduleKind.Block)]
    [TestCase(PlayoutScheduleKind.Sequential)]
    [TestCase(PlayoutScheduleKind.Scripted)]
    public async Task Reshuffle_Should_Return_202_And_Send_Command_For_Supported_Kind(PlayoutScheduleKind kind)
    {
        _mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
            .Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));

        IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);

        result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
        await _mediator.Received(1).Send(
            Arg.Is<ReshufflePlayout>(c => c.PlayoutId == 9),
            Arg.Any<CancellationToken>());
    }
```

Add this line inside `Controller_Should_Expose_Idiomatic_Rest_Routes` (next to the `EraseItemsAndHistory` assertion):

```csharp
        ShouldHaveActionRoute(nameof(PlayoutController.Reshuffle), "POST", "/api/v1/playouts/{id:int}/reshuffle");
```

- [ ] **Step 2: Run the tests to verify they fail**

Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter FullyQualifiedName~PlayoutControllerTests`
Expected: FAIL — `PlayoutController.Reshuffle` does not exist (compile error).

- [ ] **Step 3: Add the controller action**

In `ErsatzTV/Controllers/Api/PlayoutController.cs`, insert immediately after the `EraseItemsAndHistory` action (after line 684):

```csharp
    [HttpPost("/api/v1/playouts/{id:int}/reshuffle", Name = "ReshufflePlayout")]
    [Tags("Playouts")]
    [EndpointSummary("Reshuffle a playout")]
    [EndpointDescription(
        "Rolls a new random play order for a Classic, Block, Sequential, or Scripted playout by reseeding it " +
        "and rebuilding from scratch (clears rerun history). Only valid for those kinds; other kinds return 422.")]
    [EndpointGroupName("general")]
    [ProducesResponseType(StatusCodes.Status202Accepted)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
    public async Task<IActionResult> Reshuffle(int id, CancellationToken cancellationToken)
    {
        if (entityLocker.IsPlayoutLocked(id))
        {
            return PlayoutLockedProblem();
        }

        Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
        if (maybePlayout.IsNone)
        {
            return ApiResults.NotFoundProblem();
        }

        foreach (PlayoutNameViewModel playout in maybePlayout)
        {
            if (playout.ScheduleKind is not (PlayoutScheduleKind.Classic or PlayoutScheduleKind.Block
                or PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted))
            {
                return BaseError.New(
                        "[Reshuffle] is only valid for Classic, Block, Sequential, or Scripted playouts")
                    .ToErrorResult();
            }
        }

        await mediator.Send(new ReshufflePlayout(id), cancellationToken);
        return Accepted();
    }
```

- [ ] **Step 4: Run the tests to verify they pass**

Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter FullyQualifiedName~PlayoutControllerTests`
Expected: PASS (including the route assertion + the 4 new tests).

- [ ] **Step 5: Format + commit**

```bash
dotnet format whitespace ErsatzTV ErsatzTV.Tests || true
git add ErsatzTV/Controllers/Api/PlayoutController.cs ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs
git commit --no-verify -m "feat(71): POST /api/v1/playouts/{id}/reshuffle action (202/404/409/422)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

### Task 3: Surface `Seed` on the playout VM + DTOs + projections

**Files:**
- Modify: `ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs` (append `int Seed`)
- Modify: `ErsatzTV.Application/Playouts/Mapper.cs` (list projection)
- Modify: `ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs` (detail projection)
- Modify: `ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs`, `UpdateExternalJsonPlayoutHandler.cs`, `UpdateSequentialPlayoutHandler.cs`, `UpdateScriptedPlayoutHandler.cs` (VM construction sites)
- Modify: `ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs`, `PlayoutResponseModel.cs`
- Modify: `ErsatzTV/Controllers/Api/PlayoutController.cs` (`ToResponse`, `ToListItemResponse`)
- Test: `ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs` (+ update the `MakePlayout` fixture)

**Interfaces:**
- Produces: `PlayoutNameViewModel.Seed : int`, `PlayoutResponseModel.Seed : int`, `PlayoutListItemResponseModel.Seed : int` — consumed by the SPA (Task 6) after OpenAPI regen (Task 4).

- [ ] **Step 1: Write the failing seed tests**

Add to `PlayoutControllerTests.cs`:

```csharp
    [Test]
    public async Task GetById_Should_Surface_Seed()
    {
        _mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
            .Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { Seed = 4242 }));

        IActionResult result = await _controller.GetById(9, CancellationToken.None);

        result.ShouldBeOfType<OkObjectResult>()
            .Value.ShouldBeOfType<PlayoutResponseModel>()
            .Seed.ShouldBe(4242);
    }

    [Test]
    public async Task GetAll_Should_Surface_Seed()
    {
        _mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
            .Returns(new PagedPlayoutsViewModel(1, new List<PlayoutNameViewModel> { MakePlayout(9) with { Seed = 4242 } }));

        PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);

        result.Page[0].Seed.ShouldBe(4242);
    }
```

- [ ] **Step 2: Run to verify they fail**

Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter "FullyQualifiedName~PlayoutControllerTests.GetById_Should_Surface_Seed|FullyQualifiedName~PlayoutControllerTests.GetAll_Should_Surface_Seed"`
Expected: FAIL — `PlayoutResponseModel`/`PlayoutListItemResponseModel` have no `Seed`; `MakePlayout(...) with { Seed = ... }` won't compile.

- [ ] **Step 3: Append `Seed` to `PlayoutNameViewModel`**

In `ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs`, change the record header's final line `int Version)` → `int Version,\n    int Seed)`:

```csharp
public record PlayoutNameViewModel(
    int PlayoutId,
    PlayoutScheduleKind ScheduleKind,
    string ChannelName,
    string ChannelNumber,
    ChannelPlayoutMode PlayoutMode,
    string ScheduleName,
    string ScheduleFile,
    TimeSpan? DbDailyRebuildTime,
    PlayoutBuildStatus BuildStatus,
    int? DecoId,
    string DecoName,
    int Version,
    int Seed)
```

- [ ] **Step 4: Set `Seed` at all VM construction sites**

`ErsatzTV.Application/Playouts/Mapper.cs` — change `playout.Version);` (line 23) to:
```csharp
            playout.Version,
            playout.Seed);
```

`ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs` — change `p.Version));` (line 34) to:
```csharp
                p.Version,
                p.Seed));
```

In each of `UpdatePlayoutHandler.cs`, `UpdateExternalJsonPlayoutHandler.cs`, `UpdateSequentialPlayoutHandler.cs`, `UpdateScriptedPlayoutHandler.cs` — the trailing `playout.Version);` of the `return new PlayoutNameViewModel(...)` becomes:
```csharp
            playout.Version,
            playout.Seed);
```

- [ ] **Step 5: Add `Seed` to the response DTOs**

`ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs` — append `int Seed` after `bool IsLocked`:
```csharp
public record PlayoutListItemResponseModel(
    int Id,
    string ChannelNumber,
    string ChannelName,
    PlayoutScheduleKind ScheduleKind,
    string ScheduleName,
    TimeSpan? DailyRebuildTime,
    PlayoutBuildStatusResponseModel? BuildStatus,
    ChannelPlayoutMode PlayoutMode,
    bool IsLocked,
    int Seed);
```

`ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs` — append `int Seed` to both the record and the `From` factory (param list ends with `bool isLocked`; add `int seed`, and add `seed` to the `new(...)`):
```csharp
public record PlayoutResponseModel(
    int Id,
    PlayoutScheduleKind ScheduleKind,
    string ChannelName,
    string ChannelNumber,
    ChannelPlayoutMode PlayoutMode,
    string ScheduleName,
    string? ScheduleFile,
    TimeSpan? DailyRebuildTime,
    PlayoutBuildStatusResponseModel? BuildStatus,
    int? DecoId,
    string? DecoName,
    bool IsLocked,
    int Seed)
{
    public static PlayoutResponseModel From(
        int id,
        PlayoutScheduleKind scheduleKind,
        string channelName,
        string channelNumber,
        ChannelPlayoutMode playoutMode,
        string scheduleName,
        string? scheduleFile,
        TimeSpan? dailyRebuildTime,
        PlayoutBuildStatusResponseModel? buildStatus,
        int? decoId,
        string? decoName,
        bool isLocked,
        int seed) =>
        new(
            id,
            scheduleKind,
            channelName,
            channelNumber,
            playoutMode,
            scheduleName,
            scheduleFile,
            dailyRebuildTime,
            buildStatus,
            decoId,
            decoName,
            isLocked,
            seed);
}
```

- [ ] **Step 6: Pass `Seed` through the controller mappers**

`ErsatzTV/Controllers/Api/PlayoutController.cs`:
- `ToResponse` (line 776) — change the trailing `isLocked);` to `isLocked,\n            vm.Seed);`.
- `ToListItemResponse` (line 828) — change the trailing `isLocked);` to `isLocked,\n            vm.Seed);`.

- [ ] **Step 7: Update the `MakePlayout` fixture**

`ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs` `MakePlayout` (line 1331) — the constructor ends `0);` (Version). Append `0` for Seed:
```csharp
            0,
            0);
```

- [ ] **Step 8: Run the seed tests + full controller/handler suite**

Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter FullyQualifiedName~PlayoutControllerTests`
Expected: PASS (including the two seed tests). Then `TZ=UTC dotnet build ErsatzTV.sln` — Expected: `Build succeeded`, `0 Error(s)` (verifies all 4 update handlers + Mapper compile with the new positional field). Grep the output for `error CS` — there must be none.

- [ ] **Step 9: Format + commit**

```bash
dotnet format whitespace ErsatzTV.Application ErsatzTV.Core ErsatzTV ErsatzTV.Tests || true
git add ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs ErsatzTV.Application/Playouts/Mapper.cs ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs ErsatzTV.Application/Playouts/Commands/UpdateSequentialPlayoutHandler.cs ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs ErsatzTV/Controllers/Api/PlayoutController.cs ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs
git commit --no-verify -m "feat(71): surface Playout.Seed on playout list + detail DTOs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

### Task 4: Regenerate OpenAPI + TypeScript client

**Files:**
- Modify (generated): `ErsatzTV/wwwroot/openapi/v1.json`, `docs/endpoint-index.md`, `web/src/api/generated/v1.d.ts`

- [ ] **Step 1: Build, regenerate, verify**

```bash
dotnet build ErsatzTV.sln
./scripts/update-openapi.sh
cd web && npm run generate:api && npm run check:api && cd ..
```
Expected: `v1.json` gains the `/api/v1/playouts/{id}/reshuffle` POST (operationId `ReshufflePlayout`) and `seed` on `PlayoutListItemResponseModel` + `PlayoutResponseModel`; `docs/endpoint-index.md` gains the reshuffle row; `web/src/api/generated/v1.d.ts` gains `"seed": number;` on both models and the new path. `npm run check:api` exits clean.

- [ ] **Step 2: Commit the generated artifacts**

```bash
git add ErsatzTV/wwwroot/openapi/v1.json docs/endpoint-index.md web/src/api/generated/v1.d.ts
git commit --no-verify -m "chore(71): regenerate OpenAPI + client for reshuffle endpoint + seed field

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

### Task 5: SPA — reshuffle client fn + button

**Files:**
- Modify: `web/src/api/playouts.ts` (add `reshufflePlayout`)
- Modify: `web/src/screens/PlayoutsScreen.tsx` (import `Dices`, add wrapper + button)
- Test: `web/src/screens/PlayoutsScreen.test.tsx`

**Interfaces:**
- Consumes: `request<void>` (from `./client`), the regenerated route (Task 4).
- Produces: `reshufflePlayout(playoutId: number): Promise<void>`.

- [ ] **Step 1: Write the failing SPA test**

Add to `web/src/screens/PlayoutsScreen.test.tsx` (mirrors the erase-buttons test):

```tsx
  it('reshuffles a Classic playout and posts to the reshuffle route', async () => {
    mockApi({
      confirm: true,
      playoutItems: [playoutItem()],
      playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
      playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Classic' })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: 'Reshuffle' }));
    await waitFor(() => {
      expect(window.fetch).toHaveBeenCalledWith(
        '/api/v1/playouts/20/reshuffle',
        expect.objectContaining({ method: 'POST' })
      );
    });
  });
```

> If the `mockApi` fetch stub enumerates specific mutation routes (see `PlayoutsScreen.test.tsx:168-184`) rather than defaulting unknown POSTs to 200, add a matcher for `POST /api/v1/playouts/:id/reshuffle` mirroring the `erase-items` entry so the click resolves.

- [ ] **Step 2: Run to verify it fails**

Run: `cd web && npm test -- PlayoutsScreen`
Expected: FAIL — no button named "Reshuffle".

- [ ] **Step 3: Add the client fn**

In `web/src/api/playouts.ts`, add next to `erasePlayoutItemsAndHistory`:

```ts
export function reshufflePlayout(playoutId: number): Promise<void> {
  return request<void>(`/api/v1/playouts/${playoutId}/reshuffle`, { method: 'POST' });
}
```

- [ ] **Step 4: Import the icon + fn, add the wrapper + button**

In `web/src/screens/PlayoutsScreen.tsx`:

1. Add `Dices` to the `lucide-react` import block (alphabetical, after `Clock`):
```tsx
  Dices,
```

2. Add `reshufflePlayout` to the existing `from '../api'` import that already brings in `erasePlayoutItems` (add the name to that import list).

3. Add the wrapper fn next to `eraseSelectedItemsAndHistory`:
```tsx
  const reshuffleSelectedPlayout = () => {
    if (!selectedSummary) {
      return;
    }
    runMutation(
      `Reshuffle the playout for ${selectedSummary.channelName}? This rolls a new random play order and rebuilds the schedule from scratch, clearing rerun history.`,
      () => reshufflePlayout(selectedSummary.id)
    );
  };
```

4. Add the button inside the `.ctv-playout-detail-actions` group (after the "Erase items and history" button, before "Delete"), gated to the reshuffle-supported kinds:
```tsx
                {(selectedSummary.scheduleKind === 'Classic' ||
                  selectedSummary.scheduleKind === 'Block' ||
                  selectedSummary.scheduleKind === 'Sequential' ||
                  selectedSummary.scheduleKind === 'Scripted') && (
                  <Button
                    disabled={mutating || selectedLocked}
                    onClick={reshuffleSelectedPlayout}
                    size="sm"
                    startIcon={<Dices aria-hidden="true" size={13} />}
                    title={selectedLocked ? 'A build is in progress for this playout' : undefined}
                    variant="ghost"
                  >
                    Reshuffle
                  </Button>
                )}
```

- [ ] **Step 5: Run to verify it passes**

Run: `cd web && npm test -- PlayoutsScreen`
Expected: PASS (new test + existing tests still green).

- [ ] **Step 6: Commit**

```bash
git add web/src/api/playouts.ts web/src/screens/PlayoutsScreen.tsx web/src/screens/PlayoutsScreen.test.tsx
git commit --no-verify -m "feat(71): SPA per-playout Reshuffle button + client fn

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

### Task 6: SPA — seed display + help text

**Files:**
- Modify: `web/src/screens/PlayoutsScreen.tsx` (detail grid + help text)
- Test: `web/src/screens/PlayoutsScreen.test.tsx` (+ `seed` in the `listPlayout()`/`playout()` factories)

- [ ] **Step 1: Write the failing test**

First ensure the test factories default a `seed`. In `PlayoutsScreen.test.tsx`, add `seed: 0` to the object returned by the `listPlayout()` factory and the `playout()` factory (mirroring how `isLocked`/`scheduleKind` defaults are set). Then add:

```tsx
  it('shows the play-order seed for the selected playout', async () => {
    mockApi({
      playoutItems: [playoutItem()],
      playoutDetails: playout({ id: 20, scheduleKind: 'Classic', seed: 424242 }),
      playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Classic', seed: 424242 })], totalCount: 1 }
    });

    render(<PlayoutsScreen />);

    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
    expect(screen.getByDisplayValue('424242')).toBeInTheDocument();
  });
```

- [ ] **Step 2: Run to verify it fails**

Run: `cd web && npm test -- PlayoutsScreen`
Expected: FAIL — no element displays `424242`.

- [ ] **Step 3: Add the seed line + help text**

In `web/src/screens/PlayoutsScreen.tsx`, inside the `.ctv-playout-detail-grid` block (after the "Rebuild" `Input`), add:

```tsx
                <Input disabled label="Seed" value={String(playout?.seed ?? selectedSummary.seed)} />
```

Immediately after the closing `</div>` of `.ctv-playout-detail-grid`, add the help text:

```tsx
              <p className="ctv-playout-seed-help">
                The play order is saved and stays consistent across rebuilds. Reshuffle to roll a new order.
              </p>
```

- [ ] **Step 4: Run to verify it passes**

Run: `cd web && npm test -- PlayoutsScreen`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add web/src/screens/PlayoutsScreen.tsx web/src/screens/PlayoutsScreen.test.tsx
git commit --no-verify -m "feat(71): SPA display play-order seed + persistence help text

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

### Task 7: Docs (parity, domain-model, decisions)

**Files:**
- Modify: `docs/blazor-route-parity.md`, `docs/domain-model.md`, `docs/decisions.md`

- [ ] **Step 1: Update `docs/blazor-route-parity.md`**

In the Playouts row / section, note the new per-playout **Reshuffle** action (`POST /api/v1/playouts/{id}/reshuffle`) alongside Reset/Erase/Delete. (Find the Playouts entry; add one sentence.)

- [ ] **Step 2: Update `docs/domain-model.md`**

In the playout rows (~lines 45-61), add a note that `Playout.Seed` (the master shuffle seed) is now surfaced on the playout list/detail API and that a per-playout **Reshuffle** action reseeds + rebuilds (`PlayoutBuildMode.Reset`).

- [ ] **Step 3: Append to `docs/decisions.md`**

Append a dated entry (append-only):
```markdown
## 2026-07-16 — Per-playout reshuffle = scoped Reset build; seed surfaced (#71)

- The shuffle seed (`Playout.Seed`) + per-collection `CollectionEnumeratorState` already persist a stable
  shuffled order across rebuilds. #71's real gap was a **user-triggered per-playout reshuffle** (a Classic
  playout's seed is otherwise only reseeded by a full Reset, and `reset-all` uses `Refresh` for Classic, so
  it never reseeds Classic) plus visibility.
- `POST /api/v1/playouts/{id}/reshuffle` enqueues `BuildPlayout(id, PlayoutBuildMode.Reset)` — Reset already
  reseeds + clears anchors/rerun-history + rebuilds. Named `/reshuffle` (not `/reset`) to (a) match user
  intent and (b) avoid the "reset-one reseeds Classic while reset-all refreshes Classic" naming clash. It is
  deliberately more aggressive than `reset-all` for Classic: an explicit single-channel action rolls a new
  order; the bulk action stays non-disruptive.
- `Playout.Seed` is surfaced on the playout list + detail DTOs so the SPA can show it — its purpose is
  **visible confirmation** (the seed changes after a reshuffle).
```

- [ ] **Step 4: Commit**

```bash
git add docs/blazor-route-parity.md docs/domain-model.md docs/decisions.md
git commit --no-verify -m "docs(71): reshuffle action + seed surfacing (parity, domain-model, decisions)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

### Task 8: Full verification, live-E2E, PR, independent review

- [ ] **Step 1: Full local gate**

```bash
TZ=UTC dotnet build ErsatzTV.sln         # 0 errors
TZ=UTC dotnet test ErsatzTV.Tests        # all green
cd web && npm run lint && npm test -- --run && npm run build && npm run check:api && cd ..
git diff --stat origin/main              # sanity: only intended files
```
Confirm each command's output shows success before proceeding (evidence, not assumption).

- [ ] **Step 2: Add `## Done-when` to issue #71** (merge-gate H6). Post/update the issue body with a checklist: adversarial review passed; backend + SPA tests green; OpenAPI regenerated + `check:api` clean; docs updated (parity/domain-model/decisions); live-E2E (reshuffle changes seed + order; 409 while locked).

- [ ] **Step 3: Live-E2E** (required — write-path handler that enqueues a build). Follow `docs/e2e-local.md` "Seeding a local TV library for E2E":
  - Start a local instance via `scripts/e2e-local.sh`; seed a Classic playout whose schedule item uses a Shuffle playback order over a small collection.
  - `GET /api/v1/playouts/{id}` → record `seed` and `GET /api/v1/playouts/{id}/items` → record the first several item titles.
  - `POST /api/v1/playouts/{id}/reshuffle` → 202. Wait for the build to complete (poll `buildStatus`).
  - Re-GET → assert `seed` changed **and** the item order changed.
  - Hold the build lock (or reshuffle twice rapidly) → assert a `409` on the second call.
  - Drive the SPA button in headless Playwright (never open download endpoints in a tab); confirm the confirm-dialog + button disable while building. Capture evidence.

- [ ] **Step 4: Push + open PR**

```bash
git push --no-verify -u origin feat/71-reshuffle-playout
```
Open a PR to `main` with body describing the premise correction + `fixes #71`. Arm a CI monitor on the PR head sha immediately (commit-status endpoint).

- [ ] **Step 5: Independent review** (required — write-path + enqueues a build; NOT skippable). Run a cold-context adversarial review scoped "review only" over the PR diff (cross-model/Codex if quota, else a cold Claude agent). When judging the reshuffle soundness, enumerate every producer/consumer of `BuildPlayout` and confirm the per-playout `Reset` cannot cross-release another playout's lock. Post a `Review-verdict: <MERGEABLE|BLOCKED> @ <head-sha>` comment (H10). Fix findings as follow-up commits (never amend/force-push); re-review the fix commit.

- [ ] **Step 6: Merge** via the derived-consent gate (all `## Done-when` ticked + green CI + fresh positive `Review-verdict`), then run the issue close protocol (structured close comment; move nothing in #237 — it's closed) and the session-end tracker/H12 steps.
