# Maintenance Flows Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add `maintenance` as a first-class flow type with batch multi-target session launching, saved target lists, and APScheduler-based automatic session creation on a cron schedule.

**Architecture:** New `tree_type='maintenance'` (DB migration), two new tables (`target_lists`, `maintenance_schedules`), `batch_id` + `target_label` on sessions, APScheduler in-process, and a new maintenance detail page in the frontend that owns scheduling + batch launch. Execution of individual sessions reuses `ProceduralNavigationPage`.

**Tech Stack:** FastAPI + SQLAlchemy async + Alembic (backend), APScheduler 3.x (`apscheduler`), React 19 + TypeScript + Zustand (frontend), Tailwind CSS v3, Lucide React icons.

**Design doc:** `docs/plans/2026-02-17-maintenance-flows-design.md`

---

## Phase 1: Database + API

### Task 1: Expand `tree_type` check constraint

**Files:**
- Modify: `backend/app/models/tree.py:22-35`
- Modify: `backend/app/schemas/tree.py:10`

**Step 1: Update the model check constraint**

In `backend/app/models/tree.py`, change:
```python
CheckConstraint(
    "tree_type IN ('troubleshooting', 'procedural')",
    name='ck_trees_tree_type'
),
```
to:
```python
CheckConstraint(
    "tree_type IN ('troubleshooting', 'procedural', 'maintenance')",
    name='ck_trees_tree_type'
),
```

**Step 2: Update the TreeType literal in schemas**

In `backend/app/schemas/tree.py`, change:
```python
TreeType = Literal['troubleshooting', 'procedural']
```
to:
```python
TreeType = Literal['troubleshooting', 'procedural', 'maintenance']
```

**Step 3: Generate Alembic migration**

```bash
cd backend && alembic revision --autogenerate -m "add maintenance tree type"
```

Open the generated file in `backend/alembic/versions/`. The autogenerate will likely produce an empty migration because check constraints aren't always detected. Replace the `upgrade()` and `downgrade()` functions with:

```python
def upgrade() -> None:
    op.execute("ALTER TABLE trees DROP CONSTRAINT ck_trees_tree_type")
    op.execute(
        "ALTER TABLE trees ADD CONSTRAINT ck_trees_tree_type "
        "CHECK (tree_type IN ('troubleshooting', 'procedural', 'maintenance'))"
    )

def downgrade() -> None:
    # First set any maintenance trees back to procedural
    op.execute("UPDATE trees SET tree_type = 'procedural' WHERE tree_type = 'maintenance'")
    op.execute("ALTER TABLE trees DROP CONSTRAINT ck_trees_tree_type")
    op.execute(
        "ALTER TABLE trees ADD CONSTRAINT ck_trees_tree_type "
        "CHECK (tree_type IN ('troubleshooting', 'procedural'))"
    )
```

**Step 4: Apply migration**

```bash
cd backend && alembic upgrade head
```

Expected: `Running upgrade ... -> <revision_id>` with no errors.

**Step 5: Verify manually**

```bash
docker exec -it patherly_postgres psql -U postgres -d patherly -c "\d+ trees" | grep tree_type
```

Expected: constraint shown as `CHECK (tree_type = ANY (ARRAY['troubleshooting'::text, 'procedural'::text, 'maintenance'::text]))`.

**Step 6: Write a failing test**

Create `backend/tests/test_maintenance_tree_type.py`:

```python
"""Tests for maintenance tree type."""
import pytest
from httpx import AsyncClient


@pytest.mark.asyncio
async def test_create_maintenance_tree(client: AsyncClient, engineer_headers: dict):
    """Maintenance tree type is accepted by the API."""
    resp = await client.post(
        "/trees/",
        json={
            "name": "Update FSLogix",
            "description": "Monthly FSLogix update procedure",
            "tree_type": "maintenance",
            "tree_structure": {
                "steps": [
                    {"id": "step-1", "type": "procedure_step", "title": "Download installer",
                     "description": "Get latest FSLogix from Microsoft", "content_type": "action"},
                    {"id": "step-end", "type": "procedure_end", "title": "Complete"},
                ]
            },
        },
        headers=engineer_headers,
    )
    assert resp.status_code == 201, resp.text
    data = resp.json()
    assert data["tree_type"] == "maintenance"


@pytest.mark.asyncio
async def test_list_maintenance_trees_filter(client: AsyncClient, engineer_headers: dict):
    """Filtering by tree_type=maintenance returns only maintenance trees."""
    # Create one maintenance tree
    await client.post(
        "/trees/",
        json={
            "name": "Maintenance Only",
            "tree_type": "maintenance",
            "tree_structure": {
                "steps": [
                    {"id": "s1", "type": "procedure_step", "title": "Step",
                     "description": "Do it", "content_type": "action"},
                    {"id": "end", "type": "procedure_end", "title": "Done"},
                ]
            },
        },
        headers=engineer_headers,
    )
    resp = await client.get("/trees/?tree_type=maintenance", headers=engineer_headers)
    assert resp.status_code == 200
    trees = resp.json()
    assert all(t["tree_type"] == "maintenance" for t in trees)
    assert len(trees) >= 1
```

**Step 7: Run the test to verify it fails**

```bash
cd backend && pytest tests/test_maintenance_tree_type.py -v --override-ini="addopts="
```

Expected: FAIL — the check constraint hasn't been applied yet to the test DB (or tree_type is rejected). If you haven't created the test DB migration, run `alembic -x testing=true upgrade head` or apply manually.

**Step 8: Apply migration to test DB**

```bash
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly_test alembic upgrade head
```

**Step 9: Run the tests again to verify they pass**

```bash
cd backend && pytest tests/test_maintenance_tree_type.py -v --override-ini="addopts="
```

Expected: PASS.

**Step 10: Commit**

```bash
git add backend/app/models/tree.py backend/app/schemas/tree.py backend/alembic/versions/ backend/tests/test_maintenance_tree_type.py
git commit -m "feat: add maintenance tree_type with db migration and tests"
```

---

### Task 2: Add `target_lists` table and API

**Files:**
- Create: `backend/app/models/target_list.py`
- Create: `backend/app/schemas/target_list.py`
- Create: `backend/app/api/endpoints/target_lists.py`
- Modify: `backend/app/api/router.py`
- Create: `backend/tests/test_target_lists.py`

**Step 1: Write the failing tests first**

Create `backend/tests/test_target_lists.py`:

```python
"""Tests for target lists CRUD."""
import pytest
from httpx import AsyncClient


@pytest.mark.asyncio
async def test_create_target_list(client: AsyncClient, engineer_headers: dict):
    resp = await client.post(
        "/target-lists/",
        json={
            "name": "RDS Farm A",
            "description": "Production RDS servers",
            "targets": [
                {"label": "RDS-01", "notes": "192.168.1.10"},
                {"label": "RDS-02", "notes": "192.168.1.11"},
            ],
        },
        headers=engineer_headers,
    )
    assert resp.status_code == 201, resp.text
    data = resp.json()
    assert data["name"] == "RDS Farm A"
    assert len(data["targets"]) == 2


@pytest.mark.asyncio
async def test_list_target_lists(client: AsyncClient, engineer_headers: dict):
    resp = await client.get("/target-lists/", headers=engineer_headers)
    assert resp.status_code == 200
    assert isinstance(resp.json(), list)


@pytest.mark.asyncio
async def test_update_target_list(client: AsyncClient, engineer_headers: dict):
    create = await client.post(
        "/target-lists/",
        json={"name": "Old Name", "targets": [{"label": "SRV-01"}]},
        headers=engineer_headers,
    )
    list_id = create.json()["id"]
    resp = await client.put(
        f"/target-lists/{list_id}",
        json={"name": "New Name", "targets": [{"label": "SRV-01"}, {"label": "SRV-02"}]},
        headers=engineer_headers,
    )
    assert resp.status_code == 200
    assert resp.json()["name"] == "New Name"
    assert len(resp.json()["targets"]) == 2


@pytest.mark.asyncio
async def test_delete_target_list(client: AsyncClient, engineer_headers: dict):
    create = await client.post(
        "/target-lists/",
        json={"name": "To Delete", "targets": [{"label": "X"}]},
        headers=engineer_headers,
    )
    list_id = create.json()["id"]
    resp = await client.delete(f"/target-lists/{list_id}", headers=engineer_headers)
    assert resp.status_code == 204

    # Verify gone
    get = await client.get(f"/target-lists/{list_id}", headers=engineer_headers)
    assert get.status_code == 404
```

**Step 2: Run to verify tests fail**

