From 81854784e80dd85736ed77ddd8457c2bffcde121 Mon Sep 17 00:00:00 2001 From: pushpinderbal Date: Fri, 7 Aug 2026 22:27:04 -0400 Subject: [PATCH 1/2] initial commit --- .gitignore | 21 + index.html | 26 + package-lock.json | 3105 +++++++++++++++++++++++++ package.json | 37 + public/favicon.svg | 5 + public/robots.txt | 4 + public/sitemap.xml | 18 + src/App.test.tsx | 36 + src/App.tsx | 47 + src/components/LanguageTestInputs.tsx | 143 ++ src/components/ScoreCard.tsx | 62 + src/components/Seo.tsx | 53 + src/components/ui.tsx | 224 ++ src/index.css | 72 + src/lib/crs/languages.test.ts | 119 + src/lib/crs/languages.ts | 105 + src/lib/crs/score.test.ts | 469 ++++ src/lib/crs/score.ts | 134 ++ src/lib/crs/tables.ts | 255 ++ src/lib/crs/types.ts | 57 + src/lib/oinp/score.test.ts | 207 ++ src/lib/oinp/score.ts | 231 ++ src/main.tsx | 26 + src/routes/crs/CrsTool.test.tsx | 58 + src/routes/crs/CrsTool.tsx | 352 +++ src/routes/oinp/OinpTool.test.tsx | 53 + src/routes/oinp/OinpTool.tsx | 313 +++ src/vite-env.d.ts | 1 + tsconfig.json | 23 + vite.config.ts | 12 + 30 files changed, 6268 insertions(+) create mode 100644 .gitignore create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/favicon.svg create mode 100644 public/robots.txt create mode 100644 public/sitemap.xml create mode 100644 src/App.test.tsx create mode 100644 src/App.tsx create mode 100644 src/components/LanguageTestInputs.tsx create mode 100644 src/components/ScoreCard.tsx create mode 100644 src/components/Seo.tsx create mode 100644 src/components/ui.tsx create mode 100644 src/index.css create mode 100644 src/lib/crs/languages.test.ts create mode 100644 src/lib/crs/languages.ts create mode 100644 src/lib/crs/score.test.ts create mode 100644 src/lib/crs/score.ts create mode 100644 src/lib/crs/tables.ts create mode 100644 src/lib/crs/types.ts create mode 100644 src/lib/oinp/score.test.ts create mode 100644 src/lib/oinp/score.ts create mode 100644 src/main.tsx create mode 100644 src/routes/crs/CrsTool.test.tsx create mode 100644 src/routes/crs/CrsTool.tsx create mode 100644 src/routes/oinp/OinpTool.test.tsx create mode 100644 src/routes/oinp/OinpTool.tsx create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e27cb0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Dependencies +node_modules + +# Build output +dist +dist-ssr + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Editor / OS +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.local diff --git a/index.html b/index.html new file mode 100644 index 0000000..96f0f44 --- /dev/null +++ b/index.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + Immigration Tools — CRS & OINP Calculators + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e668ee0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3105 @@ +{ + "name": "immigration-tools", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "immigration-tools", + "version": "0.1.0", + "dependencies": { + "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "^5.3.0", + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/react": "^16.3.2", + "@types/node": "^26.1.2", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "jsdom": "^30.0.1", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.2.1", + "vitest": "^4.1.10" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", + "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.2.1", + "@csstools/css-color-parser": "^4.1.9", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@fontsource-variable/geist": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", + "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/geist-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist-mono/-/geist-mono-5.3.0.tgz", + "integrity": "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9340889 --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "immigration-tools", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Free, client-side Canadian immigration tools.", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "^5.3.0", + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/react": "^16.3.2", + "@types/node": "^26.1.2", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "jsdom": "^30.0.1", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.2.1", + "vitest": "^4.1.10" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..8af2e1c --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..bf65552 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://tools.s1ngh.ca/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..fb12df5 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,18 @@ + + + + https://tools.s1ngh.ca/ + monthly + 0.9 + + + https://tools.s1ngh.ca/crs + monthly + 1.0 + + + https://tools.s1ngh.ca/oinp + monthly + 0.9 + + diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..be8eb2f --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { afterEach, describe, expect, it } from 'vitest' +import { App } from './App' + +afterEach(cleanup) + +function renderApp(initialEntry = '/crs') { + return render( + + + }> + crs page} /> + oinp page} /> + + + , + ) +} + +describe('App header', () => { + it('shows the wordmark and crawlable tool links', () => { + renderApp('/crs') + expect(screen.getByRole('link', { name: /s1ngh/ })).toBeTruthy() + expect(screen.getByRole('link', { name: 'CRS Calculator' })).toBeTruthy() + expect(screen.getByRole('link', { name: 'OINP Points Calculator' })).toBeTruthy() + }) + + it('navigates via the header links', () => { + renderApp('/crs') + expect(screen.getByText('crs page')).toBeTruthy() + fireEvent.click(screen.getByRole('link', { name: 'OINP Points Calculator' })) + expect(screen.getByText('oinp page')).toBeTruthy() + }) +}) diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..7fd3ac1 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,47 @@ +import { Link, NavLink, Outlet } from 'react-router-dom' +import { cn } from './components/ui' + +const NAV = [ + { to: '/crs', label: 'CRS Calculator' }, + { to: '/oinp', label: 'OINP Points Calculator' }, +] + +export function App() { + return ( +
+
+
+ + s1ngh.ca/tools + + +
+
+ +
+ +
+ +
+
+

Estimates only — verify with official sources

