Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
cec879c
chore(backlog): T-011 done
BrandanBurgess Jul 16, 2026
d0a1e4f
docs(agent): orchestrator state snapshot for session handoff
BrandanBurgess Jul 16, 2026
613cad2
feat(screens): whiteboard page with toolbar, view menu, record/save (…
BrandanBurgess Jul 16, 2026
b5d7160
merge: T-030 whiteboard page into integration
BrandanBurgess Jul 16, 2026
24ccc96
chore(backlog): T-030 done, T-031 + T-033 doing
BrandanBurgess Jul 16, 2026
23e21ff
feat(screens): patterns page with libraries, chips, search, details (…
BrandanBurgess Jul 16, 2026
880efb4
merge: T-031 patterns page into integration
BrandanBurgess Jul 16, 2026
733ac3f
feat(screens): roster page with CRUD, sliders, double-exposure warnin…
BrandanBurgess Jul 16, 2026
598c97a
fix(infra): seed database in e2e boot path so fresh environments pass…
BrandanBurgess Jul 16, 2026
b165909
merge: T-031 seed-on-boot fix into integration
BrandanBurgess Jul 16, 2026
09c9fe2
merge: reconcile main squash history into integration
BrandanBurgess Jul 16, 2026
ea0930f
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
7fd5a3a
merge: T-033 roster page into integration (resolve nav shell, schemas…
BrandanBurgess Jul 16, 2026
add1c38
chore(backlog): T-031 done, T-033 pr, T-032 + T-034 doing
BrandanBurgess Jul 16, 2026
e8926a7
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
f331829
chore(backlog): T-033 done
BrandanBurgess Jul 16, 2026
c634850
feat(screens): formations page with keystones, keycards, rondo map (T…
BrandanBurgess Jul 16, 2026
877ad0d
merge: T-032 formations page into integration
BrandanBurgess Jul 16, 2026
35a07a2
chore(e2e): drop duplicated nav assertion left by merge
BrandanBurgess Jul 16, 2026
ce9695c
feat(screens): identity page with reference teams, pass-risk, cult co…
BrandanBurgess Jul 16, 2026
62c8b24
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
02cf1c3
merge: T-034 identity page into integration (all five nav entries live)
BrandanBurgess Jul 16, 2026
f8fde64
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
4d7ff05
chore(backlog): T-032 + T-034 done, screens phase complete
BrandanBurgess Jul 16, 2026
d1ce7c1
chore(backlog): T-040 + T-041 doing
BrandanBurgess Jul 16, 2026
abb4823
feat(collab): role gating suite, API enforcement audit (T-040)
BrandanBurgess Jul 16, 2026
817df6d
merge: T-040 role gating suite into integration
BrandanBurgess Jul 16, 2026
8732131
chore(backlog): founder decisions recorded, add T-012 + T-043
BrandanBurgess Jul 16, 2026
0151d7b
feat(collab): playstyle suggestion flow (T-041)
BrandanBurgess Jul 16, 2026
27c9211
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
2131cc4
merge: T-041 suggestion flow into integration
BrandanBurgess Jul 16, 2026
0e4cf0c
chore(backlog): T-041 pr, T-012 doing
BrandanBurgess Jul 16, 2026
302040d
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
7f69ea2
chore(backlog): T-041 done
BrandanBurgess Jul 16, 2026
ea71eae
feat(platform): role-scoped join codes, head coach member management …
BrandanBurgess Jul 16, 2026
0282683
merge: T-043 team management into integration
BrandanBurgess Jul 16, 2026
7116ee6
feat(content): identity age_hint column, migration, Bible backfill (T…
BrandanBurgess Jul 16, 2026
335e147
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
c3f273c
merge: T-012 identity age_hint into integration
BrandanBurgess Jul 16, 2026
0fc6f18
merge-fix: re-point T-012 migration to 0005 after T-043's 0004
BrandanBurgess Jul 16, 2026
41763d4
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
f23238e
docs(agent): session 2 handoff snapshot, T-012 + T-043 done
BrandanBurgess Jul 16, 2026
be6e74c
feat(collab): sessions draft builder, send, receipts, player view (T-…
BrandanBurgess Aug 7, 2026
52f8113
feat(demo): scripts/seed_demo.py and a `make demo` target
BrandanBurgess Aug 7, 2026
274dda1
feat(screens): phone pass, styled auth screens, shared ghost button (…
BrandanBurgess Aug 7, 2026
6dd1273
feat(verify): demo-path journey, permission gate in CI, one search ma…
BrandanBurgess Aug 7, 2026
8e61cb0
feat(docs): screenshot capture script, docs/screenshots, and the README
BrandanBurgess Aug 7, 2026
08a55a5
feat(platform): deployable image, single-origin serving, Render bluep…
BrandanBurgess Aug 7, 2026
4659426
fix(ci): pin the dev tooling so CI and a checkout cannot drift
BrandanBurgess Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
44 changes: 42 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
133 changes: 133 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

<img src="docs/screenshots/10-phone-pattern-portrait.png" alt="A pattern playing portrait on a phone" width="300"> <img src="docs/screenshots/11-phone-player-session.png" alt="A player's session view" width="300">

## 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 <http://127.0.0.1:5173>:

| | 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.
76 changes: 74 additions & 2 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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")
Loading
Loading