Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 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
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
3 changes: 2 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from fastapi import FastAPI

from app.routers import auth, formations, library, roster, teams, whiteboard
from app.routers import auth, formations, identity, library, roster, teams, whiteboard

app = FastAPI(title="Patterns of Play API")
app.include_router(auth.router)
Expand All @@ -9,6 +9,7 @@
app.include_router(library.router)
app.include_router(roster.router)
app.include_router(formations.router)
app.include_router(identity.router)


@app.get("/api/health")
Expand Down
34 changes: 34 additions & 0 deletions backend/app/routers/identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Identities: reference teams, style archetypes, cult corner (doc 03
section 5; Brief step 20; the Identity page's three browsable segments).
Read-only to every team member (coach and player alike, README roles
table: "Pattern library, formations, identity... Full (view + play)"), so
this only requires an authenticated user, not a team scope: Identity
carries no team_id (app/models/formations.py), and CLAUDE.md rule 4 only
requires the scoped query layer for TEAM data. Mirrors
app/routers/library.py's shape exactly.
"""

from typing import Literal

from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session

from app.deps import get_current_user, get_db
from app.models import Identity, User
from app.schemas import IdentityOut

router = APIRouter(prefix="/api", tags=["identities"])

IdentityKind = Literal["reference_team", "style_archetype", "cult_card"]


@router.get("/identities", response_model=list[IdentityOut])
def list_identities(
kind: IdentityKind | None = Query(default=None),
_current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[Identity]:
query = db.query(Identity)
if kind is not None:
query = query.filter(Identity.kind == kind)
return query.order_by(Identity.kind, Identity.code).all()
37 changes: 37 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,43 @@ class LibraryItemOut(BaseModel):
extras: dict | None = Field(default=None, validation_alias="extras_json")


# ---------------------------------------------------------------------------
# Identities: reference teams, style archetypes, cult corner (doc 03
# section 5, Bible 5, 5.7, 6; Brief step 20; T-034). Library world, same
# no-team-scope reasoning as LibraryItemOut above.
# ---------------------------------------------------------------------------


class IdentityOut(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: int
kind: Literal["style_archetype", "reference_team", "cult_card"]
code: str
name: str
tag_line: str
formation_code: str | None
core_idea: str
signature_pattern_codes: list[str]
# keystone_roles_json shape depends on kind: reference teams carry
# {"role", "note"} objects (doc 03 5 example), style archetypes and cult
# cards carry a plain role-code list or null, so this stays a free list
# rather than a fixed model (mirrors LibraryItemOut.extras above).
keystone_roles: list | None = Field(default=None, validation_alias="keystone_roles_json")
youth_takeaway: str
block: Literal["high", "mid", "low"] | None
# style archetypes only (Bible 5.7): encouraged/tolerated/discouraged/tempo_rule.
pass_risk: dict | None = Field(default=None, validation_alias="pass_risk_json")
shape_render: Literal["animated", "static", "details_only"]
signature_animation_spec: AnimationSpec | None = Field(
default=None, validation_alias="signature_animation_spec_json"
)
# {"positions": [{"slot","role_hint","x","y"}, ...], "note": str}
# (doc 03 5 Atletico/Man City examples); a free dict for the same reason
# as extras above.
static_shape: dict | None = Field(default=None, validation_alias="static_shape_json")


# ---------------------------------------------------------------------------
# Roster (doc 03 section 3, Bible sections 1-2; Brief step 19; T-033).
# ---------------------------------------------------------------------------
Expand Down
205 changes: 205 additions & 0 deletions backend/tests/test_identity_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
"""Identity content routes (doc 03 section 5; Brief step 20): GET
/api/identities serves the three browsable segments (reference teams,
style archetypes, cult corner) to any authenticated team member,
filterable by kind. Not team-scoped (identities carries no team_id), but
still requires authentication like every other route. Mirrors
test_library_routes.py's shape.
"""

import pytest
from fastapi.testclient import TestClient

from app.db import SessionLocal
from app.main import app
from app.models import Identity


@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") -> TestClient:
c = TestClient(app)
_register(c, email=email, role="coach", display_name="Coach Test")
c.post("/api/teams", json={"name": f"Team for {email}"})
return c


def _player_on_team(coach: TestClient, email: str) -> TestClient:
join_code = coach.get("/api/teams/current").json()["join_code"]
p = TestClient(app)
_register(p, email=email, role="player", display_name="Player Test")
p.post("/api/teams/join", json={"join_code": join_code})
return p


_ANIM_SPEC = {
"slots": [{"slot": "a", "role_hint": "W", "start": {"x": 50, "y": 50}}],
"ball": {"holder_slot": "a"},
"steps": [{"n": 1, "caption": "Step one.", "moves": []}],
"loop": False,
}

_STATIC_SHAPE = {
"positions": [{"slot": "gk", "role_hint": "GK", "x": 6, "y": 50}],
"note": "A static blueprint shape.",
}


def _seed_identities() -> None:
db = SessionLocal()
try:
db.add(
Identity(
kind="reference_team",
code="ref_1",
name="Reference One",
tag_line="A reference team tag line.",
formation_code=None,
core_idea="Formation: 4-3-3. The core idea text follows the formation sentence.",
signature_pattern_codes=["A1"],
keystone_roles_json=[{"role": "single_pivot", "note": "The pivot."}],
youth_takeaway="A youth takeaway line.",
block="high",
pass_risk_json=None,
shape_render="animated",
signature_animation_spec_json=_ANIM_SPEC,
static_shape_json=None,
source_ref="bible:6.1",
content_version="1.0.0",
)
)
db.add(
Identity(
kind="reference_team",
code="ref_2",
name="Reference Two",
tag_line="A static reference team.",
formation_code=None,
core_idea="Formation: 4-4-2. Static shape only, no animation.",
signature_pattern_codes=[],
keystone_roles_json=[{"role": "stopper_cb", "note": "The stopper."}],
youth_takeaway="Another youth takeaway.",
block="mid",
pass_risk_json=None,
shape_render="static",
signature_animation_spec_json=None,
static_shape_json=_STATIC_SHAPE,
source_ref="bible:6.2",
content_version="1.0.0",
)
)
db.add(
Identity(
kind="style_archetype",
code="style_1",
name="Style One",
tag_line="A style archetype tag line.",
formation_code=None,
core_idea="Keep the ball to control the game.",
signature_pattern_codes=["B5"],
keystone_roles_json=["single_pivot", "false_9"],
youth_takeaway="A style youth takeaway.",
block="high",
pass_risk_json={
"encouraged": ["Short circulation"],
"tolerated": [],
"discouraged": ["Hopeful long balls"],
"tempo_rule": "Slow-slow-fast.",
},
shape_render="details_only",
signature_animation_spec_json=None,
static_shape_json=None,
source_ref="bible:5.1",
content_version="1.0.0",
)
)
db.add(
Identity(
kind="cult_card",
code="cult_1",
name="Cult One",
tag_line="A one-line cult corner card.",
formation_code=None,
core_idea="A one-line cult corner idea.",
signature_pattern_codes=[],
keystone_roles_json=None,
youth_takeaway="A cult corner youth takeaway.",
block=None,
pass_risk_json=None,
shape_render="details_only",
signature_animation_spec_json=None,
static_shape_json=None,
source_ref="bible:6.19",
content_version="1.0.0",
)
)
db.commit()
finally:
db.close()


def test_list_all_identities(client: TestClient) -> None:
_seed_identities()
coach = _coach_with_team()
response = coach.get("/api/identities")
assert response.status_code == 200
codes = {item["code"] for item in response.json()}
assert codes == {"ref_1", "ref_2", "style_1", "cult_1"}


def test_filter_by_kind(client: TestClient) -> None:
_seed_identities()
coach = _coach_with_team()

ref_teams = coach.get("/api/identities", params={"kind": "reference_team"}).json()
assert {t["code"] for t in ref_teams} == {"ref_1", "ref_2"}

styles = coach.get("/api/identities", params={"kind": "style_archetype"}).json()
assert [s["code"] for s in styles] == ["style_1"]
assert styles[0]["pass_risk"]["tempo_rule"] == "Slow-slow-fast."

cult = coach.get("/api/identities", params={"kind": "cult_card"}).json()
assert [c["code"] for c in cult] == ["cult_1"]


def test_identity_shape_matches_the_content_model(client: TestClient) -> None:
_seed_identities()
coach = _coach_with_team()
animated = coach.get("/api/identities", params={"kind": "reference_team"}).json()
animated_ref = next(t for t in animated if t["code"] == "ref_1")
static_ref = next(t for t in animated if t["code"] == "ref_2")

assert animated_ref["shape_render"] == "animated"
assert animated_ref["signature_animation_spec"]["slots"][0]["slot"] == "a"
assert animated_ref["keystone_roles"] == [{"role": "single_pivot", "note": "The pivot."}]
assert animated_ref["static_shape"] is None

assert static_ref["shape_render"] == "static"
assert static_ref["static_shape"]["positions"][0]["slot"] == "gk"
assert static_ref["signature_animation_spec"] is None


def test_players_can_browse_identities_too(client: TestClient) -> None:
_seed_identities()
coach = _coach_with_team()
player = _player_on_team(coach, email="player@example.com")
assert len(player.get("/api/identities").json()) == 4


def test_identity_route_requires_authentication(client: TestClient) -> None:
_seed_identities()
assert client.get("/api/identities").status_code == 401
Loading
Loading