```bash
cd backend && pytest tests/test_target_lists.py -v --override-ini="addopts="
```

Expected: FAIL — `/target-lists/` route doesn't exist yet.

**Step 3: Create the model**

Create `backend/app/models/target_list.py`:

```python
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base

if TYPE_CHECKING:
    from app.models.user import User
    from app.models.team import Team


class TargetList(Base):
    __tablename__ = "target_lists"

    id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
    )
    team_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"),
        nullable=False, index=True
    )
    created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
        UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
    )
    name: Mapped[str] = mapped_column(String(255), nullable=False)
    description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    # targets: [{"label": "RDS-01", "notes": "optional notes"}, ...]
    targets: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        default=lambda: datetime.now(timezone.utc),
        onupdate=lambda: datetime.now(timezone.utc),
    )
```

**Step 4: Create schemas**

Create `backend/app/schemas/target_list.py`:

```python
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field


class TargetEntry(BaseModel):
    label: str = Field(..., min_length=1, max_length=255)
    notes: Optional[str] = Field(None, max_length=500)


class TargetListCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=255)
    description: Optional[str] = None
    targets: list[TargetEntry] = Field(..., min_length=1)


class TargetListUpdate(BaseModel):
    name: Optional[str] = Field(None, min_length=1, max_length=255)
    description: Optional[str] = None
    targets: Optional[list[TargetEntry]] = None


class TargetListResponse(BaseModel):
    id: UUID
    team_id: UUID
    created_by: Optional[UUID]
    name: str
    description: Optional[str]
    targets: list[TargetEntry]
    created_at: datetime
    updated_at: datetime

    model_config = {"from_attributes": True}
```

**Step 5: Generate + apply Alembic migration for target_lists**

```bash
cd backend && alembic revision --autogenerate -m "add target_lists table"
```

Review the generated migration to ensure it creates `target_lists` with correct columns. Then:

```bash
alembic upgrade head
# Also apply to test DB:
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly_test alembic upgrade head
```

**Step 6: Create the endpoint**

Create `backend/app/api/endpoints/target_lists.py`:

```python
"""Target lists CRUD endpoints."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.deps import get_current_active_user, get_db
from app.models.target_list import TargetList
from app.models.user import User
from app.schemas.target_list import TargetListCreate, TargetListUpdate, TargetListResponse

router = APIRouter(prefix="/target-lists", tags=["target-lists"])


@router.get("/", response_model=list[TargetListResponse])
async def list_target_lists(
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """List all target lists for the current user's team."""
    if not current_user.team_id:
        return []
    result = await db.execute(
        select(TargetList)
        .where(TargetList.team_id == current_user.team_id)
        .order_by(TargetList.name)
    )
    return result.scalars().all()


@router.post("/", response_model=TargetListResponse, status_code=201)
async def create_target_list(
    data: TargetListCreate,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create a new target list for the current team."""
    if not current_user.team_id:
        raise HTTPException(status_code=400, detail="User must belong to a team")
    target_list = TargetList(
        team_id=current_user.team_id,
        created_by=current_user.id,
        name=data.name,
        description=data.description,
        targets=[t.model_dump() for t in data.targets],
    )
    db.add(target_list)
    await db.commit()
    await db.refresh(target_list)
    return target_list


@router.get("/{list_id}", response_model=TargetListResponse)
async def get_target_list(
    list_id: UUID,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    result = await db.execute(
        select(TargetList).where(
            TargetList.id == list_id,
            TargetList.team_id == current_user.team_id,
        )
    )
    target_list = result.scalar_one_or_none()
    if not target_list:
        raise HTTPException(status_code=404, detail="Target list not found")
    return target_list


@router.put("/{list_id}", response_model=TargetListResponse)
async def update_target_list(
    list_id: UUID,
    data: TargetListUpdate,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    result = await db.execute(
        select(TargetList).where(
            TargetList.id == list_id,
            TargetList.team_id == current_user.team_id,
        )
    )
    target_list = result.scalar_one_or_none()
    if not target_list:
        raise HTTPException(status_code=404, detail="Target list not found")
    if data.name is not None:
        target_list.name = data.name
    if data.description is not None:
        target_list.description = data.description
    if data.targets is not None:
        target_list.targets = [t.model_dump() for t in data.targets]
    await db.commit()
    await db.refresh(target_list)
    return target_list


@router.delete("/{list_id}", status_code=204)
async def delete_target_list(
    list_id: UUID,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    result = await db.execute(
        select(TargetList).where(
            TargetList.id == list_id,
            TargetList.team_id == current_user.team_id,
        )
    )
    target_list = result.scalar_one_or_none()
    if not target_list:
        raise HTTPException(status_code=404, detail="Target list not found")
    await db.delete(target_list)
    await db.commit()
```

**Step 7: Register in router**

In `backend/app/api/router.py`, add:
```python
from app.api.endpoints import target_lists
# ...
api_router.include_router(target_lists.router)
```

Also add the import of `TargetList` to `backend/app/models/__init__.py` if that file exists (so Alembic detects it), or just ensure the model is imported somewhere in the startup path. Check `backend/app/core/database.py` — if it imports models explicitly, add `TargetList` there.

**Step 8: Run the tests to verify they pass**

```bash
cd backend && pytest tests/test_target_lists.py -v --override-ini="addopts="
```

Expected: PASS (4 tests).

**Step 9: Commit**

```bash
git add backend/app/models/target_list.py backend/app/schemas/target_list.py \
    backend/app/api/endpoints/target_lists.py backend/app/api/router.py \
    backend/alembic/versions/ backend/tests/test_target_lists.py
git commit -m "feat: add target_lists table, schema, and CRUD endpoints"
```

---

### Task 3: Add `batch_id` + `target_label` to sessions + batch launch endpoint

**Files:**
- Modify: `backend/app/models/session.py`
- Modify: `backend/app/schemas/` (session schemas — find `SessionCreate` or `SessionResponse`)
- Modify: `backend/app/api/endpoints/sessions.py`
- Create: `backend/tests/test_batch_sessions.py`

**Step 1: Write failing tests**

Create `backend/tests/test_batch_sessions.py`:

```python
"""Tests for batch session launching."""
import pytest
from httpx import AsyncClient


async def _create_maintenance_tree(client, headers):
    resp = await client.post(
        "/trees/",
        json={
            "name": "Patch RDS",
            "tree_type": "maintenance",
            "tree_structure": {
                "steps": [
                    {"id": "s1", "type": "procedure_step", "title": "Install patch",
                     "description": "Run installer", "content_type": "action"},
                    {"id": "end", "type": "procedure_end", "title": "Done"},
                ]
            },
        },
        headers=headers,
    )
    assert resp.status_code == 201
    return resp.json()["id"]


@pytest.mark.asyncio
async def test_batch_launch_creates_one_session_per_target(
    client: AsyncClient, engineer_headers: dict
):
    tree_id = await _create_maintenance_tree(client, engineer_headers)
    resp = await client.post(
        "/sessions/batch",
        json={
            "tree_id": tree_id,
            "targets": [
                {"label": "RDS-01", "notes": "192.168.1.10"},
                {"label": "RDS-02", "notes": "192.168.1.11"},
                {"label": "RDS-03"},
            ],
        },
        headers=engineer_headers,
    )
    assert resp.status_code == 201, resp.text
    data = resp.json()
    assert data["count"] == 3
    assert data["batch_id"] is not None
    sessions = data["sessions"]
    assert len(sessions) == 3
    # All share the same batch_id
    batch_ids = {s["batch_id"] for s in sessions}
    assert len(batch_ids) == 1
    # Each has a distinct target_label
    labels = {s["target_label"] for s in sessions}
    assert labels == {"RDS-01", "RDS-02", "RDS-03"}


@pytest.mark.asyncio
async def test_batch_launch_rejects_empty_targets(
    client: AsyncClient, engineer_headers: dict
):
    tree_id = await _create_maintenance_tree(client, engineer_headers)
    resp = await client.post(
        "/sessions/batch",
        json={"tree_id": tree_id, "targets": []},
        headers=engineer_headers,
    )
    assert resp.status_code == 422
```

**Step 2: Run to verify tests fail**

```bash
cd backend && pytest tests/test_batch_sessions.py -v --override-ini="addopts="
```

Expected: FAIL — `/sessions/batch` doesn't exist.

**Step 3: Add columns to the Session model**

In `backend/app/models/session.py`, after the existing columns, add:

