diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..34508b9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +# CI for immigration-tools +# Runs tests, typecheck, and build on every PR and push to main. +# Deploys to Cloudflare Pages only after a successful build on main. + +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build + run: npm run build + + - name: Deploy to Cloudflare Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy dist --project-name=immicalc 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/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f8c2335 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# AGENTS.md + +Guidance for AI agents working in this repo. It is a React 19 + TypeScript + Tailwind v4 + react-router SPA built with Vite, tested with vitest + Testing Library. + +## Commands + +- `npm run dev` - local dev server +- `npm run test` - run all vitest tests +- `npm run typecheck` - `tsc --noEmit` +- `npm run build` - typecheck + production build + +## Hard rules + +- No em-dashes in user-facing copy or code comments. Use hyphens, commas, or restructure. En-dashes only in numeric ranges like "13-24 months". +- When computing what-if or recommendation point gains, simulate by re-running the score function on modified copies of the input. Never hardcode point deltas. +- Run `npm run typecheck` and `npm test` before finishing any change. + +## Conventions + +- Each calculator is a pure scoring module in `src/lib//score.ts` (a `score(input)` function) plus a tool page in `src/routes//`. Use the OINP tool (`src/routes/oinp/OinpTool.tsx`) as the reference pattern. +- Each module also exports `eligibility(input)` returning `{ eligible, reasons }`, surfaced via the shared `EligibilityBanner`. Every scoring field gets a `help` prop linking to the official government doc. +- Tool pages render their score via `ToolSidebar` (pot gauge with total/max, plus a breakdown as ``). The federal CRS sidebar passes a `draws` prop for a "Breakdown / Historical draws" tab switcher; other tools show a static "Points breakdown" tab. +- The tool switcher at the top of tool pages is `ToolTiles` (``). +- The draws feed (`DrawFeed`) is client-side IRCC JSON (parse `drawNumberURL`, `drawText2`, `drawCRS`, `drawSize`; do not assume the feed filename is stable). Provincial tools do not render it yet. +- The landing page map is `src/components/CanadaMap.tsx`, rendering paths from `@svg-maps/canada` (lazy-loaded via `React.lazy`). Which provinces are clickable is driven by the `PROVINCE_ROUTES` map inside the component. +- Theme: accent is Ford blue `#003478` (`--color-accent` in `src/index.css`). Keep accent-colored UI on this token. + +## Testing + +- Wrap tool components in `MemoryRouter` when rendering (they use `react-router` `Link`s). +- Mock `DrawFeed` in tool tests: `vi.mock('../../components/DrawFeed', () => ({ DrawFeed: () => null }))`. +- Stub `global.fetch` in any test that touches the draws feed. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2aea2ef --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# ImmiCalc + +Canadian immigration points calculator web-app. Runs client-side. + +## Features + +- Runs entirely in the browser. No account, no server, nothing stored or tracked. +- Live Express Entry draws feed, pulled from IRCC's published data. +- Score-improvement tips on the CRS page, computed from your actual inputs. + +## Getting started + +```sh +npm install +npm run dev +npm test +npm run typecheck +npm run build +``` + +`npm run build` runs typecheck plus the production build. + +## Project structure + +Each calculator is a pure scoring module in `src/lib//score.ts` with a `score()` function and an `eligibility()` function, plus a tool page in `src/routes//`. Shared UI such as the score sidebar, eligibility banner, and map lives in `src/components/`. + +## Disclaimers + +Not affiliated with any government agency. Immigration rules change often. These are estimates for planning, not legal advice. Confirm anything important against the official IRCC or provincial pages. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/index.html b/index.html new file mode 100644 index 0000000..0db64d5 --- /dev/null +++ b/index.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + ImmiCalc | Free Canadian immigration calculators + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e5bf6cf --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3112 @@ +{ + "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", + "@svg-maps/canada": "^2.0.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/@svg-maps/canada": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@svg-maps/canada/-/canada-2.0.0.tgz", + "integrity": "sha512-5sBofr4voCPDaS8kqr/K3XzFGBKQ7nCWPt2M/+H1C8zvuzWBjMe4n6/HFklh7uhDm6q6Hja4mIU9tBmXhVzyow==", + "license": "CC-BY-4.0" + }, + "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..da77865 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "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", + "@svg-maps/canada": "^2.0.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/_redirects b/public/_redirects new file mode 100644 index 0000000..bbb3e7a --- /dev/null +++ b/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..c2c538f --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..b46f89f --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://immicalc.org/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..9fe404c --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,38 @@ + + + + https://immicalc.org/ + monthly + 1.0 + + + https://immicalc.org/crs + monthly + 0.9 + + + https://immicalc.org/oinp + monthly + 0.9 + + + https://immicalc.org/bc + monthly + 0.8 + + + https://immicalc.org/saskatchewan + monthly + 0.8 + + + https://immicalc.org/alberta + monthly + 0.8 + + + https://immicalc.org/manitoba + monthly + 0.8 + + diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..646ee07 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,44 @@ +// @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' +import { HomePage } from './routes/home/HomePage' + +afterEach(cleanup) + +function renderApp(initialEntry = '/') { + return render( + + + }> + } /> + crs page} /> + oinp page} /> + + + , + ) +} + +describe('App', () => { + it('shows the brand, the landing heading and the federal calculator link', () => { + renderApp('/') + expect(screen.getByRole('link', { name: /ImmiCalc/ })).toBeTruthy() + expect(screen.getByRole('heading', { name: 'Immigration tools' })).toBeTruthy() + expect(screen.getByRole('link', { name: /Express Entry \(CRS\)/ })).toBeTruthy() + }) + + it('navigates to a tool from the landing page', () => { + renderApp('/') + fireEvent.click(screen.getByRole('link', { name: /Express Entry \(CRS\)/ })) + expect(screen.getByText('crs page')).toBeTruthy() + }) + + it('returns to the landing page from a tool via the brand', () => { + renderApp('/crs') + expect(screen.getByText('crs page')).toBeTruthy() + fireEvent.click(screen.getByRole('link', { name: /ImmiCalc/ })) + expect(screen.getByRole('heading', { name: 'Immigration tools' })).toBeTruthy() + }) +}) diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..37e948d --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,36 @@ +import { Link, Outlet } from 'react-router-dom' +import { MapleLeaf } from './components/MapleLeaf' +import { AiChat } from './components/AiChat' +import { aiChatEnabled } from './lib/features' + +export function App() { + return ( +
+
+
+ + + ImmiCalc + +
+
+ +
+ +
+ +
+
+