+
+
+
+ ) +} diff --git a/src/components/LanguageTestInputs.tsx b/src/components/LanguageTestInputs.tsx new file mode 100644 index 0000000..8ccbaf2 --- /dev/null +++ b/src/components/LanguageTestInputs.tsx @@ -0,0 +1,143 @@ +import { useMemo } from 'react' +import { convertTestToClb, emptyScores } from '../lib/crs/languages' +import type { LanguageTestId, LanguageTestState } from '../lib/crs/languages' +import { ABILITIES } from '../lib/crs/tables' +import type { LanguageAbility } from '../lib/crs/types' +import { Select } from './ui' + +const TEST_LABELS: Record, string> = { + ielts: 'IELTS General', + celpip: 'CELPIP General', + pte: 'PTE Core', + tef: 'TEF Canada', + tcf: 'TCF Canada', +} + +const IELTS_OPTIONS = [ + { value: '0.0', label: '—' }, + ...Array.from({ length: 17 }, (_, i) => { + const v = 1 + i * 0.5 + return { value: v.toFixed(1), label: v.toFixed(1) } + }), +] + +const CELPIP_OPTIONS = [ + { value: '0', label: '—' }, + ...Array.from({ length: 10 }, (_, i) => { + const v = 3 + i + return { value: String(v), label: String(v) } + }), +] + +function AbilityInput({ + test, + ability, + score, + onChange, + ariaLabel, +}: { + test: Exclude + ability: LanguageAbility + score: number + onChange: (value: number) => void + ariaLabel: string +}) { + const base = 'h-9 w-full rounded-lg border border-line bg-panel px-2 font-mono text-xs tabular-nums text-ink outline-none transition-shadow focus:border-accent focus:ring-2 focus:ring-accent/20' + + if (test === 'ielts' || test === 'celpip') { + const options = test === 'ielts' ? IELTS_OPTIONS : CELPIP_OPTIONS + return ( + { + const n = Number(e.target.value) + onChange(Number.isFinite(n) ? n : 0) + }} + className={base} + /> + ) +} + +export function LanguageTestInputs({ + title, + allowedTests, + value, + onChange, +}: { + title: string + allowedTests: readonly Exclude[] + value: LanguageTestState + onChange: (next: LanguageTestState) => void +}) { + const clb = useMemo(() => convertTestToClb(value.test, value.scores), [value]) + const scale = value.test === 'tef' || value.test === 'tcf' ? 'NCLC' : 'CLB' + + return ( +
+
+

{title}

+
+ onChange(e.target.value)} + className="h-10 w-full cursor-pointer appearance-none rounded-lg border border-line bg-panel px-3 pr-9 text-sm text-ink outline-none transition-shadow focus:border-accent focus:ring-2 focus:ring-accent/20" + > + {options.map((o) => ( + + ))} + + + + +
+ ) +} + +export function Segmented({ + value, + onChange, + options, + ariaLabel, +}: { + value: T + onChange: (value: T) => void + options: ReadonlyArray<{ value: T; label: string }> + ariaLabel?: string +}) { + return ( +
+ {options.map((o) => { + const active = value === o.value + return ( + + ) + })} +
+ ) +} + +export function NumberInput({ + label, + value, + onChange, + min, + max, + step, + suffix, +}: { + label: string + value: string + onChange: (value: string) => void + min?: number + max?: number + step?: number + suffix?: string +}) { + return ( + + ) +} + +export function Slider({ + label, + value, + onChange, + min, + max, + format, +}: { + label: string + value: number + onChange: (value: number) => void + min: number + max: number + format?: (value: number) => string +}) { + const pct = max === min ? 0 : ((value - min) / (max - min)) * 100 + return ( +
+
+ {label} + + {format ? format(value) : String(value)} + +
+ onChange(Number(e.target.value))} + className="slider" + style={{ + background: `linear-gradient(to right, var(--color-accent) 0%, var(--color-accent) ${pct}%, var(--color-line) ${pct}%, var(--color-line) 100%)`, + }} + /> +
+ ) +} + +export function CheckRow({ + label, + checked, + onChange, +}: { + label: string + checked: boolean + onChange: (checked: boolean) => void +}) { + return ( + + ) +} + +export function Note({ children }: { children: ReactNode }) { + return

{children}

+} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..331b01a --- /dev/null +++ b/src/index.css @@ -0,0 +1,72 @@ +@import "@fontsource-variable/geist"; +@import "@fontsource-variable/geist-mono"; +@import "tailwindcss"; + +@theme { + --color-bg: #f8fafc; + --color-panel: #ffffff; + --color-ink: #0f172a; + --color-muted: #475569; + --color-line: #e2e8f0; + --color-accent: #2563eb; + --color-accent-soft: #eff6ff; + --font-sans: "Geist Variable", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: "Geist Mono Variable", ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} + +:root { + color-scheme: light; +} + +html { + background: var(--color-bg); +} + +body { + margin: 0; + background: var(--color-bg); + color: var(--color-ink); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +::selection { + background: var(--color-accent); + color: #fff; +} + +/* Range slider — Stripe-style thin track, blurred thumb */ +.slider { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 4px; + border-radius: 9999px; + outline: none; + cursor: pointer; +} +.slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 9999px; + background: #fff; + border: 2px solid var(--color-accent); + box-shadow: 0 1px 3px rgb(10 37 64 / 0.2); + cursor: pointer; + transition: box-shadow 0.15s ease; +} +.slider::-webkit-slider-thumb:hover { + box-shadow: 0 1px 3px rgb(15 23 42 / 0.2), 0 0 0 6px rgb(37 99 235 / 0.15); +} +.slider::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 9999px; + background: #fff; + border: 2px solid var(--color-accent); + box-shadow: 0 1px 3px rgb(10 37 64 / 0.2); + cursor: pointer; +} diff --git a/src/lib/crs/languages.test.ts b/src/lib/crs/languages.test.ts new file mode 100644 index 0000000..0aa1316 --- /dev/null +++ b/src/lib/crs/languages.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { convertTestToClb, emptyScores, overallClb } from './languages' +import type { LanguageScores } from './languages' + +const s = (reading: number, writing: number, listening: number, speaking: number): LanguageScores => ({ + reading, + writing, + listening, + speaking, +}) + +const toClb = (r: number, w: number, l: number, sp: number) => + convertTestToClb('ielts', s(r, w, l, sp)) + +describe('IELTS General → CLB (official table)', () => { + it('CLB 10 (L8.5 R8.0 W7.5 S7.5)', () => { + expect(toClb(8, 7.5, 8.5, 7.5)).toEqual({ reading: 10, writing: 10, listening: 10, speaking: 10 }) + }) + it('CLB 9 (L8.0 R7.0 W7.0 S7.0)', () => { + expect(toClb(7, 7, 8, 7)).toEqual({ reading: 9, writing: 9, listening: 9, speaking: 9 }) + }) + it('CLB 8 (L7.5 R6.5 W6.5 S6.5)', () => { + expect(toClb(6.5, 6.5, 7.5, 6.5)).toEqual({ reading: 8, writing: 8, listening: 8, speaking: 8 }) + }) + it('CLB 7 (L6.0 R6.0 W6.0 S6.0)', () => { + expect(toClb(6, 6, 6, 6)).toEqual({ reading: 7, writing: 7, listening: 7, speaking: 7 }) + }) + it('CLB 6 (L5.5 R5.0 W5.5 S5.5)', () => { + expect(toClb(5, 5.5, 5.5, 5.5)).toEqual({ reading: 6, writing: 6, listening: 6, speaking: 6 }) + }) + it('CLB 5 (L5.0 R4.0 W5.0 S5.0)', () => { + expect(toClb(4, 5, 5, 5)).toEqual({ reading: 5, writing: 5, listening: 5, speaking: 5 }) + }) + it('CLB 4 (L4.5 R3.5 W4.0 S4.0)', () => { + expect(toClb(3.5, 4, 4.5, 4)).toEqual({ reading: 4, writing: 4, listening: 4, speaking: 4 }) + }) + it('below CLB 4 maps to 0', () => { + expect(toClb(3, 3.5, 4, 3.5)).toEqual({ reading: 0, writing: 0, listening: 0, speaking: 0 }) + }) + it('handles mixed bands per ability', () => { + // reading 6.0→7, writing 6.5→8, listening 7.5→8, speaking 6.5→8 + expect(toClb(6, 6.5, 7.5, 6.5)).toEqual({ reading: 7, writing: 8, listening: 8, speaking: 8 }) + }) +}) + +describe('CELPIP General → CLB (1:1)', () => { + const c = (r: number, w: number, l: number, sp: number) => + convertTestToClb('celpip', s(r, w, l, sp)) + it('level n maps to CLB n', () => { + expect(c(7, 7, 7, 7)).toEqual({ reading: 7, writing: 7, listening: 7, speaking: 7 }) + expect(c(9, 9, 9, 9)).toEqual({ reading: 9, writing: 9, listening: 9, speaking: 9 }) + }) + it('levels 11-12 cap at CLB 10', () => { + expect(c(12, 11, 12, 11)).toEqual({ reading: 10, writing: 10, listening: 10, speaking: 10 }) + }) + it('level 3 is below CLB 4 → 0', () => { + expect(c(3, 3, 3, 3)).toEqual({ reading: 0, writing: 0, listening: 0, speaking: 0 }) + }) +}) + +describe('PTE Core → CLB (official table)', () => { + const p = (r: number, w: number, l: number, sp: number) => + convertTestToClb('pte', s(r, w, l, sp)) + it('top bands', () => { + expect(p(90, 90, 90, 90)).toEqual({ reading: 10, writing: 10, listening: 10, speaking: 10 }) + expect(p(88, 89, 89, 89)).toEqual({ reading: 10, writing: 9, listening: 10, speaking: 10 }) + }) + it('CLB 9', () => { + expect(p(78, 88, 82, 84)).toEqual({ reading: 9, writing: 9, listening: 9, speaking: 9 }) + }) + it('CLB 7', () => { + expect(p(60, 69, 60, 68)).toEqual({ reading: 7, writing: 7, listening: 7, speaking: 7 }) + }) + it('below CLB 4 → 0', () => { + expect(p(32, 31, 17, 33)).toEqual({ reading: 0, writing: 0, listening: 0, speaking: 0 }) + }) +}) + +describe('TEF Canada (post Dec 2023) → NCLC (official table)', () => { + const t = (r: number, w: number, l: number, sp: number) => + convertTestToClb('tef', s(r, w, l, sp)) + it('CLB 7 bands', () => { + expect(t(434, 428, 434, 456)).toEqual({ reading: 7, writing: 7, listening: 7, speaking: 7 }) + }) + it('CLB 9 bands', () => { + expect(t(503, 512, 503, 518)).toEqual({ reading: 9, writing: 9, listening: 9, speaking: 9 }) + }) + it('below CLB 4 → 0', () => { + expect(t(305, 267, 305, 327)).toEqual({ reading: 0, writing: 0, listening: 0, speaking: 0 }) + }) +}) + +describe('TCF Canada → NCLC (official table)', () => { + const t = (r: number, w: number, l: number, sp: number) => + convertTestToClb('tcf', s(r, w, l, sp)) + it('CLB 6 (writing/speaking are 4-20 scale)', () => { + expect(t(406, 7, 398, 7)).toEqual({ reading: 6, writing: 6, listening: 6, speaking: 6 }) + }) + it('CLB 10', () => { + expect(t(549, 16, 549, 16)).toEqual({ reading: 10, writing: 10, listening: 10, speaking: 10 }) + }) + it('below CLB 4 → 0', () => { + expect(t(341, 3, 330, 3)).toEqual({ reading: 0, writing: 0, listening: 0, speaking: 0 }) + }) +}) + +describe('no test', () => { + it('maps to all zeros', () => { + expect(convertTestToClb('none', emptyScores())).toEqual({ listening: 0, reading: 0, writing: 0, speaking: 0 }) + }) +}) + +describe('overallClb', () => { + it('is the lowest band across abilities', () => { + expect(overallClb({ listening: 9, reading: 7, writing: 8, speaking: 8 })).toBe(7) + expect(overallClb({ listening: 10, reading: 10, writing: 10, speaking: 10 })).toBe(10) + expect(overallClb({ listening: 0, reading: 7, writing: 7, speaking: 7 })).toBe(0) + }) +}) diff --git a/src/lib/crs/languages.ts b/src/lib/crs/languages.ts new file mode 100644 index 0000000..0ec7400 --- /dev/null +++ b/src/lib/crs/languages.ts @@ -0,0 +1,105 @@ +/** + * Language test score → CLB/NCLC conversion. + * + * Source: IRCC "Language results" equivalence tables (canada.ca, updated 2026-05-27): + * https://www.canada.ca/en/immigration-refugees-citizenship/services/study-canada/work/after-graduation/eligibility/language-results.html + * + * - IELTS General Training → CLB (per ability thresholds) + * - CELPIP General → CLB is 1:1 with the reported level + * - PTE Core → CLB (per ability ranges) + * - TEF Canada (post Dec 10, 2023 scoring) → NCLC + * - TCF Canada → NCLC + * + * Pure functions, no framework dependencies. + */ + +import { ABILITIES } from './tables' +import type { Clb, LanguageAbility, LanguageProficiency } from './types' + +export type LanguageTestId = 'none' | 'ielts' | 'celpip' | 'pte' | 'tef' | 'tcf' + +export type LanguageScores = Record + +export interface LanguageTestState { + test: LanguageTestId + scores: LanguageScores +} + +type Thresholds = readonly (readonly [Clb, number])[] + +const IELTS: Record = { + reading: [[10, 8], [9, 7], [8, 6.5], [7, 6], [6, 5], [5, 4], [4, 3.5]], + writing: [[10, 7.5], [9, 7], [8, 6.5], [7, 6], [6, 5.5], [5, 5], [4, 4]], + listening: [[10, 8.5], [9, 8], [8, 7.5], [7, 6], [6, 5.5], [5, 5], [4, 4.5]], + speaking: [[10, 7.5], [9, 7], [8, 6.5], [7, 6], [6, 5.5], [5, 5], [4, 4]], +} + +const PTE: Record = { + reading: [[10, 88], [9, 78], [8, 69], [7, 60], [6, 51], [5, 42], [4, 33]], + writing: [[10, 90], [9, 88], [8, 79], [7, 69], [6, 60], [5, 51], [4, 41]], + listening: [[10, 89], [9, 82], [8, 71], [7, 60], [6, 50], [5, 39], [4, 28]], + speaking: [[10, 89], [9, 84], [8, 76], [7, 68], [6, 59], [5, 51], [4, 42]], +} + +const TEF: Record = { + reading: [[10, 546], [9, 503], [8, 462], [7, 434], [6, 393], [5, 352], [4, 306]], + writing: [[10, 558], [9, 512], [8, 472], [7, 428], [6, 379], [5, 330], [4, 268]], + listening: [[10, 546], [9, 503], [8, 462], [7, 434], [6, 393], [5, 352], [4, 306]], + speaking: [[10, 556], [9, 518], [8, 494], [7, 456], [6, 422], [5, 387], [4, 328]], +} + +const TCF: Record = { + reading: [[10, 549], [9, 524], [8, 499], [7, 453], [6, 406], [5, 375], [4, 342]], + writing: [[10, 16], [9, 14], [8, 12], [7, 10], [6, 7], [5, 6], [4, 4]], + listening: [[10, 549], [9, 523], [8, 503], [7, 458], [6, 398], [5, 369], [4, 331]], + speaking: [[10, 16], [9, 14], [8, 12], [7, 10], [6, 7], [5, 6], [4, 4]], +} + +type ThresholdTest = Exclude + +const TABLES: Record> = { + ielts: IELTS, + pte: PTE, + tef: TEF, + tcf: TCF, +} + +function thresholdClb(table: Thresholds, score: number): Clb { + for (const [clb, threshold] of table) { + if (score >= threshold) return clb + } + return 0 +} + +/** Convert raw test scores to a CLB/NCLC proficiency (0 = below CLB 4 / no result). */ +export function convertTestToClb(test: LanguageTestId, scores: LanguageScores): LanguageProficiency { + const result = { listening: 0, reading: 0, writing: 0, speaking: 0 } as LanguageProficiency + if (test === 'none') return result + + if (test === 'celpip') { + for (const a of ABILITIES) { + const level = scores[a] + result[a] = level >= 11 ? 10 : level >= 4 ? (Math.round(level) as Clb) : 0 + } + return result + } + + const table = TABLES[test] + for (const a of ABILITIES) { + result[a] = thresholdClb(table[a], scores[a]) + } + return result +} + +/** Overall CLB for a language = lowest band across the four abilities. */ +export function overallClb(proficiency: LanguageProficiency): Clb { + let min = 10 + for (const a of ABILITIES) { + if (proficiency[a] < min) min = proficiency[a] + } + return min as Clb +} + +export function emptyScores(): LanguageScores { + return { listening: 0, reading: 0, writing: 0, speaking: 0 } +} diff --git a/src/lib/crs/score.test.ts b/src/lib/crs/score.test.ts new file mode 100644 index 0000000..df86f93 --- /dev/null +++ b/src/lib/crs/score.test.ts @@ -0,0 +1,469 @@ +import { describe, expect, it } from 'vitest' +import { + ADDITIONAL_CANADIAN_EDUCATION_TABLE, + CANADIAN_WORK_TABLE, + EDUCATION_TABLE, + SPOUSE_CANADIAN_WORK_TABLE, + SPOUSE_EDUCATION_TABLE, + agePoints, + certificateQualificationPoints, + clbValues, + educationCanadianWorkPoints, + educationLanguagePoints, + firstLanguagePointsPerAbility, + foreignWorkCanadianWorkPoints, + foreignWorkLanguagePoints, + secondLanguagePointsPerAbility, + spouseLanguagePointsPerAbility, +} from './tables' +import { additionalPoints, coreHumanCapitalPoints, crsScore, spousePoints, transferabilityPoints } from './score' +import type { CrsInput, EducationLevel, LanguageProficiency } from './types' + +const ab = (l: number, r: number, w: number, s: number): LanguageProficiency => ({ + listening: l as LanguageProficiency['listening'], + reading: r as LanguageProficiency['reading'], + writing: w as LanguageProficiency['writing'], + speaking: s as LanguageProficiency['speaking'], +}) + +const allClb = (c: number): LanguageProficiency => ab(c, c, c, c) + +function base(overrides: Partial = {}): CrsInput { + return { + age: 30, + hasAccompanyingSpouse: false, + education: 'bachelor', + firstOfficialLanguage: 'english', + english: allClb(7), + french: allClb(0), + canadianWorkYears: 0, + foreignWorkYears: 0, + certificateOfQualification: false, + provincialNomination: false, + canadianEducation: 'none', + siblingInCanada: false, + ...overrides, + } +} + +// ---- A. Core / human capital factors ---- + +describe('age points (official table)', () => { + const rows: Array<[number, number, number]> = [ + [17, 0, 0], + [18, 90, 99], + [19, 95, 105], + [20, 100, 110], + [29, 100, 110], + [30, 95, 105], + [31, 90, 99], + [32, 85, 94], + [33, 80, 88], + [34, 75, 83], + [35, 70, 77], + [36, 65, 72], + [37, 60, 66], + [38, 55, 61], + [39, 50, 55], + [40, 45, 50], + [41, 35, 39], + [42, 25, 28], + [43, 15, 17], + [44, 5, 6], + [45, 0, 0], + [60, 0, 0], + ] + for (const [age, withSpouse, withoutSpouse] of rows) { + it(`age ${age}: ${withSpouse} / ${withoutSpouse}`, () => { + expect(agePoints(age, true)).toBe(withSpouse) + expect(agePoints(age, false)).toBe(withoutSpouse) + }) + } +}) + +describe('education points (official table)', () => { + const rows: Array<[EducationLevel, number, number]> = [ + ['less-than-secondary', 0, 0], + ['secondary', 28, 30], + ['one-year', 84, 90], + ['two-year', 91, 98], + ['bachelor', 112, 120], + ['two-plus', 119, 128], + ['master', 126, 135], + ['doctorate', 140, 150], + ] + for (const [level, withSpouse, withoutSpouse] of rows) { + it(`${level}: ${withSpouse} / ${withoutSpouse}`, () => { + expect(EDUCATION_TABLE[level]).toEqual([withSpouse, withoutSpouse]) + }) + } +}) + +describe('first official language points per ability (official table)', () => { + const rows: Array<[number, number, number]> = [ + [0, 0, 0], + [4, 6, 6], + [5, 6, 6], + [6, 8, 9], + [7, 16, 17], + [8, 22, 23], + [9, 29, 31], + [10, 32, 34], + ] + for (const [clb, withSpouse, withoutSpouse] of rows) { + it(`CLB ${clb}: ${withSpouse} / ${withoutSpouse}`, () => { + expect(firstLanguagePointsPerAbility(clb as never, true)).toBe(withSpouse) + expect(firstLanguagePointsPerAbility(clb as never, false)).toBe(withoutSpouse) + }) + } +}) + +describe('second official language points per ability (official table)', () => { + const rows: Array<[number, number]> = [ + [0, 0], + [4, 0], + [5, 1], + [6, 1], + [7, 3], + [8, 3], + [9, 6], + [10, 6], + ] + for (const [clb, points] of rows) { + it(`CLB ${clb}: ${points}`, () => { + expect(secondLanguagePointsPerAbility(clb as never)).toBe(points) + }) + } +}) + +describe('Canadian work experience points (official table)', () => { + const rows: Array<[number, number, number]> = [ + [0, 0, 0], + [1, 35, 40], + [2, 46, 53], + [3, 56, 64], + [4, 63, 72], + [5, 70, 80], + ] + for (const [years, withSpouse, withoutSpouse] of rows) { + it(`${years} year(s): ${withSpouse} / ${withoutSpouse}`, () => { + expect(CANADIAN_WORK_TABLE[years as never]).toEqual([withSpouse, withoutSpouse]) + }) + } +}) + +// ---- B. Spouse factors ---- + +describe('spouse education points (official table)', () => { + const rows: Array<[EducationLevel, number]> = [ + ['less-than-secondary', 0], + ['secondary', 2], + ['one-year', 6], + ['two-year', 7], + ['bachelor', 8], + ['two-plus', 9], + ['master', 10], + ['doctorate', 10], + ] + for (const [level, points] of rows) { + it(`${level}: ${points}`, () => { + expect(SPOUSE_EDUCATION_TABLE[level]).toBe(points) + }) + } +}) + +describe('spouse official language points per ability (official table)', () => { + const rows: Array<[number, number]> = [ + [0, 0], + [4, 0], + [5, 1], + [6, 1], + [7, 3], + [8, 3], + [9, 5], + [10, 5], + ] + for (const [clb, points] of rows) { + it(`CLB ${clb}: ${points}`, () => { + expect(spouseLanguagePointsPerAbility(clb as never)).toBe(points) + }) + } +}) + +describe('spouse Canadian work experience points (official table)', () => { + const rows: Array<[number, number]> = [ + [0, 0], + [1, 5], + [2, 7], + [3, 8], + [4, 9], + [5, 10], + ] + for (const [years, points] of rows) { + it(`${years} year(s): ${points}`, () => { + expect(SPOUSE_CANADIAN_WORK_TABLE[years as never]).toBe(points) + }) + } +}) + +// ---- C. Skill transferability ---- + +describe('education + language transferability (official table)', () => { + const clb7 = allClb(7) + const clb9 = allClb(9) + const below7 = ab(7, 7, 6, 7) + + it('secondary or less: 0', () => { + expect(educationLanguagePoints('less-than-secondary', clbValues(clb7))).toBe(0) + expect(educationLanguagePoints('secondary', clbValues(clb9))).toBe(0) + }) + it('post-secondary 1 yr+ at CLB 7-8: 13', () => { + for (const level of ['one-year', 'two-year', 'bachelor'] as const) { + expect(educationLanguagePoints(level, clbValues(clb7))).toBe(13) + } + }) + it('post-secondary 1 yr+ at CLB 9+: 25', () => { + for (const level of ['one-year', 'two-year', 'bachelor'] as const) { + expect(educationLanguagePoints(level, clbValues(clb9))).toBe(25) + } + }) + it('two-plus / master / doctorate at CLB 7-8: 25; at CLB 9+: 50', () => { + for (const level of ['two-plus', 'master', 'doctorate'] as const) { + expect(educationLanguagePoints(level, clbValues(clb7))).toBe(25) + expect(educationLanguagePoints(level, clbValues(clb9))).toBe(50) + } + }) + it('requires CLB 7 on all four abilities', () => { + expect(educationLanguagePoints('bachelor', clbValues(below7))).toBe(0) + expect(educationLanguagePoints('master', clbValues(below7))).toBe(0) + }) +}) + +describe('education + Canadian work transferability (official table)', () => { + it('secondary or less: 0', () => { + expect(educationCanadianWorkPoints('secondary', 5)).toBe(0) + }) + it('post-secondary 1 yr+ with 1 yr Canadian work: 13; 2+ yrs: 25', () => { + for (const level of ['one-year', 'two-year', 'bachelor'] as const) { + expect(educationCanadianWorkPoints(level, 1)).toBe(13) + expect(educationCanadianWorkPoints(level, 2)).toBe(25) + expect(educationCanadianWorkPoints(level, 5)).toBe(25) + } + }) + it('two-plus / master / doctorate with 1 yr Canadian work: 25; 2+ yrs: 50', () => { + for (const level of ['two-plus', 'master', 'doctorate'] as const) { + expect(educationCanadianWorkPoints(level, 1)).toBe(25) + expect(educationCanadianWorkPoints(level, 2)).toBe(50) + expect(educationCanadianWorkPoints(level, 4)).toBe(50) + } + }) + it('no Canadian work: 0', () => { + expect(educationCanadianWorkPoints('master', 0)).toBe(0) + }) +}) + +describe('foreign work + language transferability (official table)', () => { + const clb7 = allClb(7) + const clb9 = allClb(9) + it('no foreign work: 0', () => { + expect(foreignWorkLanguagePoints(0, clbValues(clb9))).toBe(0) + }) + it('1-2 years at CLB 7-8: 13; at CLB 9+: 25', () => { + expect(foreignWorkLanguagePoints(1, clbValues(clb7))).toBe(13) + expect(foreignWorkLanguagePoints(2, clbValues(clb7))).toBe(13) + expect(foreignWorkLanguagePoints(1, clbValues(clb9))).toBe(25) + }) + it('3+ years at CLB 7-8: 25; at CLB 9+: 50', () => { + expect(foreignWorkLanguagePoints(3, clbValues(clb7))).toBe(25) + expect(foreignWorkLanguagePoints(5, clbValues(clb9))).toBe(50) + }) + it('requires CLB 7+', () => { + expect(foreignWorkLanguagePoints(3, clbValues(allClb(6)))).toBe(0) + }) +}) + +describe('foreign work + Canadian work transferability (official table)', () => { + it('requires both foreign and Canadian work', () => { + expect(foreignWorkCanadianWorkPoints(0, 2)).toBe(0) + expect(foreignWorkCanadianWorkPoints(3, 0)).toBe(0) + }) + it('1-2 years foreign + 1 yr Canadian: 13; + 2+ yrs: 25', () => { + expect(foreignWorkCanadianWorkPoints(1, 1)).toBe(13) + expect(foreignWorkCanadianWorkPoints(2, 1)).toBe(13) + expect(foreignWorkCanadianWorkPoints(1, 2)).toBe(25) + }) + it('3+ years foreign + 1 yr Canadian: 25; + 2+ yrs: 50', () => { + expect(foreignWorkCanadianWorkPoints(3, 1)).toBe(25) + expect(foreignWorkCanadianWorkPoints(5, 2)).toBe(50) + }) +}) + +describe('certificate of qualification + language (official table)', () => { + it('no certificate: 0', () => { + expect(certificateQualificationPoints(false, clbValues(allClb(9)))).toBe(0) + }) + it('requires CLB 5+', () => { + expect(certificateQualificationPoints(true, clbValues(allClb(4)))).toBe(0) + }) + it('certificate + CLB 5-6: 25; + CLB 7+: 50', () => { + expect(certificateQualificationPoints(true, clbValues(allClb(5)))).toBe(25) + expect(certificateQualificationPoints(true, clbValues(allClb(6)))).toBe(25) + expect(certificateQualificationPoints(true, clbValues(allClb(7)))).toBe(50) + }) +}) + +describe('transferability section caps', () => { + it('education sub-category caps at 50', () => { + const input = base({ education: 'master', english: allClb(9), canadianWorkYears: 2 }) + expect(transferabilityPoints(input)).toBe(50) + }) + it('foreign work sub-category caps at 50 (1-2 yrs + CLB 9 + 2 yr Canadian work)', () => { + const input = base({ education: 'secondary', english: allClb(9), foreignWorkYears: 1, canadianWorkYears: 2 }) + expect(transferabilityPoints(input)).toBe(50) + }) + it('full 100 (master + CLB 9 + foreign work + 2 yr Canadian work)', () => { + const input = base({ + education: 'master', + english: allClb(9), + foreignWorkYears: 3, + canadianWorkYears: 2, + }) + expect(transferabilityPoints(input)).toBe(100) + }) + it('transferability never exceeds 100', () => { + const input = base({ + education: 'doctorate', + english: allClb(10), + foreignWorkYears: 5, + canadianWorkYears: 5, + certificateOfQualification: true, + }) + expect(transferabilityPoints(input)).toBe(100) + }) +}) + +// ---- D. Additional points ---- + +describe('additional points (official table)', () => { + it('provincial nomination: 600', () => { + expect(additionalPoints(base({ provincialNomination: true }))).toBe(600) + }) + it('Canadian education: 15 / 30', () => { + expect(ADDITIONAL_CANADIAN_EDUCATION_TABLE).toEqual({ none: 0, 'one-two-years': 15, 'three-plus-years': 30 }) + expect(additionalPoints(base({ canadianEducation: 'one-two-years' }))).toBe(15) + expect(additionalPoints(base({ canadianEducation: 'three-plus-years' }))).toBe(30) + }) + it('sibling in Canada: 15', () => { + expect(additionalPoints(base({ siblingInCanada: true }))).toBe(15) + }) + it('French NCLC 7+ with English CLB 4 or less: 25', () => { + expect(additionalPoints(base({ french: allClb(7), english: allClb(4) }))).toBe(25) + }) + it('French NCLC 7+ with English CLB 5+: 50', () => { + expect(additionalPoints(base({ french: allClb(7), english: allClb(5) }))).toBe(50) + expect(additionalPoints(base({ french: allClb(8), english: allClb(7) }))).toBe(50) + }) + it('French below NCLC 7: no bonus', () => { + expect(additionalPoints(base({ french: allClb(6), english: allClb(9) }))).toBe(0) + }) + it('no English test but strong French: 25', () => { + expect(additionalPoints(base({ french: allClb(9), english: allClb(0) }))).toBe(25) + }) + it('caps at 600', () => { + expect(additionalPoints(base({ provincialNomination: true, siblingInCanada: true, canadianEducation: 'three-plus-years', french: allClb(9) }))).toBe(600) + }) + it('French bonus on top of other additional points', () => { + expect(additionalPoints(base({ french: allClb(7), english: allClb(5), siblingInCanada: true, canadianEducation: 'three-plus-years' }))).toBe(95) + }) +}) + +// ---- Worked examples (verified against official CRS tool / CIC News walkthrough) ---- + +describe('worked examples', () => { + it('single, age 26, bachelor, CLB 9 English, 3 yr Canadian work, 3+ yr Canadian study', () => { + const input = base({ + age: 26, + education: 'bachelor', + english: allClb(9), + canadianWorkYears: 3, + canadianEducation: 'three-plus-years', + }) + // Core: 110 (age) + 120 (edu) + 124 (CLB 9 x 4) + 64 (3 yr) = 418 + expect(coreHumanCapitalPoints(input)).toBe(418) + // Transferability: education = 25 (language) + 25 (2+ yr Canadian work) = 50 + expect(transferabilityPoints(input)).toBe(50) + // Additional: Canadian study = 30 + expect(additionalPoints(input)).toBe(30) + expect(crsScore(input)).toEqual({ core: 418, spouse: 0, transferability: 50, additional: 30, total: 498 }) + }) + + it('same but adds 1 year foreign work (2 yr Canadian work)', () => { + const input = base({ + age: 26, + education: 'bachelor', + english: allClb(9), + canadianWorkYears: 2, + foreignWorkYears: 1, + canadianEducation: 'three-plus-years', + }) + // Core: 110 + 120 + 124 + 53 = 407 + expect(coreHumanCapitalPoints(input)).toBe(407) + // Transferability: education 50 + foreign (25 lang + 25 cdn) 50 = 100 + expect(transferabilityPoints(input)).toBe(100) + expect(crsScore(input).total).toBe(537) + }) + + it('single applicant with provincial nomination (IRCC walkthrough)', () => { + const input = base({ + age: 28, + education: 'master', + english: allClb(9), + canadianWorkYears: 3, + provincialNomination: true, + }) + // Core: 110 + 135 + 124 + 64 = 433 + expect(coreHumanCapitalPoints(input)).toBe(433) + // Transferability: master + CLB 9 = 50 + expect(transferabilityPoints(input)).toBe(50) + expect(crsScore(input)).toEqual({ core: 433, spouse: 0, transferability: 50, additional: 600, total: 1083 }) + }) + + it('with accompanying spouse, all factors maxed', () => { + const input = base({ + age: 29, + hasAccompanyingSpouse: true, + education: 'doctorate', + english: allClb(10), + french: allClb(10), + canadianWorkYears: 5, + foreignWorkYears: 5, + certificateOfQualification: true, + provincialNomination: true, + canadianEducation: 'three-plus-years', + siblingInCanada: true, + spouse: { + education: 'doctorate', + language: allClb(10), + canadianWorkYears: 5, + }, + }) + // Core (with spouse, max 460): age 100 + edu 140 + lang 128 + 22 = 390... wait + expect(coreHumanCapitalPoints(input)).toBe(460) + expect(spousePoints(input)).toBe(40) + expect(transferabilityPoints(input)).toBe(100) + expect(additionalPoints(input)).toBe(600) + expect(crsScore(input).total).toBe(1200) + }) + + it('second official language bonus adds up to 24 (single)', () => { + const input = base({ english: allClb(7), french: allClb(9) }) + // 105 (age 30, single) + 120 + 68 (English CLB 7 x 4) + 24 (French CLB 9 x 4) + expect(coreHumanCapitalPoints(input)).toBe(317) + }) + + it('core capped at 500 without spouse', () => { + const input = base({ age: 24, education: 'doctorate', english: allClb(10), french: allClb(10), canadianWorkYears: 5 }) + // 110 + 150 + 136 + 24 + 80 = 500 + expect(coreHumanCapitalPoints(input)).toBe(500) + }) +}) diff --git a/src/lib/crs/score.ts b/src/lib/crs/score.ts new file mode 100644 index 0000000..33df1b6 --- /dev/null +++ b/src/lib/crs/score.ts @@ -0,0 +1,134 @@ +/** + * CRS scoring engine — pure functions, zero framework dependencies. + * + * Verified against IRCC "Comprehensive Ranking System (CRS) criteria" + * (canada.ca, updated 2026-06-22) and the official CRS tool's worked examples. + */ + +import { + ABILITIES, + CANADIAN_WORK_TABLE, + MAX_ADDITIONAL, + MAX_CORE_WITH_SPOUSE, + MAX_CORE_WITHOUT_SPOUSE, + MAX_SPOUSE, + MAX_TRANSFERABILITY, + MAX_TOTAL, + SPOUSE_CANADIAN_WORK_TABLE, + SPOUSE_EDUCATION_TABLE, + agePoints, + certificateQualificationPoints, + clbValues, + clamp, + educationCanadianWorkPoints, + educationLanguagePoints, + firstLanguagePointsPerAbility, + foreignWorkCanadianWorkPoints, + foreignWorkLanguagePoints, + frenchBonusPoints, + secondLanguagePointsPerAbility, + spouseLanguagePointsPerAbility, +} from './tables' +import { ADDITIONAL_CANADIAN_EDUCATION_TABLE, EDUCATION_TABLE } from './tables' +import type { CrsBreakdown, CrsInput, LanguageProficiency } from './types' + +/** A. Core / human capital factors. Capped at 460 (with spouse) or 500 (without). */ +export function coreHumanCapitalPoints(input: CrsInput): number { + const withSpouse = input.hasAccompanyingSpouse + + const age = agePoints(input.age, withSpouse) + const education = EDUCATION_TABLE[input.education][withSpouse ? 0 : 1] + const languages = languageProficiencyPoints(input, withSpouse) + const canadianWork = CANADIAN_WORK_TABLE[input.canadianWorkYears][withSpouse ? 0 : 1] + + const cap = withSpouse ? MAX_CORE_WITH_SPOUSE : MAX_CORE_WITHOUT_SPOUSE + return clamp(age + education + languages + canadianWork, 0, cap) +} + +/** First official language (max 128 with spouse / 136 without) + second official language bonus (max 22 / 24). */ +function languageProficiencyPoints(input: CrsInput, withSpouse: boolean): number { + const first = input.firstOfficialLanguage === 'english' ? input.english : input.french + const second = input.firstOfficialLanguage === 'english' ? input.french : input.english + + const firstPoints = ABILITIES.reduce( + (sum, a) => sum + firstLanguagePointsPerAbility(first[a], withSpouse), + 0, + ) + + const secondPoints = ABILITIES.reduce( + (sum, a) => sum + secondLanguagePointsPerAbility(second[a]), + 0, + ) + const secondCap = withSpouse ? 22 : 24 + + return clamp(firstPoints, 0, withSpouse ? 128 : 136) + clamp(secondPoints, 0, secondCap) +} + +/** B. Spouse or common-law partner factors. Capped at 40. */ +export function spousePoints(input: CrsInput): number { + const spouse = input.spouse + if (!input.hasAccompanyingSpouse || !spouse) return 0 + + const education = SPOUSE_EDUCATION_TABLE[spouse.education] + + const language = ABILITIES.reduce( + (sum, a) => sum + spouseLanguagePointsPerAbility(spouse.language[a]), + 0, + ) + + const canadianWork = SPOUSE_CANADIAN_WORK_TABLE[spouse.canadianWorkYears] + + return clamp(education + language + canadianWork, 0, MAX_SPOUSE) +} + +/** C. Skill transferability factors. Capped at 100. */ +export function transferabilityPoints(input: CrsInput): number { + const firstLanguageClb = clbValues(input.firstOfficialLanguage === 'english' ? input.english : input.french) + const canadianWork = input.canadianWorkYears + const foreignWork = input.foreignWorkYears + + const education = clamp( + educationLanguagePoints(input.education, firstLanguageClb) + + educationCanadianWorkPoints(input.education, canadianWork), + 0, + 50, + ) + + const foreign = clamp( + foreignWorkLanguagePoints(foreignWork, firstLanguageClb) + + foreignWorkCanadianWorkPoints(foreignWork, canadianWork), + 0, + 50, + ) + + const certificate = certificateQualificationPoints(input.certificateOfQualification, firstLanguageClb) + + return clamp(education + foreign + certificate, 0, MAX_TRANSFERABILITY) +} + +/** D. Additional points. Capped at 600. */ +export function additionalPoints(input: CrsInput): number { + let points = 0 + if (input.provincialNomination) points += 600 + points += ADDITIONAL_CANADIAN_EDUCATION_TABLE[input.canadianEducation] + if (input.siblingInCanada) points += 15 + points += frenchBonusPoints(clbValues(input.french), clbValues(input.english)) + return clamp(points, 0, MAX_ADDITIONAL) +} + +/** Full CRS score. */ +export function crsScore(input: CrsInput): CrsBreakdown { + const core = coreHumanCapitalPoints(input) + const spouse = spousePoints(input) + const transferability = transferabilityPoints(input) + const additional = additionalPoints(input) + const total = clamp(core + spouse + transferability + additional, 0, MAX_TOTAL) + return { core, spouse, transferability, additional, total } +} + +export const LANGUAGE_PROFICIENCY_EMPTY: LanguageProficiency = { + listening: 0, + reading: 0, + writing: 0, + speaking: 0, +} diff --git a/src/lib/crs/tables.ts b/src/lib/crs/tables.ts new file mode 100644 index 0000000..3b2fff6 --- /dev/null +++ b/src/lib/crs/tables.ts @@ -0,0 +1,255 @@ +/** + * CRS point tables. + * + * Source: IRCC, "Comprehensive Ranking System (CRS) criteria" + * https://www.canada.ca/en/immigration-refugees-citizenship/services/immigrate-canada/express-entry/check-score/crs-criteria.html + * Page updated 2026-06-22. + * + * Notes on the current rules: + * - Job offer (arranged employment) points were removed by IRCC on 2025-03-25 and + * are intentionally absent. + * - Skill transferability: the education sub-category and the foreign work + * sub-category each cap at 50 points (the two tables within each sub-category + * add together up to that cap); the whole section caps at 100 points. + */ + +import type { Clb, EducationLevel, LanguageAbility, WorkYears } from './types' + +export const MAX_CORE_WITH_SPOUSE = 460 +export const MAX_CORE_WITHOUT_SPOUSE = 500 +export const MAX_SPOUSE = 40 +export const MAX_TRANSFERABILITY = 100 +export const MAX_ADDITIONAL = 600 +export const MAX_TOTAL = 1200 + +export const ABILITIES: readonly LanguageAbility[] = ['listening', 'reading', 'writing', 'speaking'] + +// --- A. Core / human capital factors --- + +/** [with spouse, without spouse] per exact age; anything < 18 or >= 45 scores 0. */ +const AGE_TABLE: ReadonlyMap = new Map([ + [18, [90, 99]], + [19, [95, 105]], + [20, [100, 110]], + [21, [100, 110]], + [22, [100, 110]], + [23, [100, 110]], + [24, [100, 110]], + [25, [100, 110]], + [26, [100, 110]], + [27, [100, 110]], + [28, [100, 110]], + [29, [100, 110]], + [30, [95, 105]], + [31, [90, 99]], + [32, [85, 94]], + [33, [80, 88]], + [34, [75, 83]], + [35, [70, 77]], + [36, [65, 72]], + [37, [60, 66]], + [38, [55, 61]], + [39, [50, 55]], + [40, [45, 50]], + [41, [35, 39]], + [42, [25, 28]], + [43, [15, 17]], + [44, [5, 6]], +]) + +export function agePoints(age: number, withSpouse: boolean): number { + const row = AGE_TABLE.get(age) + if (!row) return 0 + return withSpouse ? row[0] : row[1] +} + +/** [with spouse, without spouse] by education level. */ +export const EDUCATION_TABLE: Readonly> = { + 'less-than-secondary': [0, 0], + secondary: [28, 30], + 'one-year': [84, 90], + 'two-year': [91, 98], + bachelor: [112, 120], + 'two-plus': [119, 128], + master: [126, 135], + doctorate: [140, 150], +} + +/** First official language points, per ability, by CLB. */ +export function firstLanguagePointsPerAbility(clb: Clb, withSpouse: boolean): number { + switch (clb) { + case 0: + return 0 + case 4: + case 5: + return 6 + case 6: + return withSpouse ? 8 : 9 + case 7: + return withSpouse ? 16 : 17 + case 8: + return withSpouse ? 22 : 23 + case 9: + return withSpouse ? 29 : 31 + case 10: + return withSpouse ? 32 : 34 + } +} + +/** Second official language bonus, per ability, by CLB. */ +export function secondLanguagePointsPerAbility(clb: Clb): number { + if (clb >= 9) return 6 + if (clb >= 7) return 3 + if (clb >= 5) return 1 + return 0 +} + +/** [with spouse, without spouse] by years of Canadian skilled work. */ +export const CANADIAN_WORK_TABLE: Readonly> = { + 0: [0, 0], + 1: [35, 40], + 2: [46, 53], + 3: [56, 64], + 4: [63, 72], + 5: [70, 80], +} + +// --- B. Spouse or common-law partner factors --- + +export const SPOUSE_EDUCATION_TABLE: Readonly> = { + 'less-than-secondary': 0, + secondary: 2, + 'one-year': 6, + 'two-year': 7, + bachelor: 8, + 'two-plus': 9, + master: 10, + doctorate: 10, +} + +export function spouseLanguagePointsPerAbility(clb: Clb): number { + if (clb >= 9) return 5 + if (clb >= 7) return 3 + if (clb >= 5) return 1 + return 0 +} + +export const SPOUSE_CANADIAN_WORK_TABLE: Readonly> = { + 0: 0, + 1: 5, + 2: 7, + 3: 8, + 4: 9, + 5: 10, +} + +// --- C. Skill transferability factors --- + +/** + * Education + official language proficiency (requires CLB 7+ on all four + * first-official-language abilities). Returns points from the CLB 7-8 column or + * the CLB 9+ column. + */ +export function educationLanguagePoints( + education: EducationLevel, + firstLanguageClb: readonly Clb[], +): number { + if (firstLanguageClb.some((c) => c < 7)) return 0 + const allNine = firstLanguageClb.every((c) => c >= 9) + switch (education) { + case 'less-than-secondary': + case 'secondary': + return 0 + case 'one-year': + case 'two-year': + case 'bachelor': + return allNine ? 25 : 13 + case 'two-plus': + case 'master': + case 'doctorate': + return allNine ? 50 : 25 + } +} + +/** Education + Canadian work experience. */ +export function educationCanadianWorkPoints( + education: EducationLevel, + canadianWorkYears: WorkYears, +): number { + switch (education) { + case 'less-than-secondary': + case 'secondary': + return 0 + case 'one-year': + case 'two-year': + case 'bachelor': + return canadianWorkYears === 0 ? 0 : canadianWorkYears === 1 ? 13 : 25 + case 'two-plus': + case 'master': + case 'doctorate': + return canadianWorkYears === 0 ? 0 : canadianWorkYears === 1 ? 25 : 50 + } +} + +/** Foreign work experience + official language proficiency (requires CLB 7+). */ +export function foreignWorkLanguagePoints( + foreignWorkYears: WorkYears, + firstLanguageClb: readonly Clb[], +): number { + if (foreignWorkYears === 0) return 0 + if (firstLanguageClb.some((c) => c < 7)) return 0 + const allNine = firstLanguageClb.every((c) => c >= 9) + if (foreignWorkYears <= 2) return allNine ? 25 : 13 + return allNine ? 50 : 25 +} + +/** Foreign work experience + Canadian work experience. */ +export function foreignWorkCanadianWorkPoints( + foreignWorkYears: WorkYears, + canadianWorkYears: WorkYears, +): number { + if (foreignWorkYears === 0 || canadianWorkYears === 0) return 0 + const twoPlusCanadian = canadianWorkYears >= 2 + if (foreignWorkYears <= 2) return twoPlusCanadian ? 25 : 13 + return twoPlusCanadian ? 50 : 25 +} + +/** Certificate of qualification (trade occupations) + official language proficiency (requires CLB 5+). */ +export function certificateQualificationPoints( + hasCertificate: boolean, + firstLanguageClb: readonly Clb[], +): number { + if (!hasCertificate) return 0 + if (firstLanguageClb.some((c) => c < 5)) return 0 + const allSeven = firstLanguageClb.every((c) => c >= 7) + return allSeven ? 50 : 25 +} + +// --- D. Additional points --- + +export const ADDITIONAL_CANADIAN_EDUCATION_TABLE = { + none: 0, + 'one-two-years': 15, + 'three-plus-years': 30, +} as const satisfies Record + +/** Additional points for French ability, based on English proficiency. */ +export function frenchBonusPoints( + frenchClb: readonly Clb[], + englishClb: readonly Clb[], +): number { + const frenchAtLeastSeven = frenchClb.every((c) => c >= 7) + if (!frenchAtLeastSeven) return 0 + const englishAtLeastFive = englishClb.every((c) => c >= 5) + return englishAtLeastFive ? 50 : 25 +} + +// --- Helpers --- + +export function clbValues(proficiency: Record): Clb[] { + return ABILITIES.map((a) => proficiency[a]) +} + +export function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} diff --git a/src/lib/crs/types.ts b/src/lib/crs/types.ts new file mode 100644 index 0000000..9819990 --- /dev/null +++ b/src/lib/crs/types.ts @@ -0,0 +1,57 @@ +/** + * CRS (Comprehensive Ranking System) — domain types. + * Pure data, no framework dependencies. + */ + +/** Canadian Language Benchmark level per ability. `0` = no test result / below CLB 4. */ +export type Clb = 0 | 4 | 5 | 6 | 7 | 8 | 9 | 10 + +export type LanguageAbility = 'listening' | 'reading' | 'writing' | 'speaking' + +export type LanguageProficiency = Record + +export type EducationLevel = + | 'less-than-secondary' + | 'secondary' + | 'one-year' + | 'two-year' + | 'bachelor' + | 'two-plus' + | 'master' + | 'doctorate' + +/** Years of skilled work experience. `5` means five years or more. */ +export type WorkYears = 0 | 1 | 2 | 3 | 4 | 5 + +export type CanadianEducation = 'none' | 'one-two-years' | 'three-plus-years' + +export interface SpouseProfile { + education: EducationLevel + language: LanguageProficiency + canadianWorkYears: WorkYears +} + +export interface CrsInput { + age: number + /** Accompanying spouse/common-law partner who is not a citizen or PR of Canada. */ + hasAccompanyingSpouse: boolean + education: EducationLevel + firstOfficialLanguage: 'english' | 'french' + english: LanguageProficiency + french: LanguageProficiency + canadianWorkYears: WorkYears + foreignWorkYears: WorkYears + spouse?: SpouseProfile + certificateOfQualification: boolean + provincialNomination: boolean + canadianEducation: CanadianEducation + siblingInCanada: boolean +} + +export interface CrsBreakdown { + core: number + spouse: number + transferability: number + additional: number + total: number +} diff --git a/src/lib/oinp/score.test.ts b/src/lib/oinp/score.test.ts new file mode 100644 index 0000000..590c2ac --- /dev/null +++ b/src/lib/oinp/score.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest' +import { + canadianCredentialsPoints, + earningsPoints, + educationPoints, + languageAbilityPoints, + legalStatusPoints, + nocBroadPoints, + officialLanguagesPoints, + oinpScore, + ontarioWorkPoints, + regionPoints, + teerPoints, + tenureInPositionPoints, + wagePoints, + workExperiencePoints, +} from './score' +import type { OinpInput } from './score' + +function base(overrides: Partial = {}): OinpInput { + return { + teer: 2, + nocBroad: 0, + hourlyWage: 30, + tenureInPosition: '13-24', + ontarioWork: 'over-24', + earnings: '50k-70k', + legalStatus: 'work-permit', + education: 'bachelor', + canadianCredentials: 'one', + englishClb: 7, + frenchClb: 0, + region: 'eastern', + ...overrides, + } +} + +describe('OINP Workforce Priority factor points (official grid)', () => { + it('NOC TEER category', () => { + expect(teerPoints(0)).toBe(9) + expect(teerPoints(1)).toBe(9) + expect(teerPoints(2)).toBe(6) + expect(teerPoints(3)).toBe(6) + expect(teerPoints(4)).toBe(0) + expect(teerPoints(5)).toBe(0) + }) + + it('NOC broad occupational category', () => { + expect(nocBroadPoints(3)).toBe(10) + expect(nocBroadPoints(7)).toBe(8) + expect(nocBroadPoints(2)).toBe(6) + expect(nocBroadPoints(0)).toBe(4) + expect(nocBroadPoints(1)).toBe(4) + expect(nocBroadPoints(4)).toBe(4) + expect(nocBroadPoints(8)).toBe(4) + expect(nocBroadPoints(9)).toBe(4) + expect(nocBroadPoints(5)).toBe(2) + expect(nocBroadPoints(6)).toBe(2) + }) + + it('wage bands', () => { + expect(wagePoints(40)).toBe(15) + expect(wagePoints(39.99)).toBe(12) + expect(wagePoints(35)).toBe(12) + expect(wagePoints(34.99)).toBe(10) + expect(wagePoints(25)).toBe(8) + expect(wagePoints(24.99)).toBe(5) + expect(wagePoints(20)).toBe(5) + expect(wagePoints(19.99)).toBe(0) + }) + + it('tenure in job offer position', () => { + expect(tenureInPositionPoints('over-24')).toBe(18) + expect(tenureInPositionPoints('13-24')).toBe(15) + expect(tenureInPositionPoints('6-12')).toBe(12) + expect(tenureInPositionPoints('less-6')).toBe(0) + }) + + it('ontario work (fallback when <6 months in position)', () => { + expect(ontarioWorkPoints('over-24')).toBe(12) + expect(ontarioWorkPoints('13-24')).toBe(9) + expect(ontarioWorkPoints('6-12')).toBe(6) + expect(ontarioWorkPoints('less-6')).toBe(0) + }) + + it('work experience uses Ontario fallback only under 6 months in position', () => { + expect(workExperiencePoints('over-24', 'less-6')).toBe(18) + expect(workExperiencePoints('less-6', '13-24')).toBe(9) + expect(workExperiencePoints('less-6', 'less-6')).toBe(0) + }) + + it('earnings history', () => { + expect(earningsPoints('over-70k')).toBe(8) + expect(earningsPoints('50k-70k')).toBe(6) + expect(earningsPoints('30k-50k')).toBe(4) + expect(earningsPoints('under-30k')).toBe(0) + }) + + it('legal status', () => { + expect(legalStatusPoints('work-permit')).toBe(10) + expect(legalStatusPoints('study-permit')).toBe(5) + expect(legalStatusPoints('none')).toBe(0) + }) + + it('highest education', () => { + expect(educationPoints('doctorate')).toBe(10) + expect(educationPoints('masters')).toBe(8) + expect(educationPoints('above-bachelor')).toBe(6) + expect(educationPoints('bachelor')).toBe(6) + expect(educationPoints('ogcc')).toBe(5) + expect(educationPoints('below-bachelor')).toBe(5) + expect(educationPoints('college')).toBe(5) + expect(educationPoints('apprenticeship')).toBe(5) + expect(educationPoints('less-than-college')).toBe(0) + }) + + it('number of Canadian credentials', () => { + expect(canadianCredentialsPoints('multiple')).toBe(10) + expect(canadianCredentialsPoints('one')).toBe(5) + expect(canadianCredentialsPoints('none')).toBe(0) + }) + + it('official language ability (lowest CLB across abilities)', () => { + expect(languageAbilityPoints(9)).toBe(15) + expect(languageAbilityPoints(10)).toBe(15) + expect(languageAbilityPoints(8)).toBe(12) + expect(languageAbilityPoints(7)).toBe(8) + expect(languageAbilityPoints(6)).toBe(4) + expect(languageAbilityPoints(5)).toBe(0) + expect(languageAbilityPoints(0)).toBe(0) + }) + + it('knowledge of official languages (CLB 6 threshold for two)', () => { + expect(officialLanguagesPoints(6, 6)).toBe(10) + expect(officialLanguagesPoints(7, 8)).toBe(10) + expect(officialLanguagesPoints(7, 5)).toBe(5) + expect(officialLanguagesPoints(5, 0)).toBe(5) + expect(officialLanguagesPoints(4, 4)).toBe(0) + expect(officialLanguagesPoints(0, 0)).toBe(0) + }) + + it('regional immigration', () => { + expect(regionPoints('northern')).toBe(15) + expect(regionPoints('eastern')).toBe(10) + expect(regionPoints('central')).toBe(10) + expect(regionPoints('southwestern')).toBe(10) + expect(regionPoints('gta-except-toronto')).toBe(5) + expect(regionPoints('toronto')).toBe(0) + }) +}) + +describe('OINP total', () => { + it('maximized profile scores the full 130', () => { + const score = oinpScore( + base({ + teer: 0, + nocBroad: 3, + hourlyWage: 45, + tenureInPosition: 'over-24', + earnings: 'over-70k', + education: 'doctorate', + canadianCredentials: 'multiple', + englishClb: 9, + frenchClb: 6, + region: 'northern', + }), + ) + expect(score).toEqual({ labour: 70, education: 20, language: 25, region: 15, total: 130 }) + }) + + it('typical mid profile', () => { + const score = oinpScore(base()) + // labour: 6 (teer) + 4 (noc0) + 10 (wage) + 15 (13-24mo) + 6 (earnings) + 10 (work permit) = 51 + // education: 6 + 5 = 11 + // language: 8 (CLB7) + 5 (1 lang) = 13 + // region: 10 + expect(score).toEqual({ labour: 51, education: 11, language: 13, region: 10, total: 85 }) + }) + + it('Toronto with study permit and no earnings', () => { + const score = oinpScore( + base({ + teer: 5, + nocBroad: 6, + hourlyWage: 18, + tenureInPosition: 'less-6', + ontarioWork: 'less-6', + earnings: 'under-30k', + legalStatus: 'study-permit', + education: 'less-than-college', + canadianCredentials: 'none', + englishClb: 6, + region: 'toronto', + }), + ) + // labour: 0 + 2 + 0 + 0 + 0 + 5 = 7 + // education: 0 + 0 = 0 + // language: 4 (CLB6) + 5 (1 lang) = 9 + // region: 0 + expect(score).toEqual({ labour: 7, education: 0, language: 9, region: 0, total: 16 }) + }) + + it('language uses the higher of English/French tests', () => { + const score = oinpScore(base({ englishClb: 6, frenchClb: 8 })) + expect(score.language).toBe(22) // ability 12 (best CLB 8) + 2-language 10 + }) +}) diff --git a/src/lib/oinp/score.ts b/src/lib/oinp/score.ts new file mode 100644 index 0000000..3705ba5 --- /dev/null +++ b/src/lib/oinp/score.ts @@ -0,0 +1,231 @@ +/** + * OINP EOI scoring — Ontario Workforce Priority stream. + * + * Source: Ontario.ca "Ontario Workforce Priority stream" (updated 2026-08-04) + * https://www.ontario.ca/page/ontario-workforce-priority-stream + */ + +export type TeerCategory = 0 | 1 | 2 | 3 | 4 | 5 + +export type NocBroadCategory = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 + +export type TenureBand = 'over-24' | '13-24' | '6-12' | 'less-6' + +export type EarningsBand = 'over-70k' | '50k-70k' | '30k-50k' | 'under-30k' + +export type LegalStatus = 'work-permit' | 'study-permit' | 'none' + +export type EducationLevel = + | 'doctorate' + | 'masters' + | 'above-bachelor' + | 'bachelor' + | 'ogcc' + | 'below-bachelor' + | 'college' + | 'apprenticeship' + | 'less-than-college' + +export type CanadianCredentials = 'none' | 'one' | 'multiple' + +export type Region = 'northern' | 'eastern' | 'central' | 'southwestern' | 'gta-except-toronto' | 'toronto' + +export interface OinpInput { + teer: TeerCategory + nocBroad: NocBroadCategory + hourlyWage: number + tenureInPosition: TenureBand + ontarioWork: TenureBand + earnings: EarningsBand + legalStatus: LegalStatus + education: EducationLevel + canadianCredentials: CanadianCredentials + /** Overall CLB/NCLC of the English test (lowest band across abilities), 0 if none. */ + englishClb: number + /** Overall CLB/NCLC of the French test (lowest band across abilities), 0 if none. */ + frenchClb: number + region: Region +} + +export interface OinpBreakdown { + labour: number + education: number + language: number + region: number + total: number +} + +export const MAX_LABOUR = 70 +export const MAX_EDUCATION = 20 +export const MAX_LANGUAGE = 25 +export const MAX_REGION = 15 +export const MAX_TOTAL = 130 + +export function teerPoints(teer: TeerCategory): number { + if (teer <= 1) return 9 + if (teer <= 3) return 6 + return 0 +} + +export function nocBroadPoints(noc: NocBroadCategory): number { + switch (noc) { + case 3: + return 10 + case 7: + return 8 + case 2: + return 6 + case 0: + case 1: + case 4: + case 8: + case 9: + return 4 + case 5: + case 6: + return 2 + } +} + +export function wagePoints(hourlyWage: number): number { + if (hourlyWage >= 40) return 15 + if (hourlyWage >= 35) return 12 + if (hourlyWage >= 30) return 10 + if (hourlyWage >= 25) return 8 + if (hourlyWage >= 20) return 5 + return 0 +} + +export function tenureInPositionPoints(band: TenureBand): number { + switch (band) { + case 'over-24': + return 18 + case '13-24': + return 15 + case '6-12': + return 12 + case 'less-6': + return 0 + } +} + +export function ontarioWorkPoints(band: TenureBand): number { + switch (band) { + case 'over-24': + return 12 + case '13-24': + return 9 + case '6-12': + return 6 + case 'less-6': + return 0 + } +} + +/** Less than 6 months in the job offer position → points come from Ontario work instead. */ +export function workExperiencePoints(tenureInPosition: TenureBand, ontarioWork: TenureBand): number { + return tenureInPosition === 'less-6' ? ontarioWorkPoints(ontarioWork) : tenureInPositionPoints(tenureInPosition) +} + +export function earningsPoints(band: EarningsBand): number { + switch (band) { + case 'over-70k': + return 8 + case '50k-70k': + return 6 + case '30k-50k': + return 4 + case 'under-30k': + return 0 + } +} + +export function legalStatusPoints(status: LegalStatus): number { + switch (status) { + case 'work-permit': + return 10 + case 'study-permit': + return 5 + case 'none': + return 0 + } +} + +export function educationPoints(level: EducationLevel): number { + switch (level) { + case 'doctorate': + return 10 + case 'masters': + return 8 + case 'above-bachelor': + case 'bachelor': + return 6 + case 'ogcc': + case 'below-bachelor': + case 'college': + case 'apprenticeship': + return 5 + case 'less-than-college': + return 0 + } +} + +export function canadianCredentialsPoints(count: CanadianCredentials): number { + switch (count) { + case 'multiple': + return 10 + case 'one': + return 5 + case 'none': + return 0 + } +} + +export function languageAbilityPoints(bestClb: number): number { + if (bestClb >= 9) return 15 + if (bestClb === 8) return 12 + if (bestClb === 7) return 8 + if (bestClb === 6) return 4 + return 0 +} + +/** 2 official languages requires CLB/NCLC 6+ across both tests. */ +export function officialLanguagesPoints(englishClb: number, frenchClb: number): number { + if (englishClb >= 6 && frenchClb >= 6) return 10 + if (Math.max(englishClb, frenchClb) >= 5) return 5 + return 0 +} + +export function regionPoints(region: Region): number { + switch (region) { + case 'northern': + return 15 + case 'eastern': + case 'central': + case 'southwestern': + return 10 + case 'gta-except-toronto': + return 5 + case 'toronto': + return 0 + } +} + +export function oinpScore(input: OinpInput): OinpBreakdown { + const labour = + teerPoints(input.teer) + + nocBroadPoints(input.nocBroad) + + wagePoints(input.hourlyWage) + + workExperiencePoints(input.tenureInPosition, input.ontarioWork) + + earningsPoints(input.earnings) + + legalStatusPoints(input.legalStatus) + + const education = educationPoints(input.education) + canadianCredentialsPoints(input.canadianCredentials) + + const bestClb = Math.max(input.englishClb, input.frenchClb) + const language = languageAbilityPoints(bestClb) + officialLanguagesPoints(input.englishClb, input.frenchClb) + + const region = regionPoints(input.region) + + return { labour, education, language, region, total: labour + education + language + region } +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..6b9af01 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,26 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom' +import { App } from './App' +import { CrsTool } from './routes/crs/CrsTool' +import { OinpTool } from './routes/oinp/OinpTool' +import './index.css' + +const router = createBrowserRouter([ + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: 'crs', element: }, + { path: 'oinp', element: }, + { path: '*', element: }, + ], + }, +]) + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/src/routes/crs/CrsTool.test.tsx b/src/routes/crs/CrsTool.test.tsx new file mode 100644 index 0000000..7006808 --- /dev/null +++ b/src/routes/crs/CrsTool.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { CrsTool } from '../../routes/crs/CrsTool' + +afterEach(cleanup) + +const total = () => screen.getByRole('status').textContent + +describe('CrsTool', () => { + it('renders the score for the default input', () => { + render() + // age 30 single (105) + bachelor (120) + English CLB 7 x4 (68) = 293 core + // + transferability (bachelor + CLB 7 = 13) = 306 + expect(total()).toBe('306') + }) + + it('updates the score when age changes', () => { + render() + const age = screen.getByRole('slider') + fireEvent.change(age, { target: { value: '26' } }) + // 110 + 120 + 68 = 298 core + 13 = 311 + expect(total()).toBe('311') + }) + + it('shows the spouse section and recalculates when an accompanying spouse is selected', () => { + render() + expect(screen.queryByText('Spouse or partner')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Yes' })) + expect(screen.getByText('Spouse or partner')).toBeTruthy() + // with-spouse core (95 + 112 + 64 = 271) + spouse (2) + transferability (13) = 286 + expect(total()).toBe('286') + }) + + it('awards the provincial nomination bonus', () => { + render() + fireEvent.click(screen.getByLabelText('Provincial nomination')) + // 306 + 600 = 906 + expect(total()).toBe('906') + }) + + it('converts IELTS scores to CLB and scores accordingly', () => { + render() + // listening 8.0 → CLB 9 (was CLB 7); other abilities stay CLB 7 + const listening = screen.getByLabelText('English listening') as HTMLSelectElement + fireEvent.change(listening, { target: { value: '8.0' } }) + // core: 105 + 120 + (17+17+31+17) = 307; transferability 13 → 320 + expect(total()).toBe('320') + }) + + it('switching the first official language resets the other to not taken', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'French' })) + // French is now first: no English test → core language only from French (0) + // age 105 + edu 120 = 225 + expect(total()).toBe('225') + }) +}) diff --git a/src/routes/crs/CrsTool.tsx b/src/routes/crs/CrsTool.tsx new file mode 100644 index 0000000..a726552 --- /dev/null +++ b/src/routes/crs/CrsTool.tsx @@ -0,0 +1,352 @@ +import { useMemo, useState } from 'react' +import { LanguageTestInputs } from '../../components/LanguageTestInputs' +import { ScoreCard } from '../../components/ScoreCard' +import { Seo } from '../../components/Seo' +import { CheckRow, Field, Note, Section, Segmented, Select, Slider } from '../../components/ui' +import { convertTestToClb, emptyScores } from '../../lib/crs/languages' +import type { LanguageTestState } from '../../lib/crs/languages' +import { crsScore } from '../../lib/crs/score' +import { + MAX_ADDITIONAL, + MAX_CORE_WITH_SPOUSE, + MAX_CORE_WITHOUT_SPOUSE, + MAX_SPOUSE, + MAX_TOTAL, + MAX_TRANSFERABILITY, +} from '../../lib/crs/tables' +import type { CrsBreakdown, CrsInput, EducationLevel, WorkYears } from '../../lib/crs/types' +import type { CanadianEducation } from '../../lib/crs/types' + +const EDUCATION_OPTIONS: ReadonlyArray<{ value: EducationLevel; label: string }> = [ + { value: 'less-than-secondary', label: 'Less than secondary' }, + { value: 'secondary', label: 'Secondary school' }, + { value: 'one-year', label: '1-year program' }, + { value: 'two-year', label: '2-year program' }, + { value: 'bachelor', label: "Bachelor's — 3+ years" }, + { value: 'two-plus', label: 'Two or more credentials' }, + { value: 'master', label: "Master's / professional degree" }, + { value: 'doctorate', label: 'Doctoral degree' }, +] + +const WORK_YEARS_OPTIONS = [ + { value: '0', label: 'None' }, + { value: '1', label: '1 year' }, + { value: '2', label: '2 years' }, + { value: '3', label: '3 years' }, + { value: '4', label: '4 years' }, + { value: '5', label: '5+ years' }, +] + +const CANADIAN_EDUCATION_OPTIONS: ReadonlyArray<{ value: CanadianEducation; label: string }> = [ + { value: 'none', label: 'None' }, + { value: 'one-two-years', label: '1–2 years' }, + { value: 'three-plus-years', label: '3+ years' }, +] + +const ENGLISH_TESTS = ['ielts', 'celpip', 'pte'] as const +const FRENCH_TESTS = ['tef', 'tcf'] as const +const ALL_TESTS = ['ielts', 'celpip', 'pte', 'tef', 'tcf'] as const + +interface SpouseUi { + education: EducationLevel + test: LanguageTestState + canadianWorkYears: WorkYears +} + +interface CrsUiState { + age: number + hasAccompanyingSpouse: boolean + education: EducationLevel + firstOfficialLanguage: 'english' | 'french' + english: LanguageTestState + french: LanguageTestState + canadianWorkYears: WorkYears + foreignWorkYears: WorkYears + spouse?: SpouseUi + certificateOfQualification: boolean + provincialNomination: boolean + canadianEducation: CanadianEducation + siblingInCanada: boolean +} + +const DEFAULT_ENGLISH: LanguageTestState = { + test: 'ielts', + scores: { listening: 6, reading: 6, writing: 6, speaking: 6 }, +} + +const DEFAULT_FRENCH: LanguageTestState = { test: 'none', scores: emptyScores() } + +const DEFAULT_SPOUSE: SpouseUi = { + education: 'secondary', + test: { test: 'none', scores: emptyScores() }, + canadianWorkYears: 0, +} + +const DEFAULT_UI: CrsUiState = { + age: 30, + hasAccompanyingSpouse: false, + education: 'bachelor', + firstOfficialLanguage: 'english', + english: DEFAULT_ENGLISH, + french: DEFAULT_FRENCH, + canadianWorkYears: 0, + foreignWorkYears: 0, + certificateOfQualification: false, + provincialNomination: false, + canadianEducation: 'none', + siblingInCanada: false, +} + +export function CrsTool() { + const [ui, setUi] = useState(DEFAULT_UI) + + const score = useMemo(() => { + const english = convertTestToClb(ui.english.test, ui.english.scores) + const french = convertTestToClb(ui.french.test, ui.french.scores) + const spouseLanguage = convertTestToClb(ui.spouse?.test.test ?? 'none', ui.spouse?.test.scores ?? emptyScores()) + + const input: CrsInput = { + age: ui.age, + hasAccompanyingSpouse: ui.hasAccompanyingSpouse, + education: ui.education, + firstOfficialLanguage: ui.firstOfficialLanguage, + english, + french, + canadianWorkYears: ui.canadianWorkYears, + foreignWorkYears: ui.foreignWorkYears, + spouse: ui.hasAccompanyingSpouse + ? { + education: ui.spouse?.education ?? DEFAULT_SPOUSE.education, + language: spouseLanguage, + canadianWorkYears: ui.spouse?.canadianWorkYears ?? 0, + } + : undefined, + certificateOfQualification: ui.certificateOfQualification, + provincialNomination: ui.provincialNomination, + canadianEducation: ui.canadianEducation, + siblingInCanada: ui.siblingInCanada, + } + return crsScore(input) + }, [ui]) + + const patch = (p: Partial) => setUi((prev) => ({ ...prev, ...p })) + + const setFirstLanguage = (v: 'english' | 'french') => { + setUi((prev) => { + const demoted = v === 'english' ? 'french' : 'english' + return { ...prev, firstOfficialLanguage: v, [demoted]: { test: 'none', scores: emptyScores() } } + }) + } + + const secondLang = ui.firstOfficialLanguage === 'french' ? ui.english : ui.french + const secondKey = ui.firstOfficialLanguage === 'french' ? 'english' : 'french' + const secondTitle = ui.firstOfficialLanguage === 'french' ? 'English' : 'French' + const secondTests = ui.firstOfficialLanguage === 'french' ? ENGLISH_TESTS : FRENCH_TESTS + + const addSecondLanguage = () => { + patch({ [secondKey]: { test: secondTests[0], scores: emptyScores() } }) + } + + const setSpouseFlag = (v: 'no' | 'yes') => { + setUi((prev) => ({ + ...prev, + hasAccompanyingSpouse: v === 'yes', + spouse: v === 'yes' ? (prev.spouse ?? DEFAULT_SPOUSE) : undefined, + })) + } + + const patchSpouse = (p: Partial) => { + setUi((prev) => ({ ...prev, spouse: { ...(prev.spouse ?? DEFAULT_SPOUSE), ...p } })) + } + + return ( +
+ +

CRS Calculator

+

+ Estimate your Express Entry Comprehensive Ranking System (CRS) score based on the official IRCC grid. +

+ +
+
+
+ patch({ age: v })} + format={(v) => (v >= 45 ? '45+' : String(v))} + /> + + + +
+ + patch({ canadianEducation: v as CanadianEducation })} + options={CANADIAN_EDUCATION_OPTIONS} + /> + +
+
+ + patch({ foreignWorkYears: Number(v) as WorkYears })} + options={WORK_YEARS_OPTIONS} + /> + +
+
+ +
+ + + + {ui.firstOfficialLanguage === 'english' ? ( + patch({ english: next })} + /> + ) : ( + patch({ french: next })} + /> + )} + {secondLang.test === 'none' ? ( + + ) : ( + patch({ [secondKey]: next })} + /> + )} +
+ + {ui.hasAccompanyingSpouse && ( +
+ + patchSpouse({ canadianWorkYears: Number(v) as WorkYears })} + options={WORK_YEARS_OPTIONS} + /> + +
+ )} + +
+
+ patch({ provincialNomination: v })} + /> + patch({ siblingInCanada: v })} + /> + patch({ certificateOfQualification: v })} + /> + No points for LMIA-backed job offers. +
+
+
+ + +
+
+ ) +} + +function ScorePanel({ score, withSpouse }: { score: CrsBreakdown; withSpouse: boolean }) { + const rows: Array<{ label: string; value: number; max?: number }> = [ + { label: 'Core', value: score.core, max: withSpouse ? MAX_CORE_WITH_SPOUSE : MAX_CORE_WITHOUT_SPOUSE }, + { label: 'Spouse', value: score.spouse, max: withSpouse ? MAX_SPOUSE : undefined }, + { label: 'Transferability', value: score.transferability, max: MAX_TRANSFERABILITY }, + { label: 'Additional', value: score.additional, max: MAX_ADDITIONAL }, + ] + return ( + + ) +} diff --git a/src/routes/oinp/OinpTool.test.tsx b/src/routes/oinp/OinpTool.test.tsx new file mode 100644 index 0000000..c6c3625 --- /dev/null +++ b/src/routes/oinp/OinpTool.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { OinpTool } from '../../routes/oinp/OinpTool' + +afterEach(cleanup) + +const total = () => screen.getByRole('status').textContent + +describe('OinpTool', () => { + it('renders the score for the default input', () => { + render() + // labour 46 (teer6 + noc4 + wage10 + tenure12 + earnings4 + permit10) + // + education 11 (bachelor6 + one credential5) + // + language 13 (CLB7 ability8 + 1 language5) + // + region 10 (eastern) = 80 + expect(total()).toBe('80') + }) + + it('awards 2-language points when a French test at CLB 6 is added', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /Add French test/ })) + // English CLB 7 + French NCLC 6 → 2 official languages (CLB 6 threshold) + const frenchScores: Record = { reading: '393', writing: '379', listening: '393', speaking: '422' } + for (const [ability, score] of Object.entries(frenchScores)) { + fireEvent.change(screen.getByLabelText(`French ${ability}`), { target: { value: score } }) + } + // language: ability 8 (CLB 7) + 2-language 10 = 18 (was 13) → total 85 + expect(total()).toBe('85') + }) + + it('updates when the hourly wage changes', () => { + render() + const wage = screen.getByRole('spinbutton') as HTMLInputElement + fireEvent.change(wage, { target: { value: '50' } }) + // labour +5 → 85 + expect(total()).toBe('85') + }) + + it('updates when the region changes to Toronto', () => { + render() + fireEvent.change(screen.getByLabelText('Location of work in job offer'), { target: { value: 'toronto' } }) + // region 0 (was 10) → 70 + expect(total()).toBe('70') + }) + + it('shows Ontario work select when tenure in position is under 6 months', () => { + render() + expect(screen.queryByLabelText('Time working in Ontario')).toBeNull() + fireEvent.change(screen.getByLabelText('Time in job offer position'), { target: { value: 'less-6' } }) + expect(screen.getByLabelText('Time working in Ontario')).toBeTruthy() + }) +}) diff --git a/src/routes/oinp/OinpTool.tsx b/src/routes/oinp/OinpTool.tsx new file mode 100644 index 0000000..9c27427 --- /dev/null +++ b/src/routes/oinp/OinpTool.tsx @@ -0,0 +1,313 @@ +import { useMemo, useState } from 'react' +import { LanguageTestInputs } from '../../components/LanguageTestInputs' +import { ScoreCard } from '../../components/ScoreCard' +import { Seo } from '../../components/Seo' +import { Field, NumberInput, Section, Select } from '../../components/ui' +import { convertTestToClb, emptyScores, overallClb } from '../../lib/crs/languages' +import type { LanguageTestState } from '../../lib/crs/languages' +import { + MAX_EDUCATION, + MAX_LABOUR, + MAX_LANGUAGE, + MAX_REGION, + MAX_TOTAL, + oinpScore, +} from '../../lib/oinp/score' +import type { + CanadianCredentials, + EarningsBand, + EducationLevel, + LegalStatus, + NocBroadCategory, + OinpBreakdown, + OinpInput, + Region, + TeerCategory, + TenureBand, +} from '../../lib/oinp/score' + +const TEER_OPTIONS = [ + { value: '0', label: 'TEER 0 or 1' }, + { value: '2', label: 'TEER 2 or 3' }, + { value: '4', label: 'TEER 4' }, + { value: '5', label: 'TEER 5' }, +] + +const NOC_BROAD_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [ + { value: '0', label: '0 — Management' }, + { value: '1', label: '1 — Business & finance' }, + { value: '2', label: '2 — Natural & applied sciences' }, + { value: '3', label: '3 — Health' }, + { value: '4', label: '4 — Education, law & social' }, + { value: '5', label: '5 — Art, culture & sport' }, + { value: '6', label: '6 — Sales & service' }, + { value: '7', label: '7 — Trades & transport' }, + { value: '8', label: '8 — Natural resources' }, + { value: '9', label: '9 — Manufacturing & utilities' }, +] + +const TENURE_OPTIONS = [ + { value: 'over-24', label: 'Over 24 months' }, + { value: '13-24', label: '13–24 months' }, + { value: '6-12', label: '6–12 months' }, + { value: 'less-6', label: 'Less than 6 months' }, +] + +const EARNINGS_OPTIONS: ReadonlyArray<{ value: EarningsBand; label: string }> = [ + { value: 'over-70k', label: '$70k or more' }, + { value: '50k-70k', label: '$50k–$69,999' }, + { value: '30k-50k', label: '$30k–$49,999' }, + { value: 'under-30k', label: 'Under $30k' }, +] + +const LEGAL_STATUS_OPTIONS: ReadonlyArray<{ value: LegalStatus; label: string }> = [ + { value: 'work-permit', label: 'Valid work permit' }, + { value: 'study-permit', label: 'Valid study permit' }, + { value: 'none', label: 'No valid work or study permit' }, +] + +const EDUCATION_OPTIONS: ReadonlyArray<{ value: EducationLevel; label: string }> = [ + { value: 'doctorate', label: 'Doctorate / professional medical degree' }, + { value: 'masters', label: "Master's degree" }, + { value: 'above-bachelor', label: 'Certificate/diploma above bachelor level' }, + { value: 'bachelor', label: "Bachelor's degree or equivalent" }, + { value: 'ogcc', label: 'Ontario College Graduate Certificate' }, + { value: 'below-bachelor', label: 'Certificate/diploma below bachelor level' }, + { value: 'college', label: 'College / CEGEP certificate or diploma' }, + { value: 'apprenticeship', label: 'Apprenticeship or trades certificate' }, + { value: 'less-than-college', label: 'Less than college or trades certificate' }, +] + +const CREDENTIALS_OPTIONS: ReadonlyArray<{ value: CanadianCredentials; label: string }> = [ + { value: 'multiple', label: 'More than one Canadian credential' }, + { value: 'one', label: 'One Canadian credential' }, + { value: 'none', label: 'None' }, +] + +const REGION_OPTIONS: ReadonlyArray<{ value: Region; label: string }> = [ + { value: 'northern', label: 'Northern Ontario' }, + { value: 'eastern', label: 'Eastern Ontario' }, + { value: 'central', label: 'Central Ontario (outside GTA)' }, + { value: 'southwestern', label: 'Southwestern Ontario' }, + { value: 'gta-except-toronto', label: 'GTA, outside Toronto' }, + { value: 'toronto', label: 'Toronto' }, +] + +const ENGLISH_TESTS = ['ielts', 'celpip', 'pte'] as const +const FRENCH_TESTS = ['tef', 'tcf'] as const + +interface OinpUiState { + teer: string + nocBroad: string + hourlyWage: string + tenureInPosition: string + ontarioWork: string + earnings: EarningsBand + legalStatus: LegalStatus + education: EducationLevel + canadianCredentials: CanadianCredentials + english: LanguageTestState + french: LanguageTestState + region: string +} + +const DEFAULT_UI: OinpUiState = { + teer: '2', + nocBroad: '0', + hourlyWage: '30', + tenureInPosition: '6-12', + ontarioWork: '13-24', + earnings: '30k-50k', + legalStatus: 'work-permit', + education: 'bachelor', + canadianCredentials: 'one', + english: { test: 'ielts', scores: { listening: 6, reading: 6, writing: 6, speaking: 6 } }, + french: { test: 'none', scores: emptyScores() }, + region: 'eastern', +} + +export function OinpTool() { + const [ui, setUi] = useState(DEFAULT_UI) + + const score = useMemo(() => { + const englishClb = overallClb(convertTestToClb(ui.english.test, ui.english.scores)) + const frenchClb = overallClb(convertTestToClb(ui.french.test, ui.french.scores)) + const input: OinpInput = { + teer: Number(ui.teer) as TeerCategory, + nocBroad: Number(ui.nocBroad) as NocBroadCategory, + hourlyWage: Number(ui.hourlyWage) || 0, + tenureInPosition: ui.tenureInPosition as TenureBand, + ontarioWork: ui.ontarioWork as TenureBand, + earnings: ui.earnings, + legalStatus: ui.legalStatus, + education: ui.education, + canadianCredentials: ui.canadianCredentials, + englishClb, + frenchClb, + region: ui.region as Region, + } + return oinpScore(input) + }, [ui]) + + const patch = (p: Partial) => setUi((prev) => ({ ...prev, ...p })) + + return ( +
+ +

OINP Points Calculator

+

+ Estimate your Ontario Workforce Priority stream EOI points based on the official OINP grid. +

+ +
+
+
+
+ + patch({ nocBroad: v })} + options={NOC_BROAD_OPTIONS} + /> + +
+
+ patch({ hourlyWage: v })} + suffix="$/hr" + /> +
+ + patch({ ontarioWork: v })} + options={TENURE_OPTIONS} + /> + + )} +
+ + patch({ legalStatus: v as LegalStatus })} + options={LEGAL_STATUS_OPTIONS} + /> + +
+
+ +
+ + patch({ canadianCredentials: v as CanadianCredentials })} + options={CREDENTIALS_OPTIONS} + /> + +
+ +
+ patch({ english: next })} + /> + {ui.french.test === 'none' ? ( + + ) : ( + patch({ french: next })} + /> + )} +
+ +
+ + +
+ + )} + + + + ) +} diff --git a/src/components/CanadaMap.tsx b/src/components/CanadaMap.tsx new file mode 100644 index 0000000..1d30fe0 --- /dev/null +++ b/src/components/CanadaMap.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import canada from '@svg-maps/canada' +import { cn } from './ui' + +const PROVINCE_ROUTES: Record = { + bc: { to: '/bc' }, + ab: { to: '/alberta' }, + sk: { to: '/saskatchewan' }, + mb: { to: '/manitoba' }, + on: { to: '/oinp' }, +} + +const PROVINCE_CODES: Record = { + ab: 'AB', + bc: 'BC', + mb: 'MB', + nb: 'NB', + nl: 'NL', + ns: 'NS', + nt: 'NT', + nu: 'NU', + on: 'ON', + pe: 'PE', + qc: 'QC', + sk: 'SK', + yt: 'YT', +} + +/** + * Polygon centroid of an SVG path. These map paths are a single closed + * polygon of relative line segments (`m` start point, then relative pairs, + * `z` to close), so we rebuild the absolute points and use the shoelace + * formula. Good enough to sit a two-letter label on each province. + */ +function labelPosition(d: string): { x: number; y: number } { + const nums = (d.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number) + const pts: Array<{ x: number; y: number }> = [{ x: nums[0] ?? 0, y: nums[1] ?? 0 }] + for (let i = 2; i + 1 < nums.length; i += 2) { + const prev = pts[pts.length - 1]! + pts.push({ x: prev.x + (nums[i] ?? 0), y: prev.y + (nums[i + 1] ?? 0) }) + } + + let twiceArea = 0 + let cx = 0 + let cy = 0 + for (let i = 0; i < pts.length; i++) { + const p = pts[i]! + const q = pts[(i + 1) % pts.length]! + const f = p.x * q.y - q.x * p.y + twiceArea += f + cx += (p.x + q.x) * f + cy += (p.y + q.y) * f + } + if (twiceArea === 0) return { x: 0, y: 0 } + return { x: Math.round(cx / (3 * twiceArea)), y: Math.round(cy / (3 * twiceArea)) } +} + +export function CanadaMap() { + const navigate = useNavigate() + const [hovered, setHovered] = useState(null) + + const go = (to: string) => navigate(to, { viewTransition: true }) + + const hoveredName = canada.locations.find((l) => l.id === hovered)?.name + + return ( +
+
+

Provincial calculators

+

+ {hoveredName ? `${hoveredName} - click to open` : 'Click a province'} +

+
+ + + {canada.locations.map((loc) => { + const entry = PROVINCE_ROUTES[loc.id] + const pos = labelPosition(loc.path) + return ( + + go(entry.to) : undefined} + onKeyDown={ + entry + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + go(entry.to) + } + } + : undefined + } + onMouseEnter={() => setHovered(loc.id)} + onMouseLeave={() => setHovered((h) => (h === loc.id ? null : h))} + > + {loc.name} + + + {PROVINCE_CODES[loc.id]} + + + ) + })} + +
+ ) +} diff --git a/src/components/DrawFeed.test.tsx b/src/components/DrawFeed.test.tsx new file mode 100644 index 0000000..9f7ca37 --- /dev/null +++ b/src/components/DrawFeed.test.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DrawFeed } from './DrawFeed' + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +const ROUNDS_JSON = (rounds: Array>) => JSON.stringify({ rounds }) + +const INSTRUCTION_HREF = + '/content/canadasite/en/immigration-refugees-citizenship/corporate/mandate/policies-operational-instructions-agreements/ministerial-instructions/express-entry-rounds/invitations.html?q=434' + +const SAMPLE_ROUNDS: Array> = [ + { + drawNumber: '434', + drawNumberURL: `434`, + drawDateFull: 'August 7, 2026', + drawName: 'Transport Occupations', + drawSize: '300', + drawCRS: '470', + drawText2: 'Federal Skilled Worker Program, Canadian Experience Class and Federal Skilled Trades Program', + }, + { + drawNumber: '433', + drawDateFull: 'July 24, 2026', + drawName: 'Canadian Experience Class', + drawSize: '1500', + drawCRS: '510', + drawText2: 'Canadian Experience Class', + }, +] + +describe('DrawFeed', () => { + it('renders the latest draws from the IRCC feed', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response(ROUNDS_JSON(SAMPLE_ROUNDS), { status: 200 })), + ) + render() + expect(await screen.findByText('August 7, 2026')).toBeTruthy() + expect(screen.getByText('#434')).toBeTruthy() + expect(screen.getByText('470')).toBeTruthy() + expect(screen.getByText('July 24, 2026')).toBeTruthy() + }) + + it('links each draw number to its ministerial instruction', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response(ROUNDS_JSON(SAMPLE_ROUNDS), { status: 200 })), + ) + render() + await screen.findByText('#434') + const link = screen.getByRole('link', { name: /#434/ }) + expect(link.getAttribute('href')).toBe( + 'https://www.canada.ca/en/immigration-refugees-citizenship/corporate/mandate/policies-operational-instructions-agreements/ministerial-instructions/express-entry-rounds/invitations.html?q=434', + ) + }) + + it('reads a versioned feed URL from the IRCC page when the known feed misses', async () => { + const versionedFeed = 'https://www.canada.ca/content/dam/ircc/documents/json/ee_rounds_456_en.json' + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url === versionedFeed) { + return Promise.resolve(new Response(ROUNDS_JSON(SAMPLE_ROUNDS), { status: 200 })) + } + if (url.includes('rounds-invitations.html')) { + return Promise.resolve( + new Response( + `
`, + { status: 200 }, + ), + ) + } + return Promise.reject(new Error('not found')) + }) + vi.stubGlobal('fetch', fetchMock) + render() + expect(await screen.findByText('August 7, 2026')).toBeTruthy() + expect(fetchMock).toHaveBeenCalledWith(versionedFeed, expect.anything()) + }) + + it('shows a fallback message when the feed cannot be reached', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down'))) + render() + expect(await screen.findByText(/Couldn't load the latest draws/)).toBeTruthy() + }) + + it('links to the official IRCC rounds page', () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(ROUNDS_JSON(SAMPLE_ROUNDS), { status: 200 }))) + render() + const link = screen.getByRole('link', { name: /View all on IRCC/ }) + expect(link.getAttribute('href')).toBe( + 'https://www.canada.ca/en/immigration-refugees-citizenship/services/immigrate-canada/express-entry/rounds-invitations.html', + ) + }) +}) diff --git a/src/components/DrawFeed.tsx b/src/components/DrawFeed.tsx new file mode 100644 index 0000000..c4f3730 --- /dev/null +++ b/src/components/DrawFeed.tsx @@ -0,0 +1,180 @@ +import { useEffect, useState } from 'react' + +interface Draw { + drawNumber: string + drawNumberHref?: string + drawDate: string + drawSize: string + drawCRS: string +} + +const ROUNDS_PAGE = + 'https://www.canada.ca/en/immigration-refugees-citizenship/services/immigrate-canada/express-entry/rounds-invitations.html' +const KNOWN_FEED = 'https://www.canada.ca/content/dam/ircc/documents/json/ee_rounds_123_en.json' +const FEED_URL_PATTERN = /\/content\/dam\/ircc\/documents\/json\/[a-z0-9_]+\.json/ +const HREF_PATTERN = /href='([^']+)'/ + +/** + * IRCC publishes the draw history as a JSON file on canada.ca with + * permissive CORS, so we can read it straight from the browser. The file + * name carries a version number, so when a fetch misses we re-read it from + * the rounds page and try again. + */ +async function loadFeedUrl(): Promise { + const res = await fetch(ROUNDS_PAGE) + if (!res.ok) throw new Error(`IRCC page responded ${res.status}`) + const html = await res.text() + const match = html.match(FEED_URL_PATTERN) + if (!match) throw new Error('Could not find the draw data feed on the IRCC page') + return `https://www.canada.ca${match[0]}` +} + +async function fetchDraws(): Promise { + const feedUrl = KNOWN_FEED + try { + return parseDraws(await fetchFeed(feedUrl)) + } catch { + return parseDraws(await fetchFeed(await loadFeedUrl())) + } +} + +async function fetchFeed(url: string): Promise { + const res = await fetch(url, { headers: { Accept: 'application/json' } }) + if (!res.ok) throw new Error(`Feed responded ${res.status}`) + return res.json() +} + +function parseDraws(data: unknown): Draw[] { + const rounds = (data as { rounds?: Array> }).rounds ?? [] + return rounds.map((r) => { + const href = String(r.drawNumberURL ?? '').match(HREF_PATTERN)?.[1] + return { + drawNumber: String(r.drawNumber ?? ''), + drawNumberHref: href ? `https://www.canada.ca${href.replace('/content/canadasite', '')}` : undefined, + drawDate: String(r.drawDateFull ?? r.drawDate ?? ''), + drawSize: String(r.drawSize ?? ''), + drawCRS: String(r.drawCRS ?? ''), + } + }) +} + +const SHOWN = 8 + +export function DrawFeed() { + const [draws, setDraws] = useState(null) + const [error, setError] = useState(false) + + useEffect(() => { + let cancelled = false + + const load = () => { + fetchDraws() + .then((d) => { + if (!cancelled) { + setDraws(d) + setError(false) + } + }) + .catch(() => { + if (!cancelled) setError(true) + }) + } + + load() + const onVisible = () => { + if (document.visibilityState === 'visible') load() + } + document.addEventListener('visibilitychange', onVisible) + return () => { + cancelled = true + document.removeEventListener('visibilitychange', onVisible) + } + }, []) + + return ( +
+
+

+

+ + View all on IRCC + +
+ + {error ? ( +

+ Couldn't load the latest draws. Check the{' '} + + official IRCC page + + . +

+ ) : draws === null ? ( +

Loading draws...

+ ) : ( + <> + + + + + + + + + + + {draws.slice(0, SHOWN).map((d) => ( + + + + + + + ))} + +
+ Draw + + Date + + Cut-off + + Invited +
+ {d.drawNumberHref ? ( + + #{d.drawNumber} + + ) : ( + #{d.drawNumber} + )} + {d.drawDate} + {d.drawCRS} + + {d.drawSize} +
+
+

+ Pulled straight from IRCC's published data. Each draw number links to its ministerial instruction. +

+
+ + )} +
+ ) +} diff --git a/src/components/EligibilityBanner.tsx b/src/components/EligibilityBanner.tsx new file mode 100644 index 0000000..6e01b59 --- /dev/null +++ b/src/components/EligibilityBanner.tsx @@ -0,0 +1,37 @@ +export function EligibilityBanner({ eligible, reasons }: { eligible: boolean; reasons?: string[] }) { + const notes = reasons ?? [] + + if (eligible) { + return ( +
+ +
+

You appear eligible

+ {notes.length > 0 && ( +

{notes.join(' ')}

+ )} +
+
+ ) + } + + return ( +
+ +
+

Not eligible yet

+
    + {notes.map((r) => ( +
  • {r}
  • + ))} +
+
+
+ ) +} diff --git a/src/components/LanguageTestInputs.tsx b/src/components/LanguageTestInputs.tsx index 8ccbaf2..b5859c9 100644 --- a/src/components/LanguageTestInputs.tsx +++ b/src/components/LanguageTestInputs.tsx @@ -3,7 +3,7 @@ import { convertTestToClb, emptyScores } from '../lib/crs/languages' import type { LanguageTestId, LanguageTestState } from '../lib/crs/languages' import { ABILITIES } from '../lib/crs/tables' import type { LanguageAbility } from '../lib/crs/types' -import { Select } from './ui' +import { HelpLink, Select } from './ui' const TEST_LABELS: Record, string> = { ielts: 'IELTS General', @@ -14,7 +14,7 @@ const TEST_LABELS: Record, string> = { } const IELTS_OPTIONS = [ - { value: '0.0', label: '—' }, + { value: '0.0', label: '-' }, ...Array.from({ length: 17 }, (_, i) => { const v = 1 + i * 0.5 return { value: v.toFixed(1), label: v.toFixed(1) } @@ -22,7 +22,7 @@ const IELTS_OPTIONS = [ ] const CELPIP_OPTIONS = [ - { value: '0', label: '—' }, + { value: '0', label: '-' }, ...Array.from({ length: 10 }, (_, i) => { const v = 3 + i return { value: String(v), label: String(v) } @@ -82,11 +82,13 @@ export function LanguageTestInputs({ allowedTests, value, onChange, + help, }: { title: string allowedTests: readonly Exclude[] value: LanguageTestState onChange: (next: LanguageTestState) => void + help?: string }) { const clb = useMemo(() => convertTestToClb(value.test, value.scores), [value]) const scale = value.test === 'tef' || value.test === 'tcf' ? 'NCLC' : 'CLB' @@ -94,7 +96,10 @@ export function LanguageTestInputs({ return (
-

{title}

+

+ {title} + {help && } +

string + help?: string }) { const pct = max === min ? 0 : ((value - min) / (max - min)) * 100 return (
- {label} + + {label} + {help && } + {format ? format(value) : String(value)} @@ -201,10 +230,12 @@ export function CheckRow({ label, checked, onChange, + help, }: { label: string checked: boolean onChange: (checked: boolean) => void + help?: string }) { return ( ) } diff --git a/src/index.css b/src/index.css index 331b01a..ac94f46 100644 --- a/src/index.css +++ b/src/index.css @@ -8,8 +8,10 @@ --color-ink: #0f172a; --color-muted: #475569; --color-line: #e2e8f0; - --color-accent: #2563eb; - --color-accent-soft: #eff6ff; + --color-accent: #003478; + --color-accent-soft: #e7ecf4; + --color-warn: #b45309; + --color-warn-soft: #fdf0e2; --font-sans: "Geist Variable", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; --font-mono: "Geist Mono Variable", ui-monospace, "SF Mono", Menlo, Consolas, monospace; } @@ -36,7 +38,103 @@ body { color: #fff; } -/* Range slider — Stripe-style thin track, blurred thumb */ +/* View Transitions: morph the landing tile into the compact tool bar */ +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 0.25s; +} +::view-transition-old(tile-crs), +::view-transition-new(tile-crs), +::view-transition-old(tile-oinp), +::view-transition-new(tile-oinp) { + animation-duration: 0.3s; + animation-timing-function: ease-out; +} + +/* Live indicator: pulsing dot for the draws feed */ +.live-dot { + width: 8px; + height: 8px; + border-radius: 9999px; + background: var(--color-accent); + box-shadow: 0 0 0 0 rgb(0 52 120 / 0.45); + animation: live-pulse 2s ease-out infinite; +} +@keyframes live-pulse { + 0% { + box-shadow: 0 0 0 0 rgb(0 52 120 / 0.45); + } + 70% { + box-shadow: 0 0 0 6px rgb(0 52 120 / 0); + } + 100% { + box-shadow: 0 0 0 0 rgb(0 52 120 / 0); + } +} + +/* Hide the sidebar's scrollbar while keeping it scrollable */ +.no-scrollbar { + scrollbar-width: none; + -ms-overflow-style: none; +} +.no-scrollbar::-webkit-scrollbar { + display: none; +} + +/* Score pot: water dripping in (rising score) or leaking out (falling score) */ +.drip-in { + animation: drip-in 1s ease-in infinite; +} +.drip-out { + animation: drip-out 1s ease-in infinite; +} +@keyframes drip-in { + 0% { + transform: translateY(0); + opacity: 0; + } + 15% { + opacity: 1; + } + 75% { + opacity: 1; + } + 100% { + transform: translateY(30px); + opacity: 0; + } +} +@keyframes drip-out { + 0% { + transform: translateY(0); + opacity: 0; + } + 15% { + opacity: 0.9; + } + 100% { + transform: translateY(26px); + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + ::view-transition-group(*), + ::view-transition-old(*), + ::view-transition-new(*) { + animation: none !important; + transition: none !important; + } + .live-dot { + animation: none; + } + .drip-in, + .drip-out { + animation: none; + } +} + +/* Range slider: Stripe-style thin track, blurred thumb */ .slider { -webkit-appearance: none; appearance: none; @@ -59,7 +157,7 @@ body { transition: box-shadow 0.15s ease; } .slider::-webkit-slider-thumb:hover { - box-shadow: 0 1px 3px rgb(15 23 42 / 0.2), 0 0 0 6px rgb(37 99 235 / 0.15); + box-shadow: 0 1px 3px rgb(15 23 42 / 0.2), 0 0 0 6px rgb(0 52 120 / 0.15); } .slider::-moz-range-thumb { width: 14px; diff --git a/src/lib/alberta/score.test.ts b/src/lib/alberta/score.test.ts new file mode 100644 index 0000000..474a1d1 --- /dev/null +++ b/src/lib/alberta/score.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'vitest' +import { + agePoints, + albertaScore, + bilingualPoints, + canadianExperiencePoints, + educationLocationPoints, + educationPoints, + eligibility, + englishLanguagePoints, + frenchLanguagePoints, + jobLocationPoints, + jobOfferPoints, + permanentJobOfferPoints, + sectorJobOfferPoints, + totalExperiencePoints, +} from './score' +import type { AlbertaInput } from './score' + +function base(overrides: Partial = {}): AlbertaInput { + return { + education: 'bachelor', + educationLocation: 'none', + englishClb: 6, + frenchClb: 0, + bilingual: false, + totalExperience: 'over-12', + canadianExperience: 'none', + age: 30, + familyConnection: false, + permanentJobOffer: false, + sectorJobOffer: 'none', + jobLocation: 'calgary-edmonton', + regulatedOccupation: false, + ...overrides, + } +} + +describe('AAIP Worker EOI factor points (official grid)', () => { + it('highest education level', () => { + expect(educationPoints('doctorate')).toBe(12) + expect(educationPoints('masters')).toBe(10) + expect(educationPoints('bachelor')).toBe(7) + expect(educationPoints('trades')).toBe(7) + expect(educationPoints('diploma')).toBe(4) + expect(educationPoints('secondary')).toBe(0) + }) + + it('location of education in Canada', () => { + expect(educationLocationPoints('alberta')).toBe(10) + expect(educationLocationPoints('other-province')).toBe(6) + expect(educationLocationPoints('none')).toBe(0) + }) + + it('English proficiency', () => { + expect(englishLanguagePoints(6)).toBe(10) + expect(englishLanguagePoints(7)).toBe(10) + expect(englishLanguagePoints(5)).toBe(8) + expect(englishLanguagePoints(4)).toBe(5) + expect(englishLanguagePoints(3)).toBe(0) + expect(englishLanguagePoints(0)).toBe(0) + }) + + it('French proficiency', () => { + expect(frenchLanguagePoints(6)).toBe(8) + expect(frenchLanguagePoints(7)).toBe(8) + expect(frenchLanguagePoints(5)).toBe(5) + expect(frenchLanguagePoints(4)).toBe(3) + expect(frenchLanguagePoints(3)).toBe(0) + expect(frenchLanguagePoints(0)).toBe(0) + }) + + it('bilingual bonus needs CLB/NCLC 4+ in both languages', () => { + expect(bilingualPoints(6, 6, true)).toBe(3) + expect(bilingualPoints(5, 4, true)).toBe(3) + expect(bilingualPoints(6, 0, true)).toBe(0) + expect(bilingualPoints(6, 6, false)).toBe(0) + }) + + it('total work experience', () => { + expect(totalExperiencePoints('over-12')).toBe(11) + expect(totalExperiencePoints('6-11')).toBe(7) + expect(totalExperiencePoints('less-6')).toBe(3) + }) + + it('Canadian work experience', () => { + expect(canadianExperiencePoints('alberta')).toBe(10) + expect(canadianExperiencePoints('other-province')).toBe(6) + expect(canadianExperiencePoints('none')).toBe(0) + }) + + it('age bands', () => { + expect(agePoints(17)).toBe(0) + expect(agePoints(18)).toBe(3) + expect(agePoints(20)).toBe(3) + expect(agePoints(21)).toBe(5) + expect(agePoints(34)).toBe(5) + expect(agePoints(35)).toBe(4) + expect(agePoints(49)).toBe(4) + expect(agePoints(50)).toBe(3) + expect(agePoints(60)).toBe(3) + }) + + it('permanent full-time job offer in Alberta', () => { + expect(permanentJobOfferPoints(true)).toBe(10) + expect(permanentJobOfferPoints(false)).toBe(0) + }) + + it('select rural community or sector job offers each award 6 points', () => { + expect(sectorJobOfferPoints('rural-renewal')).toBe(6) + expect(sectorJobOfferPoints('tourism-hospitality')).toBe(6) + expect(sectorJobOfferPoints('law-enforcement')).toBe(6) + expect(sectorJobOfferPoints('none')).toBe(0) + }) + + it('permanent and rural or sector job offer points stack to the 16-point total', () => { + expect(jobOfferPoints(true, 'rural-renewal')).toBe(16) + expect(jobOfferPoints(true, 'none')).toBe(10) + expect(jobOfferPoints(false, 'law-enforcement')).toBe(6) + expect(jobOfferPoints(false, 'none')).toBe(0) + }) + + it('Alberta job offer location', () => { + expect(jobLocationPoints('calgary-edmonton')).toBe(0) + expect(jobLocationPoints('rural-renewal')).toBe(5) + expect(jobLocationPoints('other')).toBe(5) + }) +}) + +describe('AAIP Worker EOI total', () => { + it('default input scores the human capital baseline', () => { + const score = albertaScore(base()) + // education: 7 (bachelor) + 0 = 7 + // language: 10 (English CLB 6) + 0 = 10 + // experience: 11 (12+ months) + 0 = 11 + // age: 5 + // economic factors: 0 + expect(score).toEqual({ + education: 7, + language: 10, + experience: 11, + age: 5, + family: 0, + jobOffer: 0, + location: 0, + regulated: 0, + total: 33, + }) + }) + + it('maximized profile hits the official top of every factor', () => { + const score = albertaScore( + base({ + education: 'doctorate', + educationLocation: 'alberta', + englishClb: 6, + frenchClb: 6, + bilingual: true, + totalExperience: 'over-12', + canadianExperience: 'alberta', + age: 30, + familyConnection: true, + permanentJobOffer: true, + sectorJobOffer: 'rural-renewal', + jobLocation: 'rural-renewal', + regulatedOccupation: true, + }), + ) + // human capital 69 (education 22 + language 13 + experience 21 + age 5 + family 8) + // + economic 31 (job offer 16 + location 5 + regulated 10) + expect(score).toEqual({ + education: 22, + language: 13, + experience: 21, + age: 5, + family: 8, + jobOffer: 16, + location: 5, + regulated: 10, + total: 100, + }) + }) + + it('language awards the higher of English or French points', () => { + const score = albertaScore(base({ englishClb: 5, frenchClb: 6, bilingual: true })) + // max(8 English, 8 French) + 3 bilingual = 11 + expect(score.language).toBe(11) + expect(score.total).toBe(34) + }) + + it('permanent offer and rural endorsement stack with a rural location and regulated occupation', () => { + const score = albertaScore( + base({ + permanentJobOffer: true, + sectorJobOffer: 'rural-renewal', + jobLocation: 'rural-renewal', + regulatedOccupation: true, + }), + ) + // jobOffer 16 + location 5 + regulated 10 + expect(score).toMatchObject({ jobOffer: 16, location: 5, regulated: 10, total: 64 }) + }) +}) + +describe('AAIP worker stream eligibility (separate from points)', () => { + it('default input is ineligible because it lacks a job offer despite scoring points', () => { + const result = eligibility(base()) + expect(result.eligible).toBe(false) + expect(result.reasons).toHaveLength(1) + expect(result.reasons[0]).toMatch(/full-time job offer/) + }) + + it('is eligible with a permanent full-time Alberta job offer and no other blockers', () => { + const result = eligibility(base({ permanentJobOffer: true })) + expect(result.eligible).toBe(true) + expect(result.reasons).toEqual([]) + }) + + it('a select rural or sector job offer also satisfies the job offer requirement', () => { + expect(eligibility(base({ sectorJobOffer: 'rural-renewal' })).eligible).toBe(true) + expect(eligibility(base({ sectorJobOffer: 'tourism-hospitality' })).eligible).toBe(true) + expect(eligibility(base({ sectorJobOffer: 'law-enforcement' })).eligible).toBe(true) + }) + + it('fails when the best language result is below CLB 4', () => { + const result = eligibility(base({ permanentJobOffer: true, englishClb: 3, frenchClb: 0 })) + expect(result.eligible).toBe(false) + expect(result.reasons[0]).toMatch(/CLB 4/) + }) + + it('a French result of CLB 4 or better satisfies the language requirement', () => { + const result = eligibility(base({ permanentJobOffer: true, englishClb: 0, frenchClb: 4 })) + expect(result.eligible).toBe(true) + }) + + it('fails when total work experience is below 12 months', () => { + const result = eligibility(base({ permanentJobOffer: true, totalExperience: '6-11' })) + expect(result.eligible).toBe(false) + expect(result.reasons[0]).toMatch(/12 months/) + }) + + it('reports every unmet criterion in a single evaluation', () => { + const result = eligibility(base({ englishClb: 2, frenchClb: 0, totalExperience: 'less-6' })) + expect(result.eligible).toBe(false) + expect(result.reasons).toHaveLength(3) + expect(result.reasons[0]).toMatch(/full-time job offer/) + expect(result.reasons[1]).toMatch(/CLB 4/) + expect(result.reasons[2]).toMatch(/12 months/) + }) +}) diff --git a/src/lib/alberta/score.ts b/src/lib/alberta/score.ts new file mode 100644 index 0000000..c1a110a --- /dev/null +++ b/src/lib/alberta/score.ts @@ -0,0 +1,257 @@ +/** + * AAIP Worker EOI scoring - Alberta Advantage Immigration Program. + * + * Source: Government of Alberta "Worker Stream Expression of Interest (EOI) + * Points Grid" (updated August 7, 2025) + * https://www.alberta.ca/system/files/im-worker-stream-expression-of-interest-points-grid.pdf + * See also: https://www.alberta.ca/how-to-apply-to-aaip-worker-streams + * + * Eligibility: AAIP worker streams each set minimum eligibility criteria. The + * common requirements derivable from the form (a full-time Alberta job offer, a + * minimum language score, and minimum work experience) are implemented in + * `eligibility`. Stream eligibility pages reviewed 2026-08-08: + * https://www.alberta.ca/aaip-alberta-opportunity-stream-eligibility + * https://www.alberta.ca/aaip-alberta-express-entry-stream-eligibility + * https://www.alberta.ca/aaip-rural-renewal-stream-eligibility + * https://www.alberta.ca/tourism-and-hospitality-stream-eligibility + */ + +export type EducationLevel = 'doctorate' | 'masters' | 'bachelor' | 'trades' | 'diploma' | 'secondary' + +export type EducationLocation = 'alberta' | 'other-province' | 'none' + +export type ExperienceBand = 'over-12' | '6-11' | 'less-6' + +export type CanadianExperience = 'alberta' | 'other-province' | 'none' + +/** 6-point job offer to work in select Alberta rural communities or sector. */ +export type SectorJobOffer = 'rural-renewal' | 'tourism-hospitality' | 'law-enforcement' | 'none' + +export type JobLocation = 'calgary-edmonton' | 'rural-renewal' | 'other' + +export interface AlbertaInput { + education: EducationLevel + educationLocation: EducationLocation + /** Overall CLB/NCLC of the English test (lowest band across abilities), 0 if none. */ + englishClb: number + /** Overall CLB/NCLC of the French test (lowest band across abilities), 0 if none. */ + frenchClb: number + /** CLB/NCLC 4+ in both English and French. */ + bilingual: boolean + totalExperience: ExperienceBand + canadianExperience: CanadianExperience + age: number + familyConnection: boolean + /** Permanent full-time job offer in Alberta (max 10). */ + permanentJobOffer: boolean + /** 6-point rural or sector job offer (Rural Renewal endorsement, Tourism and Hospitality, or law enforcement). */ + sectorJobOffer: SectorJobOffer + jobLocation: JobLocation + regulatedOccupation: boolean +} + +export interface AlbertaBreakdown { + education: number + language: number + experience: number + age: number + family: number + jobOffer: number + location: number + regulated: number + total: number +} + +export interface Eligibility { + eligible: boolean + /** Human sentences describing each unmet criterion when ineligible. */ + reasons: string[] +} + +export const AB_MIN_CLB = 4 +export const AB_MIN_EXPERIENCE_MONTHS = 12 + +export const AB_DOC = 'https://www.alberta.ca/how-to-apply-to-aaip-worker-streams' + +export const AB_POINTS_GRID = + 'https://www.alberta.ca/system/files/im-worker-stream-expression-of-interest-points-grid.pdf' + +export const MAX_EDUCATION = 22 +export const MAX_LANGUAGE = 13 +export const MAX_EXPERIENCE = 21 +export const MAX_AGE = 5 +export const MAX_FAMILY = 8 +export const MAX_JOB_OFFER = 16 +export const MAX_LOCATION = 5 +export const MAX_REGULATED = 10 +export const MAX_TOTAL = 100 + +export function educationPoints(level: EducationLevel): number { + switch (level) { + case 'doctorate': + return 12 + case 'masters': + return 10 + case 'bachelor': + case 'trades': + return 7 + case 'diploma': + return 4 + case 'secondary': + return 0 + } +} + +export function educationLocationPoints(location: EducationLocation): number { + switch (location) { + case 'alberta': + return 10 + case 'other-province': + return 6 + case 'none': + return 0 + } +} + +export function englishLanguagePoints(clb: number): number { + if (clb >= 6) return 10 + if (clb === 5) return 8 + if (clb === 4) return 5 + return 0 +} + +export function frenchLanguagePoints(clb: number): number { + if (clb >= 6) return 8 + if (clb === 5) return 5 + if (clb === 4) return 3 + return 0 +} + +/** Bilingual bonus only counts when both languages reach CLB/NCLC 4+. */ +export function bilingualPoints(englishClb: number, frenchClb: number, bilingual: boolean): number { + return bilingual && englishClb >= 4 && frenchClb >= 4 ? 3 : 0 +} + +export function totalExperiencePoints(band: ExperienceBand): number { + switch (band) { + case 'over-12': + return 11 + case '6-11': + return 7 + case 'less-6': + return 3 + } +} + +export function canadianExperiencePoints(experience: CanadianExperience): number { + switch (experience) { + case 'alberta': + return 10 + case 'other-province': + return 6 + case 'none': + return 0 + } +} + +export function agePoints(age: number): number { + if (age < 18) return 0 + if (age <= 20) return 3 + if (age <= 34) return 5 + if (age <= 49) return 4 + return 3 +} + +export function permanentJobOfferPoints(permanent: boolean): number { + return permanent ? 10 : 0 +} + +export function sectorJobOfferPoints(offer: SectorJobOffer): number { + return offer === 'none' ? 0 : 6 +} + +/** Permanent offer (10) and select rural or sector job offer (6) stack per the official grid (max 16). */ +export function jobOfferPoints(permanent: boolean, sector: SectorJobOffer): number { + return permanentJobOfferPoints(permanent) + sectorJobOfferPoints(sector) +} + +export function jobLocationPoints(location: JobLocation): number { + switch (location) { + case 'rural-renewal': + case 'other': + return 5 + case 'calgary-edmonton': + return 0 + } +} + +export function albertaScore(input: AlbertaInput): AlbertaBreakdown { + const education = educationPoints(input.education) + educationLocationPoints(input.educationLocation) + + const language = + Math.max(englishLanguagePoints(input.englishClb), frenchLanguagePoints(input.frenchClb)) + + bilingualPoints(input.englishClb, input.frenchClb, input.bilingual) + + const experience = totalExperiencePoints(input.totalExperience) + canadianExperiencePoints(input.canadianExperience) + + const age = agePoints(input.age) + + const family = input.familyConnection ? 8 : 0 + const jobOffer = jobOfferPoints(input.permanentJobOffer, input.sectorJobOffer) + const location = jobLocationPoints(input.jobLocation) + const regulated = input.regulatedOccupation ? 10 : 0 + + return { + education, + language, + experience, + age, + family, + jobOffer, + location, + regulated, + total: education + language + experience + age + family + jobOffer + location + regulated, + } +} + +/** + * Eligibility determination for AAIP worker streams, separate from points. + * A candidate can score well yet still fail the minimum eligibility criteria. + * + * Derived from the official stream eligibility pages (reviewed 2026-08-08): + * + * - A full-time Alberta job offer is required by the Alberta Opportunity, + * Rural Renewal and Tourism and Hospitality streams; the Express Entry + * stream requires one when invited based on a job offer. The form models + * this with `permanentJobOffer` or a select rural or sector job offer. + * - A minimum language score applies to every stream: CLB/NCLC 5 for NOC + * TEER 0-3 and CLB/NCLC 4 for TEER 4-5 in the Opportunity and Rural Renewal + * streams, and CLB/NCLC 4 in the Tourism and Hospitality stream. The tool + * does not collect a NOC, so the common floor of CLB 4 is enforced. + * - Minimum work experience: 12 months full-time within the last 18 months + * (Alberta) or 24 months within the last 30 (Canada or abroad) for the + * Opportunity stream, 12 months for Rural Renewal, and 6 consecutive months + * for Tourism and Hospitality. The common 12-month floor is enforced. + */ +export function eligibility(input: AlbertaInput): Eligibility { + const reasons: string[] = [] + + const hasJobOffer = input.permanentJobOffer || input.sectorJobOffer !== 'none' + if (!hasJobOffer) { + reasons.push( + 'AAIP worker streams require a full-time job offer or employment contract from an Alberta employer.', + ) + } + + if (Math.max(input.englishClb, input.frenchClb) < AB_MIN_CLB) { + reasons.push(`AAIP worker streams require a minimum of CLB ${AB_MIN_CLB} overall in English or French.`) + } + + if (input.totalExperience !== 'over-12') { + reasons.push( + `AAIP worker streams require a minimum of ${AB_MIN_EXPERIENCE_MONTHS} months of full-time work experience.`, + ) + } + + return { eligible: reasons.length === 0, reasons } +} diff --git a/src/lib/bc/score.test.ts b/src/lib/bc/score.test.ts new file mode 100644 index 0000000..602e65b --- /dev/null +++ b/src/lib/bc/score.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from 'vitest' +import { + areaPoints, + BC_DOC, + bcScore, + bothLanguagesPoints, + educationLocationPoints, + educationPoints, + eligibility, + languagePoints, + minimumIncomeWage, + professionalDesignationPoints, + wagePoints, + workExperiencePoints, +} from './score' +import type { BcInput } from './score' + +function base(overrides: Partial = {}): BcInput { + return { + workExperience: '2-3', + canadianExperience: false, + workingInBc: false, + education: 'bachelor', + educationLocation: 'none', + professionalDesignation: false, + englishClb: 7, + frenchClb: 0, + bothLanguages: false, + hourlyWage: 30, + area: 'area-3', + regionalExperience: false, + ...overrides, + } +} + +describe('BC PNP SIRS factor points (official grid)', () => { + it('directly related work experience bands', () => { + expect(workExperiencePoints('5-plus')).toBe(20) + expect(workExperiencePoints('4-5')).toBe(16) + expect(workExperiencePoints('3-4')).toBe(12) + expect(workExperiencePoints('2-3')).toBe(8) + expect(workExperiencePoints('1-2')).toBe(4) + expect(workExperiencePoints('less-1')).toBe(1) + expect(workExperiencePoints('none')).toBe(0) + }) + + it('highest level of education', () => { + expect(educationPoints('doctorate')).toBe(27) + expect(educationPoints('masters')).toBe(22) + expect(educationPoints('postgrad')).toBe(15) + expect(educationPoints('bachelor')).toBe(15) + expect(educationPoints('associate')).toBe(5) + expect(educationPoints('diploma')).toBe(5) + expect(educationPoints('secondary')).toBe(0) + }) + + it('additional education (single select)', () => { + expect(educationLocationPoints('bc')).toBe(8) + expect(educationLocationPoints('canada-outside-bc')).toBe(6) + expect(educationLocationPoints('none')).toBe(0) + }) + + it('eligible BC professional designation stacks with the education location bonus', () => { + expect(professionalDesignationPoints(true)).toBe(5) + expect(professionalDesignationPoints(false)).toBe(0) + }) + + it('language proficiency by overall CLB', () => { + expect(languagePoints(10)).toBe(30) + expect(languagePoints(9)).toBe(30) + expect(languagePoints(8)).toBe(25) + expect(languagePoints(7)).toBe(20) + expect(languagePoints(6)).toBe(15) + expect(languagePoints(5)).toBe(10) + expect(languagePoints(4)).toBe(5) + expect(languagePoints(3)).toBe(0) + expect(languagePoints(0)).toBe(0) + }) + + it('both official languages requires CLB 4+ on both tests', () => { + expect(bothLanguagesPoints(7, 5, true)).toBe(10) + expect(bothLanguagesPoints(9, 4, true)).toBe(10) + expect(bothLanguagesPoints(7, 3, true)).toBe(0) + expect(bothLanguagesPoints(7, 0, true)).toBe(0) + expect(bothLanguagesPoints(7, 6, false)).toBe(0) + }) + + it('hourly wage uses clamp(floor(wage) - 15, 0, 55)', () => { + expect(wagePoints(100)).toBe(55) + expect(wagePoints(70)).toBe(55) + expect(wagePoints(69.99)).toBe(54) + expect(wagePoints(40)).toBe(25) + expect(wagePoints(30)).toBe(15) + expect(wagePoints(16)).toBe(1) + expect(wagePoints(15.99)).toBe(0) + expect(wagePoints(15)).toBe(0) + }) + + it('area within BC with regional bonus only outside Area 1', () => { + expect(areaPoints('area-1', false)).toBe(0) + expect(areaPoints('area-1', true)).toBe(0) + expect(areaPoints('area-2', false)).toBe(5) + expect(areaPoints('area-2', true)).toBe(15) + expect(areaPoints('area-3', false)).toBe(15) + expect(areaPoints('area-3', true)).toBe(25) + }) +}) + +describe('BC PNP SIRS total', () => { + it('exports the official documentation URL', () => { + expect(BC_DOC).toBe('https://www.welcomebc.ca/immigrate-to-b-c/bc-pnp-si-program-guide-pdf') + }) + + it('default mid profile', () => { + const score = bcScore(base()) + // experience 8 (2-3yrs) + education 15 (bachelor) + language 20 (CLB 7) + // + wage 15 ($30/hr) + area 15 (Area 3) = 73 + expect(score).toEqual({ experience: 8, education: 15, language: 20, wage: 15, area: 15, total: 73 }) + }) + + it('work experience additional points cap the factor at 40', () => { + const score = bcScore(base({ workExperience: '5-plus', canadianExperience: true, workingInBc: true })) + // experience 20 + 10 + 10 = 40 + expect(score.experience).toBe(40) + expect(score.total).toBe(105) + }) + + it('maximized profile', () => { + const score = bcScore( + base({ + workExperience: '5-plus', + canadianExperience: true, + workingInBc: true, + education: 'doctorate', + educationLocation: 'bc', + professionalDesignation: true, + englishClb: 9, + frenchClb: 9, + bothLanguages: true, + hourlyWage: 80, + area: 'area-3', + regionalExperience: true, + }), + ) + // experience 20+10+10 = 40; education 27+8+5 = 40; language 30+10 = 40; + // wage 55; area 15+10 = 25 → total 200 + expect(score).toEqual({ experience: 40, education: 40, language: 40, wage: 55, area: 25, total: 200 }) + }) + + it('minimum profile scores zero', () => { + const score = bcScore( + base({ + workExperience: 'none', + education: 'secondary', + educationLocation: 'none', + professionalDesignation: false, + englishClb: 0, + frenchClb: 0, + hourlyWage: 10, + area: 'area-1', + regionalExperience: false, + }), + ) + expect(score).toEqual({ experience: 0, education: 0, language: 0, wage: 0, area: 0, total: 0 }) + }) + + it('language uses the higher of English or French overall CLB', () => { + const score = bcScore(base({ englishClb: 6, frenchClb: 8 })) + // ability 25 (best CLB 8) + 0 (no French CLB 4 bonus unless box checked) = 25 + expect(score.language).toBe(25) + expect(score.total).toBe(78) + }) +}) + +describe('BC PNP eligibility (Skills Immigration general requirements)', () => { + it('the default mid profile is eligible', () => { + // CLB 7, 2-3 years experience, $30/hr in Area 3 all meet the minimums + const result = eligibility(base()) + expect(result.eligible).toBe(true) + expect(result.reasons).toEqual([]) + }) + + it('fails when language is below CLB 4', () => { + const result = eligibility(base({ englishClb: 3, frenchClb: 0 })) + expect(result.eligible).toBe(false) + expect(result.reasons.join(' ')).toMatch(/below the minimum requirement of CLB 4/) + }) + + it('uses the better of English or French for the language floor', () => { + const result = eligibility(base({ englishClb: 3, frenchClb: 5 })) + expect(result.eligible).toBe(true) + }) + + it('fails when work experience is below the two-year minimum', () => { + const result = eligibility(base({ workExperience: '1-2' })) + expect(result.eligible).toBe(false) + expect(result.reasons.join(' ')).toMatch(/at least two years/) + }) + + it('accepts two-plus years of directly related work experience', () => { + expect(eligibility(base({ workExperience: '2-3' })).eligible).toBe(true) + expect(eligibility(base({ workExperience: '5-plus' })).eligible).toBe(true) + }) + + it('fails when the hourly wage is below the single-person minimum income floor', () => { + const result = eligibility(base({ hourlyWage: 10, area: 'area-1' })) + expect(result.eligible).toBe(false) + expect(result.reasons.join(' ')).toMatch(/minimum income floor/) + }) + + it('applies the higher Metro Vancouver wage floor in Area 1', () => { + expect(minimumIncomeWage('area-1')).toBe(15.03) + expect(minimumIncomeWage('area-2')).toBe(12.53) + expect(minimumIncomeWage('area-3')).toBe(12.53) + // $13/hr clears the rest-of-BC floor but not the Metro Vancouver floor + expect(eligibility(base({ hourlyWage: 13, area: 'area-1' })).eligible).toBe(false) + expect(eligibility(base({ hourlyWage: 13, area: 'area-3' })).eligible).toBe(true) + }) + + it('reports one reason per unmet requirement', () => { + const result = eligibility(base({ englishClb: 3, workExperience: 'less-1', hourlyWage: 8, area: 'area-1' })) + expect(result.eligible).toBe(false) + expect(result.reasons).toHaveLength(3) + }) + + it('scoring points do not guarantee eligibility', () => { + // High wage and strong education, but no work experience and CLB below 4 + const input = base({ workExperience: 'none', englishClb: 3, frenchClb: 0 }) + const score = bcScore(input) + const result = eligibility(input) + expect(score.total).toBeGreaterThan(0) + expect(result.eligible).toBe(false) + }) +}) diff --git a/src/lib/bc/score.ts b/src/lib/bc/score.ts new file mode 100644 index 0000000..2355db0 --- /dev/null +++ b/src/lib/bc/score.ts @@ -0,0 +1,226 @@ +/** + * BC PNP SIRS scoring - Skills Immigration / Express Entry BC points grid. + * + * Source: BC PNP "Skills Immigration Program Guide", Part 8 (effective May 28, 2026, + * guide last updated June 10, 2026) (welcomebc.ca) + * https://www.welcomebc.ca/immigrate-to-b-c/bc-pnp-si-program-guide-pdf + * + * Total = 200 points: Human Capital (max 120) + Economic (max 80). + */ + +export type WorkExperienceBand = '5-plus' | '4-5' | '3-4' | '2-3' | '1-2' | 'less-1' | 'none' + +export type EducationLevel = + | 'doctorate' + | 'masters' + | 'postgrad' + | 'bachelor' + | 'associate' + | 'diploma' + | 'secondary' + +export type EducationLocation = 'bc' | 'canada-outside-bc' | 'none' + +export type AreaBand = 'area-1' | 'area-2' | 'area-3' + +export interface BcInput { + /** Directly related work experience band. */ + workExperience: WorkExperienceBand + /** At least 1 year of directly related experience in Canada. */ + canadianExperience: boolean + /** Currently working full-time in BC for the supporting employer in the job-offer occupation. */ + workingInBc: boolean + /** Highest level of education. */ + education: EducationLevel + /** Additional education: BC / Canada outside BC. */ + educationLocation: EducationLocation + /** Eligible professional designation in B.C., stacks with the education location bonus. */ + professionalDesignation: boolean + /** Overall CLB of the English test (lowest band across abilities), 0 if none. */ + englishClb: number + /** Overall CLB of the French test (lowest band across abilities), 0 if none. */ + frenchClb: number + /** Proficiency (CLB 4+) in both English and French. */ + bothLanguages: boolean + /** Hourly wage of the BC job offer in dollars. */ + hourlyWage: number + /** Area within BC of the job offer. */ + area: AreaBand + /** Regional experience or regional alumni (only counts in Areas 2 and 3). */ + regionalExperience: boolean +} + +export interface BcBreakdown { + experience: number + education: number + language: number + wage: number + area: number + total: number +} + +export const MAX_EXPERIENCE = 40 +export const MAX_EDUCATION = 40 +export const MAX_LANGUAGE = 40 +export const MAX_WAGE = 55 +export const MAX_AREA = 25 +export const MAX_HUMAN_CAPITAL = 120 +export const MAX_ECONOMIC = 80 +export const MAX_TOTAL = 200 + +export const BC_DOC = 'https://www.welcomebc.ca/immigrate-to-b-c/bc-pnp-si-program-guide-pdf' + +export function workExperiencePoints(band: WorkExperienceBand): number { + switch (band) { + case '5-plus': + return 20 + case '4-5': + return 16 + case '3-4': + return 12 + case '2-3': + return 8 + case '1-2': + return 4 + case 'less-1': + return 1 + case 'none': + return 0 + } +} + +export function educationPoints(level: EducationLevel): number { + switch (level) { + case 'doctorate': + return 27 + case 'masters': + return 22 + case 'postgrad': + case 'bachelor': + return 15 + case 'associate': + case 'diploma': + return 5 + case 'secondary': + return 0 + } +} + +export function educationLocationPoints(location: EducationLocation): number { + switch (location) { + case 'bc': + return 8 + case 'canada-outside-bc': + return 6 + case 'none': + return 0 + } +} + +/** Eligible BC professional designation bonus, independent of the education location bonus. */ +export function professionalDesignationPoints(has: boolean): number { + return has ? 5 : 0 +} + +export function languagePoints(clb: number): number { + if (clb >= 9) return 30 + if (clb === 8) return 25 + if (clb === 7) return 20 + if (clb === 6) return 15 + if (clb === 5) return 10 + if (clb === 4) return 5 + return 0 +} + +/** Both official languages bonus: requires the box checked and CLB 4+ on both tests. */ +export function bothLanguagesPoints(englishClb: number, frenchClb: number, both: boolean): number { + return both && englishClb >= 4 && frenchClb >= 4 ? 10 : 0 +} + +/** Points = clamp(floor(hourlyWage) - 15, 0, 55). $70+ = 55, $16 = 1, below $16 = 0. */ +export function wagePoints(hourlyWage: number): number { + return Math.max(0, Math.min(55, Math.floor(hourlyWage) - 15)) +} + +/** Area base points plus a +10 regional bonus that only counts outside Area 1. */ +export function areaPoints(area: AreaBand, regionalExperience: boolean): number { + const base = area === 'area-1' ? 0 : area === 'area-2' ? 5 : 15 + const regional = regionalExperience && area !== 'area-1' ? 10 : 0 + return base + regional +} + +export function bcScore(input: BcInput): BcBreakdown { + const experience = + workExperiencePoints(input.workExperience) + + (input.canadianExperience ? 10 : 0) + + (input.workingInBc ? 10 : 0) + + const education = + educationPoints(input.education) + + educationLocationPoints(input.educationLocation) + + professionalDesignationPoints(input.professionalDesignation) + + const bestClb = Math.max(input.englishClb, input.frenchClb) + const language = + languagePoints(bestClb) + bothLanguagesPoints(input.englishClb, input.frenchClb, input.bothLanguages) + + const wage = wagePoints(input.hourlyWage) + + const area = areaPoints(input.area, input.regionalExperience) + + return { experience, education, language, wage, area, total: experience + education + language + wage + area } +} + +export interface Eligibility { + eligible: boolean + reasons: string[] +} + +/** Hours per year used to annualize an hourly wage for the minimum income test (guide section 3.10). */ +const HOURS_PER_YEAR = 40 * 52 + +/** 2024 Low-Income Cut-Off for a single-person household (guide section 3.10). */ +const LICO_METRO_VANCOUVER_SINGLE = 31264 +const LICO_REST_OF_BC_SINGLE = 26057 + +/** Bands that meet the Skilled Worker two-year experience minimum (guide section 4.1 (c)). */ +const MIN_EXPERIENCE_BANDS: readonly WorkExperienceBand[] = ['2-3', '3-4', '4-5', '5-plus'] + +/** + * Minimum hourly wage implied by the single-person LICO minimum income for an area. + * The BC PNP applies the minimum income by family size, but the calculator does not + * collect dependants, so the single-person floor is used as the general minimum. + */ +export function minimumIncomeWage(area: AreaBand): number { + const lico = area === 'area-1' ? LICO_METRO_VANCOUVER_SINGLE : LICO_REST_OF_BC_SINGLE + return Math.round((lico / HOURS_PER_YEAR) * 100) / 100 +} + +/** + * Eligibility check separate from the registration score. A candidate can score + * points yet still be ineligible. Uses the general requirements in Part 3 of the + * Skills Immigration Program Guide plus the Skilled Worker experience minimum in + * section 4.1. The CLB 4 language floor applies to NOC TEER 2, 3, 4 and 5 + * occupations, which the calculator assumes as the general case. + */ +export function eligibility(input: BcInput): Eligibility { + const reasons: string[] = [] + + const bestClb = Math.max(input.englishClb, input.frenchClb) + if (bestClb < 4) { + reasons.push('Your language test results are below the minimum requirement of CLB 4 in each of the four competencies.') + } + + const wageFloor = minimumIncomeWage(input.area) + if (input.hourlyWage < wageFloor) { + reasons.push( + `Your offered hourly wage of $${input.hourlyWage.toFixed(2)} is below the minimum income floor of $${wageFloor.toFixed(2)} per hour for your area of B.C.`, + ) + } + + if (!MIN_EXPERIENCE_BANDS.includes(input.workExperience)) { + reasons.push('You need at least two years of full-time (or full-time equivalent) directly related work experience.') + } + + return { eligible: reasons.length === 0, reasons } +} diff --git a/src/lib/crs/recommendations.test.ts b/src/lib/crs/recommendations.test.ts new file mode 100644 index 0000000..fa7ca30 --- /dev/null +++ b/src/lib/crs/recommendations.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { getCrsRecommendations } from './recommendations' +import type { CrsInput, LanguageProficiency } from './types' + +const allClb = (c: number): LanguageProficiency => ({ + listening: c as LanguageProficiency['listening'], + reading: c as LanguageProficiency['reading'], + writing: c as LanguageProficiency['writing'], + speaking: c as LanguageProficiency['speaking'], +}) + +/** Mirrors the default state CrsTool starts with (age 30, single, bachelor, English CLB 7). */ +function defaultInput(overrides: Partial = {}): CrsInput { + return { + age: 30, + hasAccompanyingSpouse: false, + education: 'bachelor', + firstOfficialLanguage: 'english', + english: allClb(7), + french: allClb(0), + canadianWorkYears: 0, + foreignWorkYears: 0, + certificateOfQualification: false, + provincialNomination: false, + canadianEducation: 'none', + siblingInCanada: false, + ...overrides, + } +} + +describe('getCrsRecommendations', () => { + it('default CRS input includes a provincial nomination suggestion worth 600 points', () => { + const recommendations = getCrsRecommendations(defaultInput()) + const nomination = recommendations.find((r) => r.id === 'provincial-nomination') + expect(nomination).toBeDefined() + expect(nomination?.potential).toBe(600) + }) + + it('default CRS input includes a language improvement suggestion', () => { + const recommendations = getCrsRecommendations(defaultInput()) + const language = recommendations.find((r) => r.id === 'first-language' || r.id === 'second-language') + expect(language).toBeDefined() + expect(language?.potential).toBeGreaterThan(0) + }) + + it('never returns an item with potential <= 0', () => { + const inputs: CrsInput[] = [ + defaultInput(), + defaultInput({ + hasAccompanyingSpouse: true, + spouse: { education: 'secondary', language: allClb(0), canadianWorkYears: 0 }, + }), + defaultInput({ age: 44 }), + defaultInput({ english: allClb(9), foreignWorkYears: 3 }), + defaultInput({ education: 'master', english: allClb(10), canadianWorkYears: 2, french: allClb(6) }), + ] + for (const input of inputs) { + for (const r of getCrsRecommendations(input)) { + expect(r.potential).toBeGreaterThan(0) + } + } + }) + + it('sorts recommendations by potential descending', () => { + const recommendations = getCrsRecommendations(defaultInput()) + for (let i = 1; i < recommendations.length; i++) { + const prev = recommendations[i - 1] + const curr = recommendations[i] + expect(prev!.potential).toBeGreaterThanOrEqual(curr!.potential) + } + }) + + it('caps the list at six items', () => { + expect(getCrsRecommendations(defaultInput()).length).toBeLessThanOrEqual(6) + }) + + it('returns an empty list for a fully maxed profile', () => { + const recommendations = getCrsRecommendations( + defaultInput({ + age: 45, + education: 'doctorate', + english: allClb(10), + french: allClb(10), + canadianWorkYears: 5, + foreignWorkYears: 5, + certificateOfQualification: true, + provincialNomination: true, + canadianEducation: 'three-plus-years', + siblingInCanada: true, + }), + ) + expect(recommendations).toEqual([]) + }) + + it('suggests improving the spouse profile when a spouse is present', () => { + const recommendations = getCrsRecommendations( + defaultInput({ + age: 20, + hasAccompanyingSpouse: true, + english: allClb(10), + canadianWorkYears: 5, + certificateOfQualification: true, + canadianEducation: 'three-plus-years', + spouse: { education: 'secondary', language: allClb(0), canadianWorkYears: 0 }, + }), + ) + const spouse = recommendations.find((r) => r.id === 'spouse') + expect(spouse).toBeDefined() + expect(spouse?.potential).toBeGreaterThan(0) + }) + + it('adds an age-loss warning when the score drops next year', () => { + const recommendations = getCrsRecommendations( + defaultInput({ + age: 40, + education: 'doctorate', + english: allClb(10), + french: allClb(10), + canadianWorkYears: 5, + foreignWorkYears: 5, + certificateOfQualification: true, + canadianEducation: 'three-plus-years', + siblingInCanada: true, + }), + ) + const age = recommendations.find((r) => r.id === 'age-warning') + expect(age).toBeDefined() + expect(age?.potential).toBeGreaterThan(0) + expect(age?.detail).toMatch(/drops by/) + }) + + it('does not mutate the input it is given', () => { + const input = defaultInput() + const snapshot = JSON.parse(JSON.stringify(input)) as CrsInput + getCrsRecommendations(input) + expect(input).toEqual(snapshot) + }) +}) diff --git a/src/lib/crs/recommendations.ts b/src/lib/crs/recommendations.ts new file mode 100644 index 0000000..5d2ab65 --- /dev/null +++ b/src/lib/crs/recommendations.ts @@ -0,0 +1,235 @@ +/** + * CRS score improvement recommendations. + * + * Pure function: never mutates the input, computes every point gain by running + * `crsScore` on hypothetical copies of the input. Provincial nomination is the + * one exception because it is a fixed +600 in the additional-points grid. + */ + +import { overallClb } from './languages' +import { crsScore } from './score' +import type { CanadianEducation, Clb, CrsInput, LanguageProficiency, WorkYears } from './types' + +export interface CrsRecommendation { + id: string + title: string + detail: string + potential: number +} + +const EMPTY_PROFICIENCY: LanguageProficiency = { listening: 0, reading: 0, writing: 0, speaking: 0 } + +interface Changes { + age?: number + canadianEducation?: CanadianEducation + canadianWorkYears?: WorkYears + foreignWorkYears?: WorkYears + english?: LanguageProficiency + french?: LanguageProficiency + spouseLanguage?: LanguageProficiency + spouseWorkYears?: WorkYears + siblingInCanada?: boolean + certificateOfQualification?: boolean + provincialNomination?: boolean +} + +/** Copy of the input with the given changes applied; the original is never touched. */ +function withChanges(input: CrsInput, changes: Changes): CrsInput { + const next: CrsInput = { ...input } + if (changes.age !== undefined) next.age = changes.age + if (changes.canadianEducation !== undefined) next.canadianEducation = changes.canadianEducation + if (changes.canadianWorkYears !== undefined) next.canadianWorkYears = changes.canadianWorkYears + if (changes.foreignWorkYears !== undefined) next.foreignWorkYears = changes.foreignWorkYears + if (changes.english !== undefined) next.english = { ...changes.english } + if (changes.french !== undefined) next.french = { ...changes.french } + if (changes.siblingInCanada !== undefined) next.siblingInCanada = changes.siblingInCanada + if (changes.certificateOfQualification !== undefined) next.certificateOfQualification = changes.certificateOfQualification + if (changes.provincialNomination !== undefined) next.provincialNomination = changes.provincialNomination + if (changes.spouseLanguage !== undefined || changes.spouseWorkYears !== undefined) { + const baseSpouse = + input.spouse ?? { education: 'secondary' as const, language: EMPTY_PROFICIENCY, canadianWorkYears: 0 as WorkYears } + next.spouse = { + ...baseSpouse, + language: changes.spouseLanguage !== undefined ? { ...changes.spouseLanguage } : { ...baseSpouse.language }, + canadianWorkYears: changes.spouseWorkYears ?? baseSpouse.canadianWorkYears, + } + } + return next +} + +function raiseTo(proficiency: LanguageProficiency, target: Clb): LanguageProficiency { + return { + listening: Math.max(proficiency.listening, target) as Clb, + reading: Math.max(proficiency.reading, target) as Clb, + writing: Math.max(proficiency.writing, target) as Clb, + speaking: Math.max(proficiency.speaking, target) as Clb, + } +} + +function pointGain(baseTotal: number, simulated: CrsInput): number { + return crsScore(simulated).total - baseTotal +} + +export function getCrsRecommendations(input: CrsInput): CrsRecommendation[] { + const baseTotal = crsScore(input).total + const recommendations: CrsRecommendation[] = [] + + const push = (id: string, title: string, detail: string, potential: number) => { + if (potential > 0) recommendations.push({ id, title, detail, potential }) + } + + const firstProficiency = input.firstOfficialLanguage === 'english' ? input.english : input.french + const secondProficiency = input.firstOfficialLanguage === 'english' ? input.french : input.english + const firstName = input.firstOfficialLanguage === 'english' ? 'English' : 'French' + const secondName = input.firstOfficialLanguage === 'english' ? 'French' : 'English' + const firstClb = overallClb(firstProficiency) + + if (firstClb < 9) { + const next = + input.firstOfficialLanguage === 'english' + ? withChanges(input, { english: raiseTo(firstProficiency, 9) }) + : withChanges(input, { french: raiseTo(firstProficiency, 9) }) + const hint = + input.firstOfficialLanguage === 'english' + ? 'For IELTS that is roughly 8.0 in listening and 7.0 in the other three skills.' + : 'Aim for NCLC 9 in listening, reading, writing, and speaking.' + push( + 'first-language', + `Reach CLB 9 in ${firstName}`, + `Retake your ${firstName} test and aim for CLB 9 in all four abilities. ${hint}`, + pointGain(baseTotal, next), + ) + } else if (firstClb === 9) { + const next = + input.firstOfficialLanguage === 'english' + ? withChanges(input, { english: raiseTo(firstProficiency, 10) }) + : withChanges(input, { french: raiseTo(firstProficiency, 10) }) + push( + 'first-language', + `Reach CLB 10 in ${firstName}`, + `Push your ${firstName} scores to CLB 10 in all four abilities to take the top band in the language table.`, + pointGain(baseTotal, next), + ) + } + + if (overallClb(secondProficiency) < 7) { + const next = + input.firstOfficialLanguage === 'english' + ? withChanges(input, { french: raiseTo(secondProficiency, 7) }) + : withChanges(input, { english: raiseTo(secondProficiency, 7) }) + const bonus = + secondName === 'French' + ? ' Strong French also earns the French ability bonus worth up to 50 points.' + : '' + push( + 'second-language', + `Reach CLB 7 in ${secondName}`, + `Take a ${secondName} test and score CLB 7 in all four abilities.${bonus}`, + pointGain(baseTotal, next), + ) + } + + if (input.canadianEducation === 'none') { + const gainShort = pointGain(baseTotal, withChanges(input, { canadianEducation: 'one-two-years' })) + const gainLong = pointGain(baseTotal, withChanges(input, { canadianEducation: 'three-plus-years' })) + push( + 'canadian-education', + 'Study in Canada for 3+ years', + 'Complete a post-secondary credential at a Canadian institution. Three or more years of study earns 30 points and even one to two years earns 15.', + Math.max(gainShort, gainLong), + ) + } + + if (input.canadianWorkYears < 5) { + push( + 'canadian-work', + 'Add a year of Canadian work', + 'Gain one more year of skilled work in Canada. Canadian experience raises both your core score and your skill transferability.', + pointGain(baseTotal, withChanges(input, { canadianWorkYears: (input.canadianWorkYears + 1) as WorkYears })), + ) + } + + if (input.foreignWorkYears < 5) { + push( + 'foreign-work', + 'Add a year of foreign work', + 'Add one more year of skilled foreign work experience to your profile. It strengthens the foreign work portion of your skill transferability points.', + pointGain(baseTotal, withChanges(input, { foreignWorkYears: (input.foreignWorkYears + 1) as WorkYears })), + ) + } + + if (!input.provincialNomination) { + push( + 'provincial-nomination', + 'Get a provincial nomination', + 'A provincial nomination adds 600 points and is the single biggest point gain in Express Entry. Apply to the streams run by the provinces where you are willing to live and work.', + 600, + ) + } + + if (!input.siblingInCanada) { + push( + 'sibling', + 'Add a sibling in Canada', + 'You earn 15 points when a sibling who is 18 or older is a Canadian citizen or permanent resident living in Canada. Update your profile if that applies to you.', + pointGain(baseTotal, withChanges(input, { siblingInCanada: true })), + ) + } + + if (!input.certificateOfQualification) { + push( + 'certificate', + 'Get a certificate of qualification', + 'If you work in a skilled trade, a certificate of qualification issued by a province adds points when combined with your language scores.', + pointGain(baseTotal, withChanges(input, { certificateOfQualification: true })), + ) + } + + if (input.hasAccompanyingSpouse && input.spouse) { + const spouse = input.spouse + let spouseRec: CrsRecommendation | undefined + + if (overallClb(spouse.language) < 7) { + const potential = pointGain(baseTotal, withChanges(input, { spouseLanguage: raiseTo(spouse.language, 7) })) + if (potential > 0) { + spouseRec = { + id: 'spouse', + title: 'Raise your spouse to CLB 7', + detail: 'Help your spouse reach CLB 7 in all four abilities on their language test. Partner language scores add directly to your total.', + potential, + } + } + } + + if (spouse.canadianWorkYears < 5) { + const potential = pointGain( + baseTotal, + withChanges(input, { spouseWorkYears: (spouse.canadianWorkYears + 1) as WorkYears }), + ) + if (potential > 0 && (spouseRec === undefined || potential > spouseRec.potential)) { + spouseRec = { + id: 'spouse', + title: 'Add a year of Canadian work for your spouse', + detail: 'Your spouse earns more points with each additional year of skilled work in Canada.', + potential, + } + } + } + + if (spouseRec) recommendations.push(spouseRec) + } + + const nextYearTotal = crsScore(withChanges(input, { age: input.age + 1 })).total + if (nextYearTotal < baseTotal) { + const lost = baseTotal - nextYearTotal + push( + 'age-warning', + 'Apply before your next birthday', + `Your score drops by ${lost} point${lost === 1 ? '' : 's'} when you turn ${input.age + 1}. Submit your Express Entry profile before that birthday to keep the age points you have now.`, + lost, + ) + } + + recommendations.sort((a, b) => b.potential - a.potential) + return recommendations.slice(0, 6) +} diff --git a/src/lib/crs/score.ts b/src/lib/crs/score.ts index 33df1b6..9cae2c5 100644 --- a/src/lib/crs/score.ts +++ b/src/lib/crs/score.ts @@ -1,5 +1,5 @@ /** - * CRS scoring engine — pure functions, zero framework dependencies. + * CRS scoring engine - pure functions, zero framework dependencies. * * Verified against IRCC "Comprehensive Ranking System (CRS) criteria" * (canada.ca, updated 2026-06-22) and the official CRS tool's worked examples. @@ -30,7 +30,7 @@ import { spouseLanguagePointsPerAbility, } from './tables' import { ADDITIONAL_CANADIAN_EDUCATION_TABLE, EDUCATION_TABLE } from './tables' -import type { CrsBreakdown, CrsInput, LanguageProficiency } from './types' +import type { CrsBreakdown, CrsInput } from './types' /** A. Core / human capital factors. Capped at 460 (with spouse) or 500 (without). */ export function coreHumanCapitalPoints(input: CrsInput): number { @@ -125,10 +125,3 @@ export function crsScore(input: CrsInput): CrsBreakdown { const total = clamp(core + spouse + transferability + additional, 0, MAX_TOTAL) return { core, spouse, transferability, additional, total } } - -export const LANGUAGE_PROFICIENCY_EMPTY: LanguageProficiency = { - listening: 0, - reading: 0, - writing: 0, - speaking: 0, -} diff --git a/src/lib/crs/types.ts b/src/lib/crs/types.ts index 9819990..9ca36ed 100644 --- a/src/lib/crs/types.ts +++ b/src/lib/crs/types.ts @@ -1,5 +1,5 @@ /** - * CRS (Comprehensive Ranking System) — domain types. + * CRS (Comprehensive Ranking System) - domain types. * Pure data, no framework dependencies. */ diff --git a/src/lib/features.test.ts b/src/lib/features.test.ts new file mode 100644 index 0000000..577d410 --- /dev/null +++ b/src/lib/features.test.ts @@ -0,0 +1,39 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { aiChatEnabled } from './features' + +afterEach(() => { + localStorage.clear() + vi.unstubAllEnvs() +}) + +describe('aiChatEnabled', () => { + it('is enabled by default', () => { + expect(aiChatEnabled()).toBe(true) + }) + + it('is disabled when the localStorage flag is off', () => { + localStorage.setItem('ff:ai-chat', 'off') + expect(aiChatEnabled()).toBe(false) + }) + + it('is enabled when the localStorage flag is set to anything else', () => { + localStorage.setItem('ff:ai-chat', 'on') + expect(aiChatEnabled()).toBe(true) + }) + + it('is disabled when VITE_FF_AI_CHAT is off', () => { + vi.stubEnv('VITE_FF_AI_CHAT', 'off') + expect(aiChatEnabled()).toBe(false) + }) + + it('is disabled when VITE_FF_AI_CHAT is 0', () => { + vi.stubEnv('VITE_FF_AI_CHAT', '0') + expect(aiChatEnabled()).toBe(false) + }) + + it('is disabled when VITE_FF_AI_CHAT is false', () => { + vi.stubEnv('VITE_FF_AI_CHAT', 'false') + expect(aiChatEnabled()).toBe(false) + }) +}) diff --git a/src/lib/features.ts b/src/lib/features.ts new file mode 100644 index 0000000..6ea08ce --- /dev/null +++ b/src/lib/features.ts @@ -0,0 +1,12 @@ +export function aiChatEnabled(): boolean { + if (typeof localStorage !== 'undefined' && localStorage.getItem('ff:ai-chat') === 'off') { + return false + } + + const flag = import.meta.env.VITE_FF_AI_CHAT + if (flag === '0' || flag === 'false' || flag === 'off') { + return false + } + + return true +} diff --git a/src/lib/manitoba/score.test.ts b/src/lib/manitoba/score.test.ts new file mode 100644 index 0000000..7944358 --- /dev/null +++ b/src/lib/manitoba/score.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it } from 'vitest' +import { + adaptabilityPoints, + agePoints, + connectionsPoints, + demandPoints, + educationPoints, + eligibility, + eligibilityAssessmentScore, + experiencePoints, + firstLanguagePoints, + languageBandPoints, + languagePoints, + manitobaScore, + riskPoints, + secondLanguagePoints, + workYearsPoints, +} from './score' +import type { ManitobaInput, ManitobaLanguageClbs } from './score' + +function allClbs(clb: number): ManitobaLanguageClbs { + return { listening: clb, reading: clb, writing: clb, speaking: clb } +} + +function base(overrides: Partial = {}): ManitobaInput { + return { + firstLanguageClbs: allClbs(7), + secondLanguage: false, + age: 30, + workYears: 3, + recognizedByLicensingBody: false, + education: 'two-year', + connections: { + closeRelative: false, + authorizedWork6Months: false, + postSecondary2Years: false, + postSecondary1Year: false, + closeFriendOrDistantRelative: true, + }, + demand: { + ongoingEmploymentJobOffer: false, + strategicInitiativeIta: false, + }, + regionalDevelopmentOutsideWinnipeg: false, + risk: { + workExperienceOtherProvince: false, + studiesOtherProvince: false, + }, + ...overrides, + } +} + +describe('MPNP Factor 1: language proficiency', () => { + it('first official language bands are per ability', () => { + expect(languageBandPoints(10)).toBe(25) + expect(languageBandPoints(8)).toBe(25) + expect(languageBandPoints(7)).toBe(22) + expect(languageBandPoints(6)).toBe(20) + expect(languageBandPoints(5)).toBe(17) + expect(languageBandPoints(4)).toBe(12) + expect(languageBandPoints(3)).toBe(0) + expect(languageBandPoints(0)).toBe(0) + }) + + it('first official language sums points across the four abilities', () => { + expect(firstLanguagePoints(allClbs(10))).toBe(100) + expect(firstLanguagePoints(allClbs(8))).toBe(100) + expect(firstLanguagePoints(allClbs(7))).toBe(88) + expect(firstLanguagePoints(allClbs(6))).toBe(80) + expect(firstLanguagePoints(allClbs(5))).toBe(68) + expect(firstLanguagePoints(allClbs(4))).toBe(48) + expect(firstLanguagePoints(allClbs(3))).toBe(0) + expect( + firstLanguagePoints({ reading: 8, writing: 7, listening: 6, speaking: 5 }), + ).toBe(84) + }) + + it('second official language at overall CLB 5+', () => { + expect(secondLanguagePoints(true)).toBe(25) + expect(secondLanguagePoints(false)).toBe(0) + }) + + it('combined language points', () => { + expect(languagePoints(allClbs(7), true)).toBe(113) + expect(languagePoints(allClbs(4), false)).toBe(48) + }) +}) + +describe('MPNP Factor 2: age', () => { + it('age bands', () => { + expect(agePoints(18)).toBe(20) + expect(agePoints(19)).toBe(30) + expect(agePoints(20)).toBe(40) + expect(agePoints(21)).toBe(75) + expect(agePoints(30)).toBe(75) + expect(agePoints(45)).toBe(75) + expect(agePoints(46)).toBe(40) + expect(agePoints(47)).toBe(30) + expect(agePoints(48)).toBe(20) + expect(agePoints(49)).toBe(10) + expect(agePoints(50)).toBe(0) + expect(agePoints(60)).toBe(0) + }) +}) + +describe('MPNP Factor 3: work experience', () => { + it('years in the last 5', () => { + expect(workYearsPoints(0)).toBe(0) + expect(workYearsPoints(1)).toBe(40) + expect(workYearsPoints(2)).toBe(50) + expect(workYearsPoints(3)).toBe(60) + expect(workYearsPoints(4)).toBe(75) + expect(workYearsPoints(5)).toBe(75) + }) + + it('licensing body recognition adds 100 up to the factor max', () => { + expect(experiencePoints(3, true)).toBe(160) + expect(experiencePoints(4, true)).toBe(175) + expect(experiencePoints(1, true)).toBe(140) + expect(experiencePoints(3, false)).toBe(60) + }) +}) + +describe('MPNP Factor 4: education', () => { + it('education bands', () => { + expect(educationPoints('master-or-doctorate')).toBe(125) + expect(educationPoints('two-post-secondary')).toBe(115) + expect(educationPoints('three-plus-year')).toBe(110) + expect(educationPoints('two-year')).toBe(100) + expect(educationPoints('one-year')).toBe(70) + expect(educationPoints('trade-certificate')).toBe(70) + expect(educationPoints('no-post-secondary')).toBe(0) + }) +}) + +describe('MPNP Factor 5: adaptability', () => { + it('connections cap at 200', () => { + expect( + connectionsPoints({ + closeRelative: false, + authorizedWork6Months: false, + postSecondary2Years: false, + postSecondary1Year: false, + closeFriendOrDistantRelative: true, + }), + ).toBe(50) + expect( + connectionsPoints({ + closeRelative: true, + authorizedWork6Months: true, + postSecondary2Years: true, + postSecondary1Year: true, + closeFriendOrDistantRelative: true, + }), + ).toBe(200) + }) + + it('demand cap at 500', () => { + expect(demandPoints({ ongoingEmploymentJobOffer: false, strategicInitiativeIta: false })).toBe(0) + expect(demandPoints({ ongoingEmploymentJobOffer: true, strategicInitiativeIta: false })).toBe(500) + expect(demandPoints({ ongoingEmploymentJobOffer: true, strategicInitiativeIta: true })).toBe(500) + }) + + it('adaptability caps the whole factor at 500', () => { + const connections: ManitobaInput['connections'] = { + closeRelative: true, + authorizedWork6Months: true, + postSecondary2Years: true, + postSecondary1Year: true, + closeFriendOrDistantRelative: true, + } + const demand: ManitobaInput['demand'] = { + ongoingEmploymentJobOffer: true, + strategicInitiativeIta: true, + } + // subtotal would be 200 + 500 + 50 = 750 + expect(adaptabilityPoints(connections, demand, true)).toBe(500) + expect(adaptabilityPoints(connections, demand, false)).toBe(500) + }) +}) + +describe('MPNP Factor 6: risk assessment', () => { + it('deductions', () => { + expect(riskPoints({ workExperienceOtherProvince: false, studiesOtherProvince: false })).toBe(0) + expect(riskPoints({ workExperienceOtherProvince: true, studiesOtherProvince: false })).toBe(-100) + expect(riskPoints({ workExperienceOtherProvince: false, studiesOtherProvince: true })).toBe(-100) + expect(riskPoints({ workExperienceOtherProvince: true, studiesOtherProvince: true })).toBe(-200) + }) +}) + +describe('MPNP total', () => { + it('default input', () => { + // language 88 (CLB 7 per ability x4) + age 75 + experience 60 + education 100 + adaptability 50 = 373 + expect(manitobaScore(base())).toEqual({ + language: 88, + age: 75, + experience: 60, + education: 100, + adaptability: 50, + risk: 0, + total: 373, + }) + }) + + it('maximized profile', () => { + const score = manitobaScore( + base({ + firstLanguageClbs: allClbs(10), + secondLanguage: true, + age: 30, + workYears: 4, + recognizedByLicensingBody: true, + education: 'master-or-doctorate', + connections: { + closeRelative: true, + authorizedWork6Months: true, + postSecondary2Years: true, + postSecondary1Year: true, + closeFriendOrDistantRelative: true, + }, + demand: { ongoingEmploymentJobOffer: true, strategicInitiativeIta: true }, + regionalDevelopmentOutsideWinnipeg: true, + }), + ) + // language 125 + age 75 + experience 175 + education 125 + adaptability 500 = 1000 + expect(score).toEqual({ + language: 125, + age: 75, + experience: 175, + education: 125, + adaptability: 500, + risk: 0, + total: 1000, + }) + }) + + it('risk deductions lower the total', () => { + const score = manitobaScore( + base({ + risk: { workExperienceOtherProvince: true, studiesOtherProvince: true }, + }), + ) + // 373 - 200 = 173 + expect(score.risk).toBe(-200) + expect(score.total).toBe(173) + }) +}) + +describe('MPNP eligibility', () => { + it('default profile is eligible', () => { + const result = eligibility(base()) + expect(result.eligible).toBe(true) + expect(result.reasons).toEqual([]) + }) + + it('fails when there is no connection to Manitoba', () => { + const result = eligibility( + base({ + connections: { + closeRelative: false, + authorizedWork6Months: false, + postSecondary2Years: false, + postSecondary1Year: false, + closeFriendOrDistantRelative: false, + }, + }), + ) + expect(result.eligible).toBe(false) + expect(result.reasons.some((r) => r.includes('connection to Manitoba'))).toBe(true) + }) + + it('fails when the language floor is not met', () => { + const result = eligibility(base({ firstLanguageClbs: allClbs(3) })) + expect(result.eligible).toBe(false) + expect(result.reasons.some((r) => r.includes('CLB 4'))).toBe(true) + }) + + it('fails when the 60-point assessment is not met', () => { + const result = eligibility( + base({ + firstLanguageClbs: allClbs(4), + age: 50, + workYears: 1, + education: 'no-post-secondary', + connections: { + closeRelative: false, + authorizedWork6Months: false, + postSecondary2Years: false, + postSecondary1Year: false, + closeFriendOrDistantRelative: false, + }, + demand: { ongoingEmploymentJobOffer: false, strategicInitiativeIta: false }, + }), + ) + expect(eligibilityAssessmentScore(base({ firstLanguageClbs: allClbs(4), age: 50, workYears: 1, education: 'no-post-secondary' }))).toBeLessThan(60) + expect(result.eligible).toBe(false) + expect(result.reasons.some((r) => r.includes('60'))).toBe(true) + }) +}) diff --git a/src/lib/manitoba/score.ts b/src/lib/manitoba/score.ts new file mode 100644 index 0000000..ecf84cc --- /dev/null +++ b/src/lib/manitoba/score.ts @@ -0,0 +1,349 @@ +/** + * MPNP EOI scoring - Manitoba Provincial Nominee Program, Skilled Worker streams. + * + * Source: Manitoba "Expression of Interest" (Immigrate Manitoba, updated 2026-04-20) + * https://immigratemanitoba.com/mpnp/apply/eoi/ + * + * Six factors, out of 1000 points: + * Factor 1 Language proficiency (max 125) + * Factor 2 Age (max 75) + * Factor 3 Work experience (max 175) + * Factor 4 Education (max 125) + * Factor 5 Adaptability (max 500) + * Factor 6 Risk assessment (deductions down to -200) + * + * Language points are awarded per ability band (reading, writing, listening, + * speaking), not on the overall CLB: 25 per band at CLB 8+, down to 0 at + * CLB 3 or lower. Second official language is a flat 25 at overall CLB 5+. + */ + +export type ManitobaLanguageAbility = 'listening' | 'reading' | 'writing' | 'speaking' + +export type ManitobaLanguageClbs = Record + +export type ManitobaEducation = + | 'master-or-doctorate' + | 'two-post-secondary' + | 'three-plus-year' + | 'two-year' + | 'one-year' + | 'trade-certificate' + | 'no-post-secondary' + +export interface ManitobaConnections { + closeRelative: boolean + authorizedWork6Months: boolean + postSecondary2Years: boolean + postSecondary1Year: boolean + closeFriendOrDistantRelative: boolean +} + +export interface ManitobaDemand { + ongoingEmploymentJobOffer: boolean + strategicInitiativeIta: boolean +} + +export interface ManitobaRisk { + workExperienceOtherProvince: boolean + studiesOtherProvince: boolean +} + +export interface ManitobaInput { + /** CLB/NCLC band of the first official language for each ability, 0 if no result. */ + firstLanguageClbs: ManitobaLanguageClbs + /** Declared second official language with an overall CLB/NCLC of 5 or higher. */ + secondLanguage: boolean + age: number + /** Years of work experience during the last 5 years. 4 means 4 or more. */ + workYears: number + recognizedByLicensingBody: boolean + education: ManitobaEducation + connections: ManitobaConnections + demand: ManitobaDemand + regionalDevelopmentOutsideWinnipeg: boolean + risk: ManitobaRisk +} + +export interface ManitobaBreakdown { + language: number + age: number + experience: number + education: number + adaptability: number + risk: number + total: number +} + +export const MAX_LANGUAGE = 125 +export const MAX_AGE = 75 +export const MAX_EXPERIENCE = 175 +export const MAX_EDUCATION = 125 +export const MAX_ADAPTABILITY = 500 +export const MAX_CONNECTIONS = 200 +export const MAX_DEMAND = 500 +export const MAX_RISK = 200 +export const MAX_TOTAL = 1000 + +export const MB_DOC = 'https://immigratemanitoba.com/mpnp/apply/eoi/' +export const MB_LANGUAGE_DOC = 'https://immigratemanitoba.com/mpnp/policies/language-proficiency/' + +// --- Factor 1: Language proficiency --- + +export function languageBandPoints(clb: number): number { + if (clb >= 8) return 25 + if (clb === 7) return 22 + if (clb === 6) return 20 + if (clb === 5) return 17 + if (clb === 4) return 12 + return 0 +} + +export function firstLanguagePoints(clbs: ManitobaLanguageClbs): number { + return ( + languageBandPoints(clbs.reading) + + languageBandPoints(clbs.writing) + + languageBandPoints(clbs.listening) + + languageBandPoints(clbs.speaking) + ) +} + +export function secondLanguagePoints(secondLanguage: boolean): number { + return secondLanguage ? 25 : 0 +} + +export function languagePoints(firstLanguageClbs: ManitobaLanguageClbs, secondLanguage: boolean): number { + const total = firstLanguagePoints(firstLanguageClbs) + secondLanguagePoints(secondLanguage) + return Math.min(MAX_LANGUAGE, total) +} + +// --- Factor 2: Age --- + +export function agePoints(age: number): number { + if (age >= 21 && age <= 45) return 75 + if (age === 20 || age === 46) return 40 + if (age === 19 || age === 47) return 30 + if (age === 18 || age === 48) return 20 + if (age === 49) return 10 + return 0 +} + +// --- Factor 3: Work experience --- + +export function workYearsPoints(years: number): number { + if (years >= 4) return 75 + if (years === 3) return 60 + if (years === 2) return 50 + if (years === 1) return 40 + return 0 +} + +export function experiencePoints(workYears: number, recognizedByLicensingBody: boolean): number { + const points = workYearsPoints(workYears) + (recognizedByLicensingBody ? 100 : 0) + return Math.min(MAX_EXPERIENCE, points) +} + +// --- Factor 4: Education --- + +export function educationPoints(education: ManitobaEducation): number { + switch (education) { + case 'master-or-doctorate': + return 125 + case 'two-post-secondary': + return 115 + case 'three-plus-year': + return 110 + case 'two-year': + return 100 + case 'one-year': + return 70 + case 'trade-certificate': + return 70 + case 'no-post-secondary': + return 0 + } +} + +// --- Factor 5: Adaptability --- + +export function connectionsPoints(connections: ManitobaConnections): number { + let total = 0 + if (connections.closeRelative) total += 200 + if (connections.authorizedWork6Months) total += 100 + if (connections.postSecondary2Years) total += 100 + if (connections.postSecondary1Year) total += 50 + if (connections.closeFriendOrDistantRelative) total += 50 + return Math.min(MAX_CONNECTIONS, total) +} + +export function demandPoints(demand: ManitobaDemand): number { + let total = 0 + if (demand.ongoingEmploymentJobOffer) total += 500 + if (demand.strategicInitiativeIta) total += 500 + return Math.min(MAX_DEMAND, total) +} + +export function adaptabilityPoints( + connections: ManitobaConnections, + demand: ManitobaDemand, + regionalDevelopmentOutsideWinnipeg: boolean, +): number { + const subtotal = + connectionsPoints(connections) + + demandPoints(demand) + + (regionalDevelopmentOutsideWinnipeg ? 50 : 0) + return Math.min(MAX_ADAPTABILITY, subtotal) +} + +// --- Factor 6: Risk assessment (deductions) --- + +export function riskPoints(risk: ManitobaRisk): number { + let total = 0 + if (risk.workExperienceOtherProvince) total -= 100 + if (risk.studiesOtherProvince) total -= 100 + return total +} + +export function manitobaScore(input: ManitobaInput): ManitobaBreakdown { + const language = languagePoints(input.firstLanguageClbs, input.secondLanguage) + const age = agePoints(input.age) + const experience = experiencePoints(input.workYears, input.recognizedByLicensingBody) + const education = educationPoints(input.education) + const adaptability = adaptabilityPoints( + input.connections, + input.demand, + input.regionalDevelopmentOutsideWinnipeg, + ) + const risk = riskPoints(input.risk) + + return { + language, + age, + experience, + education, + adaptability, + risk, + total: language + age + experience + education + adaptability + risk, + } +} + +// --- Eligibility determination --- +// +// Separate from the 1000-point EOI ranking: candidates must score at least +// 60 out of 100 on the MPNP eligibility assessment grid and hold a +// connection to Manitoba. Source: MPNP EOI eligibility grid +// (immigratemanitoba.com/mpnp/apply/eoi/, updated 2026-04-20). + +export const MIN_ELIGIBILITY_SCORE = 60 + +export interface ManitobaEligibility { + eligible: boolean + reasons: string[] +} + +function overallClb(clbs: ManitobaLanguageClbs): number { + return Math.min(clbs.listening, clbs.reading, clbs.writing, clbs.speaking) +} + +function eligibilityLanguagePoints(clbs: ManitobaLanguageClbs, secondLanguage: boolean): number { + const clb = overallClb(clbs) + const first = clb >= 8 ? 20 : clb === 7 ? 18 : clb === 6 ? 16 : clb === 5 ? 14 : clb === 4 ? 12 : 0 + const second = secondLanguage ? 5 : 0 + return first + second +} + +function eligibilityAgePoints(age: number): number { + if (age >= 21 && age <= 45) return 10 + if (age === 20 || age === 46) return 8 + if (age === 19 || age === 47) return 6 + if (age === 18 || age === 48) return 4 + if (age === 49) return 2 + return 0 +} + +function eligibilityWorkPoints(workYears: number): number { + if (workYears >= 4) return 15 + if (workYears === 3) return 12 + if (workYears === 2) return 10 + if (workYears === 1) return 8 + return 0 +} + +function eligibilityEducationPoints(education: ManitobaEducation): number { + switch (education) { + case 'master-or-doctorate': + return 25 + case 'two-post-secondary': + return 23 + case 'three-plus-year': + case 'two-year': + return 20 + case 'one-year': + case 'trade-certificate': + return 14 + case 'no-post-secondary': + return 0 + } +} + +function eligibilityAdaptabilityPoints( + connections: ManitobaConnections, + demand: ManitobaDemand, + regionalDevelopmentOutsideWinnipeg: boolean, +): number { + let total = 0 + if (connections.closeRelative) total += 20 + if (connections.authorizedWork6Months) total += 12 + if (connections.postSecondary2Years) total += 12 + if (connections.postSecondary1Year) total += 10 + if (connections.closeFriendOrDistantRelative) total += 10 + if (demand.strategicInitiativeIta) total += 20 + if (regionalDevelopmentOutsideWinnipeg) total += 5 + return Math.min(25, total) +} + +export function eligibilityAssessmentScore(input: ManitobaInput): number { + return ( + eligibilityLanguagePoints(input.firstLanguageClbs, input.secondLanguage) + + eligibilityAgePoints(input.age) + + eligibilityWorkPoints(input.workYears) + + eligibilityEducationPoints(input.education) + + eligibilityAdaptabilityPoints(input.connections, input.demand, input.regionalDevelopmentOutsideWinnipeg) + ) +} + +export function hasManitobaConnection(input: ManitobaInput): boolean { + const c = input.connections + return ( + c.closeRelative || + c.authorizedWork6Months || + c.postSecondary2Years || + c.postSecondary1Year || + c.closeFriendOrDistantRelative || + input.demand.strategicInitiativeIta || + input.demand.ongoingEmploymentJobOffer + ) +} + +export function eligibility(input: ManitobaInput): ManitobaEligibility { + const reasons: string[] = [] + + const clb = overallClb(input.firstLanguageClbs) + if (clb < 4) { + reasons.push('MPNP requires a minimum of CLB 4 in your first official language.') + } + + if (!hasManitobaConnection(input)) { + reasons.push( + 'Skilled Worker streams require an established connection to Manitoba: close family, previous work or study in the province, or an invitation under a strategic initiative.', + ) + } + + const assessment = eligibilityAssessmentScore(input) + if (assessment < MIN_ELIGIBILITY_SCORE) { + reasons.push( + `You scored ${assessment} out of 100 on the MPNP eligibility assessment, but you need at least ${MIN_ELIGIBILITY_SCORE}.`, + ) + } + + return { eligible: reasons.length === 0, reasons } +} diff --git a/src/lib/oinp/score.test.ts b/src/lib/oinp/score.test.ts index 590c2ac..1273798 100644 --- a/src/lib/oinp/score.test.ts +++ b/src/lib/oinp/score.test.ts @@ -3,6 +3,7 @@ import { canadianCredentialsPoints, earningsPoints, educationPoints, + eligibility, languageAbilityPoints, legalStatusPoints, nocBroadPoints, @@ -31,6 +32,7 @@ function base(overrides: Partial = {}): OinpInput { englishClb: 7, frenchClb: 0, region: 'eastern', + recentOntarioGraduate: false, ...overrides, } } @@ -130,11 +132,13 @@ describe('OINP Workforce Priority factor points (official grid)', () => { expect(languageAbilityPoints(0)).toBe(0) }) - it('knowledge of official languages (CLB 6 threshold for two)', () => { + it('knowledge of official languages (CLB 6 threshold for one or both)', () => { expect(officialLanguagesPoints(6, 6)).toBe(10) expect(officialLanguagesPoints(7, 8)).toBe(10) expect(officialLanguagesPoints(7, 5)).toBe(5) - expect(officialLanguagesPoints(5, 0)).toBe(5) + expect(officialLanguagesPoints(0, 6)).toBe(5) + expect(officialLanguagesPoints(5, 0)).toBe(0) + expect(officialLanguagesPoints(5, 5)).toBe(0) expect(officialLanguagesPoints(4, 4)).toBe(0) expect(officialLanguagesPoints(0, 0)).toBe(0) }) @@ -204,4 +208,67 @@ describe('OINP total', () => { const score = oinpScore(base({ englishClb: 6, frenchClb: 8 })) expect(score.language).toBe(22) // ability 12 (best CLB 8) + 2-language 10 }) + + it('recent Ontario graduate status is eligibility-only and adds 0 points', () => { + const regular = oinpScore(base()) + const recentGrad = oinpScore(base({ recentOntarioGraduate: true })) + expect(recentGrad.total).toBe(regular.total) + expect(recentGrad).toEqual(regular) + }) + + it('a profile with one official language at CLB 5 earns 0 bilingualism points', () => { + const score = oinpScore(base({ englishClb: 5 })) + // language: ability 0 (CLB 5 or lower) + knowledge 0 (needs CLB 6+) = 0 + expect(score.language).toBe(0) + }) +}) + +describe('OINP Workforce Priority eligibility (official requirements)', () => { + it('flags the default profile as eligible', () => { + expect(eligibility(base())).toEqual({ eligible: true, reasons: [] }) + }) + + it('allows a recent Ontario graduate with under 6 months in the job offer position', () => { + const result = eligibility(base({ recentOntarioGraduate: true, tenureInPosition: 'less-6' })) + expect(result.eligible).toBe(true) + expect(result.reasons).toEqual([]) + }) + + it('flags under 6 months in the position as ineligible for a TEER 0-3 job offer', () => { + const result = eligibility(base({ tenureInPosition: 'less-6' })) + expect(result.eligible).toBe(false) + expect(result.reasons).toEqual([ + 'TEER 0 to 3 job offers require at least 6 months of consecutive full-time work experience in the job offer position, gained within 12 months before you apply.', + ]) + }) + + it('flags a TEER 4 or 5 job offer with under 9 months in the position as ineligible', () => { + const result = eligibility(base({ teer: 4, tenureInPosition: '6-12' })) + expect(result.eligible).toBe(false) + expect(result.reasons).toEqual([ + 'TEER 4 and 5 job offers require at least 9 months of cumulative full-time work experience in the job offer position, gained within 2 years before you apply.', + ]) + }) + + it('accepts a TEER 4 or 5 job offer with 13 months or more in the position', () => { + const result = eligibility(base({ teer: 5, tenureInPosition: '13-24' })) + expect(result.eligible).toBe(true) + expect(result.reasons).toEqual([]) + }) + + it('flags a candidate without valid legal status in Canada', () => { + const result = eligibility(base({ legalStatus: 'none' })) + expect(result.eligible).toBe(false) + expect(result.reasons).toEqual([ + 'You must have valid legal status in Canada, such as a valid work permit or study permit, at the time you apply.', + ]) + }) + + it('lists every unmet requirement as a separate reason', () => { + const result = eligibility(base({ tenureInPosition: 'less-6', legalStatus: 'none' })) + expect(result.eligible).toBe(false) + expect(result.reasons).toHaveLength(2) + expect(result.reasons[0]).toMatch(/at least 6 months/) + expect(result.reasons[1]).toMatch(/legal status/) + }) }) diff --git a/src/lib/oinp/score.ts b/src/lib/oinp/score.ts index 3705ba5..aa8c37e 100644 --- a/src/lib/oinp/score.ts +++ b/src/lib/oinp/score.ts @@ -1,8 +1,26 @@ /** - * OINP EOI scoring — Ontario Workforce Priority stream. + * OINP EOI scoring - Ontario Workforce Priority stream. * * Source: Ontario.ca "Ontario Workforce Priority stream" (updated 2026-08-04) * https://www.ontario.ca/page/ontario-workforce-priority-stream + * + * Revalidated 2026-08-08 against the current official grid. The Workforce + * Priority stream is Ontario's single active OINP stream (all eight former + * streams closed June 26, 2026). Field of study and location of study are NOT + * scored. The "1 official language" bilingualism points require CLB/NCLC 6 in + * at least one official language (not CLB 5). + * + * `recentOntarioGraduate` is an eligibility determination, not a scoring + * factor. A recent Ontario graduate (eligible Ontario institution within the + * last 3 years, with a qualifying credential) only needs 3 months in the job + * offer position instead of 6, but earns no extra EOI points. + * + * Eligibility: besides points, candidates must meet the stream's work + * experience and legal status requirements. The job offer position requires 6 + * months of consecutive full-time work for TEER 0-3 (3 months for recent + * Ontario graduates) or 9 months of cumulative full-time work for TEER 4-5. + * Applicants applying from within Canada must hold valid legal status. See + * `eligibility()`. */ export type TeerCategory = 0 | 1 | 2 | 3 | 4 | 5 @@ -45,6 +63,12 @@ export interface OinpInput { /** Overall CLB/NCLC of the French test (lowest band across abilities), 0 if none. */ frenchClb: number region: Region + /** + * Recent Ontario graduate (eligible Ontario institution within the last 3 + * years). Eligibility-only: reduces the required work experience in the job + * offer position from 6 months to 3 months. Awards 0 EOI points. + */ + recentOntarioGraduate: boolean } export interface OinpBreakdown { @@ -55,6 +79,12 @@ export interface OinpBreakdown { total: number } +export interface Eligibility { + eligible: boolean + /** Human-readable sentences describing each unmet requirement. */ + reasons: string[] +} + export const MAX_LABOUR = 70 export const MAX_EDUCATION = 20 export const MAX_LANGUAGE = 25 @@ -189,10 +219,10 @@ export function languageAbilityPoints(bestClb: number): number { return 0 } -/** 2 official languages requires CLB/NCLC 6+ across both tests. */ +/** Knowledge of official languages: 10 for both at CLB/NCLC 6+, 5 for one at CLB/NCLC 6+. */ export function officialLanguagesPoints(englishClb: number, frenchClb: number): number { if (englishClb >= 6 && frenchClb >= 6) return 10 - if (Math.max(englishClb, frenchClb) >= 5) return 5 + if (englishClb >= 6 || frenchClb >= 6) return 5 return 0 } @@ -227,5 +257,39 @@ export function oinpScore(input: OinpInput): OinpBreakdown { const region = regionPoints(input.region) + // recentOntarioGraduate is an eligibility determination only and awards 0 points. return { labour, education, language, region, total: labour + education + language + region } } + +/** + * Eligibility determination for the Ontario Workforce Priority stream. + * + * Points alone do not guarantee nomination. A candidate must also meet the + * stream's work experience and legal status requirements. Because the tenure + * bands are coarse, a band that only partially satisfies a threshold (such as + * '6-12' against the 9 month rule for TEER 4-5) is treated as not meeting it + * rather than assuming the user is in the qualifying sub-range. + */ +export function eligibility(input: OinpInput): Eligibility { + const reasons: string[] = [] + + if (input.teer >= 4) { + if (input.tenureInPosition === 'less-6' || input.tenureInPosition === '6-12') { + reasons.push( + 'TEER 4 and 5 job offers require at least 9 months of cumulative full-time work experience in the job offer position, gained within 2 years before you apply.', + ) + } + } else if (!input.recentOntarioGraduate && input.tenureInPosition === 'less-6') { + reasons.push( + 'TEER 0 to 3 job offers require at least 6 months of consecutive full-time work experience in the job offer position, gained within 12 months before you apply.', + ) + } + + if (input.legalStatus === 'none') { + reasons.push( + 'You must have valid legal status in Canada, such as a valid work permit or study permit, at the time you apply.', + ) + } + + return { eligible: reasons.length === 0, reasons } +} diff --git a/src/lib/saskatchewan/score.test.ts b/src/lib/saskatchewan/score.test.ts new file mode 100644 index 0000000..6315add --- /dev/null +++ b/src/lib/saskatchewan/score.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from 'vitest' +import { + agePoints, + connectionPoints, + educationPoints, + eligibility, + firstLanguagePoints, + priorWorkExperiencePoints, + secondLanguagePoints, + sinpScore, + workExperiencePoints, +} from './score' +import type { SinpInput } from './score' + +function base(overrides: Partial = {}): SinpInput { + return { + education: 'bachelor', + workYears: 3, + priorWorkYears: 2, + firstLanguageClb: 7, + secondLanguageClb: 0, + age: 30, + subCategory: 'oid-ee', + family: true, + skWorkExperience: false, + skStudy: false, + jobOffer: false, + ...overrides, + } +} + +describe('SINP Factor I: Labour Market Success factor points (official grid)', () => { + it('education and training', () => { + expect(educationPoints('masters-doctorate')).toBe(23) + expect(educationPoints('bachelor')).toBe(20) + expect(educationPoints('trade')).toBe(20) + expect(educationPoints('diploma')).toBe(15) + expect(educationPoints('certificate')).toBe(12) + }) + + it('skilled work experience in the last 5 years', () => { + expect(workExperiencePoints(5)).toBe(10) + expect(workExperiencePoints(6)).toBe(10) + expect(workExperiencePoints(4)).toBe(8) + expect(workExperiencePoints(3)).toBe(6) + expect(workExperiencePoints(2)).toBe(4) + expect(workExperiencePoints(1)).toBe(2) + expect(workExperiencePoints(0)).toBe(0) + }) + + it('skilled work experience in the 6-10 years prior', () => { + expect(priorWorkExperiencePoints(5)).toBe(5) + expect(priorWorkExperiencePoints(6)).toBe(5) + expect(priorWorkExperiencePoints(4)).toBe(4) + expect(priorWorkExperiencePoints(3)).toBe(3) + expect(priorWorkExperiencePoints(2)).toBe(2) + expect(priorWorkExperiencePoints(1)).toBe(0) + expect(priorWorkExperiencePoints(0)).toBe(0) + }) + + it('first official language (overall CLB)', () => { + expect(firstLanguagePoints(10)).toBe(20) + expect(firstLanguagePoints(8)).toBe(20) + expect(firstLanguagePoints(7)).toBe(18) + expect(firstLanguagePoints(6)).toBe(16) + expect(firstLanguagePoints(5)).toBe(14) + expect(firstLanguagePoints(4)).toBe(12) + expect(firstLanguagePoints(3)).toBe(0) + expect(firstLanguagePoints(0)).toBe(0) + }) + + it('second official language (overall CLB)', () => { + expect(secondLanguagePoints(9)).toBe(10) + expect(secondLanguagePoints(8)).toBe(10) + expect(secondLanguagePoints(7)).toBe(8) + expect(secondLanguagePoints(6)).toBe(6) + expect(secondLanguagePoints(5)).toBe(4) + expect(secondLanguagePoints(4)).toBe(2) + expect(secondLanguagePoints(3)).toBe(0) + expect(secondLanguagePoints(0)).toBe(0) + }) + + it('age', () => { + expect(agePoints(17)).toBe(0) + expect(agePoints(18)).toBe(8) + expect(agePoints(21)).toBe(8) + expect(agePoints(22)).toBe(12) + expect(agePoints(30)).toBe(12) + expect(agePoints(34)).toBe(12) + expect(agePoints(35)).toBe(10) + expect(agePoints(45)).toBe(10) + expect(agePoints(46)).toBe(8) + expect(agePoints(50)).toBe(8) + expect(agePoints(51)).toBe(0) + expect(agePoints(60)).toBe(0) + }) +}) + +describe('SINP Factor II: Connection to Saskatchewan', () => { + it('OID/EE: close family relative in Saskatchewan', () => { + expect(connectionPoints(base({ family: true }))).toBe(20) + expect(connectionPoints(base({ family: false }))).toBe(0) + }) + + it('OID/EE: past work and study in Saskatchewan add up', () => { + expect(connectionPoints(base({ family: false, skWorkExperience: true }))).toBe(5) + expect(connectionPoints(base({ family: false, skStudy: true }))).toBe(5) + expect(connectionPoints(base({ family: true, skWorkExperience: true, skStudy: true }))).toBe(30) + }) + + it('Employment Offer: high-skilled job offer is worth the full 30', () => { + expect(connectionPoints(base({ subCategory: 'employment-offer', jobOffer: true }))).toBe(30) + expect(connectionPoints(base({ subCategory: 'employment-offer', jobOffer: false }))).toBe(0) + }) +}) + +describe('SINP total', () => { + it('maximized profile', () => { + const score = sinpScore( + base({ + education: 'masters-doctorate', + workYears: 5, + priorWorkYears: 5, + firstLanguageClb: 10, + secondLanguageClb: 10, + age: 30, + subCategory: 'employment-offer', + jobOffer: true, + }), + ) + // education 23 + work 10 + prior work 5 + language 30 + age 12 + connection 30 + expect(score).toEqual({ + education: 23, + work: 10, + workPrior: 5, + language: 30, + age: 12, + connection: 30, + total: 110, + }) + }) + + it('default profile', () => { + const score = sinpScore(base()) + // education 20 (bachelor) + work 6 (3 yrs) + prior work 2 (2 yrs 6-10 ago) + // + language 18 (CLB 7) + age 12 (30) + connection 20 (family) = 78 + expect(score).toEqual({ education: 20, work: 6, workPrior: 2, language: 18, age: 12, connection: 20, total: 78 }) + }) + + it('no points with a minimal profile', () => { + const score = sinpScore( + base({ + education: 'certificate', + workYears: 0, + priorWorkYears: 0, + firstLanguageClb: 0, + age: 17, + family: false, + }), + ) + // education 12 + connection 0 = 12 + expect(score).toEqual({ education: 12, work: 0, workPrior: 0, language: 0, age: 0, connection: 0, total: 12 }) + }) + + it('awards second-language points when a second test at CLB 6 is added', () => { + const score = sinpScore(base({ secondLanguageClb: 6 })) + // language 18 (first CLB 7) + 6 (second CLB 6) = 24 → total 84 + expect(score).toEqual({ education: 20, work: 6, workPrior: 2, language: 24, age: 12, connection: 20, total: 84 }) + }) +}) + +describe('SINP eligibility (official criteria, separate from points)', () => { + it('marks the default profile as eligible', () => { + expect(eligibility(base())).toEqual({ eligible: true, reasons: [] }) + }) + + it('flags a score below 60 even when language and work requirements are met', () => { + const input = base({ education: 'certificate', workYears: 1, priorWorkYears: 0, age: 20, family: false }) + // total 40 (below 60), but CLB 7 and 1 year of work are fine + const result = eligibility(input) + expect(result.eligible).toBe(false) + expect(result.reasons).toHaveLength(1) + expect(result.reasons[0]).toMatch(/at least 60 points/) + }) + + it('flags a first language below CLB 4 even with enough points', () => { + const input = base({ firstLanguageClb: 3, secondLanguageClb: 5 }) + // total 64 (≥ 60) and 5 years of work, but first language CLB 3 is below 4 + const result = eligibility(input) + expect(result.eligible).toBe(false) + expect(result.reasons).toHaveLength(1) + expect(result.reasons[0]).toMatch(/CLB 4/) + }) + + it('flags no first-language test even with enough points', () => { + const input = base({ firstLanguageClb: 0, secondLanguageClb: 0, education: 'masters-doctorate', family: true }) + // total 63 (≥ 60) and 5 years of work, but no first-language test + const result = eligibility(input) + expect(result.eligible).toBe(false) + expect(result.reasons).toHaveLength(1) + expect(result.reasons[0]).toMatch(/language test/) + }) + + it('flags missing work experience even with a high score', () => { + const input = base({ + education: 'masters-doctorate', + workYears: 0, + priorWorkYears: 0, + firstLanguageClb: 8, + secondLanguageClb: 8, + family: true, + }) + // total 85 (≥ 60), but no work experience in the past 10 years + const result = eligibility(input) + expect(result.eligible).toBe(false) + expect(result.reasons).toHaveLength(1) + expect(result.reasons[0]).toMatch(/one year of full-time paid work experience/) + }) + + it('flags an Employment Offer profile without a job offer even with a high score', () => { + const input = base({ + subCategory: 'employment-offer', + jobOffer: false, + education: 'masters-doctorate', + workYears: 5, + priorWorkYears: 5, + firstLanguageClb: 10, + secondLanguageClb: 10, + }) + // total 80 (≥ 60), but no job offer for the Employment Offer sub-category + const result = eligibility(input) + expect(result.eligible).toBe(false) + expect(result.reasons).toHaveLength(1) + expect(result.reasons[0]).toMatch(/job offer/) + }) + + it('accepts an Employment Offer profile with a job offer', () => { + const input = base({ subCategory: 'employment-offer', jobOffer: true }) + expect(eligibility(input)).toEqual({ eligible: true, reasons: [] }) + }) + + it('lists multiple reasons when several criteria fail', () => { + const input = base({ workYears: 0, priorWorkYears: 0, firstLanguageClb: 3, family: false }) + // points (32 < 60), language (CLB 3) and work experience all fail + const result = eligibility(input) + expect(result.eligible).toBe(false) + expect(result.reasons.length).toBeGreaterThanOrEqual(3) + expect(result.reasons.join(' ')).toMatch(/at least 60 points/) + expect(result.reasons.join(' ')).toMatch(/CLB 4/) + expect(result.reasons.join(' ')).toMatch(/work experience/) + }) +}) diff --git a/src/lib/saskatchewan/score.ts b/src/lib/saskatchewan/score.ts new file mode 100644 index 0000000..2bb6e73 --- /dev/null +++ b/src/lib/saskatchewan/score.ts @@ -0,0 +1,201 @@ +/** + * SINP EOI scoring - Saskatchewan International Skilled Worker. + * + * Sources: Saskatchewan Immigrant Nominee Program, "Assess Your Eligibility" / "SINP + * International Skilled Worker Points Grid" (current official page) plus the official + * sub-category pages for Occupation In-Demand, Saskatchewan Express Entry and Employment Offer. + * https://www.saskatchewan.ca/residents/moving-to-saskatchewan/live-in-saskatchewan/by-immigrating/saskatchewan-immigrant-nominee-program/assess-your-eligibility + * + * Total = 110 points: Factor I (Labour Market Success) max 80 + + * Factor II (Connection to Saskatchewan) max 30. + */ + +export type EducationLevel = + | 'masters-doctorate' + | 'bachelor' + | 'trade' + | 'diploma' + | 'certificate' + +export type SubCategory = 'oid-ee' | 'employment-offer' + +export interface SinpInput { + /** Highest education / training credential. */ + education: EducationLevel + /** Years of skilled work experience in the last 5 years (5 = 5 or more). */ + workYears: number + /** Years of skilled work experience in the 6-10 years prior (5 = 5 or more, 0 = less than 1). */ + priorWorkYears: number + /** Overall CLB of the first official language (lowest of the four abilities), 0 if none. */ + firstLanguageClb: number + /** Overall CLB of the second official language (lowest of the four abilities), 0 if none. */ + secondLanguageClb: number + /** Applicant age. */ + age: number + /** SINP International Skilled Worker sub-category being scored. */ + subCategory: SubCategory + /** Close family relative in Saskatchewan (OID/EE). */ + family: boolean + /** 12+ months of work experience in Saskatchewan in the last 5 years (OID/EE). */ + skWorkExperience: boolean + /** At least one full-time academic year of study in Saskatchewan (OID/EE). */ + skStudy: boolean + /** High-skilled job offer from a Saskatchewan employer (Employment Offer). */ + jobOffer: boolean +} + +export interface SinpBreakdown { + education: number + work: number + workPrior: number + language: number + age: number + connection: number + total: number +} + +export const MAX_EDUCATION = 23 +export const MAX_WORK = 10 +export const MAX_PRIOR_WORK = 5 +export const MAX_LANGUAGE = 30 +export const MAX_AGE = 12 +export const MAX_FACTOR_I = 80 +export const MAX_FAMILY = 20 +export const MAX_SK_WORK = 5 +export const MAX_SK_STUDY = 5 +export const MAX_CONNECTION = 30 +export const MAX_TOTAL = 110 + +export function educationPoints(level: EducationLevel): number { + switch (level) { + case 'masters-doctorate': + return 23 + case 'bachelor': + case 'trade': + return 20 + case 'diploma': + return 15 + case 'certificate': + return 12 + } +} + +export function workExperiencePoints(workYears: number): number { + if (workYears >= 5) return 10 + if (workYears === 4) return 8 + if (workYears === 3) return 6 + if (workYears === 2) return 4 + if (workYears === 1) return 2 + return 0 +} + +export function priorWorkExperiencePoints(workYears: number): number { + if (workYears >= 5) return 5 + if (workYears === 4) return 4 + if (workYears === 3) return 3 + if (workYears === 2) return 2 + return 0 +} + +export function firstLanguagePoints(clb: number): number { + if (clb >= 8) return 20 + if (clb === 7) return 18 + if (clb === 6) return 16 + if (clb === 5) return 14 + if (clb === 4) return 12 + return 0 +} + +export function secondLanguagePoints(clb: number): number { + if (clb >= 8) return 10 + if (clb === 7) return 8 + if (clb === 6) return 6 + if (clb === 5) return 4 + if (clb === 4) return 2 + return 0 +} + +export function agePoints(age: number): number { + if (age < 18) return 0 + if (age <= 21) return 8 + if (age <= 34) return 12 + if (age <= 45) return 10 + if (age <= 50) return 8 + return 0 +} + +export function connectionPoints(input: SinpInput): number { + if (input.subCategory === 'employment-offer') return input.jobOffer ? 30 : 0 + return (input.family ? 20 : 0) + (input.skWorkExperience ? 5 : 0) + (input.skStudy ? 5 : 0) +} + +export function sinpScore(input: SinpInput): SinpBreakdown { + const education = educationPoints(input.education) + const work = workExperiencePoints(input.workYears) + const workPrior = priorWorkExperiencePoints(input.priorWorkYears) + const language = firstLanguagePoints(input.firstLanguageClb) + secondLanguagePoints(input.secondLanguageClb) + const age = agePoints(input.age) + const connection = connectionPoints(input) + + return { + education, + work, + workPrior, + language, + age, + connection, + total: education + work + workPrior + language + age + connection, + } +} + +export interface Eligibility { + eligible: boolean + reasons: string[] +} + +/** Minimum points on the SINP point assessment grid, required by all ISW sub-categories. */ +export const MIN_EOI_POINTS = 60 +/** Minimum overall CLB for the first official language (CLB 4 for OID and Employment Offer). */ +export const MIN_FIRST_LANGUAGE_CLB = 4 +/** Minimum years of skilled work experience required in the past 10 years. */ +export const MIN_WORK_EXPERIENCE_YEARS = 1 + +/** + * Eligibility for the SINP International Skilled Worker category, separate from points. + * A candidate can score 60+ points yet still be ineligible for a sub-category, so these + * criteria are checked on top of the EOI total. Only rules expressible with the existing + * SinpInput fields are enforced here (occupation-specific and settlement-fund criteria are + * not modelled). + */ +export function eligibility(input: SinpInput): Eligibility { + const reasons: string[] = [] + const total = sinpScore(input).total + + if (total < MIN_EOI_POINTS) { + reasons.push( + `You must score at least ${MIN_EOI_POINTS} points on the SINP point assessment grid, but your estimated score is ${total}.`, + ) + } + + if (input.firstLanguageClb < MIN_FIRST_LANGUAGE_CLB) { + reasons.push( + input.firstLanguageClb === 0 + ? `You need a first official language score of at least Canadian Language Benchmark (CLB) ${MIN_FIRST_LANGUAGE_CLB}, but you have not entered a qualifying language test.` + : `Your first official language overall CLB of ${input.firstLanguageClb} is below the minimum of CLB ${MIN_FIRST_LANGUAGE_CLB}.`, + ) + } + + if (input.workYears + input.priorWorkYears < MIN_WORK_EXPERIENCE_YEARS) { + reasons.push( + 'You must have at least one year of full-time paid work experience in a skilled occupation within the past 10 years.', + ) + } + + if (input.subCategory === 'employment-offer' && !input.jobOffer) { + reasons.push( + 'You must have a permanent, full-time job offer from a Saskatchewan employer to apply under the Employment Offer sub-category.', + ) + } + + return { eligible: reasons.length === 0, reasons } +} diff --git a/src/main.tsx b/src/main.tsx index 6b9af01..1aa9411 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,8 +2,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom' import { App } from './App' +import { HomePage } from './routes/home/HomePage' import { CrsTool } from './routes/crs/CrsTool' import { OinpTool } from './routes/oinp/OinpTool' +import { BcTool } from './routes/bc/BcTool' +import { SaskatchewanTool } from './routes/saskatchewan/SaskatchewanTool' +import { AlbertaTool } from './routes/alberta/AlbertaTool' +import { ManitobaTool } from './routes/manitoba/ManitobaTool' import './index.css' const router = createBrowserRouter([ @@ -11,10 +16,14 @@ const router = createBrowserRouter([ path: '/', element: , children: [ - { index: true, element: }, + { index: true, element: }, { path: 'crs', element: }, { path: 'oinp', element: }, - { path: '*', element: }, + { path: 'bc', element: }, + { path: 'saskatchewan', element: }, + { path: 'alberta', element: }, + { path: 'manitoba', element: }, + { path: '*', element: }, ], }, ]) diff --git a/src/routes/alberta/AlbertaTool.test.tsx b/src/routes/alberta/AlbertaTool.test.tsx new file mode 100644 index 0000000..c38cc8d --- /dev/null +++ b/src/routes/alberta/AlbertaTool.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AlbertaTool } from './AlbertaTool' + +vi.mock('../../components/DrawFeed', () => ({ DrawFeed: () => null })) + +afterEach(cleanup) + +function renderAlberta() { + return render( + + + , + ) +} + +const total = () => screen.getByRole('status').textContent + +/** Fills the classic test profile: bachelor, English IELTS 6 (CLB 6), 12+ months experience, age 30. */ +function fillStandardProfile() { + fireEvent.change(screen.getByLabelText('Highest level of education'), { target: { value: 'bachelor' } }) + fireEvent.change(screen.getByLabelText('Total work experience'), { target: { value: 'over-12' } }) + fireEvent.change(screen.getByRole('slider'), { target: { value: '30' } }) + fireEvent.change(screen.getByLabelText('English test'), { target: { value: 'ielts' } }) + for (const a of ['listening', 'reading', 'writing', 'speaking']) { + fireEvent.change(screen.getByLabelText(`English ${a}`), { target: { value: '6.0' } }) + } +} + +describe('AlbertaTool', () => { + it('starts blank with a near-zero score', () => { + renderAlberta() + // experience 3 (less-6 fallback) + age 3 (18, slider minimum) = 6 + expect(total()).toBe('6') + }) + + it('renders the score for the standard profile', () => { + renderAlberta() + fillStandardProfile() + // education 7 (bachelor) + language 10 (English CLB 6) + // + experience 11 (12+ months) + age 5 = 33 + expect(total()).toBe('33') + }) + + it('updates when the education level changes to doctorate', () => { + renderAlberta() + fillStandardProfile() + fireEvent.change(screen.getByLabelText('Highest level of education'), { target: { value: 'doctorate' } }) + // education 7 → 12 → total 38 + expect(total()).toBe('38') + }) + + it('awards family connection points when the checkbox is checked', () => { + renderAlberta() + fillStandardProfile() + fireEvent.click(screen.getByRole('checkbox', { name: /Family connection/ })) + // +8 → 41 + expect(total()).toBe('41') + }) + + it('awards regulated occupation points when the checkbox is checked', () => { + renderAlberta() + fillStandardProfile() + fireEvent.click(screen.getByRole('checkbox', { name: /Regulated occupation/ })) + // +10 → 43 + expect(total()).toBe('43') + }) + + it('awards permanent full-time job offer points when the checkbox is checked', () => { + renderAlberta() + fillStandardProfile() + fireEvent.click(screen.getByRole('checkbox', { name: /Permanent full-time job offer/ })) + // +10 → 43 + expect(total()).toBe('43') + }) + + it('awards rural or sector job offer points from the select', () => { + renderAlberta() + fillStandardProfile() + fireEvent.change(screen.getByLabelText('Job offer to work in select Alberta rural communities or sector'), { + target: { value: 'rural-renewal' }, + }) + // +6 → 39 + expect(total()).toBe('39') + }) + + it('permanent offer and rural endorsement stack', () => { + renderAlberta() + fillStandardProfile() + fireEvent.click(screen.getByRole('checkbox', { name: /Permanent full-time job offer/ })) + fireEvent.change(screen.getByLabelText('Job offer to work in select Alberta rural communities or sector'), { + target: { value: 'rural-renewal' }, + }) + // +10 +6 = 16 job offer → total 49 + expect(total()).toBe('49') + }) + + it('awards bilingual bonus when a French test at CLB 4+ is added and bilingual is checked', () => { + renderAlberta() + fillStandardProfile() + fireEvent.click(screen.getByRole('button', { name: /Add French test/ })) + // English CLB 6 + French NCLC 6 → bilingual (CLB 4 threshold) + const frenchScores: Record = { reading: '393', writing: '379', listening: '393', speaking: '422' } + for (const [ability, score] of Object.entries(frenchScores)) { + fireEvent.change(screen.getByLabelText(`French ${ability}`), { target: { value: score } }) + } + fireEvent.click(screen.getByRole('checkbox', { name: /Bilingual/ })) + // language: 10 (English) + 3 bilingual = 13 (was 10) → total 36 + expect(total()).toBe('36') + }) + + it('flags the standard profile as not eligible without a job offer', () => { + renderAlberta() + fillStandardProfile() + expect(screen.getByText('Not eligible yet')).toBeTruthy() + expect(screen.getByText(/full-time job offer or employment contract/)).toBeTruthy() + }) + + it('shows an eligible banner once a permanent full-time job offer is selected', () => { + renderAlberta() + fillStandardProfile() + fireEvent.click(screen.getByRole('checkbox', { name: /Permanent full-time job offer/ })) + expect(screen.getByText('You appear eligible')).toBeTruthy() + }) + + it('shows an eligible banner with a select rural or sector job offer', () => { + renderAlberta() + fillStandardProfile() + fireEvent.change(screen.getByLabelText('Job offer to work in select Alberta rural communities or sector'), { + target: { value: 'rural-renewal' }, + }) + expect(screen.getByText('You appear eligible')).toBeTruthy() + }) +}) diff --git a/src/routes/alberta/AlbertaTool.tsx b/src/routes/alberta/AlbertaTool.tsx new file mode 100644 index 0000000..f7f5576 --- /dev/null +++ b/src/routes/alberta/AlbertaTool.tsx @@ -0,0 +1,310 @@ +import { useMemo, useState } from 'react' +import { EligibilityBanner } from '../../components/EligibilityBanner' +import { LanguageTestInputs } from '../../components/LanguageTestInputs' +import { ScoreCard } from '../../components/ScoreCard' +import { Seo } from '../../components/Seo' +import { ToolSidebar } from '../../components/ToolSidebar' +import { ToolTiles } from '../../components/ToolTiles' +import { CheckRow, Field, Section, Segmented, Select, Slider } from '../../components/ui' +import { convertTestToClb, emptyScores, overallClb } from '../../lib/crs/languages' +import type { LanguageTestState } from '../../lib/crs/languages' +import { + AB_DOC, + MAX_AGE, + MAX_EDUCATION, + MAX_EXPERIENCE, + MAX_FAMILY, + MAX_JOB_OFFER, + MAX_LANGUAGE, + MAX_LOCATION, + MAX_REGULATED, + MAX_TOTAL, + albertaScore, + eligibility, +} from '../../lib/alberta/score' +import type { + AlbertaInput, + CanadianExperience, + EducationLevel, + EducationLocation, + ExperienceBand, + JobLocation, + SectorJobOffer, +} from '../../lib/alberta/score' + +const EDUCATION_OPTIONS: ReadonlyArray<{ value: EducationLevel | ''; label: string }> = [ + { value: '', label: 'Select...' }, + { value: 'doctorate', label: 'Doctorate' }, + { value: 'masters', label: "Master's degree" }, + { value: 'bachelor', label: "Bachelor's degree" }, + { value: 'trades', label: 'Trades certificate or diploma' }, + { value: 'diploma', label: 'Diploma or certificate' }, + { value: 'secondary', label: 'Secondary school or lower' }, +] + +const EDUCATION_LOCATION_OPTIONS: ReadonlyArray<{ value: EducationLocation | ''; label: string }> = [ + { value: '', label: 'Select...' }, + { value: 'alberta', label: 'Completed in Alberta' }, + { value: 'other-province', label: 'Completed in another province or territory' }, + { value: 'none', label: 'Not completed in Canada' }, +] + +const EXPERIENCE_OPTIONS: ReadonlyArray<{ value: ExperienceBand | ''; label: string }> = [ + { value: '', label: 'Select...' }, + { value: 'over-12', label: '12 or more months' }, + { value: '6-11', label: '6 to 11 months' }, + { value: 'less-6', label: 'Less than 6 months' }, +] + +const CANADIAN_EXPERIENCE_OPTIONS: ReadonlyArray<{ value: CanadianExperience; label: string }> = [ + { value: 'alberta', label: 'Alberta' }, + { value: 'other-province', label: 'Another province' }, + { value: 'none', label: 'None' }, +] + +const SECTOR_JOB_OFFER_OPTIONS: ReadonlyArray<{ value: SectorJobOffer | ''; label: string }> = [ + { value: '', label: 'Select...' }, + { value: 'rural-renewal', label: 'Endorsement letter from a Rural Renewal designated community' }, + { value: 'tourism-hospitality', label: 'Tourism and Hospitality Stream (qualifying sector association employer)' }, + { value: 'law-enforcement', label: 'Law enforcement occupation (AACP member employer)' }, + { value: 'none', label: 'None' }, +] + +const JOB_LOCATION_OPTIONS: ReadonlyArray<{ value: JobLocation | ''; label: string }> = [ + { value: '', label: 'Select...' }, + { value: 'calgary-edmonton', label: 'Calgary or Edmonton CMA' }, + { value: 'rural-renewal', label: 'Rural Renewal designated community' }, + { value: 'other', label: 'Other Alberta community' }, +] + +const ENGLISH_TESTS = ['ielts', 'celpip', 'pte'] as const +const FRENCH_TESTS = ['tef', 'tcf'] as const + +interface AlbertaUiState { + education: EducationLevel | '' + educationLocation: EducationLocation | '' + english: LanguageTestState + french: LanguageTestState + bilingual: boolean + totalExperience: ExperienceBand | '' + canadianExperience: CanadianExperience + age: number + familyConnection: boolean + permanentJobOffer: boolean + sectorJobOffer: SectorJobOffer | '' + jobLocation: JobLocation | '' + regulatedOccupation: boolean +} + +const DEFAULT_UI: AlbertaUiState = { + education: '', + educationLocation: '', + english: { test: 'none', scores: emptyScores() }, + french: { test: 'none', scores: emptyScores() }, + bilingual: false, + totalExperience: '', + canadianExperience: 'none', + age: 18, + familyConnection: false, + permanentJobOffer: false, + sectorJobOffer: '', + jobLocation: '', + regulatedOccupation: false, +} + +export function AlbertaTool() { + const [ui, setUi] = useState(DEFAULT_UI) + + const input = useMemo(() => { + const englishClb = overallClb(convertTestToClb(ui.english.test, ui.english.scores)) + const frenchClb = overallClb(convertTestToClb(ui.french.test, ui.french.scores)) + return { + education: (ui.education === '' ? 'secondary' : ui.education) as EducationLevel, + educationLocation: (ui.educationLocation === '' ? 'none' : ui.educationLocation) as EducationLocation, + englishClb, + frenchClb, + bilingual: ui.bilingual, + totalExperience: (ui.totalExperience === '' ? 'less-6' : ui.totalExperience) as ExperienceBand, + canadianExperience: ui.canadianExperience, + age: ui.age, + familyConnection: ui.familyConnection, + permanentJobOffer: ui.permanentJobOffer, + sectorJobOffer: (ui.sectorJobOffer === '' ? 'none' : ui.sectorJobOffer) as SectorJobOffer, + jobLocation: (ui.jobLocation === '' ? 'calgary-edmonton' : ui.jobLocation) as JobLocation, + regulatedOccupation: ui.regulatedOccupation, + } + }, [ui]) + + const score = useMemo(() => albertaScore(input), [input]) + + const eligibilityResult = useMemo(() => eligibility(input), [input]) + + const patch = (p: Partial) => setUi((prev) => ({ ...prev, ...p })) + + return ( +
+ + +

Alberta AAIP Points Calculator

+

+ Alberta ranks Worker Expression of Interest candidates out of 100. Enter your details to see your points and + how they add up. +

+ +
+ +
+ +
+
+
+
+ + patch({ educationLocation: v as EducationLocation | '' })} + options={EDUCATION_LOCATION_OPTIONS} + /> + +
+ +
+ patch({ english: next })} + help={AB_DOC} + /> + {ui.french.test === 'none' ? ( + + ) : ( + patch({ french: next })} + help={AB_DOC} + /> + )} + patch({ bilingual: v })} + help={AB_DOC} + /> +
+ +
+ + patch({ sectorJobOffer: v as SectorJobOffer | '' })} + options={SECTOR_JOB_OFFER_OPTIONS} + /> + + + patch({ workExperience: v as WorkExperienceBand | '' })} + options={WORK_EXPERIENCE_OPTIONS} + /> + +
+ patch({ canadianExperience: v })} + help={BC_DOC} + /> + patch({ workingInBc: v })} + help={BC_DOC} + /> +
+
+ +
+ + patch({ educationLocation: v as EducationLocation | '' })} + options={EDUCATION_LOCATION_OPTIONS} + /> + + patch({ professionalDesignation: v })} + help={BC_DOC} + /> +
+ +
+ patch({ english: next })} + help={BC_DOC} + /> + {ui.french.test === 'none' ? ( + + ) : ( + patch({ french: next })} + help={BC_DOC} + /> + )} + patch({ bothLanguages: v })} + help={BC_DOC} + /> + The language score uses the overall CLB (lowest band across skills) of your best English or French test. +
+ +
+
+ patch({ hourlyWage: v })} + suffix="$/hr" + help={BC_DOC} + /> + + patch({ education: v as EducationLevel })} + onChange={(v) => patch({ education: v as EducationLevel | '' })} options={EDUCATION_OPTIONS} /> - + patch({ canadianWorkYears: Number(v) as WorkYears })} + onChange={(v) => patch({ canadianWorkYears: v === '' ? '' : (Number(v) as WorkYears) })} options={WORK_YEARS_OPTIONS} /> - + patchSpouse({ education: v as EducationLevel })} - options={EDUCATION_OPTIONS} - /> - +
+
+ + + patchSpouse({ canadianWorkYears: v === '' ? '' : (Number(v) as WorkYears) }) + } + options={WORK_YEARS_OPTIONS} + /> + +
patchSpouse({ test: next })} + help={IRCC_LANGUAGE} /> - - patch({ workYears: v })} + options={WORK_YEARS_OPTIONS} + /> + +
+ patch({ recognizedByLicensingBody: v })} + help={MB_DOC} + /> +
+ +
+ + - + patch({ tenureInPosition: v })} + options={TENURE_OPTIONS} + /> +
- - )} + patch({ recentOntarioGraduate: v })} + help={OINP_DOC} + /> + + Recent Ontario graduates (eligible Ontario institution within the last 3 years) only need 3 months in + the job offer position instead of 6. This is an eligibility rule and does not add EOI points. +
- + patch({ legalStatus: v as LegalStatus })} + onChange={(v) => patch({ legalStatus: v as LegalStatus | '' })} options={LEGAL_STATUS_OPTIONS} />
-
- +
+ patch({ canadianCredentials: v as CanadianCredentials })} + onChange={(v) => patch({ canadianCredentials: v as CanadianCredentials | '' })} options={CREDENTIALS_OPTIONS} />
-
+
patch({ english: next })} + help={OINP_DOC} /> {ui.french.test === 'none' ? (
-
- +
+ patch({ education: v as EducationLevel | '' })} + options={EDUCATION_OPTIONS} + /> + +
+ + patch({ priorWork: v })} + options={PRIOR_WORK_OPTIONS} + /> + +
+ patch({ age: v })} + format={(v) => String(v)} + help={SASK_DOC} + /> +
+ +
+ patch({ english: next })} + help={SASK_DOC} + /> + {ui.secondLanguage.test === 'none' ? ( + + ) : ( + patch({ secondLanguage: next })} + help={SASK_DOC} + /> + )} + The language score uses the overall CLB of your first official language, with a smaller bonus for a second official language. +
+ +
+ + patch({ subCategory: v })} + options={[ + { value: 'oid-ee', label: 'OID / Express Entry' }, + { value: 'employment-offer', label: 'Employment Offer' }, + ]} + /> + + {ui.subCategory === 'oid-ee' ? ( +
+ patch({ family: v })} + help={SASK_DOC} + /> + patch({ skWorkExperience: v })} + help={SASK_DOC} + /> + patch({ skStudy: v })} + help={SASK_DOC} + /> +
+ ) : ( +
+ patch({ jobOffer: v })} + help={SASK_DOC} + /> +
+ )} + You need at least 60 points to enter the SINP EOI pool. +
+
+ + } + /> +
+ +
+ ) +} + +function SaskatchewanScorePanel({ score }: { score: SinpBreakdown }) { + const rows: Array<{ label: string; value: number; max?: number }> = [ + { label: 'Labour market', value: score.education + score.work + score.workPrior + score.language + score.age, max: MAX_FACTOR_I }, + { label: 'Education', value: score.education, max: MAX_EDUCATION }, + { label: 'Work experience (last 5 years)', value: score.work, max: MAX_WORK }, + { label: 'Work experience (6-10 years prior)', value: score.workPrior, max: MAX_PRIOR_WORK }, + { label: 'Language', value: score.language, max: MAX_LANGUAGE }, + { label: 'Age', value: score.age, max: MAX_AGE }, + { label: 'Connection to Saskatchewan', value: score.connection, max: MAX_CONNECTION }, + ] + return ( + + ) +} diff --git a/src/svg-maps-canada.d.ts b/src/svg-maps-canada.d.ts new file mode 100644 index 0000000..c54f4db --- /dev/null +++ b/src/svg-maps-canada.d.ts @@ -0,0 +1,16 @@ +declare module '@svg-maps/canada' { + interface CanadaLocation { + id: string + name: string + path: string + } + + interface CanadaMap { + label: string + viewBox: string + locations: CanadaLocation[] + } + + const map: CanadaMap + export default map +} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..23b5bf3 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,3 @@ +name = "immicalc" +pages_build_output_dir = "dist" +compatibility_date = "2026-01-01"