diff --git a/backend/app/models/formations.py b/backend/app/models/formations.py index 768556d..f4b4f58 100644 --- a/backend/app/models/formations.py +++ b/backend/app/models/formations.py @@ -76,6 +76,10 @@ class Identity(Base): signature_pattern_codes: Mapped[list] = mapped_column(JSON, nullable=False, default=list) keystone_roles_json: Mapped[dict | list | None] = mapped_column(JSON, nullable=True) youth_takeaway: Mapped[str] = mapped_column(Text, nullable=False) + # Bible 8.2.4 / doc 03 amendment (T-012, founder decision 2026-07-16): + # every identity carries an age-suitability hint alongside its youth + # takeaway, same rule and shape as library_items.age_hint. + age_hint: Mapped[str] = mapped_column(String(60), nullable=False) block: Mapped[str | None] = mapped_column(String(10), nullable=True) # high|mid|low # style archetypes only (Bible 5.7 row): encouraged, tolerated, # discouraged, tempo_rule. diff --git a/backend/app/schemas.py b/backend/app/schemas.py index da01c2f..d351133 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -264,6 +264,7 @@ class IdentityOut(BaseModel): # rather than a fixed model (mirrors LibraryItemOut.extras above). keystone_roles: list | None = Field(default=None, validation_alias="keystone_roles_json") youth_takeaway: str + age_hint: 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") diff --git a/backend/migrations/versions/0005_identity_age_hint.py b/backend/migrations/versions/0005_identity_age_hint.py new file mode 100644 index 0000000..1a4e236 --- /dev/null +++ b/backend/migrations/versions/0005_identity_age_hint.py @@ -0,0 +1,46 @@ +"""identities.age_hint (founder decision 2026-07-16, T-012): doc 03 section +5's identities schema had no age_hint column when T-010 shipped, so Bible +8.2.4's "every identity carries a youth takeaway line and an age-suitability +hint" rule was only honoured for library_items (patterns/deliveries/ +rotations), not identities. The founder has since decided to amend the +schema rather than leave identity cards without an age hint. + +Added nullable=False with server_default="" (rather than a bare add, doc +03's identities table already has rows in any dev DB that ran T-010's +seed): SQLite's batch-recreate for an ALTER TABLE ADD COLUMN NOT NULL needs +a value for existing rows, and scripts/seed.py's upsert-by-code re-run +overwrites every row's placeholder with its real Bible-backed value +immediately afterward, so the empty string never surfaces to a user who +runs `make seed` after upgrading. + +Built off 0003 (players.flank, T-033) in parallel with T-043's 0004 +(role-scoped join codes); T-043 merged first, so this migration was +re-pointed onto 0004 at integration merge (orchestrator resolution). + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-07-16 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0005" +down_revision: Union[str, None] = "0004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("identities") as batch_op: + batch_op.add_column( + sa.Column("age_hint", sa.String(length=60), nullable=False, server_default="") + ) + + +def downgrade() -> None: + with op.batch_alter_table("identities") as batch_op: + batch_op.drop_column("age_hint") diff --git a/backend/tests/test_identity_routes.py b/backend/tests/test_identity_routes.py index 8535e3a..4bb6ec1 100644 --- a/backend/tests/test_identity_routes.py +++ b/backend/tests/test_identity_routes.py @@ -73,6 +73,7 @@ def _seed_identities() -> None: signature_pattern_codes=["A1"], keystone_roles_json=[{"role": "single_pivot", "note": "The pivot."}], youth_takeaway="A youth takeaway line.", + age_hint="U13+", block="high", pass_risk_json=None, shape_render="animated", @@ -93,6 +94,7 @@ def _seed_identities() -> None: signature_pattern_codes=[], keystone_roles_json=[{"role": "stopper_cb", "note": "The stopper."}], youth_takeaway="Another youth takeaway.", + age_hint="U11+", block="mid", pass_risk_json=None, shape_render="static", @@ -113,6 +115,7 @@ def _seed_identities() -> None: signature_pattern_codes=["B5"], keystone_roles_json=["single_pivot", "false_9"], youth_takeaway="A style youth takeaway.", + age_hint="U13+", block="high", pass_risk_json={ "encouraged": ["Short circulation"], @@ -138,6 +141,7 @@ def _seed_identities() -> None: signature_pattern_codes=[], keystone_roles_json=None, youth_takeaway="A cult corner youth takeaway.", + age_hint="U9+", block=None, pass_risk_json=None, shape_render="details_only", @@ -203,3 +207,15 @@ def test_players_can_browse_identities_too(client: TestClient) -> None: def test_identity_route_requires_authentication(client: TestClient) -> None: _seed_identities() assert client.get("/api/identities").status_code == 401 + + +def test_every_identity_carries_a_non_empty_age_hint(client: TestClient) -> None: + """T-012 (Bible 8.2.4 / doc 03 amendment): every identity, of every + kind, must serve a non-empty age_hint through the route, same rule + LibraryItemOut already enforces for patterns/deliveries/rotations.""" + _seed_identities() + coach = _coach_with_team() + identities = coach.get("/api/identities").json() + assert len(identities) == 4 + for item in identities: + assert item["age_hint"], f"{item['code']}: missing age_hint" diff --git a/backend/tests/test_seed_content.py b/backend/tests/test_seed_content.py index 55c48e5..dbf492c 100644 --- a/backend/tests/test_seed_content.py +++ b/backend/tests/test_seed_content.py @@ -133,6 +133,10 @@ def test_identity_entries_carry_the_bible_youth_and_traceability_fields(fname: s assert _word_count(item["tag_line"]) <= 25, f"{fname} {code}: tag_line over 25 words" assert item["core_idea"], f"{fname} {code}: missing core_idea" assert item["youth_takeaway"], f"{fname} {code}: missing youth_takeaway" + # T-012 (Bible 8.2.4, founder decision 2026-07-16): every identity + # carries an age-suitability hint alongside its youth takeaway, + # same rule library_items already enforces (age_hint check above). + assert item["age_hint"], f"{fname} {code}: missing age_hint" assert item["source_ref"].startswith("bible:"), f"{fname} {code}: bad source_ref" diff --git a/e2e/identity.spec.ts b/e2e/identity.spec.ts index 059c283..30635c7 100644 --- a/e2e/identity.spec.ts +++ b/e2e/identity.spec.ts @@ -173,6 +173,8 @@ test.describe("identity: five-part Section 6 template, pass-risk, cult corner", await expect(panel.getByTestId("identity-detail-youth-takeaway")).toContainText( "Positions before players" ); + // T-012: Bible 8.2.4's age-suitability hint, added after Youth takeaway. + await expect(panel.getByTestId("identity-detail-age-hint")).toContainText("U13+"); // A reference team's own pass_risk_json is null: the block never renders. await expect(panel.getByTestId("identity-detail-pass-risk")).toHaveCount(0); // Order in the DOM matches the template's own order. @@ -185,6 +187,7 @@ test.describe("identity: five-part Section 6 template, pass-risk, cult corner", "identity-detail-signature-patterns", "identity-detail-keystone-roles", "identity-detail-youth-takeaway", + "identity-detail-age-hint", ]); await page.getByTestId("identity-details-close").click(); await expect(page.getByTestId("identity-details-panel")).toHaveCount(0); @@ -206,6 +209,7 @@ test.describe("identity: five-part Section 6 template, pass-risk, cult corner", await page.getByTestId("identity-details-toggle").click(); await expect(page.getByTestId("identity-detail-formation")).toContainText("4-2-3-1"); await expect(page.getByTestId("identity-detail-youth-takeaway")).toBeVisible(); + await expect(page.getByTestId("identity-detail-age-hint")).toContainText("U13+"); await assertCleanPage(page, issues); }); @@ -229,6 +233,7 @@ test.describe("identity: five-part Section 6 template, pass-risk, cult corner", // Style archetypes' core_idea has no "Formation:" leading sentence, so // the template does not fabricate a Formation & shape row for them. await expect(page.getByTestId("identity-detail-formation")).toHaveCount(0); + await expect(page.getByTestId("identity-detail-age-hint")).toContainText("U13+"); await assertCleanPage(page, issues); }); @@ -251,6 +256,7 @@ test.describe("identity: five-part Section 6 template, pass-risk, cult corner", await expect(page.getByTestId("identity-detail-keystone-roles")).toHaveCount(0); await expect(page.getByTestId("identity-detail-pass-risk")).toHaveCount(0); await expect(page.getByTestId("identity-detail-signature-patterns")).toHaveCount(0); + await expect(page.getByTestId("identity-detail-age-hint")).toContainText("U11+"); await assertCleanPage(page, issues); }); diff --git a/frontend/src/identityApi.ts b/frontend/src/identityApi.ts index 8c40ca8..1a54857 100644 --- a/frontend/src/identityApi.ts +++ b/frontend/src/identityApi.ts @@ -45,6 +45,7 @@ export interface IdentityOutWire { signature_pattern_codes: string[]; keystone_roles: KeystoneRoleWire[] | null; youth_takeaway: string; + age_hint: string; block: "high" | "mid" | "low" | null; pass_risk: PassRiskWire | null; shape_render: "animated" | "static" | "details_only"; diff --git a/frontend/src/pages/IdentityPage.tsx b/frontend/src/pages/IdentityPage.tsx index 8bc7e0a..de53a1a 100644 --- a/frontend/src/pages/IdentityPage.tsx +++ b/frontend/src/pages/IdentityPage.tsx @@ -7,9 +7,10 @@ // render Details only (CLAUDE.md rule 6: no designed surface, no // invented one). Details follows the Section 6 five-part template // (Formation & shape, Core idea, Signature patterns, Keystone roles, -// Youth takeaway); style archetypes additionally show the pass-risk -// block (Bible 5.7) between Keystone roles and Youth takeaway. Copy rule -// (doc 03 section 7): identities curate, never lock. +// Youth takeaway), plus an Age hint row (Bible 8.2.4, T-012); style +// archetypes additionally show the pass-risk block (Bible 5.7) between +// Keystone roles and Youth takeaway. Copy rule (doc 03 section 7): +// identities curate, never lock. import { useEffect, useMemo, useState } from "react"; import type { Orientation } from "../board/coords"; @@ -150,7 +151,7 @@ export function IdentityPage({ orientation }: IdentityPageProps) { className="app-page-info" aria-hidden="true" data-testid="identity-info" - title="Identities curate, never lock. Pull up Browse identities to explore the great teams and style archetypes: pick one, and its signature idea plays on the board, with the formation, keystone roles, and youth takeaway in Details." + title="Identities curate, never lock. Pull up Browse identities to explore the great teams and style archetypes: pick one, and its signature idea plays on the board, with the formation, keystone roles, youth takeaway, and age hint in Details." > i @@ -286,6 +287,10 @@ export function IdentityPage({ orientation }: IdentityPageProps) {