+ Results are estimates. Confirm anything important against the official IRCC or provincial pages. +

+

+ Not affiliated with IRCC, Canada.ca, or any organization. Non-profit. +

+
+
+ + {aiChatEnabled() && } +
+ ) +} diff --git a/src/components/AiChat.test.tsx b/src/components/AiChat.test.tsx new file mode 100644 index 0000000..667e90b --- /dev/null +++ b/src/components/AiChat.test.tsx @@ -0,0 +1,32 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { AiChat } from './AiChat' + +afterEach(() => { + cleanup() +}) + +describe('AiChat', () => { + it('renders the launcher button by default', () => { + render() + expect(screen.getByRole('button', { name: 'Open AI analysis' })).toBeTruthy() + expect(screen.queryByText(/coming soon/i)).toBeNull() + }) + + it('opens the panel with the coming-soon message when clicked', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Open AI analysis' })) + expect(screen.getByText(/^Coming soon$/)).toBeTruthy() + expect( + screen.getByText(/a personalized analysis of your score is coming soon/i), + ).toBeTruthy() + }) + + it('closes the panel when the close button is clicked', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Open AI analysis' })) + fireEvent.click(screen.getByRole('button', { name: 'Close AI analysis' })) + expect(screen.queryByText(/a personalized analysis of your score is coming soon/i)).toBeNull() + }) +}) diff --git a/src/components/AiChat.tsx b/src/components/AiChat.tsx new file mode 100644 index 0000000..62eb253 --- /dev/null +++ b/src/components/AiChat.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react' + +const COMING_SOON = + "A personalized analysis of your score is coming soon. When it's here, you can ask questions about your situation and get plain-language answers." + +export function AiChat() { + const [open, setOpen] = useState(false) + + return ( + <> + {open && ( +
+
+
+
+ +
+ +
+
+ +

{COMING_SOON}