```python
# Batch tracking (maintenance flows)
batch_id: Mapped[Optional[uuid.UUID]] = mapped_column(
    UUID(as_uuid=True), nullable=True, index=True
)
target_label: Mapped[Optional[str]] = mapped_column(
    String(255), nullable=True
)
```

**Step 4: Generate + apply migration**

```bash
cd backend && alembic revision --autogenerate -m "add batch_id and target_label to sessions"
alembic upgrade head
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly_test alembic upgrade head
```

**Step 5: Find and update the SessionResponse schema**

Search for where `SessionResponse` or the session response schema is defined:

```bash
grep -r "class Session" backend/app/schemas/ -l
```

Open that file and add `batch_id` and `target_label` fields to both the create/response schemas:

```python
# In the response schema:
batch_id: Optional[UUID] = None
target_label: Optional[str] = None
```

**Step 6: Add the batch launch endpoint**

In `backend/app/api/endpoints/sessions.py`, find where other session routes are defined and add at the end:

```python
from pydantic import BaseModel, Field
import uuid as uuid_mod

class BatchTarget(BaseModel):
    label: str = Field(..., min_length=1, max_length=255)
    notes: Optional[str] = None

class BatchLaunchRequest(BaseModel):
    tree_id: UUID
    targets: list[BatchTarget] = Field(..., min_length=1)

class BatchLaunchResponse(BaseModel):
    batch_id: UUID
    count: int
    sessions: list  # list of session response dicts


@router.post("/batch", status_code=201)
async def batch_launch_sessions(
    data: BatchLaunchRequest,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create one session per target for a maintenance flow batch run."""
    # Verify tree exists and belongs to user's team / is accessible
    tree_result = await db.execute(
        select(Tree).where(Tree.id == data.tree_id)
    )
    tree = tree_result.scalar_one_or_none()
    if not tree:
        raise HTTPException(status_code=404, detail="Tree not found")
    if tree.tree_type != "maintenance":
        raise HTTPException(status_code=400, detail="Batch launch is only for maintenance flows")

    batch_id = uuid_mod.uuid4()
    created_sessions = []

    for target in data.targets:
        session = Session(
            tree_id=tree.id,
            user_id=current_user.id,
            tree_snapshot=tree.tree_structure,
            path_taken=[],
            decisions=[],
            custom_steps=[],
            session_variables={},
            batch_id=batch_id,
            target_label=target.label,
        )
        db.add(session)
        created_sessions.append(session)

    await db.flush()
    for s in created_sessions:
        await db.refresh(s)
    await db.commit()

    return {
        "batch_id": str(batch_id),
        "count": len(created_sessions),
        "sessions": [
            {
                "id": str(s.id),
                "batch_id": str(s.batch_id),
                "target_label": s.target_label,
                "tree_id": str(s.tree_id),
            }
            for s in created_sessions
        ],
    }
```

Make sure `Tree` is imported in `sessions.py` — add `from app.models.tree import Tree` if not already present.

**Step 7: Run the tests**

```bash
cd backend && pytest tests/test_batch_sessions.py -v --override-ini="addopts="
```

Expected: PASS (2 tests).

**Step 8: Commit**

```bash
git add backend/app/models/session.py backend/app/schemas/ backend/app/api/endpoints/sessions.py \
    backend/alembic/versions/ backend/tests/test_batch_sessions.py
git commit -m "feat: add batch_id/target_label to sessions and batch launch endpoint"
```

---

### Task 4: `maintenance_schedules` table + API

**Files:**
- Create: `backend/app/models/maintenance_schedule.py`
- Create: `backend/app/schemas/maintenance_schedule.py`
- Create: `backend/app/api/endpoints/maintenance_schedules.py`
- Modify: `backend/app/api/router.py`
- Create: `backend/tests/test_maintenance_schedules.py`

**Step 1: Write failing tests**

Create `backend/tests/test_maintenance_schedules.py`:

```python
"""Tests for maintenance schedule CRUD."""
import pytest
from httpx import AsyncClient


async def _create_maintenance_tree(client, headers):
    resp = await client.post(
        "/trees/",
        json={
            "name": "Scheduled Maintenance",
            "tree_type": "maintenance",
            "tree_structure": {
                "steps": [
                    {"id": "s1", "type": "procedure_step", "title": "Step 1",
                     "description": "Do it", "content_type": "action"},
                    {"id": "end", "type": "procedure_end", "title": "Done"},
                ]
            },
        },
        headers=headers,
    )
    return resp.json()["id"]


@pytest.mark.asyncio
async def test_create_schedule(client: AsyncClient, engineer_headers: dict):
    tree_id = await _create_maintenance_tree(client, engineer_headers)
    resp = await client.post(
        f"/maintenance-schedules/",
        json={
            "tree_id": tree_id,
            "cron_expression": "0 9 15 * *",
            "timezone": "America/New_York",
        },
        headers=engineer_headers,
    )
    assert resp.status_code == 201, resp.text
    data = resp.json()
    assert data["cron_expression"] == "0 9 15 * *"
    assert data["is_active"] is True
    assert data["next_run_at"] is not None


@pytest.mark.asyncio
async def test_get_schedule_for_tree(client: AsyncClient, engineer_headers: dict):
    tree_id = await _create_maintenance_tree(client, engineer_headers)
    await client.post(
        "/maintenance-schedules/",
        json={"tree_id": tree_id, "cron_expression": "0 0 1 * *", "timezone": "UTC"},
        headers=engineer_headers,
    )
    resp = await client.get(f"/maintenance-schedules/tree/{tree_id}", headers=engineer_headers)
    assert resp.status_code == 200
    assert resp.json()["cron_expression"] == "0 0 1 * *"


@pytest.mark.asyncio
async def test_disable_schedule(client: AsyncClient, engineer_headers: dict):
    tree_id = await _create_maintenance_tree(client, engineer_headers)
    create = await client.post(
        "/maintenance-schedules/",
        json={"tree_id": tree_id, "cron_expression": "0 6 * * 1", "timezone": "UTC"},
        headers=engineer_headers,
    )
    sched_id = create.json()["id"]
    resp = await client.patch(
        f"/maintenance-schedules/{sched_id}",
        json={"is_active": False},
        headers=engineer_headers,
    )
    assert resp.status_code == 200
    assert resp.json()["is_active"] is False
```

**Step 2: Run to verify they fail**

```bash
cd backend && pytest tests/test_maintenance_schedules.py -v --override-ini="addopts="
```

Expected: FAIL.

**Step 3: Create the model**

Create `backend/app/models/maintenance_schedule.py`:

```python
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base


class MaintenanceSchedule(Base):
    __tablename__ = "maintenance_schedules"

    id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
    )
    tree_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True), ForeignKey("trees.id", ondelete="CASCADE"),
        nullable=False, index=True, unique=True  # one schedule per tree
    )
    created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
        UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
    )
    cron_expression: Mapped[str] = mapped_column(String(100), nullable=False)
    timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC")
    target_list_id: Mapped[Optional[uuid.UUID]] = mapped_column(
        UUID(as_uuid=True), ForeignKey("target_lists.id", ondelete="SET NULL"), nullable=True
    )
    is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
    next_run_at: Mapped[Optional[datetime]] = mapped_column(
        DateTime(timezone=True), nullable=True
    )
    last_run_at: Mapped[Optional[datetime]] = mapped_column(
        DateTime(timezone=True), nullable=True
    )
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        default=lambda: datetime.now(timezone.utc),
        onupdate=lambda: datetime.now(timezone.utc),
    )
```

**Step 4: Create schemas**

Create `backend/app/schemas/maintenance_schedule.py`:

```python
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field


class MaintenanceScheduleCreate(BaseModel):
    tree_id: UUID
    cron_expression: str = Field(..., min_length=9, max_length=100)
    timezone: str = Field("UTC", max_length=100)
    target_list_id: Optional[UUID] = None


class MaintenanceScheduleUpdate(BaseModel):
    cron_expression: Optional[str] = Field(None, min_length=9, max_length=100)
    timezone: Optional[str] = Field(None, max_length=100)
    target_list_id: Optional[UUID] = None
    is_active: Optional[bool] = None


class MaintenanceScheduleResponse(BaseModel):
    id: UUID
    tree_id: UUID
    created_by: Optional[UUID]
    cron_expression: str
    timezone: str
    target_list_id: Optional[UUID]
    is_active: bool
    next_run_at: Optional[datetime]
    last_run_at: Optional[datetime]
    created_at: datetime
    updated_at: datetime

    model_config = {"from_attributes": True}
```

**Step 5: Create endpoint**

Create `backend/app/api/endpoints/maintenance_schedules.py`:

