Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
53 changes: 0 additions & 53 deletions .github/dependabot.yml

This file was deleted.

4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ One line each; `→` names the archive heading that holds the reasoning.
### Docs, tests and dependencies

- Pin every version to one verified against the registry in the same turn, never one recalled from memory → *Dependency policy*
- `.github/dependabot.yml` and `workflow_dispatch` registration are read from the DEFAULT branch (`main`) only, and alerts are raised there too, so that class of file needs a two-sided `dev` + `main` copy → *`.github/dependabot.yml`* · *Branch model*
- Some pinned action SHAs are immutable releases Dependabot can never bump; check those by hand → *Pinned actions Dependabot can never bump*
- A `workflow_dispatch` registration in `.github/workflows/*.yml` is read from the DEFAULT branch (`main`) only — as are Dependabot alerts — so any file in that class needs a two-sided `dev` + `main` copy → *Branch model*
- Nothing bumps an action pin on a schedule and only a security advisory can, so every pin in `.github/workflows/*.yml` is checked BY HAND against the action's tag list; a green gate does not distinguish "current" from "abandoned" → *Pinned actions Dependabot can never bump*
- Never hardcode a version literal in Python: the root `package.json` is the sole source of truth, and `web/package.json` and `pyproject.toml` stay at `0.0.0` → *Versioning*
- Never put a database URL in `.env` — the test URL lives in `CT_TEST_DATABASE_URL` and nowhere else, exported from `~/.zshrc`, which a non-interactive shell does not read → *Local Postgres for the test suite*
- An exported variable beats the file, and the Vite dev proxy is NOT Vercel's rewrite → *`.env` is loaded for you — but only outside Vercel*
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "climb-trainer",
"version": "11.0.0",
"version": "11.2.0",
"private": true,
"description": "Climbing training app — plan generator, guided session player, training diary",
"engines": {
Expand Down
146 changes: 145 additions & 1 deletion server/sessions/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

from server.auth.deps import CurrentUser, RequestSession
from server.domain.grades import Discipline
from server.domain.vocabulary import ActivityKind, SessionStatus
from server.domain.vocabulary import CLIMBING_ASPECTS, ActivityKind, SessionStatus
from server.fields import (
SETS_PER_REQUEST_MAX,
ActualReps,
Expand All @@ -43,6 +43,8 @@
)
from server.models import (
Activity,
ClimbingAspect,
Exercise,
LoggedSession,
LoggedSet,
Microcycle,
Expand Down Expand Up @@ -769,3 +771,145 @@ def session_completion(
for planned_session_id, fold in _fold_sessions(rows).items()
],
)


# A hard server-side maximum (→ *Validate at the edge with Pydantic*, in the archive), in
# (day x aspect) ROWS: 200+ training days even if every day touched all ten aspects.
_VOLUME_ROWS_MAX: Final = 2000


class AspectVolumeOut(BaseModel):
"""One climbing aspect and the sets logged against it inside the returned window.

⚠️ **`sets` counts `logged_set` ROWS. It is not minutes, and there is no per-aspect
minutes figure to send.** `activity.duration_minutes` is session-level and a session mixes
aspects across its blocks, `session_block` deliberately snapshots no aspect at all, and
`logged_set.actual_work_seconds` is nullable — summing it would silently undercount every
rep-based exercise while looking like a total. A set count is the one per-aspect quantity
this schema can answer honestly.

Every seeded aspect is present, `sets = 0` included: "I have not touched power endurance in
a month" is the answer this view exists to give, and an absent row cannot say it.
"""

aspect_key: str
sets: int


class AspectVolumeResponse(BaseModel):
"""This climber's per-aspect training volume, over the window the row cap allowed.

`aspects` is every seeded aspect in `CLIMBING_ASPECTS` order, which is
`climbing_aspect.sort_order` — the content order, so this payload is deterministic and
complete. ⚠️ **The chart DOES re-sort it**, busiest aspect first, because a bar is a share
of the busiest: the wire order is the stable one, not the displayed one.
`from_date`, `to_date` and `training_days` describe the window the totals actually cover,
all three empty exactly when nothing is logged.

`truncated` says the row cap bit and older training is missing from these totals, so the UI
can admit it rather than presenting a partial sum as a lifetime one. It is exact rather
than a guess: the read asks for one row PAST the cap and `_usable_volume_rows` tests that
with a STRICT `>`, so a window holding exactly the cap does not cry wolf.

⚠️ **A day the cap split is dropped WHOLE rather than half-counted**, which is where this
read parts company with `_fold_sessions`. `truncated` promises "older training is missing";
it cannot say "one of these totals is short", so a surviving half-day would understate an
aspect with nothing on the wire to reveal it.
"""

aspects: list[AspectVolumeOut]
from_date: date | None
to_date: date | None
training_days: int
truncated: bool


def _volume_query(user_id: int) -> Select[Any]:
"""ONE statement: this climber's logged sets, counted per (training day x aspect). Grouped
by DAY rather than to a bare total because the fold below has to see WHERE a cut fell."""
return (
select(
Activity.occurred_on,
ClimbingAspect.key.label("aspect_key"),
func.count(LoggedSet.id).label("sets"),
)
.select_from(LoggedSet)
.join(LoggedSession, LoggedSession.activity_id == LoggedSet.logged_session_id)
.join(Activity, Activity.id == LoggedSession.activity_id)
.join(Exercise, Exercise.id == LoggedSet.exercise_id)
.join(ClimbingAspect, ClimbingAspect.id == Exercise.climbing_aspect_id)
# ⚠️ Scoped by the token's `user_id`, taken from the principal and never the request.
.where(Activity.user_id == user_id)
.group_by(Activity.occurred_on, ClimbingAspect.key, ClimbingAspect.sort_order)
# Newest day first, so the cap cuts the OLDEST; `sort_order` makes a day deterministic.
.order_by(Activity.occurred_on.desc(), ClimbingAspect.sort_order)
# One MORE than the cap, so `truncated` is exact rather than a false positive on the
# read that happens to hold exactly `_VOLUME_ROWS_MAX` rows. The extra row is cut.
.limit(_VOLUME_ROWS_MAX + 1)
)


class _VolumeRows(NamedTuple):
"""The rows the cap left usable, and whether it cut anything away."""

rows: Sequence[Any]
truncated: bool


def _usable_volume_rows(rows: Sequence[Any]) -> _VolumeRows:
"""The rows safe to total, and whether the cap cut anything. `AspectVolumeResponse` holds
both rules: a strict `>` past a `LIMIT` of cap+1, and a day the cut split dropped whole."""
if len(rows) <= _VOLUME_ROWS_MAX:
return _VolumeRows(rows, False)
kept = list(rows[:_VOLUME_ROWS_MAX])
split_day = rows[_VOLUME_ROWS_MAX].occurred_on
if kept and kept[-1].occurred_on == split_day:
kept = [row for row in kept if row.occurred_on != split_day]
return _VolumeRows(kept, True)


def _aspect_volume(rows: Sequence[Any]) -> AspectVolumeResponse:
"""One total per aspect, padded from `CLIMBING_ASPECTS` in its order so an untrained aspect
still appears and a newly seeded one cannot be forgotten here."""
usable = _usable_volume_rows(rows)
totals: dict[str, int] = {}
days: set[date] = set()
for row in usable.rows:
totals[row.aspect_key] = totals.get(row.aspect_key, 0) + row.sets
days.add(row.occurred_on)
return AspectVolumeResponse(
aspects=[
AspectVolumeOut(aspect_key=spec.key, sets=totals.get(spec.key, 0))
for spec in CLIMBING_ASPECTS
],
from_date=min(days) if days else None,
to_date=max(days) if days else None,
training_days=len(days),
truncated=usable.truncated,
)


@router.get("/volume")
def read_aspect_volume(
principal: CurrentUser,
session: RequestSession,
response: Response,
) -> AspectVolumeResponse:
"""How many sets this climber has logged against each climbing aspect.

**Sets, not minutes** — `AspectVolumeOut` carries the reason, which is a fact about the
schema rather than a preference.

**The join runs through `logged_set.exercise_id`, which is NOT NULL**, so off-plan sets
count and no row is lost. The prescription-side path could do neither: its
`prescribed_set_id` is nullable and `session_block` snapshots no aspect at all.

**Totals only, and the per-day rows are folded HERE rather than sent.** One chart over ten
numbers is what reads them, and a per-day series on the wire with no reader is exactly the
orphaned-wire-field shape already on the register.

**One statement, one Neon wake**, and read-only: a demo token may call it. There is no
window parameter — the row cap is the bound, and `truncated` reports it.
"""
response.headers["cache-control"] = _CACHE_CONTROL
return _aspect_volume(session.execute(_volume_query(principal.user_id)).all())
7 changes: 7 additions & 0 deletions tests/comment_budget_allowlist.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8190,6 +8190,13 @@ anchor = "tests.test_planner_climbing_floor.test_no_accepted_monotonicity_except
limit = 4
reason = "A reverse arm reads as redundant until someone knows what it caught: a stale row is a claim about the generator that has stopped being true, and a mistyped label lands here too."

[[exception]]
path = "tests/test_planner_climbing_floor.py"
kind = "docstring"
anchor = "tests.test_planner_climbing_floor.test_no_single_exercise_DOMINATES_a_plan"
limit = 24
reason = "Ruling 55 is the only home for why issue #89 closed as a non-defect, and a ceiling with no measurement behind it is a number the next reader re-picks: it carries what this sweep reads at HEAD, what a one-line narrowing of prescribable() turns that into, and PR #120's own quoted figure - the three re-derivable facts that put both ceilings where they are, and none of them rests on a sweep an older commit does not have."

[[exception]]
path = "tests/test_planner_climbing_floor.py"
kind = "hash_run"
Expand Down
2 changes: 1 addition & 1 deletion tests/test_claude_md_claims.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ def test_the_token_regex_keeps_hyphens_dots_underscores_and_slashes() -> None:
"""The trap that produced nine phantom failures against correct documentation."""
assert TOKEN.findall("`web/src/mf-contract.test.ts`") == ["web/src/mf-contract.test.ts"]
assert TOKEN.findall("server/auth/*.py") == ["server/auth/*.py"]
assert TOKEN.findall(".github/dependabot.yml") == [".github/dependabot.yml"]
assert TOKEN.findall(".github/pull_request_template.md") == [".github/pull_request_template.md"]


def test_the_fence_stripper_hides_shell_snippets_from_the_path_arm() -> None:
Expand Down
35 changes: 35 additions & 0 deletions tests/test_node_types_pin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""`@types/node` is HELD at the runtime major in `.nvmrc`, never moved to the newest major.

Type-checking against a runtime we do not run is a defect TypeScript accepts silently: newer
types describe APIs the installed Node does not have, so `tsc` stays green and the call throws.
Staleness is the CORRECT state here, so no other check in this repo objects to a newer major —
a hold nothing enforces is one the next reader "fixes". The reasoning is archived under
"Dependency policy"; the `@types/*` rule there is general, and Node is the case we have.
"""

import json
import re
from typing import Final

from server.settings import ROOT

NVMRC: Final = ROOT / ".nvmrc"
WEB_PACKAGE_JSON: Final = ROOT / "web" / "package.json"


def _major(spec: str) -> int:
"""First run of digits: `24`, `^24.13.3` and `>=24.15.0` all mean major 24."""
match = re.search(r"\d+", spec)
assert match, f"no version number in {spec!r}"
return int(match.group())


def test_types_node_is_held_at_the_runtime_major() -> None:
"""A newer major is refused, not merged, however routine the bump looks."""
runtime = _major(NVMRC.read_text(encoding="utf-8"))
web = json.loads(WEB_PACKAGE_JSON.read_text(encoding="utf-8"))
types = _major(web["devDependencies"]["@types/node"])
assert types == runtime, (
f"@types/node is on major {types} but .nvmrc runs Node {runtime}. Hold the types at "
f"the runtime major; if the runtime itself moved, move both in the same change."
)
80 changes: 80 additions & 0 deletions tests/test_planner_climbing_floor.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,11 @@ def _is_length_fill(block: BlockBlueprint, chunk: int) -> bool:
_WALL_ONLY: tuple[str, ...] = ("bouldering_wall",)
_SESSION_STEPS: tuple[int, ...] = (1, 2, 3, 4, 5, 6)

# Ruling 55's two ceilings: the most of one plan any single exercise may take. The measured
# maxima and why the gap to them is this wide are in `test_no_single_exercise_DOMINATES_a_plan`.
_MONOCULTURE_BLOCK_SHARE_PCT = 17
_MONOCULTURE_MINUTE_SHARE_PCT = 23


@dataclass(frozen=True, slots=True)
class _Sweep:
Expand Down Expand Up @@ -647,6 +652,81 @@ def test_no_accepted_monotonicity_exception_has_quietly_become_true() -> None:
)


def _heaviest_exercise(plan: PlanBlueprint) -> tuple[tuple[str, int, int], tuple[str, int, int]]:
"""`(key, its blocks, all blocks)` and `(key, its seconds, all seconds)` for the exercise
taking most of each. Ruling 29's filler family is out of the numerator AND the denominator."""
blocks: Counter[str] = Counter()
seconds: Counter[str] = Counter()
for mesocycle in plan.mesocycles:
for microcycle in mesocycle.microcycles:
for session in microcycle.sessions:
for block in session.blocks:
if block.exercise_key in OPEN_CLIMBING_KEYS:
continue
blocks[block.exercise_key] += 1
seconds[block.exercise_key] += _block_seconds(block)
block_key, block_count = blocks.most_common(1)[0]
minute_key, minute_seconds = seconds.most_common(1)[0]
return (
(block_key, block_count, sum(blocks.values())),
(minute_key, minute_seconds, sum(seconds.values())),
)


@pytest.mark.parametrize("sweep", _MONOTONICITY_SWEEP, ids=lambda sweep: sweep.label)
def test_no_single_exercise_DOMINATES_a_plan(sweep: _Sweep) -> None:
"""⚠️ GUARD, ruling 55. No ONE exercise may take more than its ceiling of a generated plan.

This is what issue #89 is CLOSED on, and "~36 exercises carry ~80% of every plan" is not the
metric: that head count is mostly set by the ELIGIBLE POOL, which the climber's equipment
decides, so it moves on a purchase rather than on a defect, and there is no concentrated head
in a generated plan to begin with. Ruling 47 already priced the one route to moving the number
— re-weighting the rotation — as presence and not proportion. What a plan does owe is that
nothing in it is a MONOCULTURE, and that is a per-plan ceiling on its single largest exercise.

BOTH halves, because PR #120 fixed a real monoculture at its cause and stated the result in
MINUTES while the metric #89 tracked is BLOCKS: a ceiling on one leaves the other to regress.
⚠️ `OPEN_CLIMBING_KEYS` is out of both shares — those blocks are ruling 27's length fill
arriving by ruling 29's decision, so counting them would measure a ruling, not a defect.

Measured over eighteen plans per row here (`_SESSION_STEPS` x `None` and both `_WEAKNESSES`),
216 across the twelve: the worst plan gives one exercise 14.85% of its blocks
(`push_ups_with_scapular_control`, advanced wall-only at 5 sessions) and 19.79% of its minutes
(`outdoor_redpoint_burns` in a one-session week, where block LENGTH rather than repetition is
what concentrates). Both ceilings are deliberately wider than a rounding on those, and the gap
is priced at HEAD rather than against a sweep no older commit has: narrow `prescribable()` to
one row per cell — the pre-#120 condition, and a one-line break — and this same sweep puts
`limit_boulders` at 17.50% of blocks and 30.78% of minutes, over both. `8edc819`'s own body is
the historical anchor and needs no measurement of mine: 25.5-27.9% of a plan's minutes.
"""
for sessions in _SESSION_STEPS:
for weakness in (None, *_WEAKNESSES):
plan = generate(
_input(
sweep.discipline,
sweep.system,
sweep.grade,
sessions,
0b111_1111,
gap=sweep.gap,
equipment=sweep.equipment,
weakness=weakness,
)
)
where = f"{sweep.label} at {sessions}/wk, weakness {weakness}"
(key, count, blocks), (minute_key, seconds, total) = _heaviest_exercise(plan)
assert count * 100 <= _MONOCULTURE_BLOCK_SHARE_PCT * blocks, (
f"{where} gives {key} {count} of the plan's {blocks} prescribed blocks "
f"({100 * count / blocks:.1f}%), against a ceiling of "
f"{_MONOCULTURE_BLOCK_SHARE_PCT}%. One exercise is running the plan."
)
assert seconds * 100 <= _MONOCULTURE_MINUTE_SHARE_PCT * total, (
f"{where} gives {minute_key} {seconds} of the plan's {total} prescribed seconds "
f"({100 * seconds / total:.1f}%), against a ceiling of "
f"{_MONOCULTURE_MINUTE_SHARE_PCT}%. One exercise is running the plan."
)


@pytest.mark.parametrize(("level", "discipline", "system", "label"), _CLIMBERS)
def test_every_session_lands_inside_its_types_window_AND_ITS_PHASES_LENGTH(
level: Level, discipline: Discipline, system: GradeSystemKey, label: str
Expand Down
Loading