Move domain models into domain package with schemas and validate with zod + elysia. Some maintenence as well

This commit is contained in:
Dominic Ferrando
2026-06-22 13:59:04 -04:00
parent ed5abac209
commit 6fa3d78571
16 changed files with 220 additions and 166 deletions
+6 -4
View File
@@ -8,11 +8,13 @@
"start": "bun run src/index.ts" "start": "bun run src/index.ts"
}, },
"dependencies": { "dependencies": {
"@elysiajs/cors": "^1.4.2",
"elysia": "^1.4.28",
"zipcodes-us": "^1.1.3",
"@blade-and-brawn/calculator": "workspace:*", "@blade-and-brawn/calculator": "workspace:*",
"@blade-and-brawn/commerce": "workspace:*", "@blade-and-brawn/commerce": "workspace:*",
"ml-levenberg-marquardt": "^5.0.1" "@blade-and-brawn/domain": "workspace:*",
"@elysiajs/cors": "^1.4.2",
"elysia": "^1.4.29",
"ml-levenberg-marquardt": "^5.0.1",
"zipcodes-us": "^1.1.3",
"zod": "^4.4.3"
} }
} }
+38 -19
View File
@@ -2,9 +2,14 @@ import {
DEFAULT_STANDARDS_DATA, DEFAULT_STANDARDS_DATA,
LevelCalculator, LevelCalculator,
Standards, Standards,
} from "@blade-and-brawn/calculator";
import {
ActivityPerformanceSchema,
PlayerSchema,
type ActivityPerformance, type ActivityPerformance,
type Player, type Player,
} from "@blade-and-brawn/calculator"; } from "@blade-and-brawn/domain"
import { cors } from "@elysiajs/cors"; import { cors } from "@elysiajs/cors";
import { Elysia, NotFoundError } from "elysia"; import { Elysia, NotFoundError } from "elysia";
import { import {
@@ -16,6 +21,7 @@ import {
FetchError, FetchError,
} from "@blade-and-brawn/commerce"; } from "@blade-and-brawn/commerce";
import zipcodesUs from "zipcodes-us"; import zipcodesUs from "zipcodes-us";
import z from "zod";
const levelCalculator = new LevelCalculator( const levelCalculator = new LevelCalculator(
new Standards(DEFAULT_STANDARDS_DATA), new Standards(DEFAULT_STANDARDS_DATA),
@@ -25,13 +31,18 @@ export const app = new Elysia()
.use( .use(
cors({ cors({
origin: [ origin: [
// bladeandbrawn.com (apex + any subdomain) // WEBSITE
// -----------
// production
/^https?:\/\/([a-z0-9-]+\.)?bladeandbrawn\.com$/i, /^https?:\/\/([a-z0-9-]+\.)?bladeandbrawn\.com$/i,
// Webflow preview domains // testing
/^https?:\/\/([a-z0-9-]+\.)?bladeandbrawn\.webflow\.io$/i, /^https?:\/\/([a-z0-9-]+\.)?bladeandbrawn\.webflow\.io$/i,
// Fly app host (this one already works)
// PORTAL
// -----------
// production
/^https?:\/\/blade-and-brawn\.fly\.dev$/i, /^https?:\/\/blade-and-brawn\.fly\.dev$/i,
// Local dev portal // development
"http://localhost:5173", "http://localhost:5173",
], ],
}), }),
@@ -65,14 +76,9 @@ export const app = new Elysia()
// CALCULATOR // CALCULATOR
.post("/calculate", async ({ body, query }) => { .post("/calculate", async ({ body }) => {
interface CalcRequest {
player: Player;
activityPerformances: ActivityPerformance[];
}
const { player, activityPerformances } = body as CalcRequest;
const output = { const output = {
levels: levelCalculator.calculate(player, activityPerformances), levels: levelCalculator.calculate(body.player, body.activityPerformances),
}; };
// console.log({ log: query?.log }); // console.log({ log: query?.log });
@@ -85,6 +91,11 @@ export const app = new Elysia()
// } // }
return output; return output;
}, {
body: z.object({
player: PlayerSchema,
activityPerformances: z.array(ActivityPerformanceSchema)
})
}) })
// COMMERCE // COMMERCE
@@ -93,18 +104,26 @@ export const app = new Elysia()
app app
.group("/sync", (app) => .group("/sync", (app) =>
app app
.get("/", async ({}) => SyncService.state) // Sync status
.post("/:printfulProductId?", async ({ params, set }) => { .get("/", async ({ }) => SyncService.state)
// Full sync
.post("/", async ({ set }) => {
if (SyncService.state.isSyncing) { if (SyncService.state.isSyncing) {
set.status = 409; set.status = 409;
return { error: "Sync already in progress" }; return { error: "Sync already in progress" };
} }
const printfulProductId = params.printfulProductId await SyncService.sync();
? +params.printfulProductId
: undefined;
await SyncService.sync(printfulProductId);
return { ok: true }; return { ok: true };
}), })
// Per-product sync
.post("/:printfulProductId", async ({ params, set }) => {
if (SyncService.state.isSyncing) {
set.status = 409;
return { error: "Sync already in progress" };
}
await SyncService.sync(params.printfulProductId);
return { ok: true };
}, { params: z.object({ printfulProductId: z.number() }) }),
) )
.get("/:printfulProductId", async ({ params }) => { .get("/:printfulProductId", async ({ params }) => {
const printfulProductId = +params.printfulProductId; const printfulProductId = +params.printfulProductId;
+3 -3
View File
@@ -2,7 +2,7 @@
"name": "@blade-and-brawn/portal", "name": "@blade-and-brawn/portal",
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@sveltejs/kit": "^2.65.1", "@sveltejs/kit": "^2.66.0",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"svelte": "^5.56.3", "svelte": "^5.56.3",
@@ -22,9 +22,9 @@
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
"daisyui": "^5.5.23", "daisyui": "^5.5.23",
"memoirist": "^0.4.0",
"tailwindcss": "^4.3.1", "tailwindcss": "^4.3.1",
"@blade-and-brawn/calculator": "workspace:*", "@blade-and-brawn/calculator": "workspace:*",
"@blade-and-brawn/commerce": "workspace:*" "@blade-and-brawn/commerce": "workspace:*",
"@blade-and-brawn/domain": "workspace:*"
} }
} }
@@ -1,16 +1,18 @@
<script lang="ts"> <script lang="ts">
import { import {
LevelCalculator, LevelCalculator,
type LevelCalculatorOutput,
type Standards,
} from "@blade-and-brawn/calculator";
import {
Activity, Activity,
Attribute, Attribute,
Gender, Gender,
lbToKg, lbToKg,
range, range,
type LevelCalculatorOutput,
type Standards,
type ActivityPerformance, type ActivityPerformance,
type Player, type Player,
} from "@blade-and-brawn/calculator"; } from "@blade-and-brawn/domain";
interface CalcData { interface CalcData {
levels?: LevelCalculatorOutput; levels?: LevelCalculatorOutput;
@@ -1,14 +1,16 @@
<script lang="ts"> <script lang="ts">
import { import {
Standards, Standards,
type Standard,
type StandardsConfig,
} from "@blade-and-brawn/calculator";
import {
Activity, Activity,
cmToIn, cmToIn,
kgToLb, kgToLb,
range, range,
type Metrics, type Metrics,
type Standard, } from "@blade-and-brawn/domain";
type StandardsConfig,
} from "@blade-and-brawn/calculator";
interface Props { interface Props {
allStandards: Standards; allStandards: Standards;
@@ -1,13 +1,15 @@
<script lang="ts"> <script lang="ts">
import { import {
Activity,
DEFAULT_STANDARDS_DATA, DEFAULT_STANDARDS_DATA,
Gender,
lbToKg,
Standards, Standards,
type Metrics,
type StandardsConfig, type StandardsConfig,
} from "@blade-and-brawn/calculator"; } from "@blade-and-brawn/calculator";
import {
Activity,
Gender,
lbToKg,
type Metrics,
} from "@blade-and-brawn/domain";
import StandardsTable from "$lib/components/StandardsTable.svelte"; import StandardsTable from "$lib/components/StandardsTable.svelte";
import PlayersTable from "$lib/components/PlayersTable.svelte"; import PlayersTable from "$lib/components/PlayersTable.svelte";
@@ -15,6 +17,7 @@
const defaultStandards = new Standards(DEFAULT_STANDARDS_DATA); const defaultStandards = new Standards(DEFAULT_STANDARDS_DATA);
// TODO: try to clean this up
let input = $state({ let input = $state({
activity: Activity.BenchPress, activity: Activity.BenchPress,
metrics: { metrics: {
+27 -12
View File
@@ -16,10 +16,12 @@
"dependencies": { "dependencies": {
"@blade-and-brawn/calculator": "workspace:*", "@blade-and-brawn/calculator": "workspace:*",
"@blade-and-brawn/commerce": "workspace:*", "@blade-and-brawn/commerce": "workspace:*",
"@blade-and-brawn/domain": "workspace:*",
"@elysiajs/cors": "^1.4.2", "@elysiajs/cors": "^1.4.2",
"elysia": "^1.4.28", "elysia": "^1.4.29",
"ml-levenberg-marquardt": "^5.0.1", "ml-levenberg-marquardt": "^5.0.1",
"zipcodes-us": "^1.1.3", "zipcodes-us": "^1.1.3",
"zod": "^4.4.3",
}, },
}, },
"apps/portal": { "apps/portal": {
@@ -27,26 +29,27 @@
"dependencies": { "dependencies": {
"@blade-and-brawn/calculator": "workspace:*", "@blade-and-brawn/calculator": "workspace:*",
"@blade-and-brawn/commerce": "workspace:*", "@blade-and-brawn/commerce": "workspace:*",
"@tailwindcss/vite": "^4.2.1", "@blade-and-brawn/domain": "workspace:*",
"daisyui": "^5.5.19", "@tailwindcss/vite": "^4.3.1",
"memoirist": "^0.4.0", "daisyui": "^5.5.23",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.3.1",
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/kit": "^2.54.0", "@sveltejs/kit": "^2.66.0",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@types/bun": "^1.3.10", "@types/bun": "^1.3.14",
"svelte": "^5.53.10", "svelte": "^5.56.3",
"svelte-adapter-bun": "^0.5.2", "svelte-adapter-bun": "^0.5.2",
"svelte-check": "^4.4.5", "svelte-check": "^4.6.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^7.3.5",
}, },
}, },
"packages/calculator": { "packages/calculator": {
"name": "@blade-and-brawn/calculator", "name": "@blade-and-brawn/calculator",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@blade-and-brawn/domain": "workspace:*",
"ml-levenberg-marquardt": "^5.0.0", "ml-levenberg-marquardt": "^5.0.0",
}, },
}, },
@@ -54,6 +57,14 @@
"name": "@blade-and-brawn/commerce", "name": "@blade-and-brawn/commerce",
"version": "0.0.0", "version": "0.0.0",
}, },
"packages/domain": {
"name": "@blade-and-brawn/domain",
"version": "0.0.0",
"dependencies": {
"@blade-and-brawn/domain": "workspace:*",
"zod": "^4.4.3",
},
},
}, },
"packages": { "packages": {
"@blade-and-brawn/api": ["@blade-and-brawn/api@workspace:apps/api"], "@blade-and-brawn/api": ["@blade-and-brawn/api@workspace:apps/api"],
@@ -62,6 +73,8 @@
"@blade-and-brawn/commerce": ["@blade-and-brawn/commerce@workspace:packages/commerce"], "@blade-and-brawn/commerce": ["@blade-and-brawn/commerce@workspace:packages/commerce"],
"@blade-and-brawn/domain": ["@blade-and-brawn/domain@workspace:packages/domain"],
"@blade-and-brawn/portal": ["@blade-and-brawn/portal@workspace:apps/portal"], "@blade-and-brawn/portal": ["@blade-and-brawn/portal@workspace:apps/portal"],
"@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="],
@@ -188,7 +201,7 @@
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="],
"@sveltejs/kit": ["@sveltejs/kit@2.65.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-Sa1rFYYqBB+zv3rIxAg/CsFskR/x4aj5BY/hvLxBd9r/mqbipxM945As1K3PqsDicJAyekPR0BlWoVIiw2OHYg=="], "@sveltejs/kit": ["@sveltejs/kit@2.66.0", "", { "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-7nN4Ur4+nofZ36DVo83JbRe02m61Vc+I441mML/DYa1pUTZ/x26+lbrdqPen8gjmsUc6flMtHEqAtn0UfmfvAw=="],
"@sveltejs/load-config": ["@sveltejs/load-config@0.1.1", "", {}, "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA=="], "@sveltejs/load-config": ["@sveltejs/load-config@0.1.1", "", {}, "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA=="],
@@ -264,7 +277,7 @@
"devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
"elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="], "elysia": ["elysia@1.4.29", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-GwMRGGwSdjfPt+w3LA0fqTuYJtS8uVRJicvoar98/HrO5qdFKDc9CwjIb6Kja+v39lkY+58hr2JvdR9jQzlUuA=="],
"enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
@@ -404,6 +417,8 @@
"zipcodes-us": ["zipcodes-us@1.1.3", "", {}, "sha512-gOz8WAO6iWBU4aUc0lljpa+TKZckRKmgoHKcc1jWnn4eTbdc9XO5cXEaGccYn7GAjykueO0Wn+N7zdmzG0Uf5w=="], "zipcodes-us": ["zipcodes-us@1.1.3", "", {}, "sha512-gOz8WAO6iWBU4aUc0lljpa+TKZckRKmgoHKcc1jWnn4eTbdc9XO5cXEaGccYn7GAjykueO0Wn+N7zdmzG0Uf5w=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@sveltejs/kit/cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], "@sveltejs/kit/cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
+2 -1
View File
@@ -4,7 +4,8 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"ml-levenberg-marquardt": "^5.0.0" "ml-levenberg-marquardt": "^5.0.0",
"@blade-and-brawn/domain": "workspace:*"
}, },
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
+30
View File
@@ -0,0 +1,30 @@
import type { Gender, Metrics, StandardUnit } from "@blade-and-brawn/domain";
import avgWeightDataJson from "./data/avg-weights.json" assert { type: "json" };
export const getAvgWeight = (gender: Gender, age: number) => {
type AvgWeightData = {
metadata: {
unit: StandardUnit;
};
weights: Metrics[];
};
const avgWeightData = avgWeightDataJson as AvgWeightData;
const avgWeights = avgWeightData.weights.filter((a) => a.gender === gender);
const firstAvgWeight = avgWeights[0];
if (!firstAvgWeight) throw new Error("No average weights");
const closest = {
diff: Math.abs(firstAvgWeight.age - age),
avg: firstAvgWeight,
};
for (const avg of avgWeights) {
const diff = Math.abs(avg.age - age);
if (diff < closest.diff) {
closest.diff = diff;
closest.avg = avg;
}
}
return closest.avg.weight;
};
+4 -11
View File
@@ -2,17 +2,18 @@ import {
Activity, Activity,
Attribute, Attribute,
Gender, Gender,
getAvgWeight,
kgToLb, kgToLb,
lbToKg, lbToKg,
range, range,
type Metrics,
type ActivityPerformance, type ActivityPerformance,
type Player, type Player,
type StandardUnit, type StandardUnit,
} from "../src/util"; } from "@blade-and-brawn/domain";
import { levenbergMarquardt as LM } from "ml-levenberg-marquardt"; import { levenbergMarquardt as LM } from "ml-levenberg-marquardt";
import defaultConfig from "./config.json" assert { type: "json" }; import defaultConfig from "./config.json" assert { type: "json" };
import defaultStandardsJson from "./data/default-standards.json" assert { type: "json" }; import defaultStandardsJson from "./data/default-standards.json" assert { type: "json" };
import { getAvgWeight } from "./avg-weights";
// SOURCES // SOURCES
// Squat, Bench, Dead Lift: // Squat, Bench, Dead Lift:
@@ -24,8 +25,6 @@ import defaultStandardsJson from "./data/default-standards.json" assert { type:
// 3 Cone drill: // 3 Cone drill:
// https://nflsavant.com/combine.php?utm_source=chatgpt.com // https://nflsavant.com/combine.php?utm_source=chatgpt.com
export * from "./util";
export type LevelCalculatorOutput = { export type LevelCalculatorOutput = {
player: number; player: number;
attributes: Record<Attribute, number>; attributes: Record<Attribute, number>;
@@ -33,12 +32,6 @@ export type LevelCalculatorOutput = {
export type Levels = Record<string, number>; export type Levels = Record<string, number>;
export interface Metrics {
age: number;
weight: number;
gender: Gender;
}
type NumberMetric = Extract<keyof Metrics, "age" | "weight">; type NumberMetric = Extract<keyof Metrics, "age" | "weight">;
export interface Standard { export interface Standard {
@@ -72,7 +65,7 @@ const metricPriority = (m: NumberMetric) => {
export const DEFAULT_STANDARDS_DATA = defaultStandardsJson as StandardsData; export const DEFAULT_STANDARDS_DATA = defaultStandardsJson as StandardsData;
export class LevelCalculator { export class LevelCalculator {
standards: Standards; readonly standards: Standards;
public constructor(standards: Standards) { public constructor(standards: Standards) {
this.standards = standards; this.standards = standards;
-100
View File
@@ -1,100 +0,0 @@
import { type Metrics } from ".";
import avgWeightDataRaw from "./data/avg-weights.json";
// -------------------------------------------------------------------------------------------------
// DATA MODELS
// -------------------------------------------------------------------------------------------------
export type Player = {
name?: string;
metrics: Metrics;
};
export enum Attribute {
Strength = "Strength",
Power = "Power",
Endurance = "Endurance",
Agility = "Agility",
}
export enum Activity {
BackSquat = "BackSquat",
Deadlift = "Deadlift",
BenchPress = "BenchPress",
Run = "Run",
BroadJump = "BroadJump",
ConeDrill = "ConeDrill",
}
export interface ActivityPerformance {
activity: Activity;
performance: number;
}
export enum Gender {
Male = "Male",
Female = "Female",
}
// -------------------------------------------------------------------------------------------------
// Conversion utilities
// -------------------------------------------------------------------------------------------------
export type StandardUnit = "ms" | "cm" | "kg";
export const lbToKg = (lb: number | string): number => +lb * 0.453592;
export const kgToLb = (kg: number | string): number => +kg * 2.20462;
export const minToMs = (min: number) => min * 60000;
export const secToMs = (sec: number) => sec * 1000;
export const msToMin = (ms: number) => ms / 60000;
export const ftToCm = (ft: number) => ft * 30.48;
export const inToCm = (inches: number) => inches * 2.54;
export const cmToIn = (cm: number) => cm / 2.54;
export const msToTime = (ms: number, includeMs = false): string => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
const milliseconds = Math.floor(ms % 1000);
let formattedTime = `${minutes}:${seconds.toString().padStart(2, "0")}`;
if (includeMs) {
formattedTime += `.${milliseconds.toString().padStart(3, "0")}`;
}
return formattedTime;
};
export const range = (length: number) =>
Array.from({ length: length }, (_, i) => i);
export const clamp = (x: number, lo: number, hi: number) =>
Math.max(lo, Math.min(hi, x));
export const getAvgWeight = (gender: Gender, age: number) => {
type AvgWeightData = {
metadata: {
unit: StandardUnit;
};
weights: Metrics[];
};
let avgWeightData = avgWeightDataRaw as AvgWeightData;
const avgWeights = avgWeightData.weights.filter((a) => a.gender === gender);
const firstAvgWeight = avgWeights[0];
if (!firstAvgWeight) throw new Error("No average weights");
const closest = {
diff: Math.abs(firstAvgWeight.age - age),
avg: firstAvgWeight,
};
for (const avg of avgWeights) {
const diff = Math.abs(avg.age - age);
if (diff < closest.diff) {
closest.diff = diff;
closest.avg = avg;
}
}
return closest.avg.weight;
};
+1 -1
View File
@@ -24,7 +24,7 @@ export class SyncService {
syncingIds: [] as number[], syncingIds: [] as number[],
}; };
static async sync(printfulProductId: number | undefined) { static async sync(printfulProductId?: number) {
this.state.isSyncing = true; this.state.isSyncing = true;
// Populate products // Populate products
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@blade-and-brawn/domain",
"private": true,
"version": "0.0.0",
"type": "module",
"dependencies": {
"@blade-and-brawn/domain": "workspace:*",
"zod": "^4.4.3"
},
"exports": {
".": "./src/index.ts"
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./models"
export * from "./utils"
+44
View File
@@ -0,0 +1,44 @@
import { z } from "zod";
export enum Attribute {
Strength = "Strength",
Power = "Power",
Endurance = "Endurance",
Agility = "Agility",
}
export enum Activity {
BackSquat = "BackSquat",
Deadlift = "Deadlift",
BenchPress = "BenchPress",
Run = "Run",
BroadJump = "BroadJump",
ConeDrill = "ConeDrill",
}
export enum Gender {
Male = "Male",
Female = "Female",
}
export const MetricsSchema = z.object({
age: z.number(),
weight: z.number(),
gender: z.enum(Gender),
});
export const PlayerSchema = z.object({
name: z.string().optional(),
metrics: MetricsSchema,
});
export const ActivityPerformanceSchema = z.object({
activity: z.enum(Activity),
performance: z.number(),
});
export type StandardUnit = "ms" | "cm" | "kg";
export type Metrics = z.infer<typeof MetricsSchema>;
export type Player = z.infer<typeof PlayerSchema>;
export type ActivityPerformance = z.infer<typeof ActivityPerformanceSchema>;
+28
View File
@@ -0,0 +1,28 @@
export const lbToKg = (lb: number | string): number => +lb * 0.453592;
export const kgToLb = (kg: number | string): number => +kg * 2.20462;
export const minToMs = (min: number) => min * 60000;
export const secToMs = (sec: number) => sec * 1000;
export const msToMin = (ms: number) => ms / 60000;
export const ftToCm = (ft: number) => ft * 30.48;
export const inToCm = (inches: number) => inches * 2.54;
export const cmToIn = (cm: number) => cm / 2.54;
export const msToTime = (ms: number, includeMs = false): string => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
const milliseconds = Math.floor(ms % 1000);
let formattedTime = `${minutes}:${seconds.toString().padStart(2, "0")}`;
if (includeMs) {
formattedTime += `.${milliseconds.toString().padStart(3, "0")}`;
}
return formattedTime;
};
export const range = (length: number) =>
Array.from({ length: length }, (_, i) => i);
export const clamp = (x: number, lo: number, hi: number) =>
Math.max(lo, Math.min(hi, x));