```python
"""Maintenance schedule CRUD endpoints."""
from typing import Annotated, Optional
from uuid import UUID
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from croniter import croniter

from app.api.deps import get_current_active_user, get_db
from app.models.maintenance_schedule import MaintenanceSchedule
from app.models.user import User
from app.schemas.maintenance_schedule import (
    MaintenanceScheduleCreate,
    MaintenanceScheduleUpdate,
    MaintenanceScheduleResponse,
)

router = APIRouter(prefix="/maintenance-schedules", tags=["maintenance-schedules"])


def _compute_next_run(cron_expression: str, tz_name: str) -> datetime:
    """Compute next run time from cron expression."""
    import pytz
    tz = pytz.timezone(tz_name)
    now = datetime.now(tz)
    cron = croniter(cron_expression, now)
    return cron.get_next(datetime).astimezone(timezone.utc)


@router.post("/", response_model=MaintenanceScheduleResponse, status_code=201)
async def create_schedule(
    data: MaintenanceScheduleCreate,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    # Check no existing schedule for this tree
    existing = await db.execute(
        select(MaintenanceSchedule).where(MaintenanceSchedule.tree_id == data.tree_id)
    )
    if existing.scalar_one_or_none():
        raise HTTPException(status_code=409, detail="Schedule already exists for this tree. Update the existing one.")

    next_run = _compute_next_run(data.cron_expression, data.timezone)
    schedule = MaintenanceSchedule(
        tree_id=data.tree_id,
        created_by=current_user.id,
        cron_expression=data.cron_expression,
        timezone=data.timezone,
        target_list_id=data.target_list_id,
        is_active=True,
        next_run_at=next_run,
    )
    db.add(schedule)
    await db.commit()
    await db.refresh(schedule)
    return schedule


@router.get("/tree/{tree_id}", response_model=MaintenanceScheduleResponse)
async def get_schedule_for_tree(
    tree_id: UUID,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    result = await db.execute(
        select(MaintenanceSchedule).where(MaintenanceSchedule.tree_id == tree_id)
    )
    schedule = result.scalar_one_or_none()
    if not schedule:
        raise HTTPException(status_code=404, detail="No schedule found for this tree")
    return schedule


@router.patch("/{schedule_id}", response_model=MaintenanceScheduleResponse)
async def update_schedule(
    schedule_id: UUID,
    data: MaintenanceScheduleUpdate,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    result = await db.execute(
        select(MaintenanceSchedule).where(MaintenanceSchedule.id == schedule_id)
    )
    schedule = result.scalar_one_or_none()
    if not schedule:
        raise HTTPException(status_code=404, detail="Schedule not found")

    if data.cron_expression is not None:
        schedule.cron_expression = data.cron_expression
    if data.timezone is not None:
        schedule.timezone = data.timezone
    if data.target_list_id is not None:
        schedule.target_list_id = data.target_list_id
    if data.is_active is not None:
        schedule.is_active = data.is_active

    # Recompute next_run_at if cron or timezone changed
    if data.cron_expression or data.timezone:
        schedule.next_run_at = _compute_next_run(schedule.cron_expression, schedule.timezone)

    await db.commit()
    await db.refresh(schedule)
    return schedule
```

**Step 6: Install dependencies**

```bash
cd backend && pip install croniter pytz apscheduler
```

Add to `backend/requirements.txt`:
```
croniter>=2.0.0
pytz>=2024.1
apscheduler>=3.10.0
```

**Step 7: Register in router**

In `backend/app/api/router.py`, add:
```python
from app.api.endpoints import maintenance_schedules
# ...
api_router.include_router(maintenance_schedules.router)
```

**Step 8: Generate + apply migration**

```bash
cd backend && alembic revision --autogenerate -m "add maintenance_schedules table"
alembic upgrade head
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly_test alembic upgrade head
```

**Step 9: Run tests**

```bash
cd backend && pytest tests/test_maintenance_schedules.py -v --override-ini="addopts="
```

Expected: PASS (3 tests).

**Step 10: Run all backend tests to check nothing broke**

```bash
cd backend && pytest --override-ini="addopts="
```

Expected: All pass (or same failures as before this feature).

**Step 11: Commit**

```bash
git add backend/app/models/maintenance_schedule.py backend/app/schemas/maintenance_schedule.py \
    backend/app/api/endpoints/maintenance_schedules.py backend/app/api/router.py \
    backend/alembic/versions/ backend/requirements.txt backend/tests/test_maintenance_schedules.py
git commit -m "feat: add maintenance_schedules table, schema, and CRUD endpoints"
```

---

## Phase 2: Core Frontend UI

### Task 5: Types, API clients, and routing

**Files:**
- Create: `frontend/src/types/maintenance.ts`
- Modify: `frontend/src/types/index.ts`
- Create: `frontend/src/api/targetLists.ts`
- Create: `frontend/src/api/maintenanceSchedules.ts`
- Modify: `frontend/src/api/index.ts`
- Modify: `frontend/src/lib/routing.ts`

**Step 1: Create TypeScript types**

Create `frontend/src/types/maintenance.ts`:

```typescript
export interface TargetEntry {
  label: string
  notes?: string
}

export interface TargetList {
  id: string
  team_id: string
  created_by?: string
  name: string
  description?: string
  targets: TargetEntry[]
  created_at: string
  updated_at: string
}

export interface TargetListCreate {
  name: string
  description?: string
  targets: TargetEntry[]
}

export interface MaintenanceSchedule {
  id: string
  tree_id: string
  created_by?: string
  cron_expression: string
  timezone: string
  target_list_id?: string
  is_active: boolean
  next_run_at?: string
  last_run_at?: string
  created_at: string
  updated_at: string
}

export interface MaintenanceScheduleCreate {
  tree_id: string
  cron_expression: string
  timezone: string
  target_list_id?: string
}

export interface MaintenanceScheduleUpdate {
  cron_expression?: string
  timezone?: string
  target_list_id?: string
  is_active?: boolean
}

export interface BatchLaunchRequest {
  tree_id: string
  targets: TargetEntry[]
}

export interface BatchLaunchResponse {
  batch_id: string
  count: number
  sessions: Array<{
    id: string
    batch_id: string
    target_label: string
    tree_id: string
  }>
}
```

**Step 2: Export from index**

In `frontend/src/types/index.ts`, add at the end:
```typescript
export type {
  TargetEntry,
  TargetList,
  TargetListCreate,
  MaintenanceSchedule,
  MaintenanceScheduleCreate,
  MaintenanceScheduleUpdate,
  BatchLaunchRequest,
  BatchLaunchResponse,
} from './maintenance'
```

**Step 3: Create API clients**

Create `frontend/src/api/targetLists.ts`:

```typescript
import { apiClient } from './client'
import type { TargetList, TargetListCreate } from '@/types'

export const targetListsApi = {
  list: (): Promise<TargetList[]> =>
    apiClient.get('/target-lists/').then(r => r.data),

  get: (id: string): Promise<TargetList> =>
    apiClient.get(`/target-lists/${id}`).then(r => r.data),

  create: (data: TargetListCreate): Promise<TargetList> =>
    apiClient.post('/target-lists/', data).then(r => r.data),

  update: (id: string, data: Partial<TargetListCreate>): Promise<TargetList> =>
    apiClient.put(`/target-lists/${id}`, data).then(r => r.data),

  delete: (id: string): Promise<void> =>
    apiClient.delete(`/target-lists/${id}`).then(() => undefined),
}
```

Create `frontend/src/api/maintenanceSchedules.ts`:

```typescript
import { apiClient } from './client'
import type {
  MaintenanceSchedule,
  MaintenanceScheduleCreate,
  MaintenanceScheduleUpdate,
  BatchLaunchRequest,
  BatchLaunchResponse,
} from '@/types'

export const maintenanceSchedulesApi = {
  getForTree: (treeId: string): Promise<MaintenanceSchedule> =>
    apiClient.get(`/maintenance-schedules/tree/${treeId}`).then(r => r.data),

  create: (data: MaintenanceScheduleCreate): Promise<MaintenanceSchedule> =>
    apiClient.post('/maintenance-schedules/', data).then(r => r.data),

  update: (id: string, data: MaintenanceScheduleUpdate): Promise<MaintenanceSchedule> =>
    apiClient.patch(`/maintenance-schedules/${id}`, data).then(r => r.data),
}

export const batchLaunchApi = {
  launch: (data: BatchLaunchRequest): Promise<BatchLaunchResponse> =>
    apiClient.post('/sessions/batch', data).then(r => r.data),
}
```

