diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0b08040 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +# Keep the build context small and deterministic: everything the image needs +# is copied explicitly in the Dockerfile. +.git +.github +.venv +node_modules +frontend/node_modules +frontend/dist +docs +e2e +playwright-report +test-results +backend/tests +**/__pycache__ +**/*.pyc +.pytest_cache +.mypy_cache +.ruff_cache +*.db +*.db-wal +*.db-shm +.DS_Store diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0dd3f9e..d52714c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,8 @@ jobs: run: make bootstrap - name: Copy checks (em-dash scan + seed validator) run: make check-copy + - name: Permission suite (Brief section 3, every row, no skips) + run: make permissions - name: Lint + typecheck run: make lint typecheck - name: Unit + integration diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ac42c4c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,64 @@ +# Deployment image (T-060): one container, one origin, SPA plus API. +# +# Stage 1 builds the SPA with the exact lockfile the repo pins. Stage 2 is the +# runtime: Python, the backend package, the seed data, and the built SPA. Node +# does not ship in the final image. + +# --------------------------------------------------------------------------- +# Stage 1: build the SPA +# --------------------------------------------------------------------------- +FROM node:22-slim AS web + +WORKDIR /build + +# Workspace manifests first, so a dependency-only change is the only thing +# that busts the npm layer cache. +COPY package.json package-lock.json ./ +COPY frontend/package.json ./frontend/ +RUN npm ci --no-audit --no-fund + +COPY frontend/ ./frontend/ +# `npm run build` is `tsc --noEmit && vite build`: the deployed bundle is +# built by the same command that has to typecheck clean, so a type error +# fails the image rather than shipping. +RUN npm --workspace frontend run build + +# --------------------------------------------------------------------------- +# Stage 2: runtime +# --------------------------------------------------------------------------- +FROM python:3.13-slim AS runtime + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +# The backend package (no dev extras: no pytest, ruff or mypy in the image). +COPY backend/pyproject.toml ./backend/pyproject.toml +COPY backend/app ./backend/app +RUN pip install --no-cache-dir ./backend + +# Everything the boot sequence needs: the migration chain, the seed loaders, +# and the seed files themselves. +COPY backend/alembic.ini ./backend/alembic.ini +COPY backend/migrations ./backend/migrations +COPY scripts ./scripts +COPY seeds ./seeds + +COPY --from=web /build/frontend/dist ./frontend/dist + +# Where the SQLite file lives. Overridden by DATABASE_URL in the service +# config; the default points at the mount path a persistent disk would use, +# so attaching one later needs no image change. +ENV DATABASE_URL=sqlite:////data/pop.db \ + POP_FRONTEND_DIST=/app/frontend/dist \ + COOKIE_SECURE=true \ + PORT=8000 + +EXPOSE 8000 + +# alembic.ini's script_location is relative to the process working directory, +# and app/db.py resolves a relative sqlite path the same way, so the entry +# point runs from /app exactly as scripts/dev.sh runs from the repo root. +CMD ["bash", "scripts/start.sh"] diff --git a/Makefile b/Makefile index 42fb58d..164099b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: bootstrap dev migrate lint typecheck test e2e verify seed check-copy +.PHONY: bootstrap dev migrate lint typecheck test e2e verify seed seed-demo demo check-copy permissions screenshots VENV := .venv PY := $(VENV)/bin/python @@ -34,8 +34,48 @@ check-copy: $(PY) scripts/check_copy.py $(PY) scripts/validate_seeds.py +# The Brief section 3 permission table, every row (backend/tests/test_permissions.py). +# `make test` already runs it, but this target also fails when a row is +# SKIPPED rather than asserted: two rows sat as @pytest.mark.skip +# placeholders for most of the build, and a green suite that quietly stops +# checking a permission row is exactly the failure mode worth pinning. +permissions: + @out=$$($(VENV)/bin/pytest backend/tests/test_permissions.py -q --no-header 2>&1); \ + status=$$?; \ + echo "$$out"; \ + if [ $$status -ne 0 ]; then exit $$status; fi; \ + if echo "$$out" | grep -q "skipped"; then \ + echo "permissions: FAILED, a Brief section 3 row is skipped, not enforced"; \ + exit 1; \ + fi; \ + echo "permissions: every Brief section 3 row enforced, none skipped" + seed: $(PY) scripts/seed.py -verify: check-copy lint typecheck test e2e +seed-demo: + $(PY) scripts/seed_demo.py + +# One command before a meeting: throw the dev database away, rebuild it +# from the migration chain, load the library content, then populate one +# realistic team (roster with a live fit warning, a recorded pattern, a +# sent session with a receipt). Prints the demo credentials at the end. +# The rm is why this is a separate target from `seed`: `make dev` must +# never destroy data, and this always starts from zero. +demo: + rm -f dev.db dev.db-wal dev.db-shm + $(PY) -m alembic -c backend/alembic.ini upgrade head + $(PY) scripts/seed.py + $(PY) scripts/seed_demo.py + +# Marketing shots for docs/screenshots/ and the README. Reseeds the demo +# database first so the captures always show the same content, then drives +# the real UI (e2e/screenshots.spec.ts, which skips unless POP_SCREENSHOTS +# is set, so `make verify` never rewrites the images). Stop any running +# `make dev` first: this drops and rebuilds the database underneath it. +screenshots: demo + POP_SCREENSHOTS=1 npx playwright test e2e/screenshots.spec.ts \ + --project=desktop --timeout=60000 --global-timeout=300000 + +verify: check-copy permissions lint typecheck test e2e @echo "verify: all green" diff --git a/README.md b/README.md new file mode 100644 index 0000000..9c7365d --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# Patterns of Play + +**The tactics board your players actually watch.** + +Draw a pattern on a live board that shows you which passes are on and which are +covered, record it the way you would draw it on a whiteboard, and send it to the +squad's phones. You see who watched it. + +![The whiteboard with the live lane graph](docs/screenshots/01-whiteboard-lanes.png) + +## Why a coach cares + +- **The board tells you what the defence has taken away.** Every pass your player + can make is drawn as you move people. Gold means it is on, red means someone is + standing in it, and the red dot is exactly where it gets intercepted. +- **Twelve patterns, six shapes, and the rondos that train them, already in it.** + Overlaps, third-man runs, build-out against a press, pressing triggers. Each one + plays out on the pitch with the ball, not as a static diagram. +- **Your session lands on their phone, not in a group chat.** Bundle two patterns + and a note, send it, and see "3 of 5 watched" before you get to training. +- **It tells you when a pairing will cost you.** Put a flying fullback behind a + winger who does not track back and it says so, on that flank, by name. + +## The walkthrough + +**1. Move a player, and the passing picture redraws.** Solid gold is a lane the +coach has locked in; dashed red with a dot is a lane an opponent has taken away. + +![Lane graph mid-drag](docs/screenshots/01-whiteboard-lanes.png) + +**2. Hit record and coach it the way you would on a whiteboard.** Every player, +every opponent, and the ball are captured, and the ball leaves a gold trace. + +![Recording a build-out](docs/screenshots/02-whiteboard-recording.png) + +**3. Pull up the library.** Twelve pattern archetypes, eight delivery types, three +whole-team rotations, filtered by what you want to work on. + +![The pattern library](docs/screenshots/03-patterns-library-sheet.png) + +**4. Play one on the board.** The ball chases the runner it was played to, so the +pass connects the way it does on grass. + +![A third-man run playing](docs/screenshots/04-pattern-playing.png) + +**5. Load a shape and tap the players it hinges on.** The 4-3-3 with its pivot +keystone, and what that role has to be able to do. + +![4-3-3 with the pivot keystone](docs/screenshots/05-formations-keystone.png) + +**6. Turn on the rondo map.** Each zone tells you which rondo belongs there and +which pattern it trains. + +![The rondo map, first-line zone selected](docs/screenshots/06-rondo-map.png) + +**7. Show them who already plays this way.** A reference team's signature idea +runs on the board, with the five-part card behind it. + +![Barcelona's third man playing](docs/screenshots/07-identity-playing.png) + +**8. Build the squad, and get told when a flank is exposed.** Roles, work rates, +six coach-rated sliders, and the fit warning that reads the pairing. + +![Roster with the double-exposure warning](docs/screenshots/08-roster-fit-warning.png) + +**9. Send the session and watch the receipts come in.** A pattern from the +library, your own recording, a note, and per-player read receipts. + +![Session receipts](docs/screenshots/09-session-receipts.png) + +**10. On their phone, the board goes portrait.** Same pattern, same coordinates, +readable in a hand. + +A pattern playing portrait on a phone A player's session view + +## Quickstart + +```bash +make bootstrap # Python venv + npm install, once +make demo # rebuild the database and load a full demo team +make dev # http://127.0.0.1:5173 +``` + +`make demo` drops the dev database, runs the migration chain from zero, loads the +tactical content, then creates one realistic team: 14 players with roles and +sliders, a live whiteboard, a recorded pattern, and two sessions (one sent with +receipts, one draft ready to send in front of the room). Rerun it any time to get +back to a clean starting state. + +Sign in at : + +| | Email | Password | +|---|---|---| +| Coach | `coach@example.com` | `demo-pass-2026` | +| Player | `player@example.com` | `demo-pass-2026` | + +Join codes for the demo team are `TEAM24` (joins as a player) and `STAFF7` (joins +as a coach). The code decides the role, not the account. + +Other targets: `make verify` (copy scan, permission suite, lint, typecheck, unit +and integration tests, and the Playwright journeys on both viewports), +`make screenshots` (rebuilds the demo database and recaptures `docs/screenshots/`). + +## Stack and architecture + +React 19 + Vite + TypeScript on the front, FastAPI + SQLAlchemy 2 + Alembic over +SQLite (WAL) on the back. No ORM-free corners, no client state store: the server +is the source of truth and every screen re-reads from it. + +- **Board engine** (`frontend/src/board/`). SVG behind a component boundary. + Pointer input is coalesced to one update per animation frame and written + straight to the DOM, so a drag never re-renders all 23 tokens. The lane graph, + marking rings, zone overlays, animation player, and recorder all share one + coordinate and timing model. +- **Coordinates.** Every position is stored in landscape model coordinates (x 0 + to 100 toward the attacking goal, y 0 to 100 top to bottom). Orientation is a + render concern only: portrait maps `left = y, top = 100 - x`, with the inverse + applied to drag input, so a pattern recorded on a laptop replays correctly on a + phone and the round trip is covered by a test. +- **Two animation formats, one player.** Library presets are declarative specs + (player from-to plus ball waypoints bound to the player who starts or finishes + at that spot); recordings are raw keyframes. The player abstracts over both. +- **Tenancy.** Every team-scoped query goes through one scoped query layer + (`backend/app/scoped.py`) built from the caller's own membership. No route + handler filters by `team_id`, and no request body can supply one. +- **Permissions are API-enforced, not UI-hidden.** Coach-only data (fit warnings, + read receipts, join codes) is absent from a player's payload rather than nulled, + via split response models. `backend/tests/test_permissions.py` asserts every row + of the permission table, and `make permissions` fails if any row is skipped + rather than checked. +- **Content is data.** Patterns, deliveries, rotations, formations, keystones, the + rondo map, and the identity library live in `seeds/*.json` with a validator, so + the tactical content can be revised without an engineer. diff --git a/backend/app/main.py b/backend/app/main.py index eea033b..aad402e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,21 @@ -from fastapi import FastAPI +import os +import pathlib -from app.routers import auth, formations, identity, library, roster, suggestions, teams, whiteboard +from fastapi import FastAPI, HTTPException, status +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from app.routers import ( + auth, + formations, + identity, + library, + roster, + sessions, + suggestions, + teams, + whiteboard, +) app = FastAPI(title="Patterns of Play API") app.include_router(auth.router) @@ -11,8 +26,65 @@ app.include_router(suggestions.router) app.include_router(formations.router) app.include_router(identity.router) +app.include_router(sessions.router) @app.get("/api/health") def health() -> dict[str, str]: return {"status": "ok"} + + +# --------------------------------------------------------------------------- +# Single-origin serving for deployment (T-060). +# +# In development, Vite serves the SPA on its own port and proxies /api to this +# process (frontend/vite.config.ts). In a deployed environment there is one +# process and one origin: this app serves the built SPA alongside the API. +# +# One origin is not just tidiness, it is what makes the session cookie work as +# written. The cookie is SameSite=Lax and host-only (app/routers/auth.py), and +# frontend/src/api.ts fetches relative "/api/..." paths with no CORS setup and +# no credentials mode. Splitting the SPA onto a second origin would mean a +# cross-site cookie, SameSite=None, an allow-list, and a credentials flag on +# every fetch. Serving both from here means none of that exists. +# +# The whole block is conditional on the build output actually being present, +# so a dev checkout and the test suite (which never build the SPA) behave +# exactly as they did before: API only, no catch-all route registered. +# --------------------------------------------------------------------------- + +_DIST_OVERRIDE = os.environ.get("POP_FRONTEND_DIST") +FRONTEND_DIST = ( + pathlib.Path(_DIST_OVERRIDE) + if _DIST_OVERRIDE + else pathlib.Path(__file__).resolve().parents[2] / "frontend" / "dist" +) + +if FRONTEND_DIST.is_dir(): + _DIST_ROOT = FRONTEND_DIST.resolve() + _ASSETS = _DIST_ROOT / "assets" + if _ASSETS.is_dir(): + # Vite emits content-hashed filenames under /assets, so these are the + # one set of files safe to serve as immutable static content. + app.mount("/assets", StaticFiles(directory=_ASSETS), name="assets") + + @app.get("/{full_path:path}", include_in_schema=False) + def serve_spa(full_path: str) -> FileResponse: + """SPA fallback: a real file if one matches, index.html otherwise. + + Registered LAST so every API route above wins on the same path, and + /api/* is refused explicitly rather than falling through: an unknown + API path must stay a JSON 404, not silently return the HTML shell, + which would turn a typo'd endpoint into a confusing parse error on + the client instead of an obvious 404. + """ + if full_path.startswith("api/"): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") + if full_path: + candidate = (_DIST_ROOT / full_path).resolve() + # is_relative_to pins the lookup inside the build output: a + # traversal like "../../etc/passwd" resolves outside it and falls + # through to index.html instead of being served. + if candidate.is_relative_to(_DIST_ROOT) and candidate.is_file(): + return FileResponse(candidate) + return FileResponse(_DIST_ROOT / "index.html") diff --git a/backend/app/routers/sessions.py b/backend/app/routers/sessions.py new file mode 100644 index 0000000..27014e0 --- /dev/null +++ b/backend/app/routers/sessions.py @@ -0,0 +1,530 @@ +"""Sessions: the classroom loop (doc 03 section 6; Brief step 23, PNG 21-23, +26, 28; T-042). + +Design README: "bundle patterns and recorded whiteboard tactics into a +session, attach a coach note, and send to players. Draft state: +reorder/remove items, + Add from library opens a picker (presets and My +patterns, each with its mini-board thumbnail), players listed as Will +receive. Sent state: gold SENT pill with an x/y viewed counter, and +per-player read receipts (Viewed / Not yet). Receipts are coach-only, +consistent with fit warnings, players never see each other's status." + +Enforcement (CLAUDE.md rules 4 and 5, Brief section 3): + - Every route resolves team_id through get_team_scope, never a client + field. session_items and session_receipts carry no team_id of their + own (doc 03 section 6) and are read through TeamScope.query_via, + joined to their parent session's team_id. + - Create, edit, reorder, remove, and send are coach-only + (require_role_on_team("coach")); Mark as watched is player-only. + - The coach/player payload split is the RosterOut/CoachRosterOut + pattern: response_model=None plus a manual model_dump so a player's + JSON has NO receipt key at all (not a null or empty one), and a + coach's has no `you_watched` key. + +Recipient set: receipts are written for every player-role member at SEND +time (doc 03 section 6: "receipts exist for every recipient at send time +with viewed_at null"), and a player's own session list is gated on having +one of those receipt rows. A session is therefore a record of what the +team was told when it was sent: someone who joins afterwards does not +silently appear in an already-sent session's denominator, and the coach's +x/y counter never moves on its own. +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.deps import ( + CurrentMembership, + get_current_membership, + get_db, + require_role_on_team, +) +from app.models import ( + LibraryItem, + Player, + SavedPattern, + SessionItem, + SessionReceipt, + TeamMember, + TrainingSession, + User, +) +from app.models._util import utcnow +from app.routers.whiteboard import pattern_to_out +from app.schemas import ( + CoachSessionOut, + LibraryItemOut, + PlayerSessionOut, + SessionCreateRequest, + SessionItemCreateRequest, + SessionItemMoveRequest, + SessionItemOut, + SessionReceiptOut, + SessionUpdateRequest, +) +from app.scoped import TeamScope, get_team_scope + +router = APIRouter(prefix="/api/sessions", tags=["sessions"]) + + +# --------------------------------------------------------------------------- +# Reading: item hydration and the two payload shapes. +# --------------------------------------------------------------------------- + + +def _items_for(scope: TeamScope, db: Session, session_id: int) -> list[SessionItemOut]: + """Every attached item with its FULL referenced content embedded, so + one round trip gives the coach's picker thumbnails and the player's + Watch deep-link everything they need to render a board.""" + rows = ( + scope.query_via(SessionItem, TrainingSession, SessionItem.session_id == TrainingSession.id) + .filter(SessionItem.session_id == session_id) + .order_by(SessionItem.position.asc()) + .all() + ) + library_ids = [r.library_item_id for r in rows if r.library_item_id is not None] + library_by_id = { + item.id: LibraryItemOut.model_validate(item) + for item in ( + db.query(LibraryItem).filter(LibraryItem.id.in_(library_ids)).all() + if library_ids + else [] + ) + } + + # Saved patterns are TEAM data, so they resolve through the scope, not + # a raw db query: an item pointing at another team's row (impossible to + # create through this router, but the FK alone would not stop it) + # simply resolves to nothing rather than leaking across teams. + pattern_ids = [r.saved_pattern_id for r in rows if r.saved_pattern_id is not None] + pattern_rows = ( + scope.query(SavedPattern).filter(SavedPattern.id.in_(pattern_ids)).all() + if pattern_ids + else [] + ) + authors = { + u.id: u + for u in db.query(User).filter(User.id.in_([p.author_user_id for p in pattern_rows])).all() + } + pattern_by_id = { + p.id: pattern_to_out(p, authors.get(p.author_user_id)) for p in pattern_rows + } + + return [ + SessionItemOut( + id=row.id, + position=row.position, + item_kind=row.item_kind, # type: ignore[arg-type] + library_item=library_by_id.get(row.library_item_id) + if row.library_item_id is not None + else None, + saved_pattern=pattern_by_id.get(row.saved_pattern_id) + if row.saved_pattern_id is not None + else None, + ) + for row in rows + ] + + +def _jersey_by_user(scope: TeamScope) -> dict[int, int | None]: + """Claimed roster rows only (app/routers/roster.py's name-match + claim): PNG 21 renders a numbered gold badge per recipient, and an + unclaimed member simply has no number to show.""" + return { + p.user_id: p.jersey_number + for p in scope.query(Player).filter(Player.user_id.isnot(None)).all() + if p.user_id is not None + } + + +def _player_members(scope: TeamScope, db: Session) -> list[tuple[int, str]]: + """(user_id, display_name) for every player-role member, oldest first.""" + members = ( + scope.query(TeamMember) + .filter(TeamMember.role_on_team == "player") + .order_by(TeamMember.joined_at.asc()) + .all() + ) + users = { + u.id: u for u in db.query(User).filter(User.id.in_([m.user_id for m in members])).all() + } + return [(m.user_id, users[m.user_id].display_name) for m in members if m.user_id in users] + + +def _receipts_for( + scope: TeamScope, db: Session, session: TrainingSession +) -> list[SessionReceiptOut]: + """Coach-only. A SENT session reads its real receipt rows; a DRAFT has + none yet, so it lists the team's current player members as the design + README's "Will receive" preview of who the send would reach.""" + jerseys = _jersey_by_user(scope) + + if session.status != "sent": + return [ + SessionReceiptOut( + player_user_id=user_id, + display_name=display_name, + jersey_number=jerseys.get(user_id), + viewed_at=None, + viewed=False, + ) + for user_id, display_name in _player_members(scope, db) + ] + + rows = ( + scope.query_via( + SessionReceipt, TrainingSession, SessionReceipt.session_id == TrainingSession.id + ) + .filter(SessionReceipt.session_id == session.id) + .all() + ) + users = { + u.id: u + for u in db.query(User).filter(User.id.in_([r.player_user_id for r in rows])).all() + } + out = [ + SessionReceiptOut( + player_user_id=r.player_user_id, + display_name=users[r.player_user_id].display_name + if r.player_user_id in users + else "Player", + jersey_number=jerseys.get(r.player_user_id), + viewed_at=r.viewed_at, + viewed=r.viewed_at is not None, + ) + for r in rows + ] + out.sort(key=lambda r: ((r.jersey_number is None), r.jersey_number or 0, r.display_name)) + return out + + +def _coach_payload(scope: TeamScope, db: Session, session: TrainingSession) -> dict: + receipts = _receipts_for(scope, db, session) + return CoachSessionOut( + id=session.id, + title=session.title, + coach_note=session.coach_note, + status=session.status, # type: ignore[arg-type] + sent_at=session.sent_at, + created_at=session.created_at, + items=_items_for(scope, db, session.id), + receipts=receipts, + viewed_count=sum(1 for r in receipts if r.viewed), + recipient_count=len(receipts), + ).model_dump(mode="json") + + +def _player_payload( + scope: TeamScope, db: Session, session: TrainingSession, receipt: SessionReceipt +) -> dict: + # PlayerSessionOut has no receipts/viewed_count/recipient_count field + # at all, so those keys are absent from this body rather than nulled. + return PlayerSessionOut( + id=session.id, + title=session.title, + coach_note=session.coach_note, + status=session.status, # type: ignore[arg-type] + sent_at=session.sent_at, + created_at=session.created_at, + items=_items_for(scope, db, session.id), + you_watched=receipt.viewed_at is not None, + ).model_dump(mode="json") + + +def _own_receipt(scope: TeamScope, session_id: int, user_id: int) -> SessionReceipt | None: + return ( + scope.query_via( + SessionReceipt, TrainingSession, SessionReceipt.session_id == TrainingSession.id + ) + .filter( + SessionReceipt.session_id == session_id, + SessionReceipt.player_user_id == user_id, + ) + .first() + ) + + +def _session_or_404(scope: TeamScope, session_id: int) -> TrainingSession: + session = scope.get(TrainingSession, session_id) + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + return session + + +def _draft_or_409(session: TrainingSession) -> TrainingSession: + if session.status != "draft": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A sent session can no longer be edited", + ) + return session + + +# --------------------------------------------------------------------------- +# Both roles: list and read. The role decides both WHICH sessions are +# visible and WHICH payload shape comes back. +# --------------------------------------------------------------------------- + + +@router.get("", response_model=None) +def list_sessions( + ctx: CurrentMembership = Depends(get_current_membership), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> list[dict]: + if ctx.role_on_team == "coach": + sessions = ( + scope.query(TrainingSession) + .order_by(TrainingSession.created_at.desc()) + .all() + ) + return [_coach_payload(scope, db, s) for s in sessions] + + # Player: sent sessions they were an actual recipient of (README: + # "Sees sent sessions only"), newest send first. + sessions = ( + scope.query(TrainingSession) + .filter(TrainingSession.status == "sent") + .order_by(TrainingSession.sent_at.desc()) + .all() + ) + out: list[dict] = [] + for session in sessions: + receipt = _own_receipt(scope, session.id, ctx.user.id) + if receipt is None: + continue + out.append(_player_payload(scope, db, session, receipt)) + return out + + +@router.get("/{session_id}", response_model=None) +def get_session( + session_id: int, + ctx: CurrentMembership = Depends(get_current_membership), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> dict: + session = _session_or_404(scope, session_id) + if ctx.role_on_team == "coach": + return _coach_payload(scope, db, session) + + receipt = _own_receipt(scope, session.id, ctx.user.id) + if session.status != "sent" or receipt is None: + # A draft does not exist as far as a player is concerned, and + # neither does a sent session they were not a recipient of: 404, + # not 403, so the response says nothing about what else the team + # has in flight. + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + return _player_payload(scope, db, session, receipt) + + +# --------------------------------------------------------------------------- +# Coach-only: draft builder, item picker, send. +# --------------------------------------------------------------------------- + + +@router.post("", response_model=None, status_code=status.HTTP_201_CREATED) +def create_session( + payload: SessionCreateRequest, + ctx: CurrentMembership = Depends(require_role_on_team("coach")), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> dict: + session = TrainingSession( + created_by=ctx.user.id, + title=payload.title, + coach_note=payload.coach_note, + status="draft", + ) + scope.add(session) + scope.commit() + scope.refresh(session) + return _coach_payload(scope, db, session) + + +@router.patch("/{session_id}", response_model=None) +def update_session( + session_id: int, + payload: SessionUpdateRequest, + ctx: CurrentMembership = Depends(require_role_on_team("coach")), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> dict: + session = _draft_or_409(_session_or_404(scope, session_id)) + if payload.title is not None: + session.title = payload.title + if payload.coach_note is not None: + session.coach_note = payload.coach_note + scope.commit() + scope.refresh(session) + return _coach_payload(scope, db, session) + + +@router.post("/{session_id}/items", response_model=None, status_code=status.HTTP_201_CREATED) +def add_item( + session_id: int, + payload: SessionItemCreateRequest, + ctx: CurrentMembership = Depends(require_role_on_team("coach")), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> dict: + session = _draft_or_409(_session_or_404(scope, session_id)) + + if payload.item_kind == "library": + if payload.library_item_id is None or payload.saved_pattern_id is not None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="A library item needs library_item_id and no saved_pattern_id", + ) + if db.get(LibraryItem, payload.library_item_id) is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Library item not found" + ) + else: + if payload.saved_pattern_id is None or payload.library_item_id is not None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="A saved pattern needs saved_pattern_id and no library_item_id", + ) + # Scoped get: another team's recording resolves to None here, so a + # coach can only ever attach their own team's patterns. + if scope.get(SavedPattern, payload.saved_pattern_id) is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Saved pattern not found" + ) + + existing = ( + scope.query_via(SessionItem, TrainingSession, SessionItem.session_id == TrainingSession.id) + .filter(SessionItem.session_id == session.id) + .count() + ) + # SessionItem has no team_id of its own (doc 03 section 6), so it is + # added through the plain db session after its PARENT session has been + # verified through the scope above (app/scoped.py add() docstring). + item = SessionItem( + session_id=session.id, + position=existing, + item_kind=payload.item_kind, + library_item_id=payload.library_item_id, + saved_pattern_id=payload.saved_pattern_id, + ) + db.add(item) + scope.commit() + return _coach_payload(scope, db, session) + + +@router.patch("/{session_id}/items/{item_id}", response_model=None) +def move_item( + session_id: int, + item_id: int, + payload: SessionItemMoveRequest, + ctx: CurrentMembership = Depends(require_role_on_team("coach")), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> dict: + session = _draft_or_409(_session_or_404(scope, session_id)) + rows = ( + scope.query_via(SessionItem, TrainingSession, SessionItem.session_id == TrainingSession.id) + .filter(SessionItem.session_id == session.id) + .order_by(SessionItem.position.asc()) + .all() + ) + moving = next((r for r in rows if r.id == item_id), None) + if moving is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session item not found") + + target = min(payload.position, len(rows) - 1) + rows.remove(moving) + rows.insert(target, moving) + # Renormalize to 0..n-1 so positions never drift into gaps or ties. + for index, row in enumerate(rows): + row.position = index + scope.commit() + return _coach_payload(scope, db, session) + + +@router.delete("/{session_id}/items/{item_id}", response_model=None) +def remove_item( + session_id: int, + item_id: int, + ctx: CurrentMembership = Depends(require_role_on_team("coach")), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> dict: + session = _draft_or_409(_session_or_404(scope, session_id)) + rows = ( + scope.query_via(SessionItem, TrainingSession, SessionItem.session_id == TrainingSession.id) + .filter(SessionItem.session_id == session.id) + .order_by(SessionItem.position.asc()) + .all() + ) + target = next((r for r in rows if r.id == item_id), None) + if target is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session item not found") + + db.delete(target) + for index, row in enumerate([r for r in rows if r.id != item_id]): + row.position = index + scope.commit() + return _coach_payload(scope, db, session) + + +@router.post("/{session_id}/send", response_model=None) +def send_session( + session_id: int, + ctx: CurrentMembership = Depends(require_role_on_team("coach")), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> dict: + session = _draft_or_409(_session_or_404(scope, session_id)) + + items = ( + scope.query_via(SessionItem, TrainingSession, SessionItem.session_id == TrainingSession.id) + .filter(SessionItem.session_id == session.id) + .count() + ) + if items == 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Add at least one pattern before sending", + ) + + # doc 03 section 6: a receipt exists for every recipient at send time, + # viewed_at null. Receipts carry no team_id (they scope through the + # session), so they are added on the plain db session. + for user_id, _display_name in _player_members(scope, db): + db.add(SessionReceipt(session_id=session.id, player_user_id=user_id, viewed_at=None)) + + session.status = "sent" + session.sent_at = utcnow() + scope.commit() + scope.refresh(session) + return _coach_payload(scope, db, session) + + +# --------------------------------------------------------------------------- +# Player-only: Mark as watched, which feeds the coach's receipt counter +# (README roles table). A coach calling this gets 403. +# --------------------------------------------------------------------------- + + +@router.post("/{session_id}/watched", response_model=None) +def mark_watched( + session_id: int, + ctx: CurrentMembership = Depends(require_role_on_team("player")), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> dict: + session = _session_or_404(scope, session_id) + receipt = _own_receipt(scope, session.id, ctx.user.id) + if session.status != "sent" or receipt is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + + # Idempotent: re-marking keeps the FIRST watch time rather than + # resetting it, so the coach's receipt reads when the player actually + # watched it. + if receipt.viewed_at is None: + receipt.viewed_at = utcnow() + scope.commit() + scope.refresh(receipt) + return _player_payload(scope, db, session, receipt) diff --git a/backend/app/routers/whiteboard.py b/backend/app/routers/whiteboard.py index c092782..56b7192 100644 --- a/backend/app/routers/whiteboard.py +++ b/backend/app/routers/whiteboard.py @@ -44,7 +44,7 @@ def _author_label(role: str, user: User | None) -> str: return user.display_name if user is not None else "Player" -def _pattern_to_out(pattern: SavedPattern, author: User | None) -> SavedPatternOut: +def pattern_to_out(pattern: SavedPattern, author: User | None) -> SavedPatternOut: return SavedPatternOut( id=pattern.id, name=pattern.name, @@ -113,7 +113,7 @@ def list_patterns( rows = scope.query(SavedPattern).order_by(SavedPattern.created_at.desc()).all() author_ids = {r.author_user_id for r in rows} authors = {u.id: u for u in db.query(User).filter(User.id.in_(author_ids)).all()} - return [_pattern_to_out(r, authors.get(r.author_user_id)) for r in rows] + return [pattern_to_out(r, authors.get(r.author_user_id)) for r in rows] @router.post("/patterns", response_model=SavedPatternOut, status_code=status.HTTP_201_CREATED) @@ -134,7 +134,7 @@ def create_pattern( scope.add(row) scope.commit() scope.refresh(row) - return _pattern_to_out(row, ctx.user) + return pattern_to_out(row, ctx.user) @router.delete("/patterns/{pattern_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index d351133..f88dabf 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -504,3 +504,131 @@ class FormationOut(BaseModel): positions: list[FormationPositionOut] keystones: list[FormationKeystoneOut] rondo_zones: list[RondoZoneOut] + + +# --------------------------------------------------------------------------- +# Sessions: the classroom loop (doc 03 section 6; Brief step 23, PNG 21-23, +# 26, 28; T-042). Design README: "bundle patterns and recorded whiteboard +# tactics into a session, attach a coach note, and send to players... Sent +# state: gold SENT pill with an x/y viewed counter, and per-player read +# receipts (Viewed / Not yet). Receipts are coach-only, consistent with fit +# warnings, players never see each other's status." +# +# The coach/player split follows the RosterOut/CoachRosterOut precedent +# exactly: two sibling models over a shared base, picked per caller by a +# response_model=None route that returns an already-serialized dict, so a +# player's payload has no receipt KEY at all (not a null or empty one) and +# a coach's has no `you_watched` key (it is meaningless for a non-recipient). +# --------------------------------------------------------------------------- + +SessionStatus = Literal["draft", "sent"] +SessionItemKind = Literal["library", "saved_pattern"] + + +class SessionCreateRequest(BaseModel): + """POST /api/sessions body. No team_id/created_by/status field on + purpose (CLAUDE.md rule 4): team_id is stamped by TeamScope.add from + the caller's own membership, created_by from the session, and a fresh + session is always a draft.""" + + model_config = ConfigDict(extra="forbid") + + title: str = Field(min_length=1, max_length=120) + coach_note: str | None = Field(default=None, max_length=2000) + + +class SessionUpdateRequest(BaseModel): + """PATCH /api/sessions/{id}. Draft-only (a sent session is a record of + what the players were actually told, so it stops being editable), and + both fields are optional so the draft builder can save a note without + resending the title.""" + + model_config = ConfigDict(extra="forbid") + + title: str | None = Field(default=None, min_length=1, max_length=120) + coach_note: str | None = Field(default=None, max_length=2000) + + +class SessionItemCreateRequest(BaseModel): + """POST /api/sessions/{id}/items. Exactly one of the two id fields must + be set, matching `item_kind`; the route validates that pairing and that + the referenced row is reachable (library items are library world, saved + patterns are team-scoped through this same caller's scope).""" + + model_config = ConfigDict(extra="forbid") + + item_kind: SessionItemKind + library_item_id: int | None = None + saved_pattern_id: int | None = None + + +class SessionItemMoveRequest(BaseModel): + """PATCH /api/sessions/{id}/items/{item_id}: the design README's + "Draft state: reorder/remove items". Positions are renormalized to + 0..n-1 server-side after every move, so a client never has to compute + a gap-free ordering itself.""" + + model_config = ConfigDict(extra="forbid") + + position: int = Field(ge=0) + + +class SessionItemOut(BaseModel): + """One attached item. Carries the FULL referenced content (not just an + id) so both the coach's picker thumbnails and the player's Watch + deep-link can render it through the existing preview converters + (frontend/src/pages/patternPreview.ts) in one round trip, the same + embedded-detail shape FormationOut already uses for keystones.""" + + id: int + position: int + item_kind: SessionItemKind + library_item: LibraryItemOut | None = None + saved_pattern: SavedPatternOut | None = None + + +class SessionReceiptOut(BaseModel): + """One recipient's read state (doc 03 section 6). COACH-ONLY: this + model only ever appears inside CoachSessionOut. `jersey_number` is + resolved from the recipient's claimed roster row when there is one + (PNG 21's numbered badges), null otherwise.""" + + player_user_id: int + display_name: str + jersey_number: int | None + viewed_at: datetime | None + viewed: bool + + +class SessionOut(BaseModel): + """Fields both roles see. Never returned on its own: the routes always + build one of the two subclasses below.""" + + id: int + title: str + coach_note: str | None + status: SessionStatus + sent_at: datetime | None + created_at: datetime + items: list[SessionItemOut] + + +class PlayerSessionOut(SessionOut): + """Player payload. `you_watched` is the caller's OWN receipt state and + nothing else: it is what the Mark as watched button reads, and it + reveals no other player's status, which is the actual coach-only line + the design README draws ("players never see each other's status").""" + + you_watched: bool + + +class CoachSessionOut(SessionOut): + """Coach payload: the per-player receipts and the x/y counter (PNG 21's + "SENT . 3/4 viewed"). For a DRAFT the receipt rows are the team's + current player members with viewed=false, which is the design README's + "players listed as Will receive"; real receipt rows are written at send + time (doc 03 section 6).""" + + receipts: list[SessionReceiptOut] + viewed_count: int + recipient_count: int diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b8814b6..f99a50d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,11 +14,18 @@ dependencies = [ ] [project.optional-dependencies] +# Upper bounds on purpose. These were open-ended (`ruff>=0.5`, `mypy>=1.10`), +# which meant CI resolved whatever had shipped that morning while a checkout +# kept whatever it installed months ago. A ruff minor release then expanded +# its default rule set and turned a locally-green `make verify` into 140 CI +# errors in files nobody had touched. Pinning to the minor keeps patch fixes +# flowing while making a rule-set change a deliberate, reviewable bump +# instead of a surprise on someone else's pull request. dev = [ - "pytest>=8", - "httpx>=0.27", - "ruff>=0.5", - "mypy>=1.10", + "pytest>=9.1,<10", + "httpx>=0.27,<1", + "ruff>=0.15.21,<0.16", + "mypy>=2.3,<3", ] [build-system] diff --git a/backend/tests/test_permissions.py b/backend/tests/test_permissions.py index 7100f9d..7999614 100644 --- a/backend/tests/test_permissions.py +++ b/backend/tests/test_permissions.py @@ -23,17 +23,13 @@ re-derived from scratch, so a reviewer can check this file against Brief section 3 line by line without cross-referencing five other files. -Two rows -- "Suggest own playstyle" and "Sessions" -- have no API -surface yet in this codebase state: only their SQLAlchemy models exist -(app/models/roster.py PlaystyleSuggestion; app/models/sessions.py -TrainingSession/SessionItem/SessionReceipt), no router is registered for -either in app/main.py. Per Brief section 4's own build order, role -gating (step 21, this ticket) lands before the suggestion flow (step 22, -T-041) and sessions (step 23, T-042). Those two rows are marked skipped -below with a reason, not silently omitted, so the suite still names -every row of the table; T-041/T-042 must turn each skip into a real -assertion when their routes land (T-041's own ticket says as much for -the suggestion row; the same applies to sessions by the same logic). +Two rows -- "Suggest own playstyle" and "Sessions" -- were placeholder +skips when this file was written, because only their SQLAlchemy models +existed at the time (Brief section 4 lands role gating, step 21, before +the suggestion flow, step 22, and sessions, step 23). Both routers have +since landed (app/routers/suggestions.py, T-041; app/routers/sessions.py, +T-042) and both skips have been replaced by real assertions, so EVERY row +of the table is now enforced here with nothing skipped. """ import pytest @@ -290,52 +286,141 @@ def test_roster__coach_full_with_fit_warnings_player_view_only_no_fit_warnings( # --------------------------------------------------------------------------- # Row: "Suggest own playstyle" -- not applicable (coach) / free text on own # profile then "pending coach review"; coach sees a gold badge and an -# Approve / Dismiss card (player). No route exists yet: PlaystyleSuggestion -# is a model only (app/models/roster.py), no router is registered in -# app/main.py. T-041 (suggestion flow) is being built in parallel in -# another worktree and owns turning this into a real assertion. +# Approve / Dismiss card (player). Implemented by T-041 +# (app/routers/suggestions.py), which replaced this row's placeholder skip +# with the assertion below. Route-level detail (409 on a second pending +# suggestion, dismiss leaving the profile unchanged) lives in +# test_suggestions_routes.py; this states the TABLE ROW itself. # --------------------------------------------------------------------------- -@pytest.mark.skip( - reason=( - "No suggestion route exists in this worktree yet (Brief step 22 / " - "T-041, building in parallel). PlaystyleSuggestion is a model only; " - "app/main.py registers no router for it. T-041 must replace this " - "skip with a real submit/pending/approve/dismiss assertion." - ) -) def test_suggest_own_playstyle__player_submits_pending_coach_approves_or_dismisses( client: TestClient, -) -> None: # pragma: no cover - intentionally not runnable yet, see skip reason - raise AssertionError("T-041 must implement this row's route and this test") +) -> None: + coach = _coach_with_team() + # The roster row's name matches the player's display name, which is how + # a row becomes "their own profile" (app/routers/roster.py claim). + player_id = coach.post("/api/roster/players", json=_player_body(name="Sam Player")).json()["id"] + player = _player_on_team(coach, email="player@example.com", name="Sam Player") + player.get("/api/roster") # claims the matching row + + # "Not applicable" for a coach: the submit route 403s them outright. + assert ( + coach.post( + f"/api/roster/players/{player_id}/suggestions", json={"text": "Coach text"} + ).status_code + == 403 + ) + + submitted = player.post( + f"/api/roster/players/{player_id}/suggestions", + json={"text": "I read the game better as a number 8 than out wide."}, + ) + assert submitted.status_code == 201 + assert submitted.json()["status"] == "pending" + + # Never against a teammate's row, even on the same team. + teammate_id = coach.post("/api/roster/players", json=_player_body(name="Other Player")).json()[ + "id" + ] + assert ( + player.post( + f"/api/roster/players/{teammate_id}/suggestions", json={"text": "Not mine"} + ).status_code + == 403 + ) + + # The pending queue is the coach's alone. + assert player.get("/api/roster/suggestions/pending").status_code == 403 + pending = coach.get("/api/roster/suggestions/pending").json() + assert len(pending) == 1 + suggestion_id = pending[0]["id"] + + # Approve and dismiss are coach-only too. + assert player.post(f"/api/roster/suggestions/{suggestion_id}/approve").status_code == 403 + assert player.post(f"/api/roster/suggestions/{suggestion_id}/dismiss").status_code == 403 + + approved = coach.post(f"/api/roster/suggestions/{suggestion_id}/approve") + assert approved.status_code == 200 + assert approved.json()["status"] == "approved" + + # "Approve merges the note into the profile", visible to both roles. + merged = next( + row for row in coach.get("/api/roster").json()["players"] if row["id"] == player_id + ) + assert merged["playstyle_note"] == "I read the game better as a number 8 than out wide." + assert coach.get("/api/roster/suggestions/pending").json() == [] # --------------------------------------------------------------------------- # Row: "Sessions" -- create, edit drafts, send, see per-player read # receipts (coach) / sees sent sessions only, read-only, Watch deep-link, -# Mark as watched feeding the coach's receipt counter (player). No route -# exists yet: TrainingSession/SessionItem/SessionReceipt are models only -# (app/models/sessions.py); app/main.py registers no sessions router. -# That module's own docstring assigns enforcement to T-042. Same treatment -# as the suggestion row above: named and skipped, not silently omitted. +# Mark as watched feeding the coach's receipt counter (player). +# Implemented by T-042 (app/routers/sessions.py), which replaced this row's +# placeholder skip with the assertion below. Route-level detail (reorder, +# send validation, cross-team scoping) lives in test_sessions_routes.py; +# this states the TABLE ROW itself, including the two things the skip +# reason called out: receipts created for every recipient at send with +# viewed_at null, and receipt data ABSENT from player payloads. # --------------------------------------------------------------------------- -@pytest.mark.skip( - reason=( - "No sessions route exists in this worktree yet (Brief step 23 / " - "T-042). TrainingSession/SessionItem/SessionReceipt are models " - "only; app/main.py registers no router for them. T-042 must " - "replace this skip with a real create/send/receipt assertion, " - "including: receipts created for every recipient at send with " - "viewed_at null, and receipt data absent from player payloads." - ) -) def test_sessions__coach_creates_sends_sees_receipts_player_reads_and_marks_watched( client: TestClient, -) -> None: # pragma: no cover - intentionally not runnable yet, see skip reason - raise AssertionError("T-042 must implement this row's route and this test") +) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com", name="Sam Player") + other = _player_on_team(coach, email="other@example.com", name="Jordan Player") + + # Coach: create and edit a draft. + pattern_id = coach.post( + "/api/patterns", + json={"name": "Build-out", "board_snapshot": _board_snapshot(), "keyframes": _KEYFRAMES}, + ).json()["id"] + session_id = coach.post("/api/sessions", json={"title": "Tuesday"}).json()["id"] + coach.patch(f"/api/sessions/{session_id}", json={"coach_note": "Watch this before training."}) + coach.post( + f"/api/sessions/{session_id}/items", + json={"item_kind": "saved_pattern", "saved_pattern_id": pattern_id}, + ) + + # Player: create/edit/send are all 403 (the whole draft builder is + # coach-only), and a draft is not even visible to them. + assert player.post("/api/sessions", json={"title": "Mine"}).status_code == 403 + assert player.patch(f"/api/sessions/{session_id}", json={"title": "Mine"}).status_code == 403 + assert player.post(f"/api/sessions/{session_id}/send").status_code == 403 + assert player.get("/api/sessions").json() == [] + assert player.get(f"/api/sessions/{session_id}").status_code == 404 + + sent = coach.post(f"/api/sessions/{session_id}/send").json() + assert sent["status"] == "sent" + # Receipts exist for every recipient at send time, viewed_at null. + assert sent["recipient_count"] == 2 + assert sent["viewed_count"] == 0 + assert all(r["viewed_at"] is None for r in sent["receipts"]) + + # Player: sees the sent session, read-only, with the coach note and the + # content list the Watch buttons deep-link into. Receipt data is ABSENT + # from the payload, not null or empty (CLAUDE.md rule 5): players never + # see each other's status. + player_rows = player.get("/api/sessions").json() + assert [r["title"] for r in player_rows] == ["Tuesday"] + player_row = player_rows[0] + assert player_row["coach_note"] == "Watch this before training." + assert player_row["items"][0]["saved_pattern"]["name"] == "Build-out" + for coach_only_key in ("receipts", "viewed_count", "recipient_count"): + assert coach_only_key not in player_row + + # Mark as watched feeds the coach's counter and flips that row only. + assert player.post(f"/api/sessions/{session_id}/watched").json()["you_watched"] is True + assert coach.post(f"/api/sessions/{session_id}/watched").status_code == 403 # not a recipient + + after = coach.get(f"/api/sessions/{session_id}").json() + assert (after["viewed_count"], after["recipient_count"]) == (1, 2) + by_name = {r["display_name"]: r["viewed"] for r in after["receipts"]} + assert by_name == {"Sam Player": True, "Jordan Player": False} + # The other player still sees no receipt data of any kind. + assert "receipts" not in other.get(f"/api/sessions/{session_id}").json() # --------------------------------------------------------------------------- diff --git a/backend/tests/test_sessions_routes.py b/backend/tests/test_sessions_routes.py new file mode 100644 index 0000000..6e6bed8 --- /dev/null +++ b/backend/tests/test_sessions_routes.py @@ -0,0 +1,500 @@ +"""Sessions routes (doc 03 section 6; Brief step 23, PNG 21-23, 26, 28; +T-042). Covers the "Roles and sessions" DoD line from Brief section 5: + + "Session receipts: Mark as watched increments the coach's x/y counter + and flips that player's row to Viewed; players never see receipt data + in any payload." + +plus the draft-builder mechanics the design README specifies (attach +library presets AND saved recordings, reorder, remove, coach note, send) +and team scoping end to end through the HTTP layer. +""" + +import pytest +from fastapi.testclient import TestClient + +from app.db import SessionLocal +from app.main import app +from app.models import LibraryItem + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def _register(client: TestClient, *, email: str, role: str, display_name: str = "Test User"): + return client.post( + "/api/auth/register", + json={ + "email": email, + "password": "correct-horse-battery", + "display_name": display_name, + "role": role, + }, + ) + + +def _coach_with_team(email: str = "coach@example.com", name: str = "Coach Test") -> TestClient: + c = TestClient(app) + _register(c, email=email, role="coach", display_name=name) + c.post("/api/teams", json={"name": f"Team for {email}"}) + return c + + +def _player_on_team(coach: TestClient, email: str, name: str = "Player Test") -> TestClient: + join_code = coach.get("/api/teams/current").json()["join_code"] + p = TestClient(app) + _register(p, email=email, role="player", display_name=name) + p.post("/api/teams/join", json={"join_code": join_code}) + return p + + +_SPEC = { + "slots": [ + {"slot": "cm", "role_hint": "CM", "start": {"x": 40, "y": 50}}, + {"slot": "st", "role_hint": "ST", "start": {"x": 70, "y": 50}}, + ], + "ball": {"holder_slot": "cm"}, + "steps": [ + { + "n": 1, + "caption": "Play it in.", + "moves": [{"slot": "st", "to": {"x": 80, "y": 45}}], + "ball_to": {"bind_slot": "st", "trajectory": "ground"}, + } + ], + "loop": False, +} + + +def _seed_library_item(code: str = "A5", name: str = "Third-Man Run") -> int: + """The suite resets the schema per test (conftest.py), so library-world + content is empty; sessions need one real row to attach.""" + db = SessionLocal() + try: + item = LibraryItem( + code=code, + item_type="pattern", + name=name, + category="combination", + blurb="The third player arrives to receive.", + when_to_use="Against a compact block.", + coaching_points_json=["Time the run."], + youth_takeaway="Look past the first pass.", + age_hint="U13+", + roles_involved=["deep_lying_playmaker"], + animation_spec_json=_SPEC, + extras_json=None, + ) + db.add(item) + db.commit() + return item.id + finally: + db.close() + + +_TOKENS = [ + {"id": "home-9", "side": "home", "label": "9", "pos": {"x": 60, "y": 30}}, + {"id": "ball", "side": "ball", "label": "", "pos": {"x": 50, "y": 50}}, +] +_SNAPSHOT = { + "tokens": _TOKENS, + "confirmed_lanes": [], + "blocking_threshold": 7.0, + "marking_threshold": 10.0, + "zones_visible": {"thirds": False, "half_spaces": False, "zone_14": False, "cutback": False}, +} +_KEYFRAMES = [ + {"t_ms": 0, "token_id": "home-9", "x": 60.0, "y": 30.0}, + {"t_ms": 500, "token_id": "home-9", "x": 72.0, "y": 24.0}, +] + + +def _saved_pattern(coach: TestClient, name: str = "Our build-out vs press") -> int: + return coach.post( + "/api/patterns", + json={"name": name, "board_snapshot": _SNAPSHOT, "keyframes": _KEYFRAMES}, + ).json()["id"] + + +# --------------------------------------------------------------------------- +# The full coach loop: draft, attach both item kinds, note, send, receipts. +# --------------------------------------------------------------------------- + + +def test_coach_builds_a_draft_attaches_both_item_kinds_and_sends(client: TestClient) -> None: + coach = _coach_with_team() + _player_on_team(coach, email="p1@example.com", name="Jordan T.") + library_id = _seed_library_item() + pattern_id = _saved_pattern(coach) + + created = coach.post( + "/api/sessions", + json={"title": "Tuesday, wide overloads", "coach_note": "Watch both before training."}, + ) + assert created.status_code == 201 + session = created.json() + assert session["status"] == "draft" + assert session["sent_at"] is None + assert session["items"] == [] + # Draft receipts preview the send: the design README's "Will receive". + assert session["recipient_count"] == 1 + assert session["viewed_count"] == 0 + + session_id = session["id"] + with_library = coach.post( + f"/api/sessions/{session_id}/items", + json={"item_kind": "library", "library_item_id": library_id}, + ) + assert with_library.status_code == 201 + with_pattern = coach.post( + f"/api/sessions/{session_id}/items", + json={"item_kind": "saved_pattern", "saved_pattern_id": pattern_id}, + ) + assert with_pattern.status_code == 201 + + items = with_pattern.json()["items"] + assert [i["position"] for i in items] == [0, 1] + # Items embed their full content so a thumbnail renders in one round trip. + assert items[0]["library_item"]["code"] == "A5" + assert items[0]["library_item"]["animation_spec"]["ball"]["holder_slot"] == "cm" + assert items[0]["saved_pattern"] is None + assert items[1]["saved_pattern"]["name"] == "Our build-out vs press" + assert items[1]["saved_pattern"]["author_label"] == "COACH" + assert len(items[1]["saved_pattern"]["keyframes"]) == 2 + + sent = coach.post(f"/api/sessions/{session_id}/send") + assert sent.status_code == 200 + body = sent.json() + assert body["status"] == "sent" + assert body["sent_at"] is not None + assert body["recipient_count"] == 1 + assert body["viewed_count"] == 0 + assert body["receipts"][0]["display_name"] == "Jordan T." + assert body["receipts"][0]["viewed"] is False + assert body["receipts"][0]["viewed_at"] is None + + +def test_draft_items_reorder_and_remove(client: TestClient) -> None: + coach = _coach_with_team() + first = _seed_library_item("A1", "Overlap") + second = _seed_library_item("B3", "Switch of play") + session_id = coach.post("/api/sessions", json={"title": "Draft"}).json()["id"] + + coach.post( + f"/api/sessions/{session_id}/items", + json={"item_kind": "library", "library_item_id": first}, + ) + body = coach.post( + f"/api/sessions/{session_id}/items", + json={"item_kind": "library", "library_item_id": second}, + ).json() + assert [i["library_item"]["code"] for i in body["items"]] == ["A1", "B3"] + + second_item_id = body["items"][1]["id"] + moved = coach.patch( + f"/api/sessions/{session_id}/items/{second_item_id}", json={"position": 0} + ).json() + assert [i["library_item"]["code"] for i in moved["items"]] == ["B3", "A1"] + assert [i["position"] for i in moved["items"]] == [0, 1] + + removed = coach.delete(f"/api/sessions/{session_id}/items/{second_item_id}").json() + assert [i["library_item"]["code"] for i in removed["items"]] == ["A1"] + assert [i["position"] for i in removed["items"]] == [0] + + +def test_a_sent_session_can_no_longer_be_edited(client: TestClient) -> None: + coach = _coach_with_team() + _player_on_team(coach, email="p1@example.com") + library_id = _seed_library_item() + session_id = coach.post("/api/sessions", json={"title": "Locked"}).json()["id"] + coach.post( + f"/api/sessions/{session_id}/items", + json={"item_kind": "library", "library_item_id": library_id}, + ) + item_id = coach.get(f"/api/sessions/{session_id}").json()["items"][0]["id"] + coach.post(f"/api/sessions/{session_id}/send") + + assert coach.patch(f"/api/sessions/{session_id}", json={"title": "Nope"}).status_code == 409 + assert ( + coach.post( + f"/api/sessions/{session_id}/items", + json={"item_kind": "library", "library_item_id": library_id}, + ).status_code + == 409 + ) + assert coach.delete(f"/api/sessions/{session_id}/items/{item_id}").status_code == 409 + assert coach.post(f"/api/sessions/{session_id}/send").status_code == 409 + + +def test_an_empty_session_cannot_be_sent(client: TestClient) -> None: + coach = _coach_with_team() + _player_on_team(coach, email="p1@example.com") + session_id = coach.post("/api/sessions", json={"title": "Nothing in it"}).json()["id"] + assert coach.post(f"/api/sessions/{session_id}/send").status_code == 422 + + +# --------------------------------------------------------------------------- +# The player side, and the coach-only receipt contract (CLAUDE.md rule 5). +# --------------------------------------------------------------------------- + + +def _sent_session(coach: TestClient) -> int: + library_id = _seed_library_item() + session_id = coach.post( + "/api/sessions", json={"title": "Tuesday", "coach_note": "Watch it."} + ).json()["id"] + coach.post( + f"/api/sessions/{session_id}/items", + json={"item_kind": "library", "library_item_id": library_id}, + ) + coach.post(f"/api/sessions/{session_id}/send") + return session_id + + +def test_player_sees_sent_sessions_only_and_no_receipt_data_in_any_payload( + client: TestClient, +) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="p1@example.com", name="Jordan T.") + _player_on_team(coach, email="p2@example.com", name="Sam R.") + session_id = _sent_session(coach) + # A draft that must stay invisible to the player. + coach.post("/api/sessions", json={"title": "Not sent yet"}) + + listed = player.get("/api/sessions") + assert listed.status_code == 200 + rows = listed.json() + assert [r["title"] for r in rows] == ["Tuesday"] + + row = rows[0] + # Coach-only keys are ABSENT, not null or empty (the RosterOut / + # CoachRosterOut contract, Brief section 3 principles). + assert "receipts" not in row + assert "viewed_count" not in row + assert "recipient_count" not in row + assert row["you_watched"] is False + assert row["coach_note"] == "Watch it." + assert row["items"][0]["library_item"]["code"] == "A5" + + single = player.get(f"/api/sessions/{session_id}").json() + assert "receipts" not in single + assert "viewed_count" not in single + assert "recipient_count" not in single + + # The draft 404s for a player rather than 403ing (it does not exist as + # far as they are concerned). + draft_id = next(s["id"] for s in coach.get("/api/sessions").json() if s["status"] == "draft") + assert player.get(f"/api/sessions/{draft_id}").status_code == 404 + + +def test_mark_as_watched_flips_the_row_and_increments_the_coach_counter( + client: TestClient, +) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="p1@example.com", name="Jordan T.") + _player_on_team(coach, email="p2@example.com", name="Sam R.") + session_id = _sent_session(coach) + + before = coach.get(f"/api/sessions/{session_id}").json() + assert (before["viewed_count"], before["recipient_count"]) == (0, 2) + + watched = player.post(f"/api/sessions/{session_id}/watched") + assert watched.status_code == 200 + assert watched.json()["you_watched"] is True + assert "receipts" not in watched.json() + + after = coach.get(f"/api/sessions/{session_id}").json() + assert (after["viewed_count"], after["recipient_count"]) == (1, 2) + by_name = {r["display_name"]: r for r in after["receipts"]} + assert by_name["Jordan T."]["viewed"] is True + assert by_name["Jordan T."]["viewed_at"] is not None + assert by_name["Sam R."]["viewed"] is False + assert by_name["Sam R."]["viewed_at"] is None + + +def test_mark_as_watched_is_idempotent_and_keeps_the_first_watch_time( + client: TestClient, +) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="p1@example.com") + session_id = _sent_session(coach) + + player.post(f"/api/sessions/{session_id}/watched") + first_seen = next( + r["viewed_at"] for r in coach.get(f"/api/sessions/{session_id}").json()["receipts"] + ) + player.post(f"/api/sessions/{session_id}/watched") + again = coach.get(f"/api/sessions/{session_id}").json() + assert again["viewed_count"] == 1 + assert again["receipts"][0]["viewed_at"] == first_seen + + +def test_receipts_are_written_for_every_recipient_at_send_time(client: TestClient) -> None: + coach = _coach_with_team() + _player_on_team(coach, email="p1@example.com", name="Jordan T.") + _player_on_team(coach, email="p2@example.com", name="Sam R.") + session_id = _sent_session(coach) + + body = coach.get(f"/api/sessions/{session_id}").json() + assert body["recipient_count"] == 2 + assert all(r["viewed_at"] is None for r in body["receipts"]) + + # Someone joining afterwards is not retro-added to an already-sent + # session: the counter denominator is what the team was when it was + # sent, and they see no session they were never sent. + latecomer = _player_on_team(coach, email="p3@example.com", name="Late Arrival") + assert coach.get(f"/api/sessions/{session_id}").json()["recipient_count"] == 2 + assert latecomer.get("/api/sessions").json() == [] + assert latecomer.get(f"/api/sessions/{session_id}").status_code == 404 + assert latecomer.post(f"/api/sessions/{session_id}/watched").status_code == 404 + + +# --------------------------------------------------------------------------- +# Role enforcement at the API (CLAUDE.md rule 5) and team scoping (rule 4). +# --------------------------------------------------------------------------- + + +def test_every_coach_only_session_route_403s_a_player(client: TestClient) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="p1@example.com") + library_id = _seed_library_item() + session_id = coach.post("/api/sessions", json={"title": "Coach only"}).json()["id"] + coach.post( + f"/api/sessions/{session_id}/items", + json={"item_kind": "library", "library_item_id": library_id}, + ) + item_id = coach.get(f"/api/sessions/{session_id}").json()["items"][0]["id"] + + attempts = { + "create": player.post("/api/sessions", json={"title": "Mine"}), + "update": player.patch(f"/api/sessions/{session_id}", json={"title": "Mine"}), + "add item": player.post( + f"/api/sessions/{session_id}/items", + json={"item_kind": "library", "library_item_id": library_id}, + ), + "move item": player.patch( + f"/api/sessions/{session_id}/items/{item_id}", json={"position": 0} + ), + "remove item": player.delete(f"/api/sessions/{session_id}/items/{item_id}"), + "send": player.post(f"/api/sessions/{session_id}/send"), + } + for label, response in attempts.items(): + assert response.status_code == 403, label + + # And nothing the player attempted changed anything. + unchanged = coach.get(f"/api/sessions/{session_id}").json() + assert unchanged["title"] == "Coach only" + assert unchanged["status"] == "draft" + assert len(unchanged["items"]) == 1 + + +def test_mark_as_watched_403s_a_coach(client: TestClient) -> None: + coach = _coach_with_team() + _player_on_team(coach, email="p1@example.com") + session_id = _sent_session(coach) + assert coach.post(f"/api/sessions/{session_id}/watched").status_code == 403 + + +def test_sessions_are_team_scoped_end_to_end(client: TestClient) -> None: + coach_a = _coach_with_team(email="a@example.com", name="Coach A") + coach_b = _coach_with_team(email="b@example.com", name="Coach B") + _player_on_team(coach_a, email="pa@example.com") + library_id = _seed_library_item() + + session_id = coach_a.post("/api/sessions", json={"title": "A only"}).json()["id"] + coach_a.post( + f"/api/sessions/{session_id}/items", + json={"item_kind": "library", "library_item_id": library_id}, + ) + item_id = coach_a.get(f"/api/sessions/{session_id}").json()["items"][0]["id"] + + # Team B sees nothing of team A's, and cannot touch it by id. + assert coach_b.get("/api/sessions").json() == [] + assert coach_b.get(f"/api/sessions/{session_id}").status_code == 404 + assert coach_b.patch(f"/api/sessions/{session_id}", json={"title": "Stolen"}).status_code == 404 + assert coach_b.delete(f"/api/sessions/{session_id}/items/{item_id}").status_code == 404 + assert coach_b.post(f"/api/sessions/{session_id}/send").status_code == 404 + assert coach_a.get(f"/api/sessions/{session_id}").json()["title"] == "A only" + + +def test_a_coach_cannot_attach_another_teams_recording(client: TestClient) -> None: + coach_a = _coach_with_team(email="a@example.com", name="Coach A") + coach_b = _coach_with_team(email="b@example.com", name="Coach B") + pattern_id = _saved_pattern(coach_a, name="A's recording") + + session_id = coach_b.post("/api/sessions", json={"title": "B's draft"}).json()["id"] + attached = coach_b.post( + f"/api/sessions/{session_id}/items", + json={"item_kind": "saved_pattern", "saved_pattern_id": pattern_id}, + ) + assert attached.status_code == 404 + assert coach_b.get(f"/api/sessions/{session_id}").json()["items"] == [] + + +def test_item_kind_and_id_must_agree(client: TestClient) -> None: + coach = _coach_with_team() + library_id = _seed_library_item() + pattern_id = _saved_pattern(coach) + session_id = coach.post("/api/sessions", json={"title": "Validation"}).json()["id"] + + assert ( + coach.post( + f"/api/sessions/{session_id}/items", + json={"item_kind": "library", "saved_pattern_id": pattern_id}, + ).status_code + == 422 + ) + assert ( + coach.post( + f"/api/sessions/{session_id}/items", + json={"item_kind": "saved_pattern", "library_item_id": library_id}, + ).status_code + == 422 + ) + assert ( + coach.post( + f"/api/sessions/{session_id}/items", + json={"item_kind": "library", "library_item_id": 99999}, + ).status_code + == 404 + ) + + +def test_receipts_carry_the_jersey_number_of_a_claimed_roster_row(client: TestClient) -> None: + """PNG 21 renders a numbered gold badge per recipient. The number comes + from the roster row the player's own display name claimed + (app/routers/roster.py), and is null when no row matches.""" + coach = _coach_with_team() + coach.post( + "/api/roster/players", + json={ + "name": "Jordan T.", + "jersey_number": 7, + "preferred_foot": "R", + "awr": "high", + "dwr": "med", + "attributes": { + "pace": 4, + "passing_range": 3, + "carrying_1v1": 4, + "positional_discipline": 3, + "aerial_physical": 2, + "pressing_engine": 4, + }, + }, + ) + player = _player_on_team(coach, email="p1@example.com", name="Jordan T.") + player.get("/api/roster") # the claim happens on the player's own roster fetch + session_id = _sent_session(coach) + + receipt = coach.get(f"/api/sessions/{session_id}").json()["receipts"][0] + assert receipt["display_name"] == "Jordan T." + assert receipt["jersey_number"] == 7 + + +def test_signed_out_callers_get_401(client: TestClient) -> None: + anon = TestClient(app) + assert anon.get("/api/sessions").status_code == 401 + assert anon.post("/api/sessions", json={"title": "Nope"}).status_code == 401 diff --git a/docs/agent/BACKLOG.md b/docs/agent/BACKLOG.md index bc8a254..ccb5d51 100644 --- a/docs/agent/BACKLOG.md +++ b/docs/agent/BACKLOG.md @@ -11,7 +11,7 @@ Model: sonnet default; opus = hard ticket, never downgrade. | T-004 | Scoped query layer + full schema from doc 03 + Alembic chain from zero + cross-team read test returns nothing | 4, 5 | platform | sonnet | T-001 | T-002 | done | | T-010 | Seed files: transcribe Bible per doc 03 §4-6 (12 patterns, 8 deliveries, 3 rotations, 6 formations+keystones, rondo 5 zones, 6 archetypes+pass-risk, 4 animated + 2 static ref teams, detail-only slots, cult corner, roles, synergies) | 6 | content-seeder | sonnet | T-004 | T-020 | done | | T-011 | Em-dash transform pass + CI copy scan + seed validator (required fields, blurb ≤25 words, banned identity phrases, slot refs resolve) | 7, 8 | content-seeder | sonnet | T-010 | T-020 | done | -| T-012 | Founder decision 2026-07-16: identities age_hint column (amend doc 03, Alembic migration, backfill from Bible 8.2.4, validator + seed update) | founder | content-seeder | sonnet | T-010, T-041 | T-043 | doing | +| T-012 | Founder decision 2026-07-16: identities age_hint column (amend doc 03, Alembic migration, backfill from Bible 8.2.4, validator + seed update) | founder | content-seeder | sonnet | T-010, T-041 | T-043 | done | | T-020 | Board core: pitch canvas, landscape model coords, token drag 60fps @23 tokens, portrait mapping (left=y, top=100-x) with lossless round-trip unit test FIRST | 9, 10 | board-engineer | opus | T-001 | T-010 | done | | T-021 | Lane graph: suggested/confirmed/blocked states, two independent thresholds, live recompute during drag, interception dot | 11, 12 | board-engineer | opus | T-020 | T-011 | done | | T-022 | Zones + animation player (declarative specs AND raw keyframes, ball waypoints chase bound player) + recorder (all tokens incl. opponents + ball) | 13, 14, 15 | board-engineer | opus | T-021 | none | done | @@ -20,12 +20,12 @@ Model: sonnet default; opus = hard ticket, never downgrade. | T-032 | Formations page (PNG 11, 19, 37-39, 43) + keystone pulse/keycards + Rondo Map (PNG 32, 36) | 18 | screens | sonnet | T-031 | T-033 | done | | T-033 | Roster page (PNG 12, 20): CRUD, chips, 6 sliders, double-exposure warning coach-only | 19 | screens | sonnet | T-004, T-011 | T-032 | done | | T-034 | Identity page (PNG 13, 33, 40-42, 44, 45): 4 scripted animations, 2 static shapes, detail slots, pass-risk, cult corner | 20 | screens | sonnet | T-031 | T-033 | done | -| T-040 | Role gating UI + API 403 enforcement, permission test suite both roles (Brief §3 table, every row) | 21 | collab | sonnet | T-030..T-034 | T-041 | doing | +| T-040 | Role gating UI + API 403 enforcement, permission test suite both roles (Brief §3 table, every row) | 21 | collab | sonnet | T-030..T-034 | T-041 | done | | T-041 | Playstyle suggestion flow (PNG 24, 25, 27) | 22 | collab | sonnet | T-033 | T-040 | done | -| T-042 | Sessions: draft builder + picker w/ thumbnails, send, receipts, player view w/ Watch deep-link + Mark as watched (PNG 21-23, 26, 28) | 23 | collab | sonnet | T-031, T-040 | none | todo | -| T-043 | Founder decision 2026-07-16: role-scoped join codes (player + coach code, migration), join codes coach-only in API payloads, head coach (creator) removes members + edits member roles, permission tests | founder | platform | sonnet | T-003, T-040 | T-041 | doing | -| T-050 | Phone pass: icon rail, stacked grids, portrait boards all surfaces, cross-device save/replay test | 24 | screens | sonnet | T-030..T-042 | none | todo | -| T-051 | Hardening: full em-dash sweep, permission suite in CI, demo-path e2e (Brief §6 narrative as one Playwright journey, both viewports) | 25 | verifier | sonnet | T-050 | none | todo | +| T-042 | Sessions: draft builder + picker w/ thumbnails, send, receipts, player view w/ Watch deep-link + Mark as watched (PNG 21-23, 26, 28) | 23 | collab | sonnet | T-031, T-040 | none | done | +| T-043 | Founder decision 2026-07-16: role-scoped join codes (player + coach code, migration), join codes coach-only in API payloads, head coach (creator) removes members + edits member roles, permission tests | founder | platform | sonnet | T-003, T-040 | T-041 | done | +| T-050 | Phone pass: icon rail, stacked grids, portrait boards all surfaces, cross-device save/replay test | 24 | screens | sonnet | T-030..T-042 | none | done | +| T-051 | Hardening: full em-dash sweep, permission suite in CI, demo-path e2e (Brief §6 narrative as one Playwright journey, both viewports) | 25 | verifier | sonnet | T-050 | none | done | | T-060 | Deploy: Render service (persistent volume), Litestream to object storage, env config, prod Turso decision point, smoke journey vs prod URL | doc 04 §2 | platform | sonnet | T-051 | none | todo | Sequencing: T-001 solo → (T-002 ∥ T-003 ∥ T-004) → (T-010/011 ∥ T-020/021/022) → screens fan-out → collab → phone → hardening → deploy. diff --git a/docs/agent/BUILD_TO_DEMO.md b/docs/agent/BUILD_TO_DEMO.md new file mode 100644 index 0000000..4b9d67b --- /dev/null +++ b/docs/agent/BUILD_TO_DEMO.md @@ -0,0 +1,42 @@ +# BUILD TO DEMO: single-agent finish plan + +Supersedes the orchestrator/subagent protocol in CLAUDE.md for the remainder of this +project. One agent, one branch, build straight through to a demo-ready product. + +Goal: a working product to put in front of the UofT varsity soccer coaches, plus a +README that sells it at a glance. + +## What changes from the original plan + +DROPPED: +- Orchestrator/subagent dispatch, worktrees, one-ticket-one-PR ceremony, per-ticket + port assignments, CI gating loops. Work directly on one branch. +- Screen-recording verification. +- T-060 deploy (deferred; separate decision, needs credentials). + +KEPT (cheap, already load-bearing, do not regress): +- No em dashes in user-facing strings or seeds. `make check-copy` enforces it. +- Team data goes through the scoped query layer. Handlers never filter team_id by hand. +- Permissions enforced in the API, not just the UI. Coach-only keys ABSENT from + player payloads, never null. +- Positions stored in landscape model coords (x 0-100 toward attacking goal, y 0-100 + top to bottom). Orientation is render-only. +- `make verify` green before the final merge. + +ADDED: +- One-command demo seed: a fully populated coach account, so the app is never empty + when a coach opens it. +- Screenshot capture pass + a README built around those screenshots. + +## Build order + +1. T-042 Sessions. The coach->player loop that closes the demo narrative. +2. Demo seed script. +3. T-050 Phone pass. +4. T-051 Hardening, scoped to the demo path only. +5. Screenshots + README. + +## Definition of done + +The Brief section 6 demo narrative runs end to end without a hitch, on desktop and +on a phone viewport, and the README shows it. diff --git a/docs/agent/FINISH_PROMPT.md b/docs/agent/FINISH_PROMPT.md new file mode 100644 index 0000000..74fbffa --- /dev/null +++ b/docs/agent/FINISH_PROMPT.md @@ -0,0 +1,184 @@ +# Agent prompt: finish Patterns of Play to demo-ready + +Finish Patterns of Play end to end so I can demo it to university varsity soccer +coaches. Repo: /Users/brandanburgess/Documents/patternsofplay, branch `integration`. + +You are building, not orchestrating. Ignore the orchestrator/subagent protocol and the +PR ceremony in CLAUDE.md: no worktrees, no per-ticket PRs, no CI polling loops. Work +directly on `integration` and commit as you complete each phase. Read +docs/agent/BUILD_TO_DEMO.md first, then this. + +## Ground truth (do not re-derive) + +The app already works. Merged and verified: platform/auth/teams with role-scoped join +codes, scoped query layer, full schema (Alembic head 0005), seeded tactical content +(12 patterns, 8 deliveries, 3 rotations, 6 formations, rondo map, 27 identities), the +complete board engine (drag, lane graph with suggested/confirmed/blocked, zone +overlays, declarative + keyframe animation player, recorder), all five screens +(Whiteboard, Patterns, Formations, Roster, Identity), the permission suite, and the +playstyle suggestion flow. `make verify` was green at the last commit. + +Read docs/agent/STATE.md for architecture notes and decisions already closed. Do not +reopen them. + +Stack is React 19 + Vite + TypeScript (frontend/) and FastAPI + SQLAlchemy 2 + Alembic ++ SQLite WAL (backend/). docs/source/design-handoff/ holds static PNG mockups plus one +HTML mockup file: those are a VISUAL SPEC ONLY. You write React. Never port the mockup +HTML/CSS into the app; match its look using the existing design tokens +(frontend/src/styles/tokens.css) and existing page CSS conventions. + +## Rules that still bind + +1. No em dashes in any user-facing string or seed file. Use periods, commas, colons, + parentheses. `make check-copy` fails on the character. +2. Every team-scoped query goes through the scoped query layer (backend/app/scoped.py). + Route handlers never filter by team_id manually. Client input never supplies team_id. +3. Permissions enforced in the API, not just the UI. Coach-only data (fit warnings, + receipts, join codes) must be ABSENT from player-role payloads, not null. Follow the + split-schema pattern already used for RosterOut/CoachRosterOut. +4. Board positions stored in landscape model coords (x 0-100 toward the attacking goal, + y 0-100 top to bottom). Orientation is render-only. Portrait maps left=y, top=100-x. +5. Reuse frontend/src/board/PatternPreviewBoard.tsx and pages/patternPreview.ts for any + read-only board rendering. Do not write a second board renderer. +6. Scope discipline: the Brief section 1 scope table is final. Do not invent surfaces. + If content has no designed surface, it stays seed data. + +## The bar for the boards + +This is the product. Everything else is scaffolding around it. The boards must be +genuinely functional and they must look good enough that a coach wants to keep using +them: + +- Drag stays smooth with all 23 tokens on the pitch. No jank, no dropped pointer + capture, no popover or menu intercepting a drag (this bug already bit T-030 once). +- Playback is smooth and readable: tokens ease rather than teleport, the ball chases + its bound player, trails/lane states read clearly at a glance. +- Portrait and landscape both render correctly, and a pattern recorded in one replays + identically in the other. +- Every board surface looks deliberate: consistent token styling, legible pitch + markings, sensible empty states. If a board looks unfinished, fix it even if no + ticket line asks for it. + +Treat any board polish gap you notice as in scope. + +## Phase 1: Sessions (T-042) + +The coach-to-player loop. Mockups: PNG 21, 22, 23, 26, 28 in +docs/source/design-handoff/. Backend models already exist in +backend/app/models/sessions.py. + +- Coach: draft builder + item picker with board thumbnails (reuse PatternPreviewBoard), + attach library patterns and saved recordings, add a note, send to the team. +- Coach: receipts view, "watched by 3 of 12" style counter, per-player state. +- Player: session list, session view, Watch deep-link that opens the item on the board + (portrait on phone), Mark as watched. +- Un-skip and implement the two @pytest.mark.skip placeholder rows in + backend/tests/test_permissions.py ("suggest own playstyle", "sessions"). Players must + never receive receipt data in any payload. +- Playwright journey in e2e/sessions.spec.ts using the e2e/fixtures.ts helpers, ending + in assertCleanPage. + +## Phase 2: Demo seed + +Add `scripts/seed_demo.py` and a `make demo` target that drops the dev DB, migrates, +seeds library content, then creates a realistic demo state so the app is never empty +when a coach opens it: + +- Coach account (print the credentials to stdout and put them in the README), a team, + and a player account joined to it. +- 12-14 roster players with roles, flanks, and slider values filled in, including one + pair that triggers the double-exposure fit warning. +- A saved custom pattern recorded on the whiteboard ("Our build-out vs press") with a + real multi-token animation, not a stub. +- A selected formation with a keystone, a set team identity, one sent session with two + items and one receipt already marked watched. + +Idempotent, safe to rerun. This is what I will run before walking into a meeting. + +## Phase 3: Phone pass (T-050) + +Mockups: PNG 14-20, 23, 28, 35, 36, 43-45. iPhone 13 (390x844) is the target. + +- Icon rail nav, stacked single-column grids, no horizontal overflow anywhere. +- Every board surface renders portrait on phone. +- Sheets and drawers are reachable and dismissible by touch. +- Cross-device test: record a pattern on desktop viewport, replay it on mobile + viewport, assert positions round-trip. + +## Phase 4: Hardening (T-051), demo-path scoped + +- Full em-dash sweep across the repo. +- Permission suite runs in CI. +- One Playwright journey, e2e/demo-path.spec.ts, that runs the Brief section 6 + narrative straight through on both viewports: coach signs up, creates a team, adds + players with roles and sliders, opens Formations and loads 4-3-3 and taps the pivot + keystone, toggles the Rondo Map and taps the first-line zone, opens Patterns and + searches "third man" and plays A5, opens the Whiteboard and drags a build-out and + records and saves it as "Our build-out vs press", creates a session with A5 plus the + recording and a note and sends it, then as a player opens the session, watches A5 + portrait, marks it watched, and back as coach the receipt counter reads 1 of N. + +Do not chase unrelated tech debt. If the demo path is green on both viewports and +`make verify` passes, hardening is done. + +## Phase 5: Screenshots and README + +Write `e2e/screenshots.spec.ts` that runs against the `make demo` database and writes +PNGs to docs/screenshots/. This is a capture script, not a test: it drives the real UI +and takes full-page or element screenshots. No screen recording, no video. + +Capture at minimum, desktop 1440x900 unless noted: +1. Whiteboard with the lane graph live, mid-drag state with confirmed and blocked lanes + visible. +2. Whiteboard recording UI, trace visible. +3. Patterns page, library sheet open with chips and search. +4. A pattern mid-playback on the board with the animation clearly in progress. +5. Formations page, 4-3-3 with the keystone pulsing, keycards visible. +6. Rondo Map with a zone selected. +7. Identity page, a reference team animation playing. +8. Roster with the double-exposure fit warning showing. +9. Session receipts view. +10. Phone (390x844): pattern playing portrait, and the player session view. + +Time the captures so animated surfaces are caught mid-motion rather than at frame zero, +and prefer the dark theme unless a screen reads better in another. These are marketing +shots: if a screen looks empty or awkward, adjust the demo seed data until it looks +good, then recapture. + +Then write README.md at the repo root: + +- One-line hook, then a two-sentence explanation of what the product does for a coach. +- The hero screenshot right at the top. +- "Why a coach cares": three or four bullets in plain football language, not + engineering language. No jargon, no architecture talk in this section. +- A walkthrough section: the screenshots in demo-narrative order, each with a one-line + caption naming what the coach is doing. +- Quickstart: `make bootstrap`, `make demo`, `make dev`, plus the demo login + credentials and the URL. +- A short stack and architecture section at the bottom, for engineers. + +Keep the README tight. A coach should get the point from the images alone; the prose is +support. + +## Working discipline (these are not optional) + +- NEVER end your turn to wait for a background task. If you start something in the + background, poll its output file with Read inside the same turn until it finishes. +- Never run a foreground dev server. Never use Playwright headed mode, --ui, or + page.pause(). +- Run Playwright with explicit timeouts: --timeout=30000 --global-timeout=200000. +- Never pipe `make verify` to `tail`: the pipeline masks the exit code. Run + `make verify > /tmp/verify.log 2>&1; echo "VERIFY_EXIT=$?"` and read the log + separately. Confirm the exit code, do not eyeball the tail. +- `scripts/dev.sh` migrates AND seeds on every boot. Do not remove the seed line, CI + depends on it. +- Commit after each phase with a Conventional Commit message. Do not open PRs. +- If something is genuinely ambiguous about auth, tenancy, permissions, or data shape, + pick the option most consistent with what is already built, implement it, and note + the decision in your final summary. Do not stop to ask unless proceeding would be + destructive. + +## Report at the end + +State what shipped, anything you deliberately left out, the demo credentials, and the +exact commands I run to show this to a coach. diff --git a/docs/agent/STATE.md b/docs/agent/STATE.md index 4ddc227..d1dbff8 100644 --- a/docs/agent/STATE.md +++ b/docs/agent/STATE.md @@ -1,47 +1,110 @@ # Orchestrator state snapshot -Written 2026-07-16. Read this WITH CLAUDE.md and BACKLOG.md when resuming orchestration in a fresh session. BACKLOG.md remains the ticket status source of truth; this file carries session context that the backlog does not. +Written 2026-07-16 (session 2 handoff). Read this WITH CLAUDE.md and BACKLOG.md when resuming orchestration in a fresh session. BACKLOG.md remains the ticket status source of truth; this file carries session context the backlog does not. -## Done and merged to main (all via CI-green PRs) +## Done and merged to main (all via PRs; sessions 1 + 2) | PR | Tickets | Notes | |---|---|---| -| #1 | T-001, T-002, T-003, T-020 | Batched only because GitHub auth arrived late; per-ticket PRs from #2 on | -| #2 | T-021 | Lane graph + marking rings | -| #3 | T-004 | Scoped query layer + full doc 03 schema (20 tables) | -| #4 | T-022 | Zones + animation player + recorder. Board engine complete | -| #5 | T-010 | All Bible seed content, idempotent loader | -| #6 | T-011 | Hardened seed validator; T-010 content had zero violations | +| #1-#6 | T-001..004, T-010, T-011, T-020..022 | Session 1: platform, content, board engine | +| #7 | T-030 | Whiteboard page. Root-cause fix: View menu popover intercepted recorded drags | +| #8 | T-031 | Patterns page + the seed-on-boot infra fix (scripts/dev.sh now migrates AND seeds on every boot; CI/fresh checkouts were failing without it) | +| #9 | T-033 | Roster page. Migration 0003 players.flank | +| #10 | T-032 | Formations page + rondo map. Reuses T-031's PatternPreviewBoard | +| #11 | T-034 | Identity page. NOTE: merged before its PR check registered (process gap, fixed; post-merge CI on main was green) | +| #12 | T-040 | Permission suite (Brief §3 table, table-driven test names). Zero enforcement gaps found; purely additive | +| #13 | T-041 | Playstyle suggestion flow. No migration (table existed in 0002). Ships a name-match auto-claim of roster rows (see decisions below) | +| #14 | T-043 | Founder-directed: role-scoped join codes (migration 0004) + head-coach member management | +| #15 | T-012 | Founder-directed: identities.age_hint (migration 0005, re-pointed from 0004 at merge) | -main == integration at PR #6 plus backlog chores. Every ticket was independently re-verified by the orchestrator (make verify) before its integration merge and again after. +main == integration at PR #15 plus backlog/state chores. Every ticket was independently re-verified by the orchestrator (make verify, EXIT CODE checked) in its worktree AND after its integration merge. -## Working agreements established this session +## Founder decisions recorded 2026-07-16 (all six former open questions are CLOSED) -- Orchestrator creates AND merges PRs itself once gh pr checks is green (user instruction 2026-07-16). Permission rules for gh pr create/merge/view/checks live in .claude/settings.local.json. -- Flow per ticket: agent completes in worktree -> orchestrator reviews diff + reruns make verify -> merge to integration -> verify again -> push -> PR to main -> watch checks -> merge -> BACKLOG status -> remove worktree -> dispatch next ready tickets. -- One ticket per PR (push integration up to a cutoff commit if two tickets are locally merged). -- Worktrees at ../pop-T### with branch feat/T###-slug, based on integration. -- Every agent gets dedicated ports to avoid e2e collisions: POP_API_PORT / POP_WEB_PORT (scheme used so far: T-002 8102/5273, T-003 8103/5373, T-004 8104/5374, T-010 8110/5310, T-011 8111/5311, T-020 8120/5520, T-021 8121/5521, T-022 8122/5522, T-030 8130/5530). -- Agents must run Playwright with explicit --timeout=30000 --global-timeout=200000 and never run make dev in the foreground, never headed/--ui/page.pause (a silent 600s command kills the agent via watchdog). -- Environment: gh CLI is authenticated (BrandanBurgess); no MCP servers configured (github/render/turso absent, gh CLI substitutes for github MCP); git push works via keychain. +1. Join codes are role-scoped: player code + coach code per team; the code determines the role on join, the account role never does. Head coach (= team creator, teams.created_by) can remove members and change member roles. Implemented in T-043. +2. Join codes are coach-only in API payloads (keys absent for players). Implemented in T-043; T-040's pinned-ambiguity test was replaced with the new contract. +3. identities.age_hint: schema amended via migration; all 27 identities backfilled (editorial U9+/U11+/U13+/U15+ scale; Bible 8.2.4 gives a rule, not a table). Founder will review the hints during post-deploy content QA. Implemented in T-012. +4. Blurb spot-check sign-off: founder will do their own QA after deploy. No further action. +5. Mid-playback orientation flips: not wanted; rotation between runs is the contract. Closed. +6. Player.flank column: accepted as shipped (migration 0003). -## IN FLIGHT: T-030 whiteboard page (needs adoption by the resuming session) +Open items surfaced to the founder, awaiting reaction (NOT blocking): +- T-041's roster-row linkage is a name-match auto-claim (exactly-one unclaimed row matching display_name claims it on the player's roster fetch). Replaceable by head-coach row assignment if the founder prefers. +- T-043 member removal is a hard delete, no audit trail; removed members rejoin by code. +- T-060 deploy credentials decision: STOP and ask before deploying. -- Worktree /Users/brandanburgess/Documents/pop-T030, branch feat/T030-whiteboard-page (base 475a21b). ~29 files changed, NOTHING COMMITTED yet. -- Built so far: backend/app/routers/whiteboard.py + backend/tests/test_whiteboard_routes.py (saved patterns + boards routes via scoped layer), whiteboard page UI, nav shell, e2e/whiteboard.spec.ts; TeamDashboard.tsx renamed to TeamMeta.tsx; e2e/fixtures.ts was modified (REVIEW THIS: the clean-page contract assertions must remain intact; the agent never delivered its justification). -- Current test status (orchestrator-run): e2e/whiteboard.spec.ts = 5 passed, 1 failed. Failing: "full coach journey" on desktop at the reload-restores-state step; zone-toggle-thirds unchecked after reload (boards row round trip loses zones_visible on write or rehydrate). The agent's last hypothesis under investigation: the open view menu interferes with the drag step. -- The original agent hit 3 watchdog stalls + 1 connection drop in this session and its background task is dead. Resuming session should dispatch a FRESH screens agent to adopt the worktree: fix the failing journey in product code (not by weakening the test), full verify, commit, then the standard merge/PR loop. -- T-030 dispatch requirements (from the original brief): PNGs 01-05, 14, 34 + design README lines 39/50 + Brief step 16 + doc 03 4.2/4.3; role rules (author-stamp server-side, coach-only delete = API 403 for players); board state persists to boards row, reload restores; no localStorage; keep all existing board/lane/zone/player/recorder e2e assertions intact. +## Working agreements (carried + new this session) -## Queue after T-030 merges +- Orchestrator creates AND merges PRs once CI is green. Flow per ticket: agent completes in worktree -> orchestrator reviews diff + reruns make verify -> merge to integration -> verify again -> push -> PR to main -> confirm CI pass on the PR's CURRENT head -> squash merge -> reconcile origin/main back into integration -> BACKLOG status -> remove worktree -> dispatch next. +- One ticket per PR. Squash titles: feat(scope): title (T-###). +- SQUASH-MERGE HISTORY: after every squash merge, `git fetch origin main && git merge origin/main` into integration immediately. Skipping this causes phantom conflicts on the next PR (hit once at PR #8; the reconcile merge is always content-empty when done promptly). +- VERIFY EXIT CODES: never pipe make verify to tail (pipeline exit code masked a red verify once). Run `make verify > log 2>&1; echo "VERIFY_EXIT=$?"` and read the log tail separately. Never chain push/PR-create behind a verify. +- CI GATING: `gh pr checks N --watch` right after a push can exit "no checks reported". Poll `gh pr checks N` until an actual pass/fail registers (30s interval loop); never treat absence of checks as green. Confirm the pass is for the PR's current head SHA before merging. +- MIGRATION PARALLELISM: agents building in parallel branch off the same alembic head; whichever merges second gets its revision/down_revision re-pointed by the orchestrator at merge (T-012 became 0005 this way). Always prove the chain from zero afterward (fresh DATABASE_URL, alembic upgrade head). +- AGENT DISPATCH PROMPTS must include: explicit Playwright timeouts (--timeout=30000 --global-timeout=200000), no foreground servers, no headed/--ui/page.pause, dedicated ports, "confirm the verify EXIT CODE, not the eyeballed tail", and "NEVER end your turn to wait for a background task; poll its output file with Read inside the same turn" (three agents stalled out on this pattern before the instruction was added). +- Ports per ticket (used so far): T-030 8130/5530, T-031 8131/5531, T-032 8132/5532, T-033 8133/5533, T-034 8134/5534, T-040 8140/5540, T-041 8141/5541, T-043 8143/5543, T-012 8112/5312. Scheme: 81## API / 55## web keyed to ticket number. +- Agents that die (stream timeout / watchdog) leave work safely on disk in their worktree; resume the SAME agent via SendMessage first (context intact); dispatch a fresh agent to adopt the worktree only if resume fails repeatedly. +- Environment: gh CLI authenticated (BrandanBurgess); no MCP servers (gh substitutes for github MCP); git push via keychain. +- scripts/dev.sh migrates AND seeds on every boot (idempotent); CI and fresh checkouts depend on this. Do not remove the seed line. -1. Dispatch T-031 (patterns page) and T-033 (roster page) in parallel; T-030's nav ships inert placeholder entries, each page activates its own (trivial expected conflict, orchestrator resolves at merge). -2. Then T-032 + T-034 (deps T-031), then T-040 -> T-041 -> T-042 (collab), T-050 (phone), T-051 (hardening), T-060 (deploy; STOP for the founder's deploy credentials decision per the bootstrap prompt). +## Architecture notes for future tickets -## Open founder questions (defaults shipped, not blocking) +- Nav shell: AppShell takes an enabledKeys prop; pages activate via ENABLED_NAV_KEYS in App.tsx only. All five entries are live; AppShell.tsx should not need edits. +- Read-only board rendering: frontend/src/board/PatternPreviewBoard.tsx (autoplay, pulsing tokens, zone overlays) + pages/patternPreview.ts converters. Formations/Identity reuse it; T-042 session thumbnails should too. +- Coach-only payload pattern: split schemas (RosterOut/CoachRosterOut, TeamOut/CoachTeamOut) with response_model=None and manual model_dump, so coach-only keys are ABSENT for players, never null. Follow for receipts in T-042 (players must never see receipt data, Brief §5). +- Permission tests: backend/tests/test_permissions.py is table-driven against Brief §3, one named test per row. Two rows are @pytest.mark.skip placeholders: "suggest own playstyle" (routes now exist from T-041; un-skip in T-042) and "sessions" (T-042 implements + un-skips). +- Head-coach gate: app/deps.py require_head_coach (creator check), separate from require_role_on_team. +- Roster row <-> user linkage: name-match auto-claim in roster.py _claim_matching_row. T-042's player session views key off membership, not roster rows, but be aware of it. +- Alembic head: 0005_identity_age_hint. Chain 0001..0005 proven from zero. -1. T-003: a coach account joining another team's code becomes coach on that team; should join-by-code force player instead? -2. T-003: join codes are returned by the API to any team member (UI shows them to coaches only); Brief section 3 does not list them coach-only. Confirm before T-040 locks the pattern. -3. T-010: doc 03 section 5 identities schema has no age_hint column but Bible 8.2.4 wants an age hint per card; doc 03 won per CLAUDE.md. Amend doc 03 and add a migration? -4. T-010: founder spot-check sign-off on transformed blurbs is still open (Brief section 5 content DoD line 4; orchestrator spot-check passed). -5. T-022: mid-playback orientation flips are unsupported by design (rotation between runs works, satisfying the DoD); fine, or wanted for T-030+? +## Session 3 (build to demo, single agent on `integration`) + +Ran under docs/agent/BUILD_TO_DEMO.md, which supersedes the orchestrator +protocol above: one agent, one branch, no worktrees, no per-ticket PRs. +T-042, T-050 and T-051 all landed, plus the demo seed and the README pass. + +Decisions taken while building (none reopen a closed question): + +1. Session RECIPIENTS are fixed at send time. Receipts are written for every + player-role member when the coach sends, and a player's session list is + gated on having one of those receipt rows. Someone who joins afterwards + does not appear in an already-sent session's denominator and does not see + it. Doc 03 section 6 says receipts exist "for every recipient at send + time"; this is that read, and it keeps the x/y counter stable. +2. A player DOES see their own `you_watched` flag (it is what the Mark as + watched button reads). The coach-only line the design README draws is + "players never see each OTHER's status", so the player payload carries no + receipts list and no counter at all, only their own state. +3. A sent session is immutable (edits 409). It is the record of what the + team was actually told. +4. There is no persisted "selected formation" or "team identity" anywhere in + doc 03's schema, and Formations/Identity are board-first browse surfaces + with local selection. The demo seed therefore expresses "a shape is set" + through the one place the app does persist board state: the team's live + `boards` row, seeded with the 4-3-3 shape and two confirmed lanes. No new + table, no invented surface (Brief section 1 scope rule). +5. Search is now hyphen-insensitive across all four browse surfaces + (frontend/src/pages/search.ts). The Brief's own demo narrative types + "third man" and the content is named "Third-Man Run", so the narrative + previously found nothing. + +Gaps found and closed while checking the product against the PNGs: + +- `.ctl-ghost` was only ever defined scoped to the save bar and saved-pattern + rows, so every later reuse rendered as a default browser button. +- The four auth/onboarding screens had no styling at all (they predate the + token system). Now token-styled, no DOM change. +- The swipe-up sheets pushed the page taller instead of overlaying it as a + bottom drawer (PNG 07/38/40), which scrolled the header and the top of the + board off screen. +- The ball's gold trace while RECORDING (design README, PNG 03) was never + implemented: only playback drew a trail. Now drawn by the same overlay. +- Keystone tokens showed Chromium's blue focus ring on the pitch. +- The rondo zone card was clipped by the sheet handle. + +## Queue for the next session + +1. T-060 deploy (platform): Render + Litestream + prod Turso decision. STOP + for the founder's deploy credentials decision before any deploy action. +2. Founder review items still open from session 2 (T-041 name-match roster + claim, T-043 hard-delete member removal, identity age_hint editorial QA). diff --git a/docs/screenshots/01-whiteboard-lanes.png b/docs/screenshots/01-whiteboard-lanes.png new file mode 100644 index 0000000..be75be2 Binary files /dev/null and b/docs/screenshots/01-whiteboard-lanes.png differ diff --git a/docs/screenshots/02-whiteboard-recording.png b/docs/screenshots/02-whiteboard-recording.png new file mode 100644 index 0000000..c43d2f9 Binary files /dev/null and b/docs/screenshots/02-whiteboard-recording.png differ diff --git a/docs/screenshots/03-patterns-library-sheet.png b/docs/screenshots/03-patterns-library-sheet.png new file mode 100644 index 0000000..c0a8b75 Binary files /dev/null and b/docs/screenshots/03-patterns-library-sheet.png differ diff --git a/docs/screenshots/04-pattern-playing.png b/docs/screenshots/04-pattern-playing.png new file mode 100644 index 0000000..bf90bf7 Binary files /dev/null and b/docs/screenshots/04-pattern-playing.png differ diff --git a/docs/screenshots/05-formations-keystone.png b/docs/screenshots/05-formations-keystone.png new file mode 100644 index 0000000..1420b74 Binary files /dev/null and b/docs/screenshots/05-formations-keystone.png differ diff --git a/docs/screenshots/06-rondo-map.png b/docs/screenshots/06-rondo-map.png new file mode 100644 index 0000000..6e946ba Binary files /dev/null and b/docs/screenshots/06-rondo-map.png differ diff --git a/docs/screenshots/07-identity-playing.png b/docs/screenshots/07-identity-playing.png new file mode 100644 index 0000000..ad16175 Binary files /dev/null and b/docs/screenshots/07-identity-playing.png differ diff --git a/docs/screenshots/08-roster-fit-warning.png b/docs/screenshots/08-roster-fit-warning.png new file mode 100644 index 0000000..ef300e4 Binary files /dev/null and b/docs/screenshots/08-roster-fit-warning.png differ diff --git a/docs/screenshots/09-session-receipts.png b/docs/screenshots/09-session-receipts.png new file mode 100644 index 0000000..6f123e9 Binary files /dev/null and b/docs/screenshots/09-session-receipts.png differ diff --git a/docs/screenshots/10-phone-pattern-portrait.png b/docs/screenshots/10-phone-pattern-portrait.png new file mode 100644 index 0000000..543f6bc Binary files /dev/null and b/docs/screenshots/10-phone-pattern-portrait.png differ diff --git a/docs/screenshots/11-phone-player-session.png b/docs/screenshots/11-phone-player-session.png new file mode 100644 index 0000000..772a629 Binary files /dev/null and b/docs/screenshots/11-phone-player-session.png differ diff --git a/e2e/cross-device.spec.ts b/e2e/cross-device.spec.ts new file mode 100644 index 0000000..4b42983 --- /dev/null +++ b/e2e/cross-device.spec.ts @@ -0,0 +1,141 @@ +// Cross-device round trip (T-050, Brief section 5 Phone DoD): "All board +// surfaces render portrait per the mapping; a pattern saved on desktop +// replays correctly on phone and vice versa." +// +// e2e/recorder.spec.ts already proves the coordinate mapping survives an +// orientation flip inside ONE page (resize across the breakpoint). This +// proves the whole round trip across TWO DEVICES: a coach records on a +// 1440x900 browser, and the same coach, signed in on a separate iPhone 13 +// browser, replays it from the server and every token lands on the same +// stored model coordinates, rendered through the portrait mapping. +// +// It runs once (under the desktop project only) because it drives both +// viewports itself; running it again under the mobile project would just +// swap which context is created first. + +import { test, expect, assertCleanPage, registerCoach, signIn, watchPage } from "./fixtures"; +import type { Page } from "@playwright/test"; + +const VB = { + landscape: { width: 1050, height: 680 }, + portrait: { width: 700, height: 1000 }, +} as const; +type Orientation = keyof typeof VB; + +interface Model { + x: number; + y: number; +} + +function expectedPixel(m: Model, o: Orientation) { + const vb = VB[o]; + return o === "portrait" + ? { px: (m.y / 100) * vb.width, py: ((100 - m.x) / 100) * vb.height } + : { px: (m.x / 100) * vb.width, py: (m.y / 100) * vb.height }; +} + +function parseTranslate(transform: string) { + const m = /translate\(([-\d.]+)\s+([-\d.]+)\)/.exec(transform)!; + return { px: Number(m[1]), py: Number(m[2]) }; +} + +async function readModel(page: Page, id: string): Promise { + const el = page.locator(`[data-token-id="${id}"]`); + return { + x: Number(await el.getAttribute("data-model-x")), + y: Number(await el.getAttribute("data-model-y")), + }; +} + +/** Drags a token to a model coordinate on whichever orientation the page + * is currently rendering (the mapping is render-only, so the target pixel + * differs per device while the stored model coordinate does not). */ +async function dragTokenTo(page: Page, id: string, m: Model) { + const orientation = (await page + .locator(".board-wrap") + .getAttribute("data-orientation")) as Orientation; + const box = (await page.getByTestId("board").boundingBox())!; + const vb = VB[orientation]; + const target = expectedPixel(m, orientation); + const tokenBox = (await page.locator(`[data-token-id="${id}"]`).boundingBox())!; + await page.mouse.move(tokenBox.x + tokenBox.width / 2, tokenBox.y + tokenBox.height / 2); + await page.mouse.down(); + await page.mouse.move( + box.x + (target.px / vb.width) * box.width, + box.y + (target.py / vb.height) * box.height, + { steps: 12 } + ); + await page.mouse.up(); +} + +test.describe("cross-device: record on desktop, replay on a phone", () => { + test("token positions round-trip through the server, rendered portrait", async ({ + browser, + page, + issues, + viewport, + }) => { + test.skip((viewport?.width ?? 1440) <= 700, "this journey drives both viewports itself"); + + // --- Device 1: the coach's laptop -------------------------------------- + const { email } = await registerCoach(page, { teamName: "Cross Device FC" }); + await expect(page.locator(".board-wrap")).toHaveAttribute("data-orientation", "landscape"); + + await page.getByTestId("record").click(); + await dragTokenTo(page, "home-2", { x: 46, y: 14 }); + await dragTokenTo(page, "home-7", { x: 74, y: 22 }); + await dragTokenTo(page, "ball", { x: 46, y: 14 }); + const recorded = { + "home-2": await readModel(page, "home-2"), + "home-7": await readModel(page, "home-7"), + ball: await readModel(page, "ball"), + }; + await page.getByTestId("stop-record").click(); + await page.getByTestId("record-name").fill("Wide overload, right"); + await page.getByTestId("save-pattern").click(); + await expect( + page.getByTestId("saved-pattern").filter({ hasText: "Wide overload, right" }) + ).toHaveCount(1); + + // --- Device 2: the same coach's phone ----------------------------------- + const phoneContext = await browser.newContext({ + viewport: { width: 390, height: 844 }, + hasTouch: true, + isMobile: true, + }); + const phone = await phoneContext.newPage(); + watchPage(phone, issues); + await signIn(phone, email); + await expect(phone.locator(".board-wrap")).toHaveAttribute("data-orientation", "portrait"); + + const savedOnPhone = phone + .getByTestId("saved-pattern") + .filter({ hasText: "Wide overload, right" }); + await expect(savedOnPhone).toHaveCount(1); + await savedOnPhone.getByRole("button", { name: "Replay" }).click(); + await expect(phone.locator(".board-root")).toHaveAttribute("data-playing", "false", { + timeout: 20000, + }); + + for (const [id, want] of Object.entries(recorded)) { + const got = await readModel(phone, id); + // Model coordinates are device-independent (CLAUDE.md rule 8). + expect(got.x, `${id} model x`).toBeCloseTo(want.x, 0); + expect(got.y, `${id} model y`).toBeCloseTo(want.y, 0); + + // And the phone RENDERS them through the portrait mapping + // (left = y, top = 100 - x), which is the half of the contract a + // model-coordinate check alone would not catch. + const expected = expectedPixel(got, "portrait"); + const drawn = parseTranslate( + (await phone.locator(`[data-token-id="${id}"]`).getAttribute("transform"))! + ); + expect(drawn.px, `${id} rendered px`).toBeCloseTo(expected.px, 0); + expect(drawn.py, `${id} rendered py`).toBeCloseTo(expected.py, 0); + } + + await assertCleanPage(phone, issues); + await assertCleanPage(page, issues); + await phoneContext.close(); + }); +}); diff --git a/e2e/demo-path.spec.ts b/e2e/demo-path.spec.ts new file mode 100644 index 0000000..ce88288 --- /dev/null +++ b/e2e/demo-path.spec.ts @@ -0,0 +1,232 @@ +// The demo path (T-051, Brief section 6): the acceptance narrative, start +// to finish, as one journey. Quoting the Brief verbatim, because this file +// exists to be checkable against it line by line: +// +// "coach signs up, creates a team, adds six players with roles and +// sliders; opens Formations, loads 4-3-3, taps the pivot keystone; +// toggles the Rondo Map and taps the first-line zone; opens Patterns, +// searches 'third man', plays A5 on the board; opens the whiteboard, +// drags a build-out, records it, saves as 'Our build-out vs press'; +// creates a session with A5 plus the recording and a note, sends it; +// switches to a player account on a phone, opens the session, watches +// A5 portrait, marks watched; back on the coach account the receipt +// counter reads 1 of N." +// +// It runs under BOTH Playwright projects, so the coach half runs once at +// 1440x900 and once at 390x844. The player half always runs in its own +// 390x844 context ("switches to a player account on a phone"), which is +// also what makes "watches A5 portrait" a real assertion rather than an +// accident of which project happens to be running. + +import { + test, + expect, + assertCleanPage, + registerCoach, + registerPlayer, + watchPage, +} from "./fixtures"; +import type { Locator, Page } from "@playwright/test"; + +// Chromium's mobile emulation shrinks the visual viewport once a text +// input is focused and never restores it, so a coordinate-based click can +// land on the wrong element on a form-heavy page below the fold. Same +// mitigation e2e/roster.spec.ts already documents and uses. +async function tap(locator: Locator) { + await locator.scrollIntoViewIfNeeded(); + await locator.dispatchEvent("click"); +} + +async function setSlider(page: Page, key: string, value: number) { + await page.getByTestId(`player-attr-${key}`).evaluate((el, v) => { + const input = el as HTMLInputElement; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + setter.call(input, String(v)); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }, value); +} + +async function addPlayer( + page: Page, + opts: { + name: string; + jersey: string; + roleCode: string; + flank: "left" | "right" | "center"; + awr: "low" | "med" | "high"; + dwr: "low" | "med" | "high"; + pace: number; + } +) { + await tap(page.getByTestId("roster-add-player")); + await page.getByTestId("player-name").fill(opts.name); + await page.getByTestId("player-jersey").fill(opts.jersey); + await page.getByTestId("player-role").selectOption(opts.roleCode); + await page.getByTestId("player-flank").selectOption(opts.flank); + await page.getByTestId("player-awr").selectOption(opts.awr); + await page.getByTestId("player-dwr").selectOption(opts.dwr); + await setSlider(page, "pace", opts.pace); + await tap(page.getByTestId("player-save")); + await expect(page.getByTestId("player-save")).toHaveCount(0); +} + +/** Drags a token by a pixel offset on whichever orientation is rendering. + * The demo narrative's "drags a build-out" is about the gesture and what + * it records, not about landing on an exact coordinate, so this stays + * deliberately simple; e2e/cross-device.spec.ts is where exact model + * coordinates are pinned. */ +async function dragToken(page: Page, id: string, dx: number, dy: number) { + const box = (await page.locator(`[data-token-id="${id}"]`).boundingBox())!; + const from = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(from.x + dx, from.y + dy, { steps: 12 }); + await page.mouse.up(); +} + +test.describe("demo path: the Brief section 6 acceptance narrative", () => { + test("runs end to end, coach on this viewport and player on a phone", async ({ + browser, + page, + issues, + }) => { + // --- "coach signs up, creates a team" --------------------------------- + const { joinCode } = await registerCoach(page, { + displayName: "Coach Demo", + teamName: "Demo Path FC", + }); + await expect(page.getByTestId("board")).toBeVisible(); + + // The player joins now, before the send: receipts are written for every + // recipient at send time (doc 03 section 6), so the recipient has to be + // on the team by then. On their own phone, per the narrative. + const phoneContext = await browser.newContext({ + viewport: { width: 390, height: 844 }, + hasTouch: true, + isMobile: true, + }); + const playerPage = await phoneContext.newPage(); + watchPage(playerPage, issues); + await registerPlayer(playerPage, joinCode, { displayName: "Player Demo" }); + + // --- "adds six players with roles and sliders" ------------------------- + await tap(page.getByTestId("nav-roster")); + const squad: Parameters[1][] = [ + { name: "Ben W.", jersey: "1", roleCode: "sweeper_keeper", flank: "center", awr: "med", dwr: "high", pace: 2 }, + { name: "Marco S.", jersey: "2", roleCode: "overlapping_fb", flank: "right", awr: "high", dwr: "high", pace: 5 }, + { name: "Dev P.", jersey: "5", roleCode: "ball_playing_cb", flank: "center", awr: "med", dwr: "high", pace: 3 }, + { name: "Nils B.", jersey: "4", roleCode: "single_pivot", flank: "center", awr: "med", dwr: "high", pace: 2 }, + { name: "Jordan T.", jersey: "7", roleCode: "touchline_winger", flank: "right", awr: "high", dwr: "low", pace: 5 }, + { name: "Sam O.", jersey: "9", roleCode: "runner_in_behind", flank: "center", awr: "high", dwr: "low", pace: 5 }, + ]; + for (const player of squad) await addPlayer(page, player); + await expect(page.getByTestId(/roster-row-\d+/)).toHaveCount(6); + // Marco (right back, AWR high) behind Jordan (right wing, AWR high / + // DWR low) is the designed double-exposure pair, and it is coach-only. + await expect(page.getByTestId("fit-warning-right")).toBeVisible(); + + // --- "opens Formations, loads 4-3-3, taps the pivot keystone" ---------- + await tap(page.getByTestId("nav-formations")); + const formationsHandle = page.getByTestId("formations-sheet-handle"); + if ((await formationsHandle.getAttribute("aria-expanded")) !== "true") { + await tap(formationsHandle); + } + await page.getByTestId("formations-search").fill("4-3-3"); + await tap(page.getByTestId("formations-tile").first()); + await expect(page.getByTestId("formations-meta-bar")).toContainText("4-3-3"); + // The pivot: slot "six", the single pivot keystone. + await page.locator('[data-token-id="six"]').click(); + await expect(page.getByTestId("formations-keycard-title")).toHaveText("The 6 (single pivot)"); + await tap(page.getByTestId("formations-keycard-close")); + + // --- "toggles the Rondo Map and taps the first-line zone" ------------- + await tap(page.getByTestId("formations-rondo-toggle")); + await expect(page.getByTestId("rondo-zone-layer")).toBeVisible(); + await page.locator('[data-zone-key="first_line"]').click(); + await expect(page.getByTestId("formations-zone-card")).toBeVisible(); + await expect(page.getByTestId("formations-zone-title")).toContainText("4v2"); + await tap(page.getByTestId("formations-rondo-active-toggle")); + + // --- "opens Patterns, searches 'third man', plays A5 on the board" ---- + await tap(page.getByTestId("nav-patterns")); + const patternsHandle = page.getByTestId("patterns-sheet-handle"); + if ((await patternsHandle.getAttribute("aria-expanded")) !== "true") { + await tap(patternsHandle); + } + // Typed the way a coach says it, unhyphenated (pages/search.ts). + await page.getByTestId("patterns-search").fill("third man"); + await expect(page.getByTestId("patterns-tile")).toHaveCount(1); + await tap(page.getByTestId("patterns-tile")); + await expect(page.getByTestId("patterns-meta-title")).toContainText("A5"); + await expect(page.getByTestId("patterns-playing-pill")).toBeVisible(); + + // --- "opens the whiteboard, drags a build-out, records it, saves it" -- + await tap(page.getByTestId("nav-whiteboard")); + await expect(page.getByTestId("board")).toBeVisible(); + await tap(page.getByTestId("record")); + await expect(page.getByTestId("record-banner")).toBeVisible(); + await dragToken(page, "home-5", 60, -20); // centre-back steps out + await dragToken(page, "home-2", 50, 10); // right back pushes on + await dragToken(page, "ball", 40, -30); // the ball follows the pass + await tap(page.getByTestId("stop-record")); + await page.getByTestId("record-name").fill("Our build-out vs press"); + await tap(page.getByTestId("save-pattern")); + const saved = page.getByTestId("saved-pattern").filter({ hasText: "Our build-out vs press" }); + await expect(saved).toHaveCount(1); + await expect(saved.getByTestId("saved-pattern-author")).toHaveText("COACH"); + + // --- "creates a session with A5 plus the recording and a note, sends" - + await tap(page.getByTestId("nav-sessions")); + await page.getByTestId("sessions-new-title").fill("Tuesday, wide overloads"); + await tap(page.getByTestId("sessions-create")); + await page.getByTestId("session-note-input").fill("Watch both before training on Tuesday."); + await tap(page.getByTestId("session-note-save")); + + await tap(page.getByTestId("session-add-item")); + await page.getByTestId("session-picker-search").fill("third man"); + await expect(page.getByTestId("session-picker-row")).toHaveCount(1); + await tap(page.getByTestId("session-picker-row")); + await tap(page.getByTestId("session-picker-tab-saved")); + await page.getByTestId("session-picker-search").fill("build-out"); + await tap(page.getByTestId("session-picker-row")); + await expect(page.getByTestId("session-item")).toHaveCount(2); + + await tap(page.getByTestId("session-send")); + await expect(page.getByTestId("session-sent-pill")).toBeVisible(); + await expect(page.getByTestId("session-viewed-counter")).toHaveText("0 of 1 viewed"); + + // --- "switches to a player account on a phone, opens the session, + // watches A5 portrait, marks watched" ------------------------------ + await playerPage.getByTestId("nav-sessions").click(); + await expect(playerPage.getByTestId("session-detail-title")).toHaveText( + "Tuesday, wide overloads" + ); + await expect(playerPage.getByTestId("session-note")).toContainText("Watch both before training"); + // Read-only: no receipt data reaches a player at all. + await expect(playerPage.getByTestId("session-receipt")).toHaveCount(0); + await expect(playerPage.getByTestId("session-viewed-counter")).toHaveCount(0); + + const watchA5 = playerPage + .getByTestId("session-item") + .filter({ hasText: "Third-Man Run" }) + .getByTestId("session-watch"); + await watchA5.click(); + await expect(playerPage.getByTestId("session-watch-title")).toContainText("Third-Man Run"); + await expect(playerPage.getByTestId("pattern-board")).toBeVisible(); + await expect(playerPage.locator(".board-wrap")).toHaveAttribute("data-orientation", "portrait"); + await playerPage.getByTestId("session-watch-back").click(); + await playerPage.getByTestId("session-mark-watched").click(); + await expect(playerPage.getByTestId("session-watched-state")).toBeVisible(); + + // --- "back on the coach account the receipt counter reads 1 of N" ----- + await page.reload(); + await tap(page.getByTestId("nav-sessions")); + await expect(page.getByTestId("session-viewed-counter")).toHaveText("1 of 1 viewed"); + await expect(page.getByTestId("session-receipt-state")).toHaveText("Viewed"); + + await assertCleanPage(playerPage, issues); + await assertCleanPage(page, issues); + await phoneContext.close(); + }); +}); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index c20c676..38f9b83 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -4,12 +4,24 @@ import { test as base, expect, Page } from "@playwright/test"; type Issues = { consoleErrors: string[]; failedRequests: string[]; serverErrors: string[] }; +/** Wires the clean-page listeners onto any page, not just the fixture's + * own. Journeys that need a SECOND account signed in at the same time + * (sessions, the demo path) open a second browser context, and its page + * would otherwise report console errors and 5xx responses that nothing + * ever asserts on. Call this right after creating that page. */ +export function watchPage(page: Page, issues: Issues): void { + page.on("console", (m) => m.type() === "error" && issues.consoleErrors.push(m.text())); + page.on("requestfailed", (r) => issues.failedRequests.push(`${r.method()} ${r.url()}`)); + page.on( + "response", + (r) => r.status() >= 500 && issues.serverErrors.push(`${r.status()} ${r.url()}`) + ); +} + export const test = base.extend<{ issues: Issues }>({ issues: async ({ page }, use) => { const issues: Issues = { consoleErrors: [], failedRequests: [], serverErrors: [] }; - page.on("console", (m) => m.type() === "error" && issues.consoleErrors.push(m.text())); - page.on("requestfailed", (r) => issues.failedRequests.push(`${r.method()} ${r.url()}`)); - page.on("response", (r) => r.status() >= 500 && issues.serverErrors.push(`${r.status()} ${r.url()}`)); + watchPage(page, issues); await use(issues); }, }); @@ -108,6 +120,20 @@ export async function registerAndJoinTeam( return { email }; } +/** Signs an EXISTING account in on a fresh page (a second browser context, + * a second device). Pairs with registerCoach/registerPlayer, which return + * the email they created: the cross-device journey records on one device + * and replays on another as the SAME coach, which needs a real sign-in + * rather than another registration. */ +export async function signIn(page: Page, email: string): Promise { + await page.goto("/"); + await page.getByRole("button", { name: /Already have an account/ }).click(); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Password").fill(PASSWORD); + await page.getByRole("button", { name: "Log in", exact: true }).click(); + await expect(page.getByTestId("board")).toBeVisible(); +} + /** Flips the board's rendered orientation by resizing the viewport across * the phone/desktop breakpoint (design README: portrait on phone, * landscape otherwise). Replaces the old dev-only "Rotate board" toggle diff --git a/e2e/phone.spec.ts b/e2e/phone.spec.ts new file mode 100644 index 0000000..428569c --- /dev/null +++ b/e2e/phone.spec.ts @@ -0,0 +1,142 @@ +// Phone pass (T-050, Brief step 24, PNG 14-20, 23, 28, 34-36, 43-45). +// +// The rest of the suite already runs every journey under BOTH Playwright +// projects, and assertCleanPage fails any page that overflows horizontally, +// so this file is not another copy of those journeys. It pins the things +// that are specifically PHONE contracts and that no other spec asserts: +// +// - the sidebar collapses to the 52px icon rail (labels gone, icons kept) +// - every one of the six pages renders portrait boards with no overflow +// - the swipe-up sheets and the board's view menu open AND close by tap +// - the player's session Watch view plays portrait on a phone +// +// It runs only under the mobile project; on desktop each test skips, since +// every assertion here is about the phone breakpoint. + +import { test, expect, assertCleanPage, registerCoach, registerPlayer, watchPage } from "./fixtures"; +import type { Page } from "@playwright/test"; + +const PAGES = ["whiteboard", "patterns", "sessions", "formations", "roster", "identity"] as const; + +test.beforeEach(({ viewport }) => { + test.skip((viewport?.width ?? 1440) > 700, "phone-breakpoint contracts only"); +}); + +async function hasOverflow(page: Page): Promise { + return page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth); +} + +test.describe("phone: icon rail, portrait boards, no overflow", () => { + test("every page fits the iPhone 13 frame", async ({ page, issues }) => { + await registerCoach(page, { teamName: "Phone FC" }); + + // The rail: icons only, no labels, and narrow (design README: "sidebar + // collapses to a 52px vertical icon rail"). + const rail = page.locator(".app-sidebar"); + const railBox = (await rail.boundingBox())!; + expect(railBox.width).toBeLessThanOrEqual(56); + await expect(page.locator(".app-nav-label").first()).toBeHidden(); + for (const key of PAGES) { + await expect(page.getByTestId(`nav-${key}`)).toBeVisible(); + } + + for (const key of PAGES) { + await page.getByTestId(`nav-${key}`).click(); + await expect(page.getByTestId(`nav-${key}`)).toHaveAttribute("aria-current", "page"); + expect(await hasOverflow(page), `${key} overflows horizontally`).toBe(false); + + // Every page that renders a board renders it PORTRAIT here. Roster is + // the one page in the nav with no board at all. + const board = page.locator(".board-wrap"); + if ((await board.count()) > 0) { + await expect(board.first()).toHaveAttribute("data-orientation", "portrait"); + const box = (await board.first().boundingBox())!; + // 7:10 portrait pitch (700x1000 viewBox), taller than it is wide. + expect(box.height).toBeGreaterThan(box.width); + expect(box.width).toBeLessThanOrEqual(390); + } + } + + await assertCleanPage(page, issues); + }); +}); + +test.describe("phone: sheets and menus are reachable and dismissible by touch", () => { + test("each swipe-up sheet and the board view menu opens and closes on tap", async ({ + page, + issues, + }) => { + await registerCoach(page, { teamName: "Sheets FC" }); + + const sheets: [(typeof PAGES)[number], string, string][] = [ + ["patterns", "patterns-sheet-handle", "patterns-sheet-body"], + ["formations", "formations-sheet-handle", "formations-sheet-body"], + ["identity", "identity-sheet-handle", "identity-sheet-body"], + ]; + + for (const [navKey, handle, body] of sheets) { + await page.getByTestId(`nav-${navKey}`).click(); + const handleEl = page.getByTestId(handle); + await expect(handleEl).toBeVisible(); + // tap(), not click(): this is the touch path a phone actually takes. + await handleEl.tap(); + await expect(page.getByTestId(body)).toBeVisible(); + expect(await hasOverflow(page), `${navKey} sheet overflows`).toBe(false); + await handleEl.tap(); + await expect(page.getByTestId(body)).toHaveCount(0); + } + + // The board's view menu is pinned to the viewport top on phone (it + // would otherwise blanket a narrow portrait pitch); it must still open + // and close by tap and never push the page sideways. + await page.getByTestId("nav-whiteboard").click(); + await page.getByTestId("view-menu").tap(); + await expect(page.getByTestId("zone-toggle-thirds")).toBeVisible(); + expect(await hasOverflow(page)).toBe(false); + await page.getByTestId("zone-toggle-thirds").tap(); + await expect(page.getByTestId("zone-toggle-thirds")).toBeChecked(); + await page.getByTestId("view-menu").tap(); + await expect(page.getByTestId("zone-toggle-thirds")).toHaveCount(0); + + // The head coach's Manage team panel is the other overlay on this + // screen, and it is anchored near the right edge. + await page.getByTestId("team-members-toggle").tap(); + await expect(page.getByTestId("team-members-panel")).toBeVisible(); + expect(await hasOverflow(page)).toBe(false); + await page.getByTestId("team-members-toggle").tap(); + await expect(page.getByTestId("team-members-panel")).toHaveCount(0); + + await assertCleanPage(page, issues); + }); +}); + +test.describe("phone: a player watches a session item portrait", () => { + test("the Watch deep-link plays on a portrait board", async ({ browser, page, issues }) => { + const { joinCode } = await registerCoach(page, { teamName: "Watch FC" }); + const playerContext = await browser.newContext({ viewport: page.viewportSize() ?? undefined }); + const playerPage = await playerContext.newPage(); + watchPage(playerPage, issues); + await registerPlayer(playerPage, joinCode); + + await page.getByTestId("nav-sessions").click(); + await page.getByTestId("sessions-new-title").fill("Phone session"); + await page.getByTestId("sessions-create").click(); + await page.getByTestId("session-add-item").tap(); + await page.getByTestId("session-picker-search").fill("third-man"); + await page.getByTestId("session-picker-row").tap(); + await page.getByTestId("session-send").tap(); + await expect(page.getByTestId("session-sent-pill")).toBeVisible(); + + await playerPage.getByTestId("nav-sessions").click(); + await playerPage.getByTestId("session-watch").first().tap(); + await expect(playerPage.getByTestId("pattern-board")).toBeVisible(); + await expect(playerPage.locator(".board-wrap")).toHaveAttribute("data-orientation", "portrait"); + expect(await hasOverflow(playerPage)).toBe(false); + await playerPage.getByTestId("session-watch-back").tap(); + await playerPage.getByTestId("session-mark-watched").tap(); + await expect(playerPage.getByTestId("session-watched-state")).toBeVisible(); + + await assertCleanPage(playerPage, issues); + await playerContext.close(); + }); +}); diff --git a/e2e/recorder.spec.ts b/e2e/recorder.spec.ts index 7316654..0fa0bd4 100644 --- a/e2e/recorder.spec.ts +++ b/e2e/recorder.spec.ts @@ -170,3 +170,38 @@ test("record in one orientation, replay correctly in the other", async ({ page, await assertCleanPage(page, issues); }); + +// The ball's gold trace while a take is being recorded (design README: +// "Record (red dot): captures timestamped keyframes of every drag; ball +// leaves a gold trace", PNG 03). It uses the same AnimationOverlay +// polyline the playback trail uses, so this checks the one thing that +// distinguishes it: the trace is being laid down DURING the recording, +// before anything is saved or replayed. +test("the ball leaves a gold trace while recording", async ({ page, issues }) => { + await registerCoach(page); + const trail = page.getByTestId("ball-trail"); + + // Nothing drawn on a fresh board (the attribute is not set at all until + // the overlay first renders). + expect(await trail.getAttribute("points")).toBeFalsy(); + + await page.getByTestId("record").click(); + await dragTokenTo(page, "ball", { x: 40, y: 30 }); + await dragTokenTo(page, "ball", { x: 65, y: 20 }); + + const points = (await trail.getAttribute("points")) ?? ""; + expect(points.trim().length, "trace has geometry").toBeGreaterThan(0); + expect(points.trim().split(/\s+/).length, "trace has several waypoints").toBeGreaterThan(2); + + // Dragging a PLAYER adds nothing to the trace: it is the ball's path. + const before = await trail.getAttribute("points"); + await dragTokenTo(page, "home-7", { x: 78, y: 26 }); + await expect(trail).toHaveAttribute("points", before!); + + // Discarding the take clears it, so the next one starts on a clean pitch. + await page.getByTestId("stop-record").click(); + await page.getByRole("button", { name: "Discard" }).click(); + await expect(trail).toHaveAttribute("points", ""); + + await assertCleanPage(page, issues); +}); diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts new file mode 100644 index 0000000..ec5a341 --- /dev/null +++ b/e2e/screenshots.spec.ts @@ -0,0 +1,232 @@ +// Screenshot capture for docs/screenshots/ and the README. +// +// This is a CAPTURE SCRIPT, not a test. It asserts almost nothing: it +// drives the real UI against the `make demo` database and writes PNGs. +// Because it lives in the Playwright testDir alongside the real journeys, +// it skips unless POP_SCREENSHOTS=1, so `make e2e` and `make verify` never +// run it and never rewrite the images. Run it with `make screenshots`, +// which reseeds the demo database first. +// +// Credentials and content come from scripts/seed_demo.py; if that file's +// constants change, change them here too. +// +// Theme: the "pitch" theme (deep pitch green, mown stripes, trophy gold) +// rather than "dark". Every shot here is a football board, and the flat +// grey dark theme with its blue accent reads as a generic dashboard +// instead of a pitch. The three themes all ship and all work; this is a +// marketing choice about which one sells the product at a glance. +// +// Timing: animated surfaces are captured MID-motion, not at frame zero. +// Playback runs at a known duration per surface, so each capture waits a +// beat into the run rather than screenshotting the first frame. + +import { test, expect } from "./fixtures"; +import type { Page } from "@playwright/test"; + +const OUT = "docs/screenshots"; +const COACH_EMAIL = "coach@example.com"; +const PLAYER_EMAIL = "player@example.com"; +const PASSWORD = "demo-pass-2026"; +const SESSION_TITLE = "Tuesday, wide overloads"; + +const DESKTOP = { width: 1440, height: 900 }; +const PHONE = { width: 390, height: 844 }; + +test.describe.configure({ mode: "serial" }); + +test.beforeEach(() => { + test.skip( + process.env.POP_SCREENSHOTS !== "1", + "capture script, not a test: run `make screenshots`" + ); +}); + +async function signInDemo(page: Page, email: string) { + await page.goto("/"); + await page.getByRole("button", { name: /Already have an account/ }).click(); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Password").fill(PASSWORD); + await page.getByRole("button", { name: "Log in", exact: true }).click(); + await expect(page.getByTestId("board")).toBeVisible(); + // Pitch theme, and give the self-hosted fonts a moment to paint. + await page.getByTestId("theme-switch-pitch").click(); + await page.evaluate(() => document.fonts.ready); +} + +async function shot(page: Page, name: string) { + await page.screenshot({ path: `${OUT}/${name}.png` }); +} + +async function tokenCenter(page: Page, id: string) { + const box = (await page.locator(`[data-token-id="${id}"]`).boundingBox())!; + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +} + +/** Landscape model coordinate to a client point on the rendered board, so + * these captures can place a token exactly where the shot needs it rather + * than nudging by guessed pixel offsets. */ +async function modelToClient(page: Page, m: { x: number; y: number }) { + const box = (await page.getByTestId("board").boundingBox())!; + return { x: box.x + (m.x / 100) * box.width, y: box.y + (m.y / 100) * box.height }; +} + +async function dragTo(page: Page, id: string, m: { x: number; y: number }) { + const from = await tokenCenter(page, id); + const to = await modelToClient(page, m); + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(to.x, to.y, { steps: 14 }); + await page.mouse.up(); +} + +test("captures the desktop set", async ({ browser }) => { + const context = await browser.newContext({ viewport: DESKTOP }); + const page = await context.newPage(); + await signInDemo(page, COACH_EMAIL); + + // --- 01: the whiteboard with the lane graph live, mid-drag ------------- + // The three lane states have to read together in one frame: + // confirmed (solid bright gold): the demo seed ships home-4 to home-5, + // and this shot leaves that line clear so it stays gold. + // blocked (dashed red plus the red interception dot): one opponent is + // parked on the home-2 to home-8 line, and only that line. + // suggested (dashed dim gold): everything else in range. + // An opponent dropped in the middle of the pitch blocks nearly every line + // at once and the whole board goes red, which is the opposite of the + // point, so this places it precisely rather than by pixel nudge. + await dragTo(page, "away-8", { x: 32, y: 23 }); + await expect(page.getByTestId("board-save-status")).toHaveText("All changes saved"); + + // Now hold a second drag mid-flight, so the graph is visibly recomputing + // rather than settled. + const winger = await tokenCenter(page, "home-7"); + const target = await modelToClient(page, { x: 60, y: 26 }); + await page.mouse.move(winger.x, winger.y); + await page.mouse.down(); + await page.mouse.move(target.x, target.y, { steps: 16 }); + await page.waitForTimeout(120); + await shot(page, "01-whiteboard-lanes"); + await page.mouse.up(); + + // --- 02: the recording UI with the ball's gold trace visible ------------ + await page.getByTestId("record").click(); + await expect(page.getByTestId("record-banner")).toBeVisible(); + // A build-out: the centre-back steps out, the fullback goes high, and the + // ball travels keeper to centre-back to fullback, laying a gold trace + // behind it (design README, PNG 03). + await dragTo(page, "home-5", { x: 30, y: 34 }); + await dragTo(page, "home-2", { x: 46, y: 12 }); + const ball = await tokenCenter(page, "ball"); + const via = await modelToClient(page, { x: 30, y: 34 }); + const end = await modelToClient(page, { x: 46, y: 12 }); + await page.mouse.move(ball.x, ball.y); + await page.mouse.down(); + await page.mouse.move(via.x, via.y, { steps: 18 }); + await page.mouse.move(end.x, end.y, { steps: 18 }); + await page.waitForTimeout(120); + await shot(page, "02-whiteboard-recording"); + await page.mouse.up(); + await page.getByTestId("stop-record").click(); + await page.getByRole("button", { name: "Discard" }).click(); + + // --- 03: the Patterns library sheet, chips and search ------------------ + await page.getByTestId("nav-patterns").click(); + await page.getByTestId("patterns-sheet-handle").click(); + await expect(page.getByTestId("patterns-sheet-body")).toBeVisible(); + await page.getByTestId("patterns-chip-combination").click(); + await page.waitForTimeout(150); + await shot(page, "03-patterns-library-sheet"); + + // --- 04: a pattern mid-playback ---------------------------------------- + await page.getByTestId("patterns-chip-all").click(); + await page.getByTestId("patterns-search").fill("third man"); + await page.getByTestId("patterns-tile").first().click(); + await expect(page.getByTestId("patterns-playing-pill")).toBeVisible(); + // A5 runs three declarative steps at 900ms each: land in the middle of + // the second, where the ball is in flight and the trail has built up. + await page.waitForTimeout(1400); + await shot(page, "04-pattern-playing"); + + // --- 05: Formations, 4-3-3, keystone pulsing with its keycard ---------- + await page.getByTestId("nav-formations").click(); + await expect(page.getByTestId("formations-meta-bar")).toContainText("4-3-3"); + await page.locator('[data-token-id="six"]').click(); + await expect(page.getByTestId("formations-keycard")).toBeVisible(); + await page.waitForTimeout(400); // let the keystone pulse reach full glow + await shot(page, "05-formations-keystone"); + await page.getByTestId("formations-keycard-close").click(); + + // --- 06: the Rondo Map with a zone selected ---------------------------- + await page.getByTestId("formations-rondo-toggle").click(); + await expect(page.getByTestId("rondo-zone-layer")).toBeVisible(); + await page.locator('[data-zone-key="first_line"]').click(); + await expect(page.getByTestId("formations-zone-card")).toBeVisible(); + await page.waitForTimeout(150); + await shot(page, "06-rondo-map"); + + // --- 07: Identity, a reference team's signature idea playing ----------- + await page.getByTestId("nav-identity").click(); + await page.getByTestId("identity-sheet-handle").click(); + await expect(page.getByTestId("identity-sheet-body")).toBeVisible(); + await page.getByTestId("identity-search").fill("Barcelona"); + await page.getByTestId("identity-tile").first().click(); + await expect(page.getByTestId("identity-meta-bar")).toBeVisible(); + await page.getByTestId("identity-details-toggle").click(); + await page.waitForTimeout(1300); // mid-sequence, ball in flight + await shot(page, "07-identity-playing"); + + // --- 08: the Roster with the coach-only fit warning -------------------- + await page.getByTestId("nav-roster").click(); + await expect(page.getByTestId("fit-warning-right")).toBeVisible(); + await page.getByTestId(/roster-row-\d+/).filter({ hasText: "Jordan Tavares" }).click(); + await page.waitForTimeout(150); + await shot(page, "08-roster-fit-warning"); + + // --- 09: the session receipts view ------------------------------------- + // The rail opens on the newest session, which the demo seed leaves as a + // draft (so a coach has something to send live); this shot wants the + // SENT one, with its counter and per-player receipts. + await page.getByTestId("nav-sessions").click(); + await page.getByTestId("session-list-item").filter({ hasText: SESSION_TITLE }).click(); + await expect(page.getByTestId("session-sent-pill")).toBeVisible(); + await page.waitForTimeout(150); + await shot(page, "09-session-receipts"); + + await context.close(); +}); + +test("captures the phone set", async ({ browser }) => { + const context = await browser.newContext({ + viewport: PHONE, + hasTouch: true, + isMobile: true, + deviceScaleFactor: 2, + }); + const page = await context.newPage(); + + // --- 10: a pattern playing portrait on a phone ------------------------- + await signInDemo(page, COACH_EMAIL); + await page.getByTestId("nav-patterns").click(); + await page.getByTestId("patterns-sheet-handle").tap(); + await page.getByTestId("patterns-search").fill("third man"); + await page.getByTestId("patterns-tile").first().tap(); + await expect(page.locator(".board-wrap")).toHaveAttribute("data-orientation", "portrait"); + await page.waitForTimeout(1400); + await shot(page, "10-phone-pattern-portrait"); + await context.close(); + + // --- 11: the player's session view ------------------------------------- + const playerContext = await browser.newContext({ + viewport: PHONE, + hasTouch: true, + isMobile: true, + deviceScaleFactor: 2, + }); + const playerPage = await playerContext.newPage(); + await signInDemo(playerPage, PLAYER_EMAIL); + await playerPage.getByTestId("nav-sessions").tap(); + await expect(playerPage.getByTestId("session-detail-title")).toBeVisible(); + await playerPage.waitForTimeout(150); + await shot(playerPage, "11-phone-player-session"); + await playerContext.close(); +}); diff --git a/e2e/sessions.spec.ts b/e2e/sessions.spec.ts new file mode 100644 index 0000000..21aaa89 --- /dev/null +++ b/e2e/sessions.spec.ts @@ -0,0 +1,192 @@ +// Sessions journey (T-042, Brief step 23, PNG 21-23, 26, 28). Runs under +// both Playwright projects (desktop landscape, iPhone 13 portrait) and +// covers the "Roles and sessions" DoD line from Brief section 5: +// +// "Session receipts: Mark as watched increments the coach's x/y counter +// and flips that player's row to Viewed; players never see receipt data +// in any payload." +// +// plus the design README's Sessions spec: draft with reorder/remove items, +// "+ Add from library" picker with mini-board thumbnails over both presets +// and My patterns, coach note, send, gold SENT pill with the viewed +// counter, and the player's read-only view with Watch deep-links. + +import { + test, + expect, + assertCleanPage, + registerCoach, + registerPlayer, + watchPage, +} from "./fixtures"; +import type { Page } from "@playwright/test"; + +async function recordAPattern(page: Page, name: string) { + await page.getByTestId("record").click(); + const token = page.locator('[data-token-id="home-4"]'); + const box = (await token.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 90, box.y + box.height / 2 - 40, { steps: 10 }); + await page.mouse.up(); + await page.getByTestId("stop-record").click(); + await page.getByTestId("record-name").fill(name); + await page.getByTestId("save-pattern").click(); + await expect(page.getByTestId("saved-pattern").filter({ hasText: name })).toHaveCount(1); +} + +test.describe("sessions: coach builds and sends, player watches and marks watched", () => { + test("the full classroom loop", async ({ browser, page, issues }) => { + // The coach records something of their own first, so the picker has + // both sources to offer (presets and My patterns). + const { joinCode } = await registerCoach(page, { teamName: "Sessions FC" }); + await recordAPattern(page, "Our build-out vs press"); + + // A player must be on the team before the send: receipts are written + // for every recipient AT send time (doc 03 section 6). + const playerContext = await browser.newContext({ + viewport: page.viewportSize() ?? undefined, + }); + const playerPage = await playerContext.newPage(); + watchPage(playerPage, issues); + await registerPlayer(playerPage, joinCode, { displayName: "Jordan T." }); + + // --- Coach: the draft builder --------------------------------------- + await page.getByTestId("nav-sessions").click(); + await expect(page.getByTestId("nav-sessions")).toHaveAttribute("aria-current", "page"); + await expect(page.getByTestId("sessions-empty")).toBeVisible(); + + await page.getByTestId("sessions-new-title").fill("Tuesday, wide overloads"); + await page.getByTestId("sessions-create").click(); + await expect(page.getByTestId("session-detail-title")).toHaveText("Tuesday, wide overloads"); + await expect(page.getByTestId("session-draft-pill")).toBeVisible(); + await expect(page.getByTestId("session-items-empty")).toBeVisible(); + // An empty session cannot be sent. + await expect(page.getByTestId("session-send")).toBeDisabled(); + + await page.getByTestId("session-note-input").fill("Watch both before training on Tuesday."); + await page.getByTestId("session-note-save").click(); + + // Picker: library presets, each with a mini-board thumbnail. + await page.getByTestId("session-add-item").click(); + await expect(page.getByTestId("session-picker")).toBeVisible(); + await page.getByTestId("session-picker-search").fill("third-man"); + await expect(page.getByTestId("session-picker-row")).toHaveCount(1); + await expect(page.getByTestId("session-picker-row").locator("svg.tile-thumb")).toBeVisible(); + await page.getByTestId("session-picker-row").click(); + await expect(page.getByTestId("session-item")).toHaveCount(1); + + // Picker: My patterns, the coach's own recording. + await page.getByTestId("session-picker-tab-saved").click(); + await page.getByTestId("session-picker-search").fill("build-out"); + await expect(page.getByTestId("session-picker-row")).toHaveCount(1); + await page.getByTestId("session-picker-row").click(); + await expect(page.getByTestId("session-item")).toHaveCount(2); + await page.getByTestId("session-add-item").click(); // close the picker + + const itemNames = page.getByTestId("session-item").locator(".sessions-item-name"); + await expect(itemNames.nth(0)).toContainText("Third-Man Run"); + await expect(itemNames.nth(1)).toHaveText("Our build-out vs press"); + + // Reorder, then put it back (design README: "reorder/remove items"). + await page.getByTestId("session-item-up").nth(1).click(); + await expect(itemNames.nth(0)).toHaveText("Our build-out vs press"); + await page.getByTestId("session-item-down").nth(0).click(); + await expect(itemNames.nth(0)).toContainText("Third-Man Run"); + + // Remove works, and re-adding restores the pair. + await page.getByTestId("session-item-remove").nth(1).click(); + await expect(page.getByTestId("session-item")).toHaveCount(1); + await page.getByTestId("session-add-item").click(); + await page.getByTestId("session-picker-tab-saved").click(); + await page.getByTestId("session-picker-row").filter({ hasText: "build-out" }).click(); + await expect(page.getByTestId("session-item")).toHaveCount(2); + + // The recipient shows as "Will receive" before the send. + await expect(page.getByTestId("session-receipt")).toHaveCount(1); + await expect(page.getByTestId("session-receipt-state")).toHaveText("Will receive"); + + // --- Send ------------------------------------------------------------- + await page.getByTestId("session-send").click(); + await expect(page.getByTestId("session-sent-pill")).toBeVisible(); + await expect(page.getByTestId("session-viewed-counter")).toHaveText("0 of 1 viewed"); + await expect(page.getByTestId("session-receipt-state")).toHaveText("Not yet"); + // A sent session is a record, not a draft: no builder controls remain. + await expect(page.getByTestId("session-add-item")).toHaveCount(0); + await expect(page.getByTestId("session-item-remove")).toHaveCount(0); + await expect(page.getByTestId("session-note")).toContainText("Watch both before training"); + + // --- Player: read-only, Watch deep-link, Mark as watched --------------- + await playerPage.getByTestId("nav-sessions").click(); + await expect(playerPage.getByTestId("session-list-item")).toHaveCount(1); + await expect(playerPage.getByTestId("session-list-item")).toContainText("New"); + await expect(playerPage.getByTestId("session-detail-title")).toHaveText( + "Tuesday, wide overloads" + ); + await expect(playerPage.getByTestId("session-note")).toContainText("Watch both before training"); + + // Receipts never render for a player (they are absent from the payload). + await expect(playerPage.getByTestId("session-receipt")).toHaveCount(0); + await expect(playerPage.getByTestId("session-viewed-counter")).toHaveCount(0); + await expect(playerPage.getByTestId("session-send")).toHaveCount(0); + await expect(playerPage.getByTestId("session-add-item")).toHaveCount(0); + + // Watch opens the item on the board, playing. + await expect(playerPage.getByTestId("session-watch")).toHaveCount(2); + await playerPage.getByTestId("session-watch").first().click(); + await expect(playerPage.getByTestId("session-watch-view")).toBeVisible(); + await expect(playerPage.getByTestId("session-watch-title")).toContainText("Third-Man Run"); + await expect(playerPage.getByTestId("pattern-board")).toBeVisible(); + // Boards render portrait on a phone viewport and landscape otherwise, + // on this surface exactly like every other (design README). + const expectedOrientation = + (playerPage.viewportSize()?.width ?? 1440) <= 700 ? "portrait" : "landscape"; + await expect(playerPage.locator(".board-wrap")).toHaveAttribute( + "data-orientation", + expectedOrientation + ); + await playerPage.getByTestId("session-watch-back").click(); + await expect(playerPage.getByTestId("session-watch-view")).toHaveCount(0); + + await playerPage.getByTestId("session-mark-watched").click(); + await expect(playerPage.getByTestId("session-watched-state")).toBeVisible(); + await expect(playerPage.getByTestId("session-list-item")).toContainText("Watched"); + + // --- Back on the coach account: the counter has moved ----------------- + await page.reload(); + await page.getByTestId("nav-sessions").click(); + await expect(page.getByTestId("session-viewed-counter")).toHaveText("1 of 1 viewed"); + await expect(page.getByTestId("session-receipt-state")).toHaveText("Viewed"); + + await assertCleanPage(playerPage, issues); + await assertCleanPage(page, issues); + await playerContext.close(); + }); +}); + +test.describe("sessions: a player never sees a draft", () => { + test("drafts are invisible until they are sent", async ({ browser, page, issues }) => { + const { joinCode } = await registerCoach(page, { teamName: "Drafts FC" }); + const playerContext = await browser.newContext({ + viewport: page.viewportSize() ?? undefined, + }); + const playerPage = await playerContext.newPage(); + watchPage(playerPage, issues); + await registerPlayer(playerPage, joinCode); + + await page.getByTestId("nav-sessions").click(); + await page.getByTestId("sessions-new-title").fill("Matchday prep"); + await page.getByTestId("sessions-create").click(); + await expect(page.getByTestId("session-draft-pill")).toBeVisible(); + + await playerPage.getByTestId("nav-sessions").click(); + await expect(playerPage.getByTestId("sessions-empty")).toBeVisible(); + await expect(playerPage.getByTestId("session-list-item")).toHaveCount(0); + // The player has no way to make one either. + await expect(playerPage.getByTestId("sessions-create")).toHaveCount(0); + await expect(playerPage.getByTestId("sessions-new-title")).toHaveCount(0); + + await assertCleanPage(playerPage, issues); + await playerContext.close(); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 297fded..b22b470 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { AppShell, type NavKey } from "./AppShell"; import { TeamOnboarding } from "./TeamOnboarding"; import { WhiteboardPage } from "./pages/WhiteboardPage"; import { PatternsPage } from "./pages/PatternsPage"; +import { SessionsPage } from "./pages/SessionsPage"; import { RosterPage } from "./pages/RosterPage"; import { FormationsPage } from "./pages/FormationsPage"; import { IdentityPage } from "./pages/IdentityPage"; @@ -14,11 +15,12 @@ import ThemeSwitcher from "./theme/ThemeSwitcher"; import "./App.css"; // Every screens ticket has landed (T-030 Whiteboard, T-031 Patterns, -// T-032 Formations, T-033 Roster, T-034 Identity): all five nav entries -// are live. +// T-032 Formations, T-033 Roster, T-034 Identity) and T-042 adds Sessions: +// all six nav entries are live. const ENABLED_NAV_KEYS: readonly NavKey[] = [ "whiteboard", "patterns", + "sessions", "roster", "formations", "identity", @@ -127,6 +129,8 @@ export default function App() { > {page === "patterns" ? ( setPage("whiteboard")} /> + ) : page === "sessions" ? ( + ) : page === "roster" ? ( ) : page === "formations" ? ( diff --git a/frontend/src/AppShell.css b/frontend/src/AppShell.css index 30a3f8e..2384a06 100644 --- a/frontend/src/AppShell.css +++ b/frontend/src/AppShell.css @@ -222,10 +222,46 @@ .app-main { padding: 10px; } - /* Team meta (name, role, join code, log out) stays visible on phone: the + /* Team meta (name, role, join codes, log out) stays visible on phone: the T-003 platform DoD journey asserts on it in both viewports. The topbar - already wraps (flex-wrap), so this never forces horizontal overflow. */ + already wraps (flex-wrap), so this never forces horizontal overflow. + What it DID do was eat a fifth of an iPhone 13's height before the + board started (T-050), so on phone it compresses: tighter padding, the + two join codes side by side on one line instead of stacked, and + smaller type throughout. Nothing is hidden. */ .app-topbar { - padding: 10px 12px; + padding: 8px 12px; + gap: 8px; + } + .app-topbar-end { + gap: 8px; + } + .team-meta { + gap: 8px; + } + .team-meta-name { + font-size: 12px; + } + .team-meta-role, + .join-code { + font-size: 10px; + } + .join-codes { + flex-direction: row; + gap: 10px; + flex-wrap: wrap; + } + .team-meta-logout, + .team-members-toggle { + padding: 4px 10px; + font-size: 11px; + } + /* The panel is anchored to the toggle, which sits near the right edge on + phone: pin it to the viewport instead so it can never overhang. */ + .team-members-panel { + right: -8px; + min-width: 0; + width: calc(100vw - 24px); + max-width: calc(100vw - 24px); } } diff --git a/frontend/src/AppShell.tsx b/frontend/src/AppShell.tsx index 0f3eeb8..81dba3f 100644 --- a/frontend/src/AppShell.tsx +++ b/frontend/src/AppShell.tsx @@ -28,6 +28,21 @@ function IconPatterns() { ); } +function IconSessions() { + // Paper plane (mockup nav, PNG 21-23): "send this to the team". + return ( + + ); +} function IconFormations() { return (
+
-
+
-
+
+ + {itemKicker(watching) ? `${itemKicker(watching)}: ` : ""} + {itemName(watching)} + +
+ + {selected && !isCoachSession(selected) && !selected.you_watched && ( + + )} +
+ + ); + } + + return ( +
+ + + {loadError && ( +

+ {loadError} +

+ )} + +
+ + +
+ {!selected ? ( +

+ Pick a session on the left to see what is in it. +

+ ) : ( + <> +
+
+

+ {selected.title} +

+

+ {formatSessionDate(selected.sent_at ?? selected.created_at)} +

+
+
+ {isCoachSession(selected) ? ( + selected.status === "sent" ? ( + + SENT + + {selected.viewed_count} of {selected.recipient_count} viewed + + + ) : ( + <> + + DRAFT + + + + ) + ) : selected.you_watched ? ( + + Watched + + ) : ( + + )} +
+
+ + {actionError && ( +

+ {actionError} +

+ )} + + {/* Coach note (PNG 21, 22, 26): editable while the session is + still a draft, read-only once it has been sent. */} + {isCoachSession(selected) && selected.status === "draft" ? ( +
+

Coach note

+