+
+ +
+
+ )} + + + + ) +} 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 new file mode 100644 index 0000000..b5859c9 --- /dev/null +++ b/src/components/LanguageTestInputs.tsx @@ -0,0 +1,148 @@ +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 { HelpLink, 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, + 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' + + return ( +
+
+

+ {title} + {help && } +

+
+ 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, + help, +}: { + label: string + value: string + onChange: (value: string) => void + min?: number + max?: number + step?: number + suffix?: string + help?: string +}) { + return ( + + ) +} + +export function Slider({ + label, + value, + onChange, + min, + max, + format, + help, +}: { + label: string + value: number + onChange: (value: number) => void + min: number + max: number + format?: (value: number) => string + help?: string +}) { + const pct = max === min ? 0 : ((value - min) / (max - min)) * 100 + return ( +
+
+ + {label} + {help && } + + + {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, + help, +}: { + label: string + checked: boolean + onChange: (checked: boolean) => void + help?: string +}) { + 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..ac94f46 --- /dev/null +++ b/src/index.css @@ -0,0 +1,170 @@ +@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: #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; +} + +: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; +} + +/* 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; + 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(0 52 120 / 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/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/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/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.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..9cae2c5 --- /dev/null +++ b/src/lib/crs/score.ts @@ -0,0 +1,127 @@ +/** + * 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 } 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 } +} 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..9ca36ed --- /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/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 new file mode 100644 index 0000000..1273798 --- /dev/null +++ b/src/lib/oinp/score.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from 'vitest' +import { + canadianCredentialsPoints, + earningsPoints, + educationPoints, + eligibility, + 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', + recentOntarioGraduate: false, + ...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 one or both)', () => { + expect(officialLanguagesPoints(6, 6)).toBe(10) + expect(officialLanguagesPoints(7, 8)).toBe(10) + expect(officialLanguagesPoints(7, 5)).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) + }) + + 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 + }) + + 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 new file mode 100644 index 0000000..aa8c37e --- /dev/null +++ b/src/lib/oinp/score.ts @@ -0,0 +1,295 @@ +/** + * 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 + +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 + /** + * 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 { + labour: number + education: number + language: number + region: number + 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 +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 +} + +/** 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 (englishClb >= 6 || frenchClb >= 6) 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) + + // 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 new file mode 100644 index 0000000..1aa9411 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,35 @@ +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([ + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: 'crs', element: }, + { path: 'oinp', element: }, + { path: 'bc', element: }, + { path: 'saskatchewan', element: }, + { path: 'alberta', element: }, + { path: 'manitoba', element: }, + { path: '*', element: }, + ], + }, +]) + +createRoot(document.getElementById('root')!).render( + + + , +) 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 | '' })} + options={EDUCATION_OPTIONS} + /> + + + patch({ canadianWorkYears: v === '' ? '' : (Number(v) as WorkYears) })} + options={WORK_YEARS_OPTIONS} + /> + + + patchSpouse({ education: v as EducationLevel | '' })} + options={EDUCATION_OPTIONS} + /> + + + patch({ workYears: v })} + options={WORK_YEARS_OPTIONS} + /> + +
+ patch({ recognizedByLicensingBody: v })} + help={MB_DOC} + /> +
+ +
+ + patch({ teer: v })} + options={TEER_OPTIONS} + /> + + + patch({ tenureInPosition: v })} + options={TENURE_OPTIONS} + /> + +
+ {ui.tenureInPosition === 'less-6' && ( + + patch({ earnings: v as EarningsBand | '' })} + options={EARNINGS_OPTIONS} + /> + + + patch({ education: v as EducationLevel | '' })} + options={EDUCATION_OPTIONS} + /> + + + patch({ region: v })} + options={REGION_OPTIONS} + /> + + +
+ + } + /> +
+ +
+ ) +} + +function OinpScorePanel({ score }: { score: OinpBreakdown }) { + const rows: Array<{ label: string; value: number; max?: number }> = [ + { label: 'Labour market', value: score.labour, max: MAX_LABOUR }, + { label: 'Education', value: score.education, max: MAX_EDUCATION }, + { label: 'Language', value: score.language, max: MAX_LANGUAGE }, + { label: 'Region', value: score.region, max: MAX_REGION }, + ] + return ( + + ) +} diff --git a/src/routes/saskatchewan/SaskatchewanTool.test.tsx b/src/routes/saskatchewan/SaskatchewanTool.test.tsx new file mode 100644 index 0000000..057ca5a --- /dev/null +++ b/src/routes/saskatchewan/SaskatchewanTool.test.tsx @@ -0,0 +1,135 @@ +// @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 { SaskatchewanTool } from './SaskatchewanTool' + +vi.mock('../../components/DrawFeed', () => ({ DrawFeed: () => null })) + +afterEach(cleanup) + +function renderSask() { + return render( + + + , + ) +} + +const total = () => { + // ToolSidebar shows the live total; ScoreCard breakdown also emits an empty status span. + const statuses = screen.getAllByRole('status') + const live = statuses.find((el) => el.textContent && el.textContent.trim() !== '') + return live?.textContent ?? null +} + +/** Fills the classic test profile: bachelor, 3 yrs work, 2 yrs prior, age 30, English IELTS 6 (CLB 7), family relative. */ +function fillStandardProfile() { + fireEvent.change(screen.getByLabelText('Highest level of education or training'), { target: { value: 'bachelor' } }) + fireEvent.change(screen.getByLabelText('Skilled work experience (last 5 years)'), { target: { value: '3' } }) + fireEvent.change(screen.getByLabelText('Skilled work experience (6-10 years prior)'), { target: { value: '2' } }) + fireEvent.change(screen.getByLabelText('Age'), { target: { value: '30' } }) + fireEvent.change(screen.getByLabelText('English (first official language) test'), { target: { value: 'ielts' } }) + for (const a of ['listening', 'reading', 'writing', 'speaking']) { + fireEvent.change(screen.getByLabelText(`English (first official language) ${a}`), { target: { value: '6.0' } }) + } + fireEvent.click(screen.getByRole('checkbox', { name: /Close family relative in Saskatchewan/ })) +} + +describe('SaskatchewanTool', () => { + it('starts blank with a near-zero score', () => { + renderSask() + // education 12 (certificate, lowest band) is the only points-bearing fallback + expect(total()).toBe('12') + }) + + it('renders the score for the standard profile', () => { + renderSask() + fillStandardProfile() + // education 20 (bachelor) + work 6 (3 yrs) + prior work 2 (2 yrs 6-10 ago) + // + language 18 (IELTS 6 → CLB 7) + age 12 (30) + connection 20 (family) = 78 + expect(total()).toBe('78') + }) + + it('updates when the age changes', () => { + renderSask() + fillStandardProfile() + fireEvent.change(screen.getByLabelText('Age'), { target: { value: '40' } }) + // age 10 (was 12) → 76 + expect(total()).toBe('76') + }) + + it('updates when the work experience changes', () => { + renderSask() + fillStandardProfile() + fireEvent.change(screen.getByLabelText('Skilled work experience (last 5 years)'), { target: { value: '5' } }) + // work 10 (was 6) → 82 + expect(total()).toBe('82') + }) + + it('updates when the prior work experience changes', () => { + renderSask() + fillStandardProfile() + fireEvent.change(screen.getByLabelText('Skilled work experience (6-10 years prior)'), { target: { value: '5' } }) + // prior work 5 (was 2) → 81 + expect(total()).toBe('81') + }) + + it('removes family points when the relative box is unchecked', () => { + renderSask() + fillStandardProfile() + fireEvent.click(screen.getByRole('checkbox', { name: /Close family relative in Saskatchewan/ })) + // connection 0 (was 20) → 58 + expect(total()).toBe('58') + }) + + it('awards the full 30 points for an Employment Offer', () => { + renderSask() + fillStandardProfile() + fireEvent.click(screen.getByRole('button', { name: 'Employment Offer' })) + // connection 0 (no job offer yet) → 58 + expect(total()).toBe('58') + fireEvent.click(screen.getByRole('checkbox', { name: /High-skilled job offer/ })) + // connection 30 → 88 + expect(total()).toBe('88') + }) + + it('adds second-language points when a French test at CLB 6 is added', () => { + renderSask() + fillStandardProfile() + fireEvent.click(screen.getByRole('button', { name: /Add second language test/ })) + const frenchScores: Record = { reading: '393', writing: '379', listening: '393', speaking: '422' } + for (const [ability, score] of Object.entries(frenchScores)) { + fireEvent.change(screen.getByLabelText(`French (second official language) ${ability}`), { target: { value: score } }) + } + // language 24 (18 first + 6 second) → 84 + expect(total()).toBe('84') + }) + + it('shows the eligible banner for the standard profile', () => { + renderSask() + fillStandardProfile() + expect(screen.getByText('You appear eligible')).toBeTruthy() + expect(screen.queryByText('Not eligible yet')).toBeNull() + }) + + it('shows the not eligible banner with reasons when criteria are not met', () => { + renderSask() + fillStandardProfile() + fireEvent.change(screen.getByLabelText('Skilled work experience (last 5 years)'), { target: { value: '0' } }) + fireEvent.change(screen.getByLabelText('Skilled work experience (6-10 years prior)'), { target: { value: '0' } }) + fireEvent.click(screen.getByRole('checkbox', { name: /Close family relative in Saskatchewan/ })) + // points 50 (below 60) and no work experience both fail; language stays at CLB 7 + expect(screen.getByText('Not eligible yet')).toBeTruthy() + expect(screen.getByText(/You must score at least 60 points/)).toBeTruthy() + expect(screen.getByText(/one year of full-time paid work experience/)).toBeTruthy() + }) + + it('keeps the eligible banner for an Employment Offer with a job offer', () => { + renderSask() + fillStandardProfile() + fireEvent.click(screen.getByRole('button', { name: 'Employment Offer' })) + fireEvent.click(screen.getByRole('checkbox', { name: /High-skilled job offer/ })) + expect(screen.getByText('You appear eligible')).toBeTruthy() + }) +}) diff --git a/src/routes/saskatchewan/SaskatchewanTool.tsx b/src/routes/saskatchewan/SaskatchewanTool.tsx new file mode 100644 index 0000000..15f98b0 --- /dev/null +++ b/src/routes/saskatchewan/SaskatchewanTool.tsx @@ -0,0 +1,272 @@ +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, Note, Section, Segmented, Select, Slider } from '../../components/ui' +import { convertTestToClb, emptyScores, overallClb } from '../../lib/crs/languages' +import type { LanguageTestState } from '../../lib/crs/languages' +import { + MAX_AGE, + MAX_CONNECTION, + MAX_EDUCATION, + MAX_FACTOR_I, + MAX_LANGUAGE, + MAX_PRIOR_WORK, + MAX_TOTAL, + MAX_WORK, + eligibility, + sinpScore, +} from '../../lib/saskatchewan/score' +import type { EducationLevel, Eligibility, SinpBreakdown, SinpInput, SubCategory } from '../../lib/saskatchewan/score' + +const SASK_DOC = + 'https://www.saskatchewan.ca/residents/moving-to-saskatchewan/live-in-saskatchewan/by-immigrating/saskatchewan-immigrant-nominee-program/assess-your-eligibility' + +const EDUCATION_OPTIONS: ReadonlyArray<{ value: EducationLevel | ''; label: string }> = [ + { value: '', label: 'Select...' }, + { value: 'masters-doctorate', label: "Master's degree or doctorate" }, + { value: 'bachelor', label: "Bachelor's degree or 3+ year degree" }, + { value: 'trade', label: 'Trade certification (journeyperson)' }, + { value: 'diploma', label: 'Diploma (2 to <3 year program)' }, + { value: 'certificate', label: 'Certificate / <2-year program' }, +] + +const WORK_OPTIONS = [ + { value: '', label: 'Select...' }, + { 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 PRIOR_WORK_OPTIONS = [ + { value: '', label: 'Select...' }, + { value: '0', label: 'Less than 1 year' }, + { value: '2', label: '2 years' }, + { value: '3', label: '3 years' }, + { value: '4', label: '4 years' }, + { value: '5', label: '5+ years' }, +] + +const ENGLISH_TESTS = ['ielts', 'celpip', 'pte'] as const +const FRENCH_TESTS = ['tef', 'tcf'] as const + +interface SinpUiState { + education: EducationLevel | '' + work: string + priorWork: string + age: number + english: LanguageTestState + secondLanguage: LanguageTestState + subCategory: SubCategory + family: boolean + skWorkExperience: boolean + skStudy: boolean + jobOffer: boolean +} + +const DEFAULT_UI: SinpUiState = { + education: '', + work: '', + priorWork: '', + age: 17, + english: { test: 'none', scores: emptyScores() }, + secondLanguage: { test: 'none', scores: emptyScores() }, + subCategory: 'oid-ee', + family: false, + skWorkExperience: false, + skStudy: false, + jobOffer: false, +} + +function buildInput(ui: SinpUiState): SinpInput { + const firstLanguageClb = overallClb(convertTestToClb(ui.english.test, ui.english.scores)) + const secondLanguageClb = overallClb(convertTestToClb(ui.secondLanguage.test, ui.secondLanguage.scores)) + return { + education: (ui.education === '' ? 'certificate' : ui.education) as EducationLevel, + workYears: Number(ui.work) || 0, + priorWorkYears: Number(ui.priorWork) || 0, + age: ui.age, + firstLanguageClb, + secondLanguageClb, + subCategory: ui.subCategory, + family: ui.family, + skWorkExperience: ui.skWorkExperience, + skStudy: ui.skStudy, + jobOffer: ui.jobOffer, + } +} + +export function SaskatchewanTool() { + const [ui, setUi] = useState(DEFAULT_UI) + + const input = useMemo(() => buildInput(ui), [ui]) + const score = useMemo(() => sinpScore(input), [input]) + const eligibilityResult: Eligibility = useMemo(() => eligibility(input), [input]) + + const patch = (p: Partial) => setUi((prev) => ({ ...prev, ...p })) + + return ( +
+ + +

SINP Points Calculator

+

+ Saskatchewan scores International Skilled Worker candidates out of 110. Enter your details to see your points + and how they add up. +

+ +
+ +
+ +
+
+
+ + patch({ work: v })} + options={WORK_OPTIONS} + /> + + +