**Step 4: Export from api/index.ts**

In `frontend/src/api/index.ts`, add:
```typescript
export { targetListsApi } from './targetLists'
export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules'
```

**Step 5: Update routing helper**

In `frontend/src/lib/routing.ts`, update `getTreeNavigatePath`:

```typescript
export function getTreeNavigatePath(
  treeId: string,
  treeType?: string
): string {
  if (treeType === 'procedural') {
    return `/flows/${treeId}/navigate`
  }
  if (treeType === 'maintenance') {
    return `/flows/${treeId}/maintenance`
  }
  return `/trees/${treeId}/navigate`
}

export function getTreeEditorPath(
  treeId: string,
  treeType?: string
): string {
  if (treeType === 'procedural' || treeType === 'maintenance') {
    return `/flows/${treeId}/edit`
  }
  return `/trees/${treeId}/edit`
}
```

**Step 6: Build check**

```bash
cd frontend && npm run build
```

Expected: No TypeScript errors.

**Step 7: Commit**

```bash
git add frontend/src/types/maintenance.ts frontend/src/types/index.ts \
    frontend/src/api/targetLists.ts frontend/src/api/maintenanceSchedules.ts \
    frontend/src/api/index.ts frontend/src/lib/routing.ts
git commit -m "feat: add maintenance types, API clients, and routing for maintenance flows"
```

---

### Task 6: Sidebar + TreeLibraryPage updates

**Files:**
- Modify: `frontend/src/components/layout/Sidebar.tsx`
- Modify: `frontend/src/pages/TreeLibraryPage.tsx`

**Step 1: Update Sidebar to count and show maintenance flows**

In `frontend/src/components/layout/Sidebar.tsx`:

Change `treeCounts` state (line ~32):
```typescript
const [treeCounts, setTreeCounts] = useState({ total: 0, troubleshooting: 0, procedural: 0, maintenance: 0 })
```

In the `fetchData` function, update the counts:
```typescript
const maintenance = allTrees.filter(t => t.tree_type === 'maintenance').length
setTreeCounts({ total, troubleshooting, procedural, maintenance })
```

Update the nav sub-items (line ~145):
```typescript
children={[
  { href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: treeCounts.troubleshooting || undefined },
  { href: '/trees?type=procedural', label: 'Projects', count: treeCounts.procedural || undefined },
  { href: '/trees?type=maintenance', label: 'Maintenance', count: treeCounts.maintenance || undefined },
]}
```

**Step 2: Update TreeLibraryPage type filter**

In `frontend/src/pages/TreeLibraryPage.tsx`:

Change the `typeFilter` state type and initialization (line ~40):
```typescript
const [typeFilter, setTypeFilter] = useState<'all' | 'troubleshooting' | 'procedural' | 'maintenance'>(
  urlType === 'troubleshooting' || urlType === 'procedural' || urlType === 'maintenance' ? urlType : 'all'
)
```

Update the sync effect (line ~47):
```typescript
if (t === 'troubleshooting' || t === 'procedural' || t === 'maintenance') {
  setTypeFilter(t)
} else {
  setTypeFilter('all')
}
```

**Step 3: Add maintenance badge to tree cards**

Find the tree card component(s) in `frontend/src/components/library/` (likely `TreeGridView.tsx`, `TreeListView.tsx`, `TreeTableView.tsx`). In each, find where the `tree_type` badge is rendered and add the maintenance case:

Look for something like `tree_type === 'procedural'` and add:
```tsx
{tree.tree_type === 'maintenance' && (
  <span className="inline-flex items-center gap-1 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wide text-amber-400">
    <Wrench className="h-3 w-3" />
    Maintenance
  </span>
)}
```

Import `Wrench` from `lucide-react` at the top of those files.

**Step 4: Build check**

```bash
cd frontend && npm run build
```

Expected: No errors.

**Step 5: Commit**

```bash
git add frontend/src/components/layout/Sidebar.tsx frontend/src/pages/TreeLibraryPage.tsx \
    frontend/src/components/library/
git commit -m "feat: add maintenance flows to sidebar nav and library type filter"
```

---

### Task 7: BatchLaunchModal component

**Files:**
- Create: `frontend/src/components/maintenance/BatchLaunchModal.tsx`

**Step 1: Create the component**

Create `frontend/src/components/maintenance/BatchLaunchModal.tsx`:

```tsx
import { useState } from 'react'
import { X, List, Clock, PenLine, ExternalLink } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { targetListsApi, batchLaunchApi } from '@/api'
import type { TargetList, TargetEntry } from '@/types'

interface BatchLaunchModalProps {
  treeId: string
  treeName: string
  onClose: () => void
  onLaunched: (batchId: string, count: number) => void
}

type TabId = 'saved' | 'previous' | 'manual' | 'psa'

export function BatchLaunchModal({ treeId, treeName, onClose, onLaunched }: BatchLaunchModalProps) {
  const [activeTab, setActiveTab] = useState<TabId>('manual')
  const [savedLists, setSavedLists] = useState<TargetList[] | null>(null)
  const [selectedListId, setSelectedListId] = useState<string | null>(null)
  const [manualInput, setManualInput] = useState('')
  const [isLoading, setIsLoading] = useState(false)
  const [isLaunching, setIsLaunching] = useState(false)

  const loadSavedLists = async () => {
    if (savedLists !== null) return
    try {
      const lists = await targetListsApi.list()
      setSavedLists(lists)
    } catch {
      toast.error('Failed to load saved lists')
    }
  }

  const handleTabChange = (tab: TabId) => {
    setActiveTab(tab)
    if (tab === 'saved') loadSavedLists()
  }

  const getTargets = (): TargetEntry[] => {
    if (activeTab === 'saved' && selectedListId && savedLists) {
      const list = savedLists.find(l => l.id === selectedListId)
      return list?.targets ?? []
    }
    if (activeTab === 'manual') {
      return manualInput
        .split('\n')
        .map(l => l.trim())
        .filter(Boolean)
        .map(label => ({ label }))
    }
    return []
  }

  const targets = getTargets()

  const handleLaunch = async () => {
    if (targets.length === 0) {
      toast.error('Add at least one target before launching')
      return
    }
    setIsLaunching(true)
    try {
      const result = await batchLaunchApi.launch({ tree_id: treeId, targets })
      toast.success(`Launched ${result.count} sessions`)
      onLaunched(result.batch_id, result.count)
    } catch {
      toast.error('Failed to launch batch')
    } finally {
      setIsLaunching(false)
    }
  }

  const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
    { id: 'manual', label: 'Manual Entry', icon: <PenLine className="h-3.5 w-3.5" /> },
    { id: 'saved', label: 'Saved List', icon: <List className="h-3.5 w-3.5" /> },
    { id: 'previous', label: 'Previous Run', icon: <Clock className="h-3.5 w-3.5" /> },
    { id: 'psa', label: 'PSA/RMM Import', icon: <ExternalLink className="h-3.5 w-3.5" /> },
  ]

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
      <div className="w-full max-w-lg rounded-xl border border-border bg-card shadow-2xl">
        {/* Header */}
        <div className="flex items-center justify-between border-b border-border px-6 py-4">
          <div>
            <h2 className="text-base font-semibold text-foreground">Batch Launch</h2>
            <p className="text-[0.8125rem] text-muted-foreground">{treeName}</p>
          </div>
          <button onClick={onClose} className="rounded-lg p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground">
            <X className="h-4 w-4" />
          </button>
        </div>

        {/* Tabs */}
        <div className="flex gap-1 border-b border-border px-4 pt-3">
          {tabs.map(tab => (
            <button
              key={tab.id}
              onClick={() => handleTabChange(tab.id)}
              className={cn(
                "flex items-center gap-1.5 rounded-t-md px-3 py-2 font-label text-[0.75rem] uppercase tracking-wide transition-colors",
                activeTab === tab.id
                  ? "border-b-2 border-primary text-foreground"
                  : "text-muted-foreground hover:text-foreground"
              )}
            >
              {tab.icon}
              {tab.label}
            </button>
          ))}
        </div>

        {/* Tab Content */}
        <div className="p-6">
          {activeTab === 'manual' && (
            <div className="space-y-2">
              <label className="font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
                Server names (one per line)
              </label>
              <textarea
                className="h-40 w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
                placeholder={"RDS-01\nRDS-02\nRDS-03"}
                value={manualInput}
                onChange={e => setManualInput(e.target.value)}
              />
            </div>
          )}

          {activeTab === 'saved' && (
            <div className="space-y-2">
              {savedLists === null ? (
                <p className="text-sm text-muted-foreground">Loading...</p>
              ) : savedLists.length === 0 ? (
                <p className="text-sm text-muted-foreground">No saved lists yet. Create one in Team Settings → Target Lists.</p>
              ) : (
                <div className="space-y-2">
                  {savedLists.map(list => (
                    <button
                      key={list.id}
                      onClick={() => setSelectedListId(list.id)}
                      className={cn(
                        "w-full rounded-lg border px-4 py-3 text-left transition-colors",
                        selectedListId === list.id
                          ? "border-primary/30 bg-primary/10 text-foreground"
                          : "border-border text-muted-foreground hover:border-border/80 hover:bg-accent hover:text-foreground"
                      )}
                    >
                      <div className="font-medium">{list.name}</div>
                      <div className="text-[0.8125rem]">{list.targets.length} targets</div>
                    </button>
                  ))}
                </div>
              )}
            </div>
          )}

          {activeTab === 'previous' && (
            <p className="text-sm text-muted-foreground">Previous run history will appear here.</p>
          )}

          {activeTab === 'psa' && (
            <div className="rounded-lg border border-border bg-accent/30 p-4 text-center">
              <ExternalLink className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
              <p className="font-medium text-foreground">PSA/RMM Import</p>
              <p className="mt-1 text-[0.8125rem] text-muted-foreground">ConnectWise, Kaseya, and RMM integrations coming soon.</p>
            </div>
          )}
        </div>

        {/* Preview + Footer */}
        {targets.length > 0 && (
          <div className="border-t border-border px-6 py-3">
            <p className="text-[0.8125rem] text-muted-foreground">
              Will create <span className="font-semibold text-foreground">{targets.length} sessions</span>:{' '}
              {targets.slice(0, 5).map(t => t.label).join(', ')}
              {targets.length > 5 && ` +${targets.length - 5} more`}
            </p>
          </div>
        )}

        <div className="flex justify-end gap-2 border-t border-border px-6 py-4">
          <button
            onClick={onClose}
            className="rounded-lg border border-border px-4 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
          >
            Cancel
          </button>
          <button
            onClick={handleLaunch}
            disabled={isLaunching || targets.length === 0}
            className="rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
          >
            {isLaunching ? 'Launching...' : `Launch ${targets.length > 0 ? targets.length + ' Sessions' : ''}`}
          </button>
        </div>
      </div>
    </div>
  )
}
```

