Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc7bf5d8d8 | ||
|
|
e299d1e5cc | ||
|
|
d828e7e85c |
@@ -35,3 +35,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
# Agents
|
||||
.claude/settings.local.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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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
|
||||
|
||||
Bun workspaces, no build orchestrator (no Turborepo) — 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`).
|
||||
+55
-9
@@ -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 (production)
|
||||
- `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.
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@types/pg": "^8.20.0",
|
||||
"elysia": "^1.4.29",
|
||||
"kysely": "^0.29.4",
|
||||
"ml-levenberg-marquardt": "^5.0.1",
|
||||
"ml-levenberg-marquardt": "^5.1.0",
|
||||
"pg": "^8.22.0",
|
||||
"zipcodes-us": "^1.1.3"
|
||||
},
|
||||
|
||||
@@ -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}` :
|
||||
|
||||
@@ -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`
|
||||
@@ -3,13 +3,13 @@
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@blade-and-brawn/api": "workspace:*",
|
||||
"@sveltejs/kit": "^2.70.1",
|
||||
"@sveltejs/kit": "^2.70.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@types/bun": "^1.3.14",
|
||||
"elysia": "^1.4.29",
|
||||
"svelte": "^5.56.8",
|
||||
"svelte-adapter-bun": "^1.0.1",
|
||||
"svelte-check": "^4.7.3",
|
||||
"svelte-check": "^4.7.4",
|
||||
"vite": "^7.3.6"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -26,8 +26,8 @@
|
||||
"@blade-and-brawn/domain": "workspace:*",
|
||||
"@elysia/eden": "^1.4.10",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"daisyui": "^5.7.4",
|
||||
"jose": "^6.2.4",
|
||||
"daisyui": "^5.7.9",
|
||||
"jose": "^6.2.5",
|
||||
"tailwindcss": "^4.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@types/pg": "^8.20.0",
|
||||
"elysia": "^1.4.29",
|
||||
"kysely": "^0.29.4",
|
||||
"ml-levenberg-marquardt": "^5.0.1",
|
||||
"ml-levenberg-marquardt": "^5.1.0",
|
||||
"pg": "^8.22.0",
|
||||
"zipcodes-us": "^1.1.3",
|
||||
},
|
||||
@@ -42,19 +42,19 @@
|
||||
"@blade-and-brawn/domain": "workspace:*",
|
||||
"@elysia/eden": "^1.4.10",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"daisyui": "^5.7.4",
|
||||
"jose": "^6.2.4",
|
||||
"daisyui": "^5.7.9",
|
||||
"jose": "^6.2.5",
|
||||
"tailwindcss": "^4.3.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@blade-and-brawn/api": "workspace:*",
|
||||
"@sveltejs/kit": "^2.70.1",
|
||||
"@sveltejs/kit": "^2.70.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@types/bun": "^1.3.14",
|
||||
"elysia": "^1.4.29",
|
||||
"svelte": "^5.56.8",
|
||||
"svelte-adapter-bun": "^1.0.1",
|
||||
"svelte-check": "^4.7.3",
|
||||
"svelte-check": "^4.7.4",
|
||||
"vite": "^7.3.6",
|
||||
},
|
||||
},
|
||||
@@ -270,9 +270,9 @@
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="],
|
||||
|
||||
"@sveltejs/kit": ["@sveltejs/kit@2.70.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA=="],
|
||||
"@sveltejs/kit": ["@sveltejs/kit@2.70.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w=="],
|
||||
|
||||
"@sveltejs/load-config": ["@sveltejs/load-config@0.2.0", "", {}, "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg=="],
|
||||
"@sveltejs/load-config": ["@sveltejs/load-config@0.2.1", "", {}, "sha512-5m3B2cbqQ4TbwW6Xkh66Ntw6dD7gNc77cCxABTTesWcq9jxIzMgTk97pZx5vEtvQx8iokgi7GIphqZe+PGwcZA=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
|
||||
"cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="],
|
||||
|
||||
"daisyui": ["daisyui@5.7.4", "", {}, "sha512-zKzFIwvEvn2QvE1TbUUtDnYFOqWoxeZh+j4QXfhkZKYAT++pO2ijyT9rkFm0GIPDWR1/oo9hHZzd96AsTK0iZg=="],
|
||||
"daisyui": ["daisyui@5.7.9", "", {}, "sha512-oPL7yddYPQrMsDmYtxNqPGBYc+gqm14GTwu7cDxAaUq0bPg5ZcaeR/DVMgSwgo26InVn8AECbUBUCK0rMs96Kg=="],
|
||||
|
||||
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
|
||||
|
||||
@@ -470,7 +470,7 @@
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"jose": ["jose@6.2.4", "", {}, "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA=="],
|
||||
"jose": ["jose@6.2.5", "", {}, "sha512-2E5L2yRp03FnwreJLJX8/r7mHiZICCf8kG7fAsTWkSQTDAcc46NIZoQLKy+EJ8sPoJlxyS4OQR5H70LjIZZlIQ=="],
|
||||
|
||||
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
|
||||
|
||||
@@ -528,9 +528,9 @@
|
||||
|
||||
"ml-array-rescale": ["ml-array-rescale@2.0.0", "", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-max": "^2.0.0", "ml-array-min": "^2.0.0" } }, "sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg=="],
|
||||
|
||||
"ml-levenberg-marquardt": ["ml-levenberg-marquardt@5.0.1", "", { "dependencies": { "is-any-array": "^3.0.0", "ml-matrix": "^6.12.2" } }, "sha512-sOaxcZ2aIBiRrPi3v7RmqSuNmjNKHURUB9Ft2Vcr8MWes152VF1DDkcZz3N8Nj/cWbFB359cNg/o7Z99vt58Iw=="],
|
||||
"ml-levenberg-marquardt": ["ml-levenberg-marquardt@5.1.0", "", { "dependencies": { "is-any-array": "^3.0.0", "ml-matrix": "^6.14.0" } }, "sha512-yBYlUQV8+zCmz3CT7Qzt4o2o+UvOx9oxaG/HTgrMjcZRjRxVIAHm8mZM3L0GS2hLMUJpzSLilZdLb4Z5WkgdJw=="],
|
||||
|
||||
"ml-matrix": ["ml-matrix@6.12.2", "", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-rescale": "^2.0.0" } }, "sha512-GC+BnW+pBh8Auap8goAxY0senAmF0IEoc3HNVSfnfbvGw0buuDIYb9kAKMS1l+GiwJ1rfK2bzJ8IHhwjzATSFA=="],
|
||||
"ml-matrix": ["ml-matrix@6.14.0", "", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-rescale": "^2.0.0" } }, "sha512-5W31+w+6jIm05l85N3Ik04fl+5LfN1lyJLBrEM/r/j7YmvwRclcUyQGXGFWXnN7ASzVjsAnAa3FUXrduAt168A=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
@@ -634,7 +634,7 @@
|
||||
|
||||
"svelte-adapter-bun": ["svelte-adapter-bun@1.0.1", "", { "dependencies": { "rolldown": "^1.0.0-beta.38" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0", "typescript": "^5" } }, "sha512-tNOvfm8BGgG+rmEA7hkmqtq07v7zoo4skLQc+hIoQ79J+1fkEMpJEA2RzCIe3aPc8JdrsMJkv3mpiZPMsgahjA=="],
|
||||
|
||||
"svelte-check": ["svelte-check@4.7.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.0", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg=="],
|
||||
"svelte-check": ["svelte-check@4.7.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.1", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-IW9ot9YqAoyv8FvyN+eb4ZTe8zgcKZrJLNYU6dzSKkGwEBsSPc4K7lmQ8bKn8W2YMXM6WDfZSSVOaGtekyUfOQ=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# 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 recorded in code as a comment in `packages/calculator/src/index.ts`
|
||||
(top of file) and 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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user