diff --git a/.prettierignore b/.prettierignore index 1916473..653166b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,5 +10,10 @@ pnpm-lock.yaml *.docx *.pdf +# Playwright artifacts +**/test-results/ +**/playwright-report/ +**/playwright/.cache/ + # User-authored project doc — preserve exact formatting CLAUDE.md diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 2472ac6..da260b4 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -3,3 +3,7 @@ out/ next-env.d.ts.bak .vercel/ *.tsbuildinfo +test-results/ +playwright-report/ +playwright/.cache/ +blob-report/ diff --git a/apps/web/e2e/home.spec.ts b/apps/web/e2e/home.spec.ts new file mode 100644 index 0000000..0ba446b --- /dev/null +++ b/apps/web/e2e/home.spec.ts @@ -0,0 +1,11 @@ +import { expect, test } from '@playwright/test'; + +test('home page renders Italian copy and CTAs', async ({ page }) => { + await page.goto('/'); + + await expect(page).toHaveTitle(/KetoPath/); + await expect(page.getByRole('heading', { name: 'KetoPath' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Inizia gratis' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Scopri come funziona' })).toBeVisible(); + await expect(page.getByText(/non sostituiscono il parere di un medico/)).toBeVisible(); +}); diff --git a/apps/web/package.json b/apps/web/package.json index d336931..b0a742b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start --port 3000", "typecheck": "tsc --noEmit", - "lint": "next lint --max-warnings=0" + "lint": "next lint --max-warnings=0", + "test:e2e": "playwright test" }, "dependencies": { "@ketopath/shared": "workspace:*", @@ -26,6 +27,7 @@ "autoprefixer": "^10.4.19", "eslint-config-next": "^14.2.5", "postcss": "^8.4.39", - "tailwindcss": "^3.4.4" + "tailwindcss": "^3.4.4", + "@playwright/test": "^1.45.3" } } diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..10c1e33 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = 3000; +const baseURL = `http://localhost:${PORT}`; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL, + trace: 'on-first-retry', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'pnpm dev', + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/package.json b/package.json index c009aa6..8e91b24 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc -b", - "test": "pnpm --filter \"./packages/*\" --filter \"./apps/*\" -r --if-present test", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "test:e2e": "pnpm --filter @ketopath/web test:e2e", "db:generate": "pnpm --filter @ketopath/db db:generate", "db:migrate": "pnpm --filter @ketopath/db db:migrate", @@ -40,7 +42,9 @@ "lint-staged": "^15.2.2", "prettier": "^3.2.5", "prettier-plugin-tailwindcss": "^0.5.14", - "typescript": "5.4.5" + "typescript": "5.4.5", + "vitest": "^1.6.0", + "@vitest/coverage-v8": "^1.6.0" }, "lint-staged": { "*.{ts,tsx,js,jsx,cjs,mjs}": [ diff --git a/packages/shared/package.json b/packages/shared/package.json index 5fb63ef..0db2b1a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -10,7 +10,9 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "lint": "eslint src --max-warnings=0" + "lint": "eslint src --max-warnings=0", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "@ketopath/eslint-config": "workspace:*", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cb0ff5c..6fac531 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1 +1,3 @@ -export {}; +export * from './nutrition/bmr.js'; +export * from './nutrition/tdee.js'; +export * from './nutrition/types.js'; diff --git a/packages/shared/src/nutrition/bmr.test.ts b/packages/shared/src/nutrition/bmr.test.ts new file mode 100644 index 0000000..3a52c42 --- /dev/null +++ b/packages/shared/src/nutrition/bmr.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { calculateBmr } from './bmr.js'; + +describe('calculateBmr (Mifflin-St Jeor)', () => { + it('matches the male formula for the "Michele" PRD persona', () => { + // 49 anni, uomo, 76 kg, 170 cm + // 10·76 + 6.25·170 − 5·49 + 5 = 760 + 1062.5 − 245 + 5 = 1582.5 + const bmr = calculateBmr({ weightKg: 76, heightCm: 170, ageYears: 49, gender: 'MALE' }); + expect(bmr).toBeCloseTo(1582.5, 2); + }); + + it('matches the female formula for the "Laura" PRD persona', () => { + // 46 anni, donna, 70 kg, 165 cm + // 10·70 + 6.25·165 − 5·46 − 161 = 700 + 1031.25 − 230 − 161 = 1340.25 + const bmr = calculateBmr({ weightKg: 70, heightCm: 165, ageYears: 46, gender: 'FEMALE' }); + expect(bmr).toBeCloseTo(1340.25, 2); + }); + + it('uses the average sex-constant for OTHER', () => { + const male = calculateBmr({ weightKg: 70, heightCm: 170, ageYears: 30, gender: 'MALE' }); + const female = calculateBmr({ weightKg: 70, heightCm: 170, ageYears: 30, gender: 'FEMALE' }); + const other = calculateBmr({ weightKg: 70, heightCm: 170, ageYears: 30, gender: 'OTHER' }); + expect(other).toBeCloseTo((male + female) / 2, 5); + }); + + it.each([ + { weightKg: 0, heightCm: 170, ageYears: 30 }, + { weightKg: 70, heightCm: 0, ageYears: 30 }, + { weightKg: 70, heightCm: 170, ageYears: 0 }, + { weightKg: -1, heightCm: 170, ageYears: 30 }, + ])('rejects non-positive inputs %j', (input) => { + expect(() => calculateBmr({ ...input, gender: 'MALE' })).toThrow(RangeError); + }); +}); diff --git a/packages/shared/src/nutrition/bmr.ts b/packages/shared/src/nutrition/bmr.ts new file mode 100644 index 0000000..f8dc921 --- /dev/null +++ b/packages/shared/src/nutrition/bmr.ts @@ -0,0 +1,27 @@ +import type { Gender } from './types.js'; + +export interface BmrInput { + weightKg: number; + heightCm: number; + ageYears: number; + gender: Gender; +} + +// Mifflin-St Jeor equation. For Gender 'OTHER' we use the average of the +// male and female sex-specific constants (-78), as a neutral approximation. +export function calculateBmr({ weightKg, heightCm, ageYears, gender }: BmrInput): number { + if (weightKg <= 0 || heightCm <= 0 || ageYears <= 0) { + throw new RangeError('weightKg, heightCm and ageYears must be positive'); + } + + const base = 10 * weightKg + 6.25 * heightCm - 5 * ageYears; + + switch (gender) { + case 'MALE': + return base + 5; + case 'FEMALE': + return base - 161; + case 'OTHER': + return base - 78; + } +} diff --git a/packages/shared/src/nutrition/tdee.test.ts b/packages/shared/src/nutrition/tdee.test.ts new file mode 100644 index 0000000..caf82a4 --- /dev/null +++ b/packages/shared/src/nutrition/tdee.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { calculateBmr } from './bmr.js'; +import { calculateTdee } from './tdee.js'; + +describe('calculateTdee', () => { + it('multiplies BMR by sedentary factor 1.2 for "Michele"', () => { + const bmr = calculateBmr({ weightKg: 76, heightCm: 170, ageYears: 49, gender: 'MALE' }); + expect(calculateTdee(bmr, 'SEDENTARY')).toBeCloseTo(1899, 1); + }); + + it('multiplies BMR by moderate factor 1.55 for "Laura"', () => { + const bmr = calculateBmr({ weightKg: 70, heightCm: 165, ageYears: 46, gender: 'FEMALE' }); + expect(calculateTdee(bmr, 'MODERATE')).toBeCloseTo(2077.39, 2); + }); + + it.each([ + ['SEDENTARY', 1.2], + ['LIGHT', 1.375], + ['MODERATE', 1.55], + ['INTENSE', 1.725], + ] as const)('applies the %s multiplier (%f)', (level, factor) => { + expect(calculateTdee(2000, level)).toBeCloseTo(2000 * factor, 5); + }); + + it('rejects non-positive BMR', () => { + expect(() => calculateTdee(0, 'SEDENTARY')).toThrow(RangeError); + expect(() => calculateTdee(-1, 'SEDENTARY')).toThrow(RangeError); + }); +}); diff --git a/packages/shared/src/nutrition/tdee.ts b/packages/shared/src/nutrition/tdee.ts new file mode 100644 index 0000000..30433f7 --- /dev/null +++ b/packages/shared/src/nutrition/tdee.ts @@ -0,0 +1,8 @@ +import { ACTIVITY_MULTIPLIERS, type ActivityLevel } from './types.js'; + +export function calculateTdee(bmr: number, activityLevel: ActivityLevel): number { + if (bmr <= 0) { + throw new RangeError('bmr must be positive'); + } + return bmr * ACTIVITY_MULTIPLIERS[activityLevel]; +} diff --git a/packages/shared/src/nutrition/types.ts b/packages/shared/src/nutrition/types.ts new file mode 100644 index 0000000..888d624 --- /dev/null +++ b/packages/shared/src/nutrition/types.ts @@ -0,0 +1,10 @@ +export type Gender = 'MALE' | 'FEMALE' | 'OTHER'; + +export type ActivityLevel = 'SEDENTARY' | 'LIGHT' | 'MODERATE' | 'INTENSE'; + +export const ACTIVITY_MULTIPLIERS: Record = { + SEDENTARY: 1.2, + LIGHT: 1.375, + MODERATE: 1.55, + INTENSE: 1.725, +}; diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts new file mode 100644 index 0000000..f46f517 --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + name: '@ketopath/shared', + environment: 'node', + include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/index.ts'], + thresholds: { + statements: 70, + branches: 70, + functions: 70, + lines: 70, + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acb7b3d..6046057 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ diff --git a/vitest.workspace.ts b/vitest.workspace.ts new file mode 100644 index 0000000..9a6c8cb --- /dev/null +++ b/vitest.workspace.ts @@ -0,0 +1,3 @@ +import { defineWorkspace } from 'vitest/config'; + +export default defineWorkspace(['./packages/shared']);