**Step 2: Build check**

```bash
cd frontend && npm run build
```

Expected: No errors.

**Step 3: Commit**

```bash
git add frontend/src/components/maintenance/
git commit -m "feat: add BatchLaunchModal for maintenance flow multi-target launching"
```

---

### Task 8: MaintenanceFlowDetailPage

**Files:**
- Create: `frontend/src/pages/MaintenanceFlowDetailPage.tsx`
- Modify: `frontend/src/router.tsx`

**Step 1: Create the page**

Create `frontend/src/pages/MaintenanceFlowDetailPage.tsx`:

```tsx
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Wrench, Calendar, Play, Settings, Clock, CheckCircle, AlertCircle } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules'
import type { Tree, MaintenanceSchedule } from '@/types'
import { BatchLaunchModal } from '@/components/maintenance/BatchLaunchModal'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'

export default function MaintenanceFlowDetailPage() {
  const { id } = useParams<{ id: string }>()
  const navigate = useNavigate()
  const [tree, setTree] = useState<Tree | null>(null)
  const [schedule, setSchedule] = useState<MaintenanceSchedule | null>(null)
  const [recentSessions, setRecentSessions] = useState<any[]>([])
  const [showBatchModal, setShowBatchModal] = useState(false)
  const [isLoading, setIsLoading] = useState(true)

  useEffect(() => {
    if (!id) return
    const load = async () => {
      try {
        const [treeData, sessionsData] = await Promise.all([
          treesApi.get(id),
          sessionsApi.list({ tree_id: id, size: 20 }).catch(() => []),
        ])
        setTree(treeData)
        setRecentSessions(sessionsData)

        // Try to load schedule (404 is fine — no schedule set yet)
        try {
          const sched = await maintenanceSchedulesApi.getForTree(id)
          setSchedule(sched)
        } catch {
          // No schedule yet
        }
      } catch {
        toast.error('Failed to load maintenance flow')
        navigate('/trees?type=maintenance')
      } finally {
        setIsLoading(false)
      }
    }
    load()
  }, [id, navigate])

  const handleLaunched = (_batchId: string, count: number) => {
    setShowBatchModal(false)
    toast.success(`${count} sessions created — check Sessions to run them`)
    navigate('/sessions')
  }

  if (isLoading) {
    return (
      <div className="flex h-64 items-center justify-center">
        <div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
      </div>
    )
  }

  if (!tree) return null

  // Group sessions by batch_id
  const batches = recentSessions.reduce((acc: Record<string, any[]>, s: any) => {
    const key = s.batch_id ?? s.id
    if (!acc[key]) acc[key] = []
    acc[key].push(s)
    return acc
  }, {})
  const batchList = Object.entries(batches).slice(0, 10)

  return (
    <div className="mx-auto max-w-4xl space-y-6 p-6">
      {/* Header */}
      <div className="flex items-start justify-between">
        <div className="flex items-center gap-3">
          <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-500/10 text-amber-400">
            <Wrench className="h-5 w-5" />
          </div>
          <div>
            <h1 className="text-xl font-semibold text-foreground">{tree.name}</h1>
            <p className="text-[0.8125rem] text-muted-foreground">{tree.description}</p>
          </div>
        </div>
        <div className="flex gap-2">
          <button
            onClick={() => navigate(`/flows/${id}/edit`)}
            className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
          >
            <Settings className="h-3.5 w-3.5" />
            Edit Flow
          </button>
          <button
            onClick={() => setShowBatchModal(true)}
            className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
          >
            <Play className="h-3.5 w-3.5" />
            Batch Launch
          </button>
        </div>
      </div>

      {/* Schedule Panel */}
      <div className="rounded-xl border border-border bg-card p-5">
        <div className="flex items-center justify-between">
          <div className="flex items-center gap-2">
            <Calendar className="h-4 w-4 text-muted-foreground" />
            <h2 className="font-semibold text-foreground">Schedule</h2>
          </div>
        </div>
        {schedule ? (
          <div className="mt-3 space-y-2 text-[0.875rem]">
            <div className="flex items-center gap-2">
              <span className={cn(
                "inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wide",
                schedule.is_active
                  ? "bg-emerald-500/10 text-emerald-400"
                  : "bg-muted text-muted-foreground"
              )}>
                {schedule.is_active ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
                {schedule.is_active ? 'Active' : 'Paused'}
              </span>
              <span className="text-muted-foreground">·</span>
              <code className="rounded bg-accent px-1.5 py-0.5 text-[0.8125rem] text-foreground">{schedule.cron_expression}</code>
              <span className="text-muted-foreground">({schedule.timezone})</span>
            </div>
            {schedule.next_run_at && (
              <p className="text-muted-foreground">
                Next run: {new Date(schedule.next_run_at).toLocaleString()}
              </p>
            )}
          </div>
        ) : (
          <p className="mt-3 text-[0.875rem] text-muted-foreground">
            No schedule configured. Sessions can still be launched manually via Batch Launch.
          </p>
        )}
      </div>

      {/* Run History */}
      <div className="rounded-xl border border-border bg-card p-5">
        <div className="flex items-center gap-2 mb-4">
          <Clock className="h-4 w-4 text-muted-foreground" />
          <h2 className="font-semibold text-foreground">Run History</h2>
        </div>
        {batchList.length === 0 ? (
          <p className="text-[0.875rem] text-muted-foreground">No runs yet.</p>
        ) : (
          <div className="space-y-2">
            {batchList.map(([batchId, sessions]) => {
              const completed = sessions.filter((s: any) => s.completed_at).length
              const total = sessions.length
              const date = sessions[0]?.started_at
              return (
                <div key={batchId} className="flex items-center justify-between rounded-lg border border-border px-4 py-3">
                  <div>
                    <p className="text-[0.875rem] font-medium text-foreground">
                      {total} target{total !== 1 ? 's' : ''}
                    </p>
                    {date && (
                      <p className="text-[0.8125rem] text-muted-foreground">
                        {new Date(date).toLocaleDateString()}
                      </p>
                    )}
                  </div>
                  <span className={cn(
                    "font-label text-[0.75rem] uppercase tracking-wide",
                    completed === total ? "text-emerald-400" : "text-amber-400"
                  )}>
                    {completed}/{total} complete
                  </span>
                </div>
              )
            })}
          </div>
        )}
      </div>

      {showBatchModal && (
        <BatchLaunchModal
          treeId={id!}
          treeName={tree.name}
          onClose={() => setShowBatchModal(false)}
          onLaunched={handleLaunched}
        />
      )}
    </div>
  )
}
```