Youth takeaway

{selection.youth_takeaway}

+
+

Age hint

+

{selection.age_hint}

+
)} diff --git a/scripts/validate_seeds.py b/scripts/validate_seeds.py index a8a7676..fd338f2 100644 --- a/scripts/validate_seeds.py +++ b/scripts/validate_seeds.py @@ -80,7 +80,7 @@ SYNERGY_REQUIRED_FIELDS = ["code", "name", "why_it_works", "source_ref", "content_version"] CLASH_REQUIRED_FIELDS = ["code", "name", "trigger_expression", "warning_copy", "source_ref", "content_version"] IDENTITY_REQUIRED_FIELDS = [ - "code", "name", "tag_line", "core_idea", "youth_takeaway", "shape_render", + "code", "name", "tag_line", "core_idea", "youth_takeaway", "age_hint", "shape_render", "source_ref", "content_version", ] diff --git a/seeds/identities_archetypes.json b/seeds/identities_archetypes.json index 5bf8575..2b6c4d8 100644 --- a/seeds/identities_archetypes.json +++ b/seeds/identities_archetypes.json @@ -15,6 +15,7 @@ "single_pivot", "advanced_8", "false_9", "inside_forward", "touchline_winger" ], "youth_takeaway": "Teach the team to occupy zones and form triangles, and the free man appears from structure, not from individual talent.", + "age_hint": "U13+", "block": "high", "pass_risk_json": { "encouraged": ["Short circulation", "line-breakers into feet", "F7 slide-rule through balls", "prepared B3 switches", "F1 cutbacks"], @@ -40,6 +41,7 @@ "overlapping_fb", "anchor_destroyer", "classic_10", "wide_forward", "runner_in_behind" ], "youth_takeaway": "A team does not need the ball to be dangerous: know exactly who sprints and where the instant it is won back.", + "age_hint": "U11+", "block": "mid", "pass_risk_json": { "encouraged": ["First pass forward", "F8 ball over the top", "F7 slide-rule through balls", "F2 early crosses"], @@ -65,6 +67,7 @@ "box_to_box_8", "false_9", "inside_forward" ], "youth_takeaway": "Effort can be a system, not just a vibe: pressing has choreography, with curved runs and triggers, that can be trained like a passing drill.", + "age_hint": "U13+", "block": "high", "pass_risk_json": { "encouraged": ["Vertical ground passes", "F3 driven crosses", "quick F7 through balls after regains"], @@ -90,6 +93,7 @@ "anchor_destroyer", "box_to_box_8", "target_man", "runner_in_behind" ], "youth_takeaway": "Defending deep is a plan, not a surrender: never send a block out without an outlet to break with.", + "age_hint": "U11+", "block": "low", "pass_risk_json": { "encouraged": ["F13 channel outlets", "long clearances to the target", "F8 over the top when sprung"], @@ -115,6 +119,7 @@ "box_to_box_8", "touchline_winger", "target_man", "second_striker" ], "youth_takeaway": "Winning the loose ball after a long pass is a team job that can be organised and drilled, not left to chance.", + "age_hint": "U9+", "block": "mid", "pass_risk_json": { "encouraged": ["F13 channel balls", "long diagonals to the target", "F4 floated crosses", "forward throw-ins"], @@ -140,6 +145,7 @@ "anchor_destroyer", "classic_10", "second_striker", "target_man" ], "youth_takeaway": "Teams do not have to pick a single lane: the sanest identity for most youth sides is one that can do both.", + "age_hint": "U13+", "block": "mid", "pass_risk_json": { "encouraged": ["The possession menu when controlling the game", "the counter menu on transition"], diff --git a/seeds/identities_cult_corner.json b/seeds/identities_cult_corner.json index d75a0ae..73cc20a 100644 --- a/seeds/identities_cult_corner.json +++ b/seeds/identities_cult_corner.json @@ -12,6 +12,7 @@ "signature_pattern_codes": [], "keystone_roles_json": null, "youth_takeaway": "A complete defensive plan, drilled without compromise, can beat teams with far more individual talent.", + "age_hint": "U11+", "block": "low", "pass_risk_json": null, "shape_render": "details_only", @@ -29,6 +30,7 @@ "signature_pattern_codes": ["C2"], "keystone_roles_json": null, "youth_takeaway": "A restart can be a weapon: rehearse the throw-in with the same care as any other delivery.", + "age_hint": "U9+", "block": null, "pass_risk_json": null, "shape_render": "details_only", @@ -46,6 +48,7 @@ "signature_pattern_codes": ["C2"], "keystone_roles_json": null, "youth_takeaway": "Organisation without the ball is a repeatable advantage, not a lesser style of play.", + "age_hint": "U9+", "block": "low", "pass_risk_json": null, "shape_render": "details_only", @@ -63,6 +66,7 @@ "signature_pattern_codes": ["C2"], "keystone_roles_json": null, "youth_takeaway": "Commitment to a simple plan, played without hesitation, can unsettle a more talented opponent.", + "age_hint": "U9+", "block": null, "pass_risk_json": null, "shape_render": "details_only", @@ -80,6 +84,7 @@ "signature_pattern_codes": ["B5"], "keystone_roles_json": null, "youth_takeaway": "A youth-development pathway can produce a team that outplays clubs with far larger squads.", + "age_hint": "U13+", "block": "high", "pass_risk_json": null, "shape_render": "details_only", @@ -97,6 +102,7 @@ "signature_pattern_codes": ["D1"], "keystone_roles_json": null, "youth_takeaway": "A clear defensive job for every player can be a title-winning identity on its own.", + "age_hint": "U11+", "block": "high", "pass_risk_json": null, "shape_render": "details_only", diff --git a/seeds/identities_reference_teams.json b/seeds/identities_reference_teams.json index 054fc88..1899299 100644 --- a/seeds/identities_reference_teams.json +++ b/seeds/identities_reference_teams.json @@ -17,6 +17,7 @@ {"role": "overlapping_fb", "note": "Alves, supplying all right-side width"} ], "youth_takeaway": "Positions before players: teach players to occupy zones and form triangles, and the free man appears by structure, not talent. The rondo is this entire identity in a ten-yard square.", + "age_hint": "U13+", "block": "high", "pass_risk_json": null, "shape_render": "animated", @@ -58,6 +59,7 @@ {"role": "box_to_box_8", "note": "Henderson and Fabinho supplying engine and anchoring"} ], "youth_takeaway": "Effort can be a system, not just a vibe: pressing has choreography, with curved runs and triggers, that can be trained like a passing drill. Width does not have to come from wingers.", + "age_hint": "U13+", "block": "high", "pass_risk_json": null, "shape_render": "animated", @@ -101,6 +103,7 @@ {"role": "stopper_cb", "note": "Ramos, the front-foot stopper"} ], "youth_takeaway": "Defending deep is a plan, not a surrender: the block's job is to choose where the opponent loses the ball, and a counter needs runners without the ball as much as the star with it.", + "age_hint": "U11+", "block": "mid", "pass_risk_json": null, "shape_render": "animated", @@ -140,6 +143,7 @@ {"role": "inside_forward", "note": "Mahrez, isolated on the far side"} ], "youth_takeaway": "A team that knows exactly what it is can beat eleven better players who don't.", + "age_hint": "U11+", "block": "mid", "pass_risk_json": null, "shape_render": "animated", @@ -176,6 +180,7 @@ {"role": "target_man", "note": "Costa, the outlet nine as a weapon"} ], "youth_takeaway": "A block is not passive: this team attacked opponents with its shape as much as with the ball.", + "age_hint": "U9+", "block": "mid", "pass_risk_json": null, "shape_render": "static", @@ -213,6 +218,7 @@ {"role": "advanced_8", "note": "De Bruyne, the half-space assassin"} ], "youth_takeaway": "Formations are just phone numbers: what matters is the shape in possession, and it can differ from the shape without the ball.", + "age_hint": "U15+", "block": null, "pass_risk_json": null, "shape_render": "static", @@ -251,6 +257,7 @@ {"role": "target_man", "note": "Giroud, target man as a function rather than a scorer"} ], "youth_takeaway": "A team does not need the ball to be in charge: asymmetry is legal, since one flank can defend while the other attacks.", + "age_hint": "U13+", "block": "mid", "pass_risk_json": null, "shape_render": "details_only", @@ -271,6 +278,7 @@ {"role": "advanced_8", "note": "Xavi as tempo dictator, Iniesta as the pressure-release dribbler in the half-spaces"} ], "youth_takeaway": "Patience can be a weapon: a team that never gives the ball away cannot lose its shape chasing it. The striker is optional, the free man is not.", + "age_hint": "U13+", "block": "high", "pass_risk_json": null, "shape_render": "details_only", @@ -294,6 +302,7 @@ {"role": "complete_forward", "note": "Lewandowski"} ], "youth_takeaway": "Bravery is structural: an aggressive high line only works if the keeper sweeps and someone on the back line has the recovery pace to cover for it.", + "age_hint": "U15+", "block": "high", "pass_risk_json": null, "shape_render": "details_only", @@ -315,6 +324,7 @@ {"role": "target_man", "note": "Gullit and van Basten pressing as the first defenders"} ], "youth_takeaway": "Compactness is measurable: give players the actual number of metres, not just an instruction to work harder.", + "age_hint": "U13+", "block": "high", "pass_risk_json": null, "shape_render": "details_only", @@ -335,6 +345,7 @@ {"role": "classic_10", "note": "Hernández, the release-valve ten"} ], "youth_takeaway": "Man-marking is the most teachable press for young players, since every job is clear, and it is the most physically honest system too.", + "age_hint": "U9+", "block": "high", "pass_risk_json": null, "shape_render": "details_only", @@ -356,6 +367,7 @@ {"role": "anchor_destroyer", "note": "Kanté"} ], "youth_takeaway": "Changing shape mid-season can rescue a team: it helps to teach players two shapes, not just one.", + "age_hint": "U13+", "block": "mid", "pass_risk_json": null, "shape_render": "details_only", @@ -377,6 +389,7 @@ {"role": "classic_10", "note": "Kaká, the transition-carrying ten"} ], "youth_takeaway": "A player's best position may be one nobody has tried yet: profile matters more than the label on the shirt.", + "age_hint": "U13+", "block": "mid", "pass_risk_json": null, "shape_render": "details_only", @@ -398,6 +411,7 @@ {"role": "false_9", "note": "Mertens, the micro false nine"} ], "youth_takeaway": "Scripting the first few passes removes decisions for developing players and frees their attention for the choices that matter most.", + "age_hint": "U11+", "block": "high", "pass_risk_json": null, "shape_render": "details_only", @@ -420,6 +434,7 @@ {"role": "defensive_fb", "note": "Eto'o, a star forward converted to auxiliary right back as the tactical sacrifice"} ], "youth_takeaway": "Defending with total commitment is a skill and an honour, not a failure: it is the cultural counterweight every possession-obsessed environment needs.", + "age_hint": "U15+", "block": "low", "pass_risk_json": null, "shape_render": "details_only",