diff --git a/.mcp.json b/.mcp.json index 8a58702..4a417a9 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,8 +1,8 @@ { - "mcpServers": { - "svelte": { - "type": "http", - "url": "https://mcp.svelte.dev/mcp" + "mcpServers": { + "svelte": { + "type": "http", + "url": "https://mcp.svelte.dev/mcp" + } } - } -} \ No newline at end of file +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c6aefa --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Blade & Brawn + +Platform monorepo for bladeandbrawn.com: an athletic level calculator (rating a +player's strength, power, endurance, and agility against real-world standards) +and the storefront/commerce integration that syncs products between Printful +and Webflow. + +## Structure + +Everything is wired together with plain `workspace:*` dependencies. + +| Path | What it is | +| --- | --- | +| `apps/api` | ElysiaJS backend — calculator, commerce sync, auth, admin endpoints | +| `apps/portal` | SvelteKit admin portal for managing standards config and commerce data | +| `packages/calculator` | Level calculation engine (standards generation + interpolation) | +| `packages/commerce` | Printful and Webflow API clients and product sync logic | +| `packages/domain` | Shared models, enums, and utils used by the packages above | + +Each app/package has its own README with setup and usage details. The level +calculator's algorithm and data sources are documented separately in +[`docs/level-calculator.md`](docs/level-calculator.md). + +## Prerequisites + +- [Bun](https://bun.sh) +- PostgreSQL (for `apps/api`) + +## Getting started + +```bash +bun install + +# run the API (see apps/api/README.md for required env vars) +bun run dev:api + +# run the portal (see apps/portal/README.md for required env vars) +bun run dev:portal +``` + +Both apps also have `build:api` / `build:portal` root scripts, and are +deployed to Fly.io (see each app's `fly.toml`). diff --git a/apps/api/README.md b/apps/api/README.md index af93d51..b0f3ca5 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -1,13 +1,59 @@ -# backend +# @blade-and-brawn/api -To install dependencies: +ElysiaJS backend for Blade & Brawn: the level calculator endpoint, Printful/Webflow +commerce sync, and the admin API used by `apps/portal`. -```bash -bun install -``` +## Stack -To run: +- [ElysiaJS](https://elysiajs.com) on Bun, run in `node:cluster` worker processes + (see `src/index.ts`; worker count is `min(CPU cores, MAX_WORKER_COUNT)`) +- PostgreSQL via [Kysely](https://kysely.dev) +- JWT cookie auth (`@elysia/jwt`) -```bash -bun run dev -``` +## Environment variables + +All required unless noted. See `src/util.ts` for the source of truth. + +| Variable | Purpose | +| --- | --- | +| `PRINTFUL_AUTH` | Printful API token | +| `PRINTFUL_STORE_ID` | Printful store ID | +| `PRINTFUL_WEBHOOK_SECRET` | Shared secret appended to the Printful webhook URL | +| `WEBFLOW_SITE_ID` | Webflow site ID | +| `WEBFLOW_COLLECTION_PRODUCTS_ID` | Webflow CMS collection ID for products | +| `WEBFLOW_COLLECTION_SKUS_ID` | Webflow CMS collection ID for SKUs | +| `WEBFLOW_AUTH` | Webflow API token | +| `WEBFLOW_WEBHOOK_SECRET` | Webflow webhook signing secret | +| `AUTH_SECRET` | JWT signing secret (must match `AUTH_SECRET` in `apps/portal`) | +| `ADMIN_PASSWORD` | sha256 hex digest checked against on `/auth/login` | +| `DATABASE_URL` | Postgres connection string | +| `DATABASE_POOL_MAX` | Postgres pool size | +| `MAX_WORKER_COUNT` | Upper bound on cluster worker processes | +| `NODE_ENV` | optional, defaults to `development` | +| `LOG_LEVEL` | optional, defaults to `info` | + +## Scripts + +Run from the repo root as `bun run dev:api` / `bun run build:api`, or from +this directory: + +- `dev` — run with hot reload +- `start` — run without hot reload +- `build` — compile to a standalone binary at `dist/server` +- `db:migrate:latest` / `:rollback` / `:up` / `:down` / `:reset` — Kysely migrations (`src/database/migrations`) +- `db:types:gen` — regenerate `src/database/out/db.d.ts` from the database schema +- `db:seed` — run `src/database/seed.ts` (loads `seed-data/standards-config.json`) + +## Structure + +- `src/server.ts` — Elysia app: routes, plugins, error handling +- `src/services/` — calculator, standards, commerce, events, event queue +- `src/database/` — Kysely instance, migrations, seed data +- `src/scripts/` — one-off scripts (e.g. `register-webhooks.ts` to (re)register + Printful/Webflow webhooks against this API's domain) + +## Deployment + +Deployed to Fly.io (`fly.toml`). After a domain changes, re-run +`src/scripts/register-webhooks.ts` with production env vars to point Printful's +and Webflow's webhooks at the new domain — see issue #3 for the full checklist. diff --git a/apps/api/src/scripts/register-webhooks.ts b/apps/api/src/scripts/register-webhooks.ts index 22fd2c6..675bb75 100644 --- a/apps/api/src/scripts/register-webhooks.ts +++ b/apps/api/src/scripts/register-webhooks.ts @@ -1,7 +1,9 @@ import { Printful, PrintfulClient, PrintfulError, Webflow, WebflowClient, WebflowError } from "@blade-and-brawn/commerce"; import { env } from "../util"; -const DOMAIN = "dev.api.bladeandbrawn.com"; +const DOMAIN = env.NODE_ENV === "development" ? + "dev.api.bladeandbrawn.com" : + "api.bladeandbrawn.com"; const PRINTFUL_WEBHOOK_URL = env.NODE_ENV === "development" ? `http://${DOMAIN}/webhooks/printful?secret=${env.PRINTFUL_WEBHOOK_SECRET}` : diff --git a/apps/portal/README.md b/apps/portal/README.md new file mode 100644 index 0000000..cc421de --- /dev/null +++ b/apps/portal/README.md @@ -0,0 +1,43 @@ +# @blade-and-brawn/portal + +Admin portal for Blade & Brawn — manage the level calculator's standards +configs/datasets, review apparel orders/products, and inspect activity events. +Talks to `apps/api` via a typed [`@elysia/eden`](https://elysiajs.com/eden/overview.html) +client. + +## Stack + +- SvelteKit 5 + Vite, deployed with `svelte-adapter-bun` +- Tailwind 4 + daisyUI +- `jose` for verifying the auth JWT cookie server-side + +## Environment variables + +| Variable | Purpose | +| --- | --- | +| `PUBLIC_API_URL` | Base URL of `apps/api`, used by the Eden client (`src/lib/api.ts`) | +| `AUTH_SECRET` | JWT verification secret — must match `AUTH_SECRET` in `apps/api` | + +## Auth + +`src/hooks.server.ts` gates every route except `/login` behind a valid `auth` +JWT cookie (issued by the API's `/auth/login` endpoint). Requests without one +are redirected to `/login`. + +## Scripts + +Run from the repo root as `bun run dev:portal` / `bun run build:portal`, or +from this directory: + +- `dev` — Vite dev server +- `build` — production build +- `preview` — preview a production build locally +- `check` / `check:watch` — `svelte-check` type checking + +## Structure + +- `src/routes/(app)/calculator/` — standards configs and datasets +- `src/routes/(app)/apparel/` — Printful/Webflow orders and products +- `src/routes/(app)/activity/` — activity events +- `src/routes/login/` — login page +- `src/lib/api.ts` — Eden client used to call `apps/api` diff --git a/docs/level-calculator.md b/docs/level-calculator.md new file mode 100644 index 0000000..4078473 --- /dev/null +++ b/docs/level-calculator.md @@ -0,0 +1,115 @@ +# The Level Calculator + +## Introduction + +The level calculator turns a player's raw performance on a handful of physical +tests — how much they can squat, how fast they run a mile, how far they can +broad jump — into a single, easy-to-understand number: their **level** for +each attribute, and an overall player level. + +The intent is to make performance legible and comparable across people of +different ages, weights, and genders. A 45-year-old squatting 225 lb and a +22-year-old squatting 225 lb are not doing the same thing physiologically, so +raw numbers alone aren't a fair yardstick. Instead, each activity is measured +against **standards**: tables of "what performance corresponds to what level" +for a given age/weight/gender, built from published strength and athletic +performance research. A player's level for an activity is found by comparing +their performance against the standard for players like them; their level for +an attribute (e.g. Strength) is the rounded average of their levels across +that attribute's activities; their overall player level is the rounded average +across all four attributes. + +The guiding principle is: **use real external standards as ground truth +wherever they exist, and only ever generate/extrapolate around that ground +truth** — never invent numbers from nothing. Standards data is also fully +config-driven (via `apps/portal`'s calculator config/dataset pages), so it can +be tuned or replaced without a code change. + +## Attributes and activities + +| Attribute | Activity | Unit | +| --- | --- | --- | +| Strength | Back Squat, Deadlift, Bench Press | kg | +| Power | Broad Jump | cm | +| Endurance | 1 Mile Run | ms | +| Agility | 3 Cone Drill | ms | + +## Sources + +The raw standards tables (before any generation/extrapolation) come from: + +| Activity | Source | +| --- | --- | +| Back Squat, Deadlift, Bench Press | [Lon Kilgore Strength Standard Tables (2023)](http://lonkilgore.com/resources/Lon_Kilgore_Strength_Standard_Tables-Copyright-2023.pdf) | +| 1 Mile Run | [runninglevel.com — 1 mile times](https://runninglevel.com/running-times/1-mile-times) | +| Broad Jump | [nrpt.co.uk — broad jump power test](https://nrpt.co.uk/training/tests/power/broad.htm) | +| 3 Cone Drill | [nflsavant.com combine data](https://nflsavant.com/combine.php) | + +These are also recorded per-activity as a `source` field on each activity's metadata +in the seeded standards dataset +(`apps/api/src/database/seed-data/standards-config.json`), which is what's +actually loaded at runtime — the config is editable from the portal, so that +seed file (and this document) may drift from whatever standards are live. + +## How a level is calculated + +`LevelCalculator.calculate` (`packages/calculator/src/index.ts`): + +1. For each activity performance the player submitted, look up the standard + for that activity at the player's exact age/weight/gender (interpolated — + see below), and find the level whose value is numerically closest to the + player's performance (`findLevel`). +2. Average the levels of all activities belonging to the same attribute, + rounded to the nearest whole level. That's the attribute level. +3. Average all four attribute levels, rounded, for the overall player level. + +If any required input is missing (a metric, or a performance of `0` or less), +the calculator returns level `0` rather than guessing. + +## How the standards tables are built + +The raw source data only covers a handful of discrete levels, ages, and +weights. `Standards` (`packages/calculator/src/index.ts`) expands that into a +continuous table through a fixed pipeline, run once per config: + +1. **Stretch** — the raw data defines 5 base levels. To support fewer/more + levels below/above those 5, an exponential-decay curve + (`A·e^(-B·i) + C`) is fit (via Levenberg-Marquardt) to the ratio between + consecutive levels, then used to extrapolate additional levels in either + direction, per `stretch.lower` / `stretch.upper` config. +2. **Expand / compress** — every standard's level count is resampled to a + single configurable `maxLevel`: expansion linearly inserts intermediate + levels, compression proportionally resamples down. +3. **Skew** — each activity has a `difficultyModifier` multiplier applied + uniformly across its levels, to make an activity easier or harder relative + to its source data. +4. **Age generation** — for ages missing from the source data, a standard is + derived from the nearest reference age using a parabolic falloff centered + on a configurable `peakAge` (steepness controlled by `ageModifier`, + clamped to a `[0.2, 10.0]` multiplier), scaled against average bodyweight + for that age (`avg-weights.json`). Real data always takes precedence over + generated data. +5. **Weight generation** — for weights missing from the source data, a + standard is derived from the reference weight using allometric scaling: + `newLevel = refLevel * (weight / refWeight) ^ weightModifier`. Again, real + data always takes precedence. + +## Finding a player's standard + +Given a player's exact age/weight/gender, `interpolateByAgeAndWeight` performs +bilinear interpolation across the nearest surrounding age and weight entries +in the (by now fully generated) standards table, producing a standard specific +to that player. `findLevel` then maps their submitted performance onto the +nearest level in that standard. + +## Where this lives in code + +- `packages/calculator/src/index.ts` — `LevelCalculator`, `Standards` +- `packages/calculator/src/models.ts` — schemas/types (`StandardsData`, + `StandardsParams`, etc.) +- `packages/calculator/src/avg-weights.ts` + `data/avg-weights.json` — + average bodyweight by age/gender, used in age generation +- `apps/api/src/services/calculator.ts` — loads the active `StandardsConfig` + from the database and constructs `LevelCalculator` +- `apps/api/src/database/seed-data/standards-config.json` — seeded standards + data/params, including per-activity `source` citations diff --git a/packages/calculator/README.md b/packages/calculator/README.md new file mode 100644 index 0000000..1800b42 --- /dev/null +++ b/packages/calculator/README.md @@ -0,0 +1,39 @@ +# @blade-and-brawn/calculator + +Turns a player's raw activity performances (e.g. squat weight, mile time) into +per-attribute and overall "levels", by generating and interpolating strength/ +performance standards across age, weight, and gender. + +For the algorithm itself and the external standards it's based on, see +[`docs/level-calculator.md`](../../docs/level-calculator.md) at the repo root. + +## Exports + +- `LevelCalculator` — takes a `Standards` instance; `calculate(player, activityPerformances)` + returns a level per `Attribute` plus an overall player level. +- `Standards` — takes a `StandardsConfig` (raw `StandardsData` + generation + `StandardsParams`) and builds the full interpolatable standards table + (stretching to more levels, generating missing ages/weights, etc.). +- Typebox schemas for the above (`models.ts`), re-exported from the package root. + +## Usage + +`StandardsConfig` normally comes from the database (see +`apps/api/src/services/calculator.ts`), not a static file, since standards +configs/datasets are editable from the admin portal: + +```ts +import { LevelCalculator, Standards, type StandardsConfig } from "@blade-and-brawn/calculator"; + +const config: StandardsConfig = /* fetched from storage */; +const calculator = new LevelCalculator(new Standards(config)); + +const { player, attributes } = calculator.calculate(playerMetrics, activityPerformances); +``` + +## Structure + +- `src/index.ts` — `LevelCalculator` and `Standards` +- `src/models.ts` — typebox schemas and types +- `src/avg-weights.ts` + `src/data/avg-weights.json` — average bodyweight by + age/gender, used when generating age-based standards diff --git a/packages/calculator/src/index.ts b/packages/calculator/src/index.ts index 7ebb6a3..f561768 100644 --- a/packages/calculator/src/index.ts +++ b/packages/calculator/src/index.ts @@ -13,16 +13,6 @@ import { levenbergMarquardt as LM } from "ml-levenberg-marquardt"; import { getAvgWeight } from "./avg-weights"; import { type LevelCalculatorOutput, type Levels, type NumberMetric, type Standard, type StandardsConfig, type StandardsData } from "./models"; -// SOURCES -// Squat, Bench, Dead Lift: -// http://lonkilgore.com/resources/Lon_Kilgore_Strength_Standard_Tables-Copyright-2023.pdf -// 1 mile run: -// https://runninglevel.com/running-times/1-mile-times -// Broad Jump: -// https://nrpt.co.uk/training/tests/power/broad.htm -// 3 Cone drill: -// https://nflsavant.com/combine.php?utm_source=chatgpt.com - export * from "./models"; const metricPriority = (m: NumberMetric) => { diff --git a/packages/commerce/README.md b/packages/commerce/README.md index 01cdc22..fb35db6 100644 --- a/packages/commerce/README.md +++ b/packages/commerce/README.md @@ -1,12 +1,30 @@ -# Printful +# @blade-and-brawn/commerce -## Definitions +Printful and Webflow API clients, plus the `ProductSyncer` that keeps Webflow +CMS products/SKUs in sync with Printful's catalog. -- "Color-grouped" products - - Products in this format: "Product Name [color]" - -## Constraints +## Exports -1. Color-grouped products should no more than *one* printful color variant -2. The single color-grouped product variant should match [color] -3. Printful product names should be unique +- `PrintfulClient` — `new PrintfulClient({ storeId, token, webhookSecret })`; + products, webhooks. +- `WebflowClient` — `new WebflowClient({ siteId, collectionIds: { products, skus }, token, webhookSecret })`; + CMS products/SKUs, webhooks. +- `ProductSyncer` — `new ProductSyncer(printful, webflow)`; `syncApparel(options)` + pulls Printful products and upserts them into the Webflow collections, + respecting the naming conventions below. +- Shared `Printful`/`Webflow` request/response types (`util/types.ts`) and misc + helpers (`util/misc.ts`, e.g. `formatSlug`). + +## Printful product naming conventions + +- "Color-grouped" products follow the format `Product Name [color]`. + +Constraints: + +1. A color-grouped product should have no more than *one* Printful color variant. +2. That single color-grouped product's variant should match `[color]`. +3. Printful product names should be unique. + +See `apps/api/src/scripts/register-webhooks.ts` for how webhooks are +registered against the API's domain, and `apps/api/src/services/commerce.ts` +for how `ProductSyncer` is invoked. diff --git a/packages/domain/README.md b/packages/domain/README.md new file mode 100644 index 0000000..989c354 --- /dev/null +++ b/packages/domain/README.md @@ -0,0 +1,24 @@ +# @blade-and-brawn/domain + +Shared models, enums, and utility functions with no dependencies on any other +workspace package — used by `packages/calculator`, `packages/commerce`, +`apps/api`, and `apps/portal` to avoid duplicating core types. + +## Exports + +### Models (`src/models.ts`) + +- `Attribute` — `Strength` / `Power` / `Endurance` / `Agility` +- `Activity` — `BackSquat` / `Deadlift` / `BenchPress` / `Run` / `BroadJump` / `ConeDrill` +- `Gender` — `Male` / `Female` +- `Metrics` / `Player` / `ActivityPerformance` — typebox schemas + types for a + player's age/weight/gender and their performance on a given activity + +### Utils (`src/utils.ts`) + +- Unit conversions: `lbToKg`, `kgToLb`, `minToMs`, `secToMs`, `msToMin`, + `ftToCm`, `inToCm`, `cmToIn`, `msToTime` +- `range(length)`, `clamp(x, lo, hi)` +- `parseRetryAfterMs(header, defaultMs)` — parses a `Retry-After` header + (seconds or HTTP date) for backoff, used by the Printful/Webflow clients +- `RateLimitError` — thrown by `packages/commerce` clients on HTTP 429