**Step 2: Register the route**

In `frontend/src/router.tsx`, add after the existing ProceduralNavigationPage import:

```typescript
const MaintenanceFlowDetailPage = lazy(() => import('@/pages/MaintenanceFlowDetailPage'))
```

Then in the routes array, inside the `ProtectedRoute`/`AppLayout` children, add alongside the existing `/flows/:id/navigate` route:

```tsx
{
  path: '/flows/:id/maintenance',
  element: (
    <Suspense fallback={<PageLoader />}>
      <MaintenanceFlowDetailPage />
    </Suspense>
  ),
},
```

**Step 3: Build check**

```bash
cd frontend && npm run build
```

Expected: No errors.

**Step 4: Commit**

```bash
git add frontend/src/pages/MaintenanceFlowDetailPage.tsx frontend/src/router.tsx
git commit -m "feat: add MaintenanceFlowDetailPage with schedule panel, batch launch, and run history"
```

---

## Phase 3: APScheduler Integration

### Task 9: Scheduler core + auto-session creation

**Files:**
- Create: `backend/app/core/scheduler.py`
- Modify: `backend/app/main.py`

**Step 1: Create the scheduler module**

Create `backend/app/core/scheduler.py`:

```python
"""APScheduler integration for maintenance flow auto-session creation."""
import logging
import uuid
from datetime import datetime, timezone
from typing import Optional

from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
import pytz
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker

from app.core.config import settings
from app.models.maintenance_schedule import MaintenanceSchedule
from app.models.session import Session
from app.models.target_list import TargetList
from app.models.tree import Tree

logger = logging.getLogger(__name__)

scheduler = AsyncIOScheduler()


def _make_session_factory():
    engine = create_async_engine(settings.DATABASE_URL, pool_pre_ping=True)
    return async_sessionmaker(engine, expire_on_commit=False)


async def _fire_maintenance_schedule(schedule_id: str) -> None:
    """Create batch sessions for a scheduled maintenance run."""
    session_factory = _make_session_factory()
    async with session_factory() as db:
        try:
            result = await db.execute(
                select(MaintenanceSchedule).where(
                    MaintenanceSchedule.id == uuid.UUID(schedule_id),
                    MaintenanceSchedule.is_active == True,
                )
            )
            schedule = result.scalar_one_or_none()
            if not schedule:
                logger.warning(f"Schedule {schedule_id} not found or inactive, skipping")
                return

            tree_result = await db.execute(
                select(Tree).where(Tree.id == schedule.tree_id)
            )
            tree = tree_result.scalar_one_or_none()
            if not tree:
                logger.error(f"Tree {schedule.tree_id} not found for schedule {schedule_id}")
                return

            # Resolve targets
            targets: list[dict] = []
            if schedule.target_list_id:
                list_result = await db.execute(
                    select(TargetList).where(TargetList.id == schedule.target_list_id)
                )
                target_list = list_result.scalar_one_or_none()
                if target_list:
                    targets = target_list.targets

            if not targets:
                logger.info(f"Schedule {schedule_id} has no targets — creating single session without target")
                targets = [{"label": "Unassigned"}]

            batch_id = uuid.uuid4()
            created_count = 0

            for target in targets:
                session = Session(
                    tree_id=tree.id,
                    user_id=schedule.created_by,  # attribute to the schedule creator
                    tree_snapshot=tree.tree_structure,
                    path_taken=[],
                    decisions=[],
                    custom_steps=[],
                    session_variables={},
                    batch_id=batch_id,
                    target_label=target.get("label", ""),
                )
                db.add(session)
                created_count += 1

            # Update schedule tracking
            schedule.last_run_at = datetime.now(timezone.utc)
            # Compute next_run_at
            from croniter import croniter
            tz = pytz.timezone(schedule.timezone)
            now = datetime.now(tz)
            cron = croniter(schedule.cron_expression, now)
            schedule.next_run_at = cron.get_next(datetime).astimezone(timezone.utc)

            await db.commit()
            logger.info(
                f"Schedule {schedule_id} fired: created {created_count} sessions "
                f"(batch {batch_id}) for tree '{tree.name}'"
            )
        except Exception:
            logger.exception(f"Error firing maintenance schedule {schedule_id}")
            await db.rollback()


async def load_all_schedules(db: AsyncSession) -> None:
    """Load all active schedules into APScheduler on startup."""
    result = await db.execute(
        select(MaintenanceSchedule).where(MaintenanceSchedule.is_active == True)
    )
    schedules = result.scalars().all()
    for schedule in schedules:
        register_schedule(schedule)
    logger.info(f"Loaded {len(schedules)} maintenance schedule(s)")


def register_schedule(schedule: MaintenanceSchedule) -> None:
    """Register or update a schedule in APScheduler."""
    job_id = f"maintenance_{schedule.id}"
    try:
        tz = pytz.timezone(schedule.timezone)
        trigger = CronTrigger.from_crontab(schedule.cron_expression, timezone=tz)
        if scheduler.get_job(job_id):
            scheduler.reschedule_job(job_id, trigger=trigger)
        else:
            scheduler.add_job(
                _fire_maintenance_schedule,
                trigger=trigger,
                id=job_id,
                args=[str(schedule.id)],
                replace_existing=True,
                misfire_grace_time=3600,  # 1 hour grace for missed fires
            )
        logger.info(f"Registered schedule {schedule.id} ({schedule.cron_expression})")
    except Exception:
        logger.exception(f"Failed to register schedule {schedule.id}")


def unregister_schedule(schedule_id: str) -> None:
    """Remove a schedule from APScheduler."""
    job_id = f"maintenance_{schedule_id}"
    if scheduler.get_job(job_id):
        scheduler.remove_job(job_id)
        logger.info(f"Unregistered schedule {schedule_id}")
```

**Step 2: Wire scheduler into FastAPI lifespan**

In `backend/app/main.py`, update the lifespan handler:

```python
from app.core.scheduler import scheduler, load_all_schedules
from app.core.database import get_db_context  # or however you get a DB session outside a request

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Application lifespan handler."""
    logger.info("Starting ResolutionFlow API server...")
    logger.info(f"Environment: {'Development' if settings.DEBUG else 'Production'}")
    logger.info(f"ALLOW_RAILWAY_ORIGINS: {settings.ALLOW_RAILWAY_ORIGINS}")

    # Start scheduler and load active maintenance schedules
    scheduler.start()
    from app.core.database import async_session_factory  # import your session factory
    async with async_session_factory() as db:
        await load_all_schedules(db)

    yield

    # Shutdown
    scheduler.shutdown(wait=False)
    logger.info("Shutting down ResolutionFlow API server...")
```

> **Note:** Check `backend/app/core/database.py` for the correct session factory name. It may be `AsyncSessionLocal`, `async_session`, etc. Use whatever name is already exported there. If there's no standalone session factory export, add one:
> ```python
> async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
> ```

**Step 3: Wire register/unregister into schedule endpoints**

In `backend/app/api/endpoints/maintenance_schedules.py`, import and call the scheduler functions when a schedule is created, updated, or toggled:

After the `create_schedule` endpoint commits, add:
```python
from app.core.scheduler import register_schedule
register_schedule(schedule)
```

After the `update_schedule` endpoint commits, add:
```python
from app.core.scheduler import register_schedule, unregister_schedule
if schedule.is_active:
    register_schedule(schedule)
else:
    unregister_schedule(str(schedule.id))
```

**Step 4: Verify scheduler starts cleanly**

Start the backend:
```bash
cd backend && uvicorn app.main:app --reload
```

Expected in logs: `Loaded N maintenance schedule(s)` with no exceptions.

**Step 5: Commit**

```bash
git add backend/app/core/scheduler.py backend/app/main.py backend/app/api/endpoints/maintenance_schedules.py
git commit -m "feat: APScheduler integration for maintenance flow auto-session creation"
```

---

## Phase 4: Target Lists Settings Page

### Task 10: TargetListsPage + account routing

**Files:**
- Create: `frontend/src/pages/account/TargetListsPage.tsx`
- Modify: `frontend/src/router.tsx`
- Modify: `frontend/src/components/account/AccountLayout.tsx` (add nav link)

**Step 1: Create the settings page**

Create `frontend/src/pages/account/TargetListsPage.tsx`:

```tsx
import { useEffect, useState } from 'react'
import { Plus, Pencil, Trash2, Server } from 'lucide-react'
import { targetListsApi } from '@/api'
import type { TargetList, TargetListCreate, TargetEntry } from '@/types'
import { toast } from '@/lib/toast'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'

export default function TargetListsPage() {
  const [lists, setLists] = useState<TargetList[]>([])
  const [isLoading, setIsLoading] = useState(true)
  const [showEditor, setShowEditor] = useState(false)
  const [editingList, setEditingList] = useState<TargetList | null>(null)
  const [deleteTarget, setDeleteTarget] = useState<TargetList | null>(null)

  // Editor state
  const [editorName, setEditorName] = useState('')
  const [editorDescription, setEditorDescription] = useState('')
  const [editorTargets, setEditorTargets] = useState('')
  const [isSaving, setIsSaving] = useState(false)

  const load = async () => {
    try {
      const data = await targetListsApi.list()
      setLists(data)
    } catch {
      toast.error('Failed to load target lists')
    } finally {
      setIsLoading(false)
    }
  }

  useEffect(() => { load() }, [])

  const openEditor = (list?: TargetList) => {
    if (list) {
      setEditingList(list)
      setEditorName(list.name)
      setEditorDescription(list.description ?? '')
      setEditorTargets(list.targets.map(t => t.notes ? `${t.label} # ${t.notes}` : t.label).join('\n'))
    } else {
      setEditingList(null)
      setEditorName('')
      setEditorDescription('')
      setEditorTargets('')
    }
    setShowEditor(true)
  }

  const parseTargets = (raw: string): TargetEntry[] =>
    raw.split('\n')
      .map(l => l.trim())
      .filter(Boolean)
      .map(line => {
        const [label, ...rest] = line.split('#')
        return { label: label.trim(), notes: rest.join('#').trim() || undefined }
      })

  const handleSave = async () => {
    const targets = parseTargets(editorTargets)
    if (!editorName.trim()) { toast.error('Name is required'); return }
    if (targets.length === 0) { toast.error('Add at least one target'); return }

    setIsSaving(true)
    try {
      const payload: TargetListCreate = {
        name: editorName.trim(),
        description: editorDescription.trim() || undefined,
        targets,
      }
      if (editingList) {
        const updated = await targetListsApi.update(editingList.id, payload)
        setLists(prev => prev.map(l => l.id === updated.id ? updated : l))
        toast.success('Target list updated')
      } else {
        const created = await targetListsApi.create(payload)
        setLists(prev => [...prev, created])
        toast.success('Target list created')
      }
      setShowEditor(false)
    } catch {
      toast.error('Failed to save target list')
    } finally {
      setIsSaving(false)
    }
  }

  const handleDelete = async () => {
    if (!deleteTarget) return
    try {
      await targetListsApi.delete(deleteTarget.id)
      setLists(prev => prev.filter(l => l.id !== deleteTarget.id))
      toast.success('Target list deleted')
    } catch {
      toast.error('Failed to delete target list')
    } finally {
      setDeleteTarget(null)
    }
  }

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-xl font-semibold text-foreground">Target Lists</h1>
          <p className="text-[0.8125rem] text-muted-foreground">Saved server lists for maintenance flow batch launching</p>
        </div>
        <button
          onClick={() => openEditor()}
          className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
        >
          <Plus className="h-4 w-4" />
          New List
        </button>
      </div>

      {isLoading ? (
        <div className="flex h-32 items-center justify-center">
          <div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
        </div>
      ) : lists.length === 0 ? (
        <div className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-12 text-center">
          <Server className="mb-3 h-10 w-10 text-muted-foreground" />
          <p className="font-medium text-foreground">No target lists yet</p>
          <p className="mt-1 text-[0.8125rem] text-muted-foreground">Create a list of servers to reuse across maintenance runs</p>
        </div>
      ) : (
        <div className="space-y-3">
          {lists.map(list => (
            <div key={list.id} className="flex items-center justify-between rounded-xl border border-border bg-card px-5 py-4">
              <div>
                <p className="font-medium text-foreground">{list.name}</p>
                {list.description && (
                  <p className="text-[0.8125rem] text-muted-foreground">{list.description}</p>
                )}
                <p className="mt-0.5 font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
                  {list.targets.length} target{list.targets.length !== 1 ? 's' : ''} · {list.targets.slice(0, 3).map(t => t.label).join(', ')}{list.targets.length > 3 ? '...' : ''}
                </p>
              </div>
              <div className="flex items-center gap-1">
                <button onClick={() => openEditor(list)} className="rounded-lg p-2 text-muted-foreground hover:bg-accent hover:text-foreground">
                  <Pencil className="h-4 w-4" />
                </button>
                <button onClick={() => setDeleteTarget(list)} className="rounded-lg p-2 text-muted-foreground hover:bg-accent hover:text-red-400">
                  <Trash2 className="h-4 w-4" />
                </button>
              </div>
            </div>
          ))}
        </div>
      )}

      {/* Editor Modal */}
      {showEditor && (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
          <div className="w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-2xl">
            <h2 className="mb-4 text-base font-semibold text-foreground">
              {editingList ? 'Edit Target List' : 'New Target List'}
            </h2>
            <div className="space-y-4">
              <div>
                <label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">Name</label>
                <input
                  type="text"
                  value={editorName}
                  onChange={e => setEditorName(e.target.value)}
                  className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
                  placeholder="e.g. RDS Farm A"
                />
              </div>
              <div>
                <label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">Description (optional)</label>
                <input
                  type="text"
                  value={editorDescription}
                  onChange={e => setEditorDescription(e.target.value)}
                  className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
                  placeholder="e.g. Production RDS servers"
                />
              </div>
              <div>
                <label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
                  Targets (one per line — optionally add notes after #)
                </label>
                <textarea
                  value={editorTargets}
                  onChange={e => setEditorTargets(e.target.value)}
                  rows={6}
                  className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
                  placeholder={"RDS-01 # 192.168.1.10\nRDS-02\nRDS-03 # Backup server"}
                />
              </div>
            </div>
            <div className="mt-6 flex justify-end gap-2">
              <button onClick={() => setShowEditor(false)} className="rounded-lg border border-border px-4 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground">Cancel</button>
              <button onClick={handleSave} disabled={isSaving} className="rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50">
                {isSaving ? 'Saving...' : 'Save'}
              </button>
            </div>
          </div>
        </div>
      )}

      {deleteTarget && (
        <ConfirmDialog
          title="Delete Target List"
          message={`Delete "${deleteTarget.name}"? This cannot be undone.`}
          confirmLabel="Delete"
          onConfirm={handleDelete}
          onCancel={() => setDeleteTarget(null)}
          destructive
        />
      )}
    </div>
  )
}
```

**Step 2: Register the route**

In `frontend/src/router.tsx`, add:
```typescript
const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage'))
```

Then inside the account section routes (alongside `TeamCategoriesPage`):
```tsx
{
  path: '/account/target-lists',
  element: (
    <Suspense fallback={<PageLoader />}>
      <TargetListsPage />
    </Suspense>
  ),
},
```

**Step 3: Add nav link in AccountLayout**

Open `frontend/src/components/account/AccountLayout.tsx`. Find the nav links and add:
```tsx
<NavLink to="/account/target-lists">Target Lists</NavLink>
```

(Match the exact pattern used for existing links in that file.)

**Step 4: Build check**

```bash
cd frontend && npm run build
```

Expected: No errors.

**Step 5: Run all backend tests one final time**

```bash
cd backend && pytest --override-ini="addopts="
```

Expected: All pass.

**Step 6: Final commit**

```bash
git add frontend/src/pages/account/TargetListsPage.tsx frontend/src/router.tsx \
    frontend/src/components/account/AccountLayout.tsx
git commit -m "feat: add Target Lists settings page under Team account"
```

---

## Summary

| Phase | Tasks | Key deliverables |
|-------|-------|-----------------|
| 1 — DB + API | 1–4 | `maintenance` tree_type, `target_lists` table, `maintenance_schedules` table, batch session endpoint |
| 2 — Frontend | 5–8 | Types/API clients, sidebar nav, library filter, BatchLaunchModal, MaintenanceFlowDetailPage |
| 3 — Scheduler | 9 | APScheduler, auto-session creation, schedule lifecycle wired to API |
| 4 — Settings | 10 | TargetListsPage in account settings |

Each task ends with a commit. Each phase is independently deployable.
