chore(ts-migration): establish TypeScript migration checkpoint

Phases 0-3 of the JS/JSX -> TS/TSX migration. No runtime behaviour changes:
every converted file emits byte-identical JavaScript, verified file by file.

Phase 0 - harness hardening, before any rename:
  - scripts/ssr-resolve.mjs wraps `ssrLoadModule` so the ~170 literal module
    paths in the check scripts resolve .js/.jsx/.ts/.tsx. Without it the first
    rename would have silently destroyed the 1642-check suite that guards the
    Owliver flow.
  - eslint.config.js gains a TypeScript block. Its `files` globs listed only
    {js,mjs,cjs,jsx}, so a renamed file would have dropped out of the run while
    `eslint .` went on exiting 0 - the quietest failure mode available.
  - MIGRATION_BASELINE.md records the measured starting point, including the
    pre-existing seed-fixture failure and the already-broken standalone
    owliver-baseline.mjs, so neither is later mistaken for migration damage.

Phase 1 - tsconfig.json succeeds jsconfig.json, carrying every option across at
its old value. `types` moves from [] to ["vite/client"], which fixes the eight
import.meta errors; @types/node is deliberately excluded so setTimeout stays a
number in browser code. allowJs and checkJs stay on, strict stays off.

Phase 2 - src/types/{api,entities,user}.ts. Transport envelope, error shape,
the entity-name union (the same 18 names are written down twice today, in
httpClient and base44Client, with nothing checking they agree), and the user
record. Every field transcribed from the API contract, the migrations and the
/me projection in me.go - not inferred. Entity record shapes are deliberately
absent: derivable, but nothing consumes them yet.

Phase 3 - nine leaf utilities renamed to .ts with annotations added only where
they could be established from existing usage. Entity records are typed `any`
with a comment naming what they are, rather than a guessed interface.

Verified: tsc 64 errors (65 before; one pre-existing TS2559 genuinely fixed,
none introduced), lint unchanged at 0 errors, skill-check 1641/1642 with only
the known failure, Owliver baseline section 59/59 green, production build
succeeds with the API origin inlined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
2026-09-11 15:52:01 +05:30
parent f522b6508e
commit 1775395256
25 changed files with 1458 additions and 72 deletions

141
MIGRATION_BASELINE.md Normal file
View File

@@ -0,0 +1,141 @@
# TypeScript migration — baseline
The state of this repository **immediately before** the first source file was
renamed, measured rather than assumed. Everything the migration does is checked
against these numbers; a figure that moves without a phase claiming it is a
regression.
Measured on 2026-09-11, on `main` with a clean tree, before any change in
Milestone 1.
## The numbers
| Check | Command | Baseline |
|---|---|---|
| Types | `npm run typecheck` | **FAILS — 71 errors across 27 files** |
| Lint | `npm run lint` | **PASSES**, exit 0 |
| Lint (with warnings) | `npx eslint .` | 289 problems — 0 errors, 289 warnings |
| Files linted | `npx eslint . --format json` | 302 |
| Behaviour | `npm test` | **1641 / 1642 checks passed** |
| Build | `npm run build` | succeeds |
| Owliver, via the suite | `npm test` section 21 | **all green** — schema, skills, routes, and per-context skills, suggestions, prompts and intent routing |
| Owliver, standalone | `node scripts/owliver-baseline.mjs` | **FAILS — pre-existing, see below** |
### The 71 type errors, by code
| Code | Count | What it is |
|---|---|---|
| TS2339 | 38 | Property does not exist on an inferred type |
| TS2741 | 9 | Missing required prop in JSX |
| TS2353 | 9 | Unknown property in an object literal |
| TS2345 | 6 | Argument type mismatch |
| TS2554 | 2 | Wrong argument count |
| TS2362 / TS2363 | 4 | Arithmetic on a non-number |
| TS2739 / TS2559 / TS2349 | 3 | Missing props / no common props / not callable |
These are **pre-existing**. They were not introduced by the migration and the
migration is not obliged to fix them; they are the backlog it has to avoid
adding to. Six of them are `import.meta` errors that Phase 1 resolves as a side
effect of configuring `vite/client`.
Note that `npm run typecheck` **is not run by CI** (`.github/workflows/ci.yml`
runs lint, test, build and the seed check). That is how 71 errors accumulated
without anyone seeing them. Wiring it in is the last phase of the migration, not
the first — it cannot be a gate until it is green.
## The standalone Owliver script is already broken
`node scripts/owliver-baseline.mjs` fails before any of this began — verified by
stashing every change and running it on a clean `main`, where it fails
identically. It is **not** caused by the migration.
```
Failed to scan for dependencies from entries:
index.html
scripts/__baseline__/activity-page.pre-migration.html
… the other seven snapshots …
✘ [ERROR] The server is being restarted or closed. Request is outdated [plugin vite:dep-scan]
Owliver behaviour has DRIFTED from the baseline.
```
Two things are going on, and neither is a real drift. Vite's dependency scanner
treats every `*.html` under the project root as an entry point, so the eight
captured SSR snapshots in `scripts/__baseline__/` are scanned as if they were
pages of the app. The script then calls `server.close()` as soon as
`captureBaseline` returns, while that scan is still running, and the scan
reports that it was cancelled. The capture comes back incomplete and the
comparison calls it drift.
**The coverage itself is not lost.** `skill-check.mjs` imports the same
`captureBaseline` and the same `BASELINE_PATH` and asserts against them in
section 21, and it does enough other work afterwards that the scan finishes. Run
`npm test` and the whole section is green:
```
── Owliver behaviour baseline ──
[ ok ] baseline schema matches — expected 1, got 1
[ ok ] every skill that existed before the agent layer still registers — 23 kept, 1 added since
[ ok ] every route that resolved before resolves the same way — 18 unchanged
[ ok ] controlCenter: keeps every skill it had — 7 skill(s), unchanged
…per context: page key, skills, suggestions, prompts, intent routing
```
So **`npm test` is the Owliver drift check for the duration of the migration**,
and the standalone script is a convenience wrapper that needs repairing on its
own terms — excluding `scripts/__baseline__` from the scan, or awaiting the
optimizer before closing. That repair is not part of the migration: it changes
a script's behaviour, and this milestone's whole claim is that it changed none.
## The one failing check
```
1641/1642 checks passed
Failed:
- the backend fixture is in step with this seed (stale — run `npm run seed:fixture`)
```
`scripts/__baseline__` is intact and `owliver-baseline.mjs` matches; this is not
an Owliver failure. `seed/fixtures/seed.json` in the sibling `krow-backend`
checkout has drifted from `src/api/seed.js`.
**This failure predates the migration and is unrelated to it. It is recorded
here so that it is never mistaken for something the migration caused, and it is
deliberately left alone** — regenerating the fixture would write into another
repository and change what the backend seeds, which is a product change wearing
a migration's clothes. Fix it separately, on its own terms.
## What "unchanged" means for the Owliver / agent flow
The agent flow is ~112 files and 38% of the codebase, and it is converted last,
by rename and type annotation only. The evidence that it still behaves
identically is already in the repository and needs nothing new:
- `npm test` — 1642 checks covering skill discovery, routing, context
resolution and SSR-rendered markup.
- `scripts/__baseline__/owliver-baseline.json` — contexts, skill ids and routes.
- `scripts/__baseline__/*.pre-migration.html` — eight pages of byte-level SSR
output.
- `node scripts/owliver-baseline.mjs` — the drift check.
- CI's `FLOOR = 900` — fails if the suite silently shrinks.
**These artifacts are read-only for the duration of the migration.** If the
baseline drifts, revert the change. Regenerating it to clear a red check records
the regression instead of catching it, which is worse than having no baseline —
`owliver-baseline.mjs` says as much in its own header.
`src/agents/**/*.md` and `src/skills/**/*.md` are likewise untouched throughout:
they are the definitions the flow is built from, not code being migrated.
## How to re-measure
```bash
npm run typecheck; echo "exit=$?"
npm run typecheck 2>&1 | grep -c "error TS"
npm run lint; echo "exit=$?"
npx eslint . --format json | python3 -c "import json,sys;print(len(json.load(sys.stdin)))"
npm test
node scripts/owliver-baseline.mjs
npm run build
git diff --stat -M
```

View File

@@ -3,39 +3,17 @@ import pluginJs from "@eslint/js";
import pluginReact from "eslint-plugin-react"; import pluginReact from "eslint-plugin-react";
import pluginReactHooks from "eslint-plugin-react-hooks"; import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginUnusedImports from "eslint-plugin-unused-imports"; import pluginUnusedImports from "eslint-plugin-unused-imports";
import tseslint from "typescript-eslint";
export default [ /**
{ * The rule set, written once and applied to JavaScript and TypeScript alike.
files: [ *
"src/components/**/*.{js,mjs,cjs,jsx}", * Shared rather than duplicated because the two blocks below differ in exactly
"src/pages/**/*.{js,mjs,cjs,jsx}", * one thing — which parser reads the file — and a rule that applied to `.jsx`
"src/layouts/**/*.{js,mjs,cjs,jsx}", * but not to its `.tsx` successor would make the TypeScript migration look like
"src/hooks/**/*.{js,mjs,cjs,jsx}", * it was tidying the code up. It is not; it renames files and adds types.
], */
ignores: ["src/lib/**/*", "src/components/ui/**/*"], const rules = {
...pluginJs.configs.recommended,
...pluginReact.configs.flat.recommended,
languageOptions: {
globals: globals.browser,
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
},
settings: {
react: {
version: "detect",
},
},
plugins: {
react: pluginReact,
"react-hooks": pluginReactHooks,
"unused-imports": pluginUnusedImports,
},
rules: {
"no-unused-vars": "off", "no-unused-vars": "off",
"react/jsx-uses-vars": "error", "react/jsx-uses-vars": "error",
"react/jsx-uses-react": "error", "react/jsx-uses-react": "error",
@@ -56,6 +34,88 @@ export default [
{ ignore: ["cmdk-input-wrapper", "toast-close"] }, { ignore: ["cmdk-input-wrapper", "toast-close"] },
], ],
"react-hooks/rules-of-hooks": "error", "react-hooks/rules-of-hooks": "error",
};
const plugins = {
react: pluginReact,
"react-hooks": pluginReactHooks,
"unused-imports": pluginUnusedImports,
};
const settings = { react: { version: "detect" } };
/**
* Which files are linted.
*
* Unchanged from what this config has always covered — `src/lib` and
* `src/components/ui` stay out of it — with `ts` and `tsx` added to every
* pattern. That addition is the point: ESLint matches on extension, so the
* moment a `.jsx` file became `.tsx` it would have dropped out of the run
* silently, and `eslint .` would have gone on exiting 0 while linting less and
* less of the codebase. A lint that passes because it checked nothing is worse
* than one that fails.
*/
const directories = ["src/components", "src/pages", "src/layouts", "src/hooks"];
const ignores = ["src/lib/**/*", "src/components/ui/**/*"];
const filesWith = (extensions) =>
directories.map((directory) => `${directory}/**/*.{${extensions}}`);
export default [
{
files: filesWith("js,mjs,cjs,jsx"),
ignores,
...pluginJs.configs.recommended,
...pluginReact.configs.flat.recommended,
languageOptions: {
globals: globals.browser,
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
ecmaFeatures: { jsx: true },
},
},
settings,
plugins,
rules,
},
/**
* The same block for TypeScript, with the TypeScript parser.
*
* A separate block rather than one widened glob, so that nothing about how
* the existing JavaScript is parsed or reported changes on the day this
* lands. `typescript-eslint`'s parser accepts plain JavaScript too, and
* merging the two would have been shorter — but it would also have quietly
* re-parsed 200-odd existing files, and this phase is meant to be provably
* inert.
*
* Deliberately NOT type-aware (no `projectService`): type errors are
* `npm run typecheck`'s job, and asking ESLint to build a program as well
* would make every lint run pay for it twice.
*/
{
files: filesWith("ts,tsx,mts,cts"),
ignores,
...pluginReact.configs.flat.recommended,
languageOptions: {
globals: globals.browser,
parser: tseslint.parser,
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
ecmaFeatures: { jsx: true },
},
},
settings,
plugins: { ...plugins, "@typescript-eslint": tseslint.plugin },
rules: {
...rules,
/* TypeScript resolves identifiers itself and reports the ones it cannot,
with better messages and without ESLint's browser/node globals list
needing to be right. Leaving the core rule on would report every `type`
and `interface` name as undefined. */
"no-undef": "off",
}, },
}, },
]; ];

333
package-lock.json generated
View File

@@ -57,6 +57,7 @@
"puppeteer-core": "^23.11.1", "puppeteer-core": "^23.11.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"typescript-eslint": "^8.70.0",
"vite": "^6.1.0" "vite": "^6.1.0"
} }
}, },
@@ -3378,6 +3379,301 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz",
"integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.70.0",
"@typescript-eslint/type-utils": "8.70.0",
"@typescript-eslint/utils": "8.70.0",
"@typescript-eslint/visitor-keys": "8.70.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.70.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz",
"integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz",
"integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.70.0",
"@typescript-eslint/types": "8.70.0",
"@typescript-eslint/typescript-estree": "8.70.0",
"@typescript-eslint/visitor-keys": "8.70.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz",
"integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.70.0",
"@typescript-eslint/types": "^8.70.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz",
"integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.70.0",
"@typescript-eslint/visitor-keys": "8.70.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz",
"integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz",
"integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.70.0",
"@typescript-eslint/typescript-estree": "8.70.0",
"@typescript-eslint/utils": "8.70.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz",
"integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz",
"integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.70.0",
"@typescript-eslint/tsconfig-utils": "8.70.0",
"@typescript-eslint/types": "8.70.0",
"@typescript-eslint/visitor-keys": "8.70.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz",
"integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.70.0",
"@typescript-eslint/types": "8.70.0",
"@typescript-eslint/typescript-estree": "8.70.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz",
"integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.70.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@ungap/structured-clone": { "node_modules/@ungap/structured-clone": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
@@ -9650,6 +9946,19 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.12"
},
"peerDependencies": {
"typescript": ">=4.8.4"
}
},
"node_modules/ts-interface-checker": { "node_modules/ts-interface-checker": {
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -9774,6 +10083,30 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/typescript-eslint": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz",
"integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.70.0",
"@typescript-eslint/parser": "8.70.0",
"@typescript-eslint/typescript-estree": "8.70.0",
"@typescript-eslint/utils": "8.70.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/unbox-primitive": { "node_modules/unbox-primitive": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",

View File

@@ -11,7 +11,7 @@
"test": "node scripts/skill-check.mjs", "test": "node scripts/skill-check.mjs",
"seed:fixture": "node scripts/seed-fixture.mjs --write", "seed:fixture": "node scripts/seed-fixture.mjs --write",
"seed:check": "node scripts/seed-fixture.mjs", "seed:check": "node scripts/seed-fixture.mjs",
"typecheck": "tsc -p ./jsconfig.json", "typecheck": "tsc -p ./tsconfig.json",
"preview": "vite preview", "preview": "vite preview",
"test:browser": "node scripts/browser-check.mjs" "test:browser": "node scripts/browser-check.mjs"
}, },
@@ -65,6 +65,7 @@
"puppeteer-core": "^23.11.1", "puppeteer-core": "^23.11.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"typescript-eslint": "^8.70.0",
"vite": "^6.1.0" "vite": "^6.1.0"
} }
} }

View File

@@ -16,6 +16,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { createServer } from 'vite'; import { createServer } from 'vite';
import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs'; import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs';
import { withSourceResolution } from './ssr-resolve.mjs';
const ROOT = process.cwd(); const ROOT = process.cwd();
@@ -26,6 +27,11 @@ const server = await createServer({
logLevel: 'error', logLevel: 'error',
}); });
/* Resolves the capture's module paths whatever extension they carry during the
TypeScript migration. The comparison this script performs is unaffected: it
loads the same modules and serialises the same values. */
withSourceResolution(server, ROOT);
const captured = await captureBaseline(server); const captured = await captureBaseline(server);
await server.close(); await server.close();

View File

@@ -18,6 +18,7 @@ import { join } from 'node:path';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import React from 'react'; import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server'; import { renderToStaticMarkup } from 'react-dom/server';
import { withSourceResolution } from './ssr-resolve.mjs';
/** /**
* The design system reads `window` when its modules evaluate, which is a * The design system reads `window` when its modules evaluate, which is a
@@ -97,6 +98,9 @@ if (process.argv[1] && process.argv[1].endsWith('render-page.mjs')) {
logLevel: 'error', logLevel: 'error',
resolve: { alias: { 'react-hot-toast': join(process.cwd(), 'scripts/stubs/react-hot-toast.js') } }, resolve: { alias: { 'react-hot-toast': join(process.cwd(), 'scripts/stubs/react-hot-toast.js') } },
}); });
/* The page path arrives on the command line with whatever extension it has
today; during the TypeScript migration that may be .jsx or .tsx. */
withSourceResolution(server, process.cwd());
try { try {
const html = await renderPage(server, `/${modulePath.replace(/^\//, '')}`, { route: route || '/' }); const html = await renderPage(server, `/${modulePath.replace(/^\//, '')}`, { route: route || '/' });
writeFileSync(out, html); writeFileSync(out, html);

View File

@@ -20,6 +20,7 @@ import { createServer } from 'vite';
import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { withSourceResolution } from './ssr-resolve.mjs';
export const FIXTURE_PATH = join(process.cwd(), '..', 'krow-backend', 'seed', 'fixtures', 'seed.json'); export const FIXTURE_PATH = join(process.cwd(), '..', 'krow-backend', 'seed', 'fixtures', 'seed.json');
@@ -51,6 +52,9 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const server = await createServer({ const server = await createServer({
root: process.cwd(), server: { middlewareMode: true }, appType: 'custom', logLevel: 'error', root: process.cwd(), server: { middlewareMode: true }, appType: 'custom', logLevel: 'error',
}); });
/* `buildFixture` loads `/src/api/seed.js`, which the TypeScript migration
will rename. The fixture it generates is unchanged either way. */
withSourceResolution(server, process.cwd());
const built = await buildFixture(server); const built = await buildFixture(server);
await server.close(); await server.close();

View File

@@ -19,6 +19,7 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server'; import { renderToStaticMarkup } from 'react-dom/server';
import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs'; import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs';
import { buildFixture, FIXTURE_PATH } from './seed-fixture.mjs'; import { buildFixture, FIXTURE_PATH } from './seed-fixture.mjs';
import { withSourceResolution } from './ssr-resolve.mjs';
const ROOT = process.cwd(); const ROOT = process.cwd();
const results = []; const results = [];
@@ -41,6 +42,12 @@ const server = await createServer({
resolve: { alias: { 'react-hot-toast': join(ROOT, 'scripts/stubs/react-hot-toast.js') } }, resolve: { alias: { 'react-hot-toast': join(ROOT, 'scripts/stubs/react-hot-toast.js') } },
}); });
/* Every `ssrLoadModule` path below names a file by its current extension. While
the TypeScript migration is under way that extension changes underneath them,
so the loader resolves .js/.jsx/.ts/.tsx rather than each call site having to
know which one a module is on today. The path as written is tried first. */
withSourceResolution(server, ROOT);
const reg = await server.ssrLoadModule('/src/lib/skills/registry.js'); const reg = await server.ssrLoadModule('/src/lib/skills/registry.js');
const placement = await server.ssrLoadModule('/src/components/ai-assistant/placement.js'); const placement = await server.ssrLoadModule('/src/components/ai-assistant/placement.js');

80
scripts/ssr-resolve.mjs Normal file
View File

@@ -0,0 +1,80 @@
/**
* Extension-agnostic SSR module loading, for the duration of the TypeScript
* migration.
*
* Every script in here addresses modules by literal path — `ssrLoadModule(
* '/src/lib/skills/registry.js')` — about a hundred and seventy times across
* `skill-check.mjs`, `owliver-capture.mjs`, `render-page.mjs` and
* `seed-fixture.mjs`. That is fine while every source file is JavaScript and
* fatal the moment one is not: renaming `registry.js` to `registry.ts` turns
* the check suite's very first load into a failure, and the suite is the only
* evidence the Owliver flow still behaves the way it did.
*
* Rewriting all those call sites would be a large, noisy, error-prone diff
* against the file that guards the migration — exactly the wrong thing to
* disturb. So the loader is wrapped once instead and the call sites keep the
* paths they already have, which stay readable as the names of real files.
*
* Resolution is by existence on disk, not by catching a failed load. A load
* that fails for a real reason — a syntax error, a bad import inside the module
* — must surface as itself; retrying under another extension would bury it
* behind a second, more confusing error about a file that was never there.
*
* The path as written is always tried first, so while a module is still
* JavaScript this changes nothing at all.
*
* This file is temporary. When `src` holds no `.js` or `.jsx` any more, the
* call sites can be renamed in one pass and this wrapper deleted.
*/
import { existsSync } from 'node:fs';
import { join } from 'node:path';
/**
* The candidate paths for one module specifier, in the order they are tried.
*
* Only `.js` and `.jsx` are rewritten. Anything else — a bare specifier, a
* `.mjs` file, something under `/node_modules` — is returned untouched, because
* nothing in this migration renames it.
*
* `.js` is allowed to become `.tsx` as well as `.ts`. Not because any `.js`
* file here contains JSX today — none of the 93 does, checked with a parser
* rather than a guess — but because one may be renamed that way: the only
* `.js` under `src/pages` is `admin/positions/nodes.js`, sitting among seven
* sibling `nodes.jsx` files, and whoever converts that directory will
* reasonably want all eight to end in `.tsx`. The extra candidate costs one
* `existsSync` that answers no.
*/
export function candidatesFor(path) {
const specifier = String(path);
if (!/\.jsx?$/.test(specifier)) return [specifier];
const stem = specifier.replace(/\.jsx?$/, '');
return [...new Set([specifier, `${stem}.ts`, `${stem}.tsx`])];
}
/**
* Wraps `server.ssrLoadModule` so it finds a module whichever of the four
* extensions it currently carries.
*
* Mutates and returns the server, so it reads as one line after `createServer`
* and every later call — including the dynamically-built paths, which is why
* this is done here rather than at the call sites — goes through it.
*
* `root` is where the leading-slash paths are rooted; it defaults to the
* process's working directory, which is what every caller here uses.
*/
export function withSourceResolution(server, root = process.cwd()) {
const load = server.ssrLoadModule.bind(server);
server.ssrLoadModule = (path, options) => {
for (const candidate of candidatesFor(path)) {
if (existsSync(join(root, candidate.replace(/^\//, '')))) {
return load(candidate, options);
}
}
/* Nothing on disk under any extension. Load the path as written so the
error names what the caller actually asked for. */
return load(path, options);
};
return server;
}

View File

@@ -8,6 +8,18 @@
* worker WANTS rather than what a posting OFFERS. * worker WANTS rather than what a posting OFFERS.
*/ */
/**
* `draft` is a EmployeeRole record, typed `any` deliberately.
*
* The shared types in `src/types/` describe the transport and the signed-in
* user; they do not describe entity records yet. That shape is derivable — the
* backend generates a column registry from `information_schema` — but it
* belongs with the phase that converts the transport layer. A partial interface
* written here would be a guess that every later reader treats as settled.
* `any` states what is actually known today and is one annotation to replace.
*/
type EmployeeRoleDraft = any;
/** English levels, in the order the schema declares them. */ /** English levels, in the order the schema declares them. */
export const EMPLOYEE_ROLE_STATUSES = ['seeking', 'placed', 'inactive']; export const EMPLOYEE_ROLE_STATUSES = ['seeking', 'placed', 'inactive'];
@@ -35,7 +47,7 @@ export const defaultEmployeeRole = () => ({
* A maximum of zero means "no ceiling stated" rather than "free", which is why * A maximum of zero means "no ceiling stated" rather than "free", which is why
* it is not rendered as a range ending at nothing. * it is not rendered as a range ending at nothing.
*/ */
export function desiredPayLabel(record = {}) { export function desiredPayLabel(record: EmployeeRoleDraft = {}): string | null {
const min = Number(record.desired_pay_min) || 0; const min = Number(record.desired_pay_min) || 0;
const max = Number(record.desired_pay_max) || 0; const max = Number(record.desired_pay_max) || 0;
if (!min && !max) return null; if (!min && !max) return null;
@@ -56,7 +68,7 @@ export function desiredPayLabel(record = {}) {
* defaults one, or falls back to another record's; an absent email reaches the * defaults one, or falls back to another record's; an absent email reaches the
* server absent, and the server refuses it. * server absent, and the server refuses it.
*/ */
export function toNewWorkerWithRolePayload(draft = {}) { export function toNewWorkerWithRolePayload(draft: EmployeeRoleDraft = {}) {
const role = toEmployeeRolePayload(draft); const role = toEmployeeRolePayload(draft);
/* The identity fields belong to the worker. The server copies them onto the /* The identity fields belong to the worker. The server copies them onto the
role from the row it just created, so sending them twice would let the two role from the row it just created, so sending them twice would let the two
@@ -82,7 +94,7 @@ export function toNewWorkerWithRolePayload(draft = {}) {
* deliberately: both are the server's, derived from the session, and a value * deliberately: both are the server's, derived from the session, and a value
* sent for either is dropped before the insert. * sent for either is dropped before the insert.
*/ */
export function toEmployeeRolePayload(draft = {}) { export function toEmployeeRolePayload(draft: EmployeeRoleDraft = {}) {
const record = { ...defaultEmployeeRole(), ...draft }; const record = { ...defaultEmployeeRole(), ...draft };
return { return {
...record, ...record,

View File

@@ -1,10 +1,47 @@
/**
* The records this engine reads — WorkerProfile, JobPosting, Course and
* LearningPath — are typed `any` deliberately.
*
* `src/types/` describes the transport and the signed-in user; it does not
* describe entity records yet. Those shapes are derivable from the backend's
* generated column registry, but they belong with the phase that converts the
* transport layer. Written here they would be a guess, and this is the file
* where a wrong guess is most expensive: these weights decide what a worker's
* score is and which jobs they are shown.
*
* `computeProfileCompletion` additionally indexes the profile by a field name
* held in a list (`profile[f]`), which no partial interface would permit
* without either widening it back to `any` or rewriting the loop — and
* rewriting it would be a behaviour change in a scoring path.
*/
type ProfileRecord = any;
type JobPostingRecord = any;
type CourseRecord = any;
type LearningPathRecord = any;
/** The score, its headline inputs, and the per-dimension breakdown a reader sees. */
export interface KrowScoreResult {
krow_score: number;
reliability: number;
breakdown: {
/** `null`, not 0, where there is no record — an absent dimension renders "—". */
attendance: number | null;
performance: number;
education: number;
clientReviews: number;
supervisorReviews: number;
growth: number;
experience: number;
};
}
// Engine 6 — KROW Score Engine (formula-based, auto-recalculated) // Engine 6 — KROW Score Engine (formula-based, auto-recalculated)
// Reliability = Attendance×.30 + Performance×.25 + Education×.10 + ClientReviews×.15 + SupervisorReviews×.10 + Growth×.05 + Experience×.05 // Reliability = Attendance×.30 + Performance×.25 + Education×.10 + ClientReviews×.15 + SupervisorReviews×.10 + Growth×.05 + Experience×.05
// KROW Score = Reliability×.40 + AI Interview×.25 + Education×.15 + Growth×.10 + Experience×.10 // KROW Score = Reliability×.40 + AI Interview×.25 + Education×.15 + Growth×.10 + Experience×.10
const clamp = (n) => Math.max(0, Math.min(100, n)); const clamp = (n: number) => Math.max(0, Math.min(100, n));
export function computeKrowScore(profile) { export function computeKrowScore(profile: ProfileRecord): KrowScoreResult {
/** /**
* Attendance defaults to 100 for *display*: an unrated worker shows a full bar * Attendance defaults to 100 for *display*: an unrated worker shows a full bar
* rather than an accusatory 0. That default must not become a scoring input. * rather than an accusatory 0. That default must not become a scoring input.
@@ -64,7 +101,7 @@ export function computeKrowScore(profile) {
const CORE_FIELDS = ['full_name', 'email', 'phone', 'address', 'current_position', 'desired_position', 'career_goals', 'transportation', 'resume_url']; const CORE_FIELDS = ['full_name', 'email', 'phone', 'address', 'current_position', 'desired_position', 'career_goals', 'transportation', 'resume_url'];
export function computeProfileCompletion(profile) { export function computeProfileCompletion(profile: ProfileRecord): number {
let filled = 0; let filled = 0;
for (const f of CORE_FIELDS) if (profile[f]) filled++; for (const f of CORE_FIELDS) if (profile[f]) filled++;
if (profile.languages?.length) filled++; if (profile.languages?.length) filled++;
@@ -83,7 +120,7 @@ export function computeProfileCompletion(profile) {
} }
/** Returns only the recomputed fields — merge into an update patch */ /** Returns only the recomputed fields — merge into an update patch */
export function recalcProfilePatch(profile) { export function recalcProfilePatch(profile: ProfileRecord) {
const { krow_score, reliability, breakdown } = computeKrowScore(profile); const { krow_score, reliability, breakdown } = computeKrowScore(profile);
return { return {
krow_score, krow_score,
@@ -95,7 +132,7 @@ export function recalcProfilePatch(profile) {
// Engine 11 (light) — recommendation helpers // Engine 11 (light) — recommendation helpers
export function recommendNextCourse(profile, learningPaths = [], courses = []) { export function recommendNextCourse(profile: ProfileRecord, learningPaths: LearningPathRecord[] = [], courses: CourseRecord[] = []) {
const p = profile || {}; const p = profile || {};
const completedIds = new Set((p.completed_courses || []).map(c => c.course_id)); const completedIds = new Set((p.completed_courses || []).map(c => c.course_id));
const desired = (p.desired_position || '').toLowerCase(); const desired = (p.desired_position || '').toLowerCase();
@@ -116,14 +153,17 @@ export function recommendNextCourse(profile, learningPaths = [], courses = []) {
return uncompleted[0] ? { course: uncompleted[0], path: null } : { course: null, path }; return uncompleted[0] ? { course: uncompleted[0], path: null } : { course: null, path };
} }
export function computeJobMatch(profile, job) { export function computeJobMatch(profile: ProfileRecord, job: JobPostingRecord): number {
const p = profile || {}; const p = profile || {};
let score = 0; let score = 0;
const desired = (p.desired_position || '').toLowerCase(); const desired = (p.desired_position || '').toLowerCase();
if (desired && (job.title?.toLowerCase().includes(desired) || job.role_category?.toLowerCase().includes(desired))) score += 35; if (desired && (job.title?.toLowerCase().includes(desired) || job.role_category?.toLowerCase().includes(desired))) score += 35;
const profileSkills = new Set((p.skills || []).map(s => s.toLowerCase())); /* `new Set(x)` where x is `any` infers `Set<unknown>`, and the loops below
const profileCerts = new Set((p.earned_badges || []).map(b => b.name?.toLowerCase()).filter(Boolean)); call `.includes` on the members. Naming the element type is the whole
fix; the values put into the set are unchanged. */
const profileSkills = new Set<string>((p.skills || []).map(s => s.toLowerCase()));
const profileCerts = new Set<string>((p.earned_badges || []).map(b => b.name?.toLowerCase()).filter(Boolean));
const reqSkills = (job.qualifications || []).map(q => q.toLowerCase()); const reqSkills = (job.qualifications || []).map(q => q.toLowerCase());
const reqCerts = (job.certifications_required || []).map(c => c.toLowerCase()); const reqCerts = (job.certifications_required || []).map(c => c.toLowerCase());
@@ -144,7 +184,7 @@ export function computeJobMatch(profile, job) {
return Math.min(100, Math.round(score)); return Math.min(100, Math.round(score));
} }
export function recommendJobs(profile, jobPostings = [], limit = 3) { export function recommendJobs(profile: ProfileRecord, jobPostings: JobPostingRecord[] = [], limit = 3) {
return jobPostings return jobPostings
.filter(j => j.status === 'active') .filter(j => j.status === 'active')
.map(j => ({ job: j, match: computeJobMatch(profile, j) })) .map(j => ({ job: j, match: computeJobMatch(profile, j) }))

View File

@@ -1,5 +1,57 @@
import { base44 } from '@/api/base44Client'; import { base44 } from '@/api/base44Client';
/**
* Course, WorkerProfile and the conversation turns are typed `any`
* deliberately — `src/types/` does not describe entity records yet, and a
* partial interface written here would be a guess later readers treat as
* settled. See the same note in `krowScore.ts`.
*/
type CourseRecord = any;
type ProfileRecord = any;
/** One turn of the roleplay transcript, as the challenge runner stores it. */
interface ChallengeTurn {
role: string;
content: string;
}
/**
* The body sent to `InvokeLLM`, built up across branches.
*
* Annotated rather than inferred because the code assigns `file_urls` and
* `prompt` onto the object AFTER creating it. TypeScript permits that in a
* `.js` file — it treats a JavaScript object literal as expandable — and
* refuses it in a `.ts` file, where the literal's type is fixed at creation.
* That difference is the only thing this interface exists to absorb: the
* assignments, their order and their values are untouched, and the emitted
* JavaScript is the same either way.
*/
interface ChallengeEvalCall {
response_json_schema: unknown;
model: string;
file_urls?: string[];
prompt?: string;
}
/** What the evaluator is asked to return; the schema below is its contract. */
export interface ChallengeEvaluation {
verdict: 'verified' | 'needs_work' | 'failed';
score: number;
rubric: Record<string, number>;
feedback: string;
strengths: string[];
concerns: string[];
}
/** The inputs a challenge submission carries, by challenge type. */
export interface ChallengeSubmission {
type: string;
mediaUrl?: string;
transcript?: string;
identified?: { label: string; x: number; y: number }[];
workerName?: string;
}
const CHALLENGE_EVAL_SCHEMA = { const CHALLENGE_EVAL_SCHEMA = {
type: 'object', type: 'object',
properties: { properties: {
@@ -13,7 +65,7 @@ const CHALLENGE_EVAL_SCHEMA = {
}; };
/** Real-work unlock gate — shifts, reliability, and badges must be earned first. */ /** Real-work unlock gate — shifts, reliability, and badges must be earned first. */
export function isUnlocked(course, profile = {}) { export function isUnlocked(course: CourseRecord, profile: ProfileRecord = {}) {
const req = course?.unlock_requirements; const req = course?.unlock_requirements;
if (!req) return { unlocked: true, reasons: [] }; if (!req) return { unlocked: true, reasons: [] };
const reasons = []; const reasons = [];
@@ -27,7 +79,7 @@ export function isUnlocked(course, profile = {}) {
} }
/** One sharp follow-up question during a roleplay challenge. */ /** One sharp follow-up question during a roleplay challenge. */
export async function challengeFollowUp(course, history) { export async function challengeFollowUp(course: CourseRecord, history: ChallengeTurn[]) {
const convo = history.map((m) => `${m.role === 'user' ? 'Worker' : 'KROW'}: ${m.content}`).join('\n'); const convo = history.map((m) => `${m.role === 'user' ? 'Worker' : 'KROW'}: ${m.content}`).join('\n');
const prompt = `You are "KROW", running a short proving-ground challenge for the skill "${course?.proof_skill || course?.title}". const prompt = `You are "KROW", running a short proving-ground challenge for the skill "${course?.proof_skill || course?.title}".
Challenge scenario: ${course?.challenge?.prompt || course?.description} Challenge scenario: ${course?.challenge?.prompt || course?.description}
@@ -41,7 +93,7 @@ Ask your follow-up now. Respond with only the question.`;
/** Evaluate a worker's proof — transcript for roleplay, attached media for photo/video, /** Evaluate a worker's proof — transcript for roleplay, attached media for photo/video,
* identified hazards for photo_identify (with the scene image attached). */ * identified hazards for photo_identify (with the scene image attached). */
export async function evaluateChallenge(course, { type, mediaUrl, transcript, identified, workerName }) { export async function evaluateChallenge(course: CourseRecord, { type, mediaUrl, transcript, identified, workerName }: ChallengeSubmission) {
const ch = course?.challenge || {}; const ch = course?.challenge || {};
const skill = course?.proof_skill || course?.title; const skill = course?.proof_skill || course?.title;
const criteria = (ch.rubric || []).map((r) => r.criterion).join(', ') || 'overall_performance'; const criteria = (ch.rubric || []).map((r) => r.criterion).join(', ') || 'overall_performance';
@@ -50,7 +102,7 @@ export async function evaluateChallenge(course, { type, mediaUrl, transcript, id
: 'Score "overall_performance" 0-100 in the rubric object.'; : 'Score "overall_performance" 0-100 in the rubric object.';
let mediaPart; let mediaPart;
const call = { response_json_schema: CHALLENGE_EVAL_SCHEMA, model: 'claude_sonnet_4_6' }; const call: ChallengeEvalCall = { response_json_schema: CHALLENGE_EVAL_SCHEMA, model: 'claude_sonnet_4_6' };
if (type === 'photo_identify') { if (type === 'photo_identify') {
const list = (identified || []).map((h) => `- "${h.label}" at (${Math.round(h.x * 100)}%, ${Math.round(h.y * 100)}%)`).join('\n') || '(no hazards marked)'; const list = (identified || []).map((h) => `- "${h.label}" at (${Math.round(h.x * 100)}%, ${Math.round(h.y * 100)}%)`).join('\n') || '(no hazards marked)';

View File

@@ -1,15 +1,56 @@
// Derives the Talent "credit score" dimensions and motivational metrics // Derives the Talent "credit score" dimensions and motivational metrics
// from a WorkerProfile. Keeps the UI honest by mapping to real fields only. // from a WorkerProfile. Keeps the UI honest by mapping to real fields only.
const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
export function getTalentScore(profile = {}) { /**
* `profile` is a WorkerProfile record, typed `any` deliberately.
*
* The shared types do not describe entity records yet — that shape is derivable
* from the backend's generated column registry and belongs with the phase that
* converts the transport layer, not with this one. Writing a partial
* WorkerProfile here would be a guess wearing an interface, and every reader
* after it would treat the guess as settled. `any` says what is actually known
* today, which is nothing, and it is one annotation to replace when the real
* record type exists.
*/
type WorkerProfileRecord = any;
/**
* One row of the reputation breakdown.
*
* `big` marks the headline dimension — Talent Score — which renders larger and
* is the one `getFactors` filters out. It is set on exactly one entry and
* absent from the other six, so it is optional rather than `boolean`: writing
* it as required would describe an object this module never builds.
*/
export interface ReputationDimension {
key: string;
label: string;
value: number;
big?: boolean;
}
/** A dimension carrying the weight it contributes, as `getFactors` returns it. */
export interface ReputationFactor extends ReputationDimension {
weight: number;
}
/** The credit-rating tier for a score, with the colour it renders in. */
export interface ScoreBand {
label: string;
color: string;
}
const clamp = (n: unknown, min = 0, max = 100) =>
Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
export function getTalentScore(profile: WorkerProfileRecord = {}) {
return clamp(profile.krow_score || 0); return clamp(profile.krow_score || 0);
} }
// Career Score — FICO-style 300–850 scale, the number shown on the KROW ID. // Career Score — FICO-style 300–850 scale, the number shown on the KROW ID.
export const toFICO = (s) => 300 + Math.round((clamp(s) / 100) * 550); export const toFICO = (s: number) => 300 + Math.round((clamp(s) / 100) * 550);
export function getStars(profile = {}) { export function getStars(profile: WorkerProfileRecord = {}) {
const s = getTalentScore(profile); const s = getTalentScore(profile);
if (s >= 90) return 5; if (s >= 90) return 5;
if (s >= 75) return 4; if (s >= 75) return 4;
@@ -20,18 +61,18 @@ export function getStars(profile = {}) {
} }
// Market value uplift ($/hr) derived from Talent Score // Market value uplift ($/hr) derived from Talent Score
export function getMarketValue(profile = {}) { export function getMarketValue(profile: WorkerProfileRecord = {}) {
const s = getTalentScore(profile); const s = getTalentScore(profile);
return Math.max(0, Math.round((s - 70) / 5)); return Math.max(0, Math.round((s - 70) / 5));
} }
// Approximate growth this month (motivational, derived from XP) // Approximate growth this month (motivational, derived from XP)
export function getGrowth(profile = {}) { export function getGrowth(profile: WorkerProfileRecord = {}) {
const xp = Number(profile.xp) || 0; const xp = Number(profile.xp) || 0;
return clamp(Math.round(xp / 25), 0, 99); return clamp(Math.round(xp / 25), 0, 99);
} }
export function getReputation(profile = {}) { export function getReputation(profile: WorkerProfileRecord = {}): ReputationDimension[] {
const s = profile || {}; const s = profile || {};
const courses = s.completed_courses || []; const courses = s.completed_courses || [];
const learning = clamp(courses.length * 8 + (Number(s.xp) || 0) / 10); const learning = clamp(courses.length * 8 + (Number(s.xp) || 0) / 10);
@@ -47,7 +88,7 @@ export function getReputation(profile = {}) {
} }
// Weight each reputation factor like a credit score (FICO-style, sums to 100) // Weight each reputation factor like a credit score (FICO-style, sums to 100)
export const FACTOR_WEIGHTS = { export const FACTOR_WEIGHTS: Record<string, number> = {
reliability: 25, reliability: 25,
attendance: 20, attendance: 20,
communication: 15, communication: 15,
@@ -57,7 +98,7 @@ export const FACTOR_WEIGHTS = {
}; };
// Score band — the "credit rating" tier for the talent // Score band — the "credit rating" tier for the talent
export function getScoreBand(score = 0) { export function getScoreBand(score = 0): ScoreBand {
if (score >= 90) return { label: 'Elite', color: '#0838E0' }; if (score >= 90) return { label: 'Elite', color: '#0838E0' };
if (score >= 75) return { label: 'Excellent', color: '#16A34A' }; if (score >= 75) return { label: 'Excellent', color: '#16A34A' };
if (score >= 60) return { label: 'Solid', color: '#2563EB' }; if (score >= 60) return { label: 'Solid', color: '#2563EB' };
@@ -66,7 +107,7 @@ export function getScoreBand(score = 0) {
return { label: 'No score yet', color: '#D1D5DB' }; return { label: 'No score yet', color: '#D1D5DB' };
} }
export function getFactors(profile = {}) { export function getFactors(profile: WorkerProfileRecord = {}): ReputationFactor[] {
return getReputation(profile) return getReputation(profile)
.filter((d) => !d.big) .filter((d) => !d.big)
.map((d) => ({ ...d, weight: FACTOR_WEIGHTS[d.key] || 0 })); .map((d) => ({ ...d, weight: FACTOR_WEIGHTS[d.key] || 0 }));

View File

@@ -2,8 +2,27 @@
// client endorsements. Used by both the talent card and the talent detail // client endorsements. Used by both the talent card and the talent detail
// modal so the two stay in sync. // modal so the two stay in sync.
/**
* `profile` is a WorkerProfile record, typed `any` deliberately.
*
* The shared types in `src/types/` describe the transport and the signed-in
* user; they do not describe entity records yet. That shape is derivable — the
* backend generates a column registry from `information_schema` — but it
* belongs with the phase that converts the transport layer. A partial interface
* written here would be a guess that every later reader treats as settled.
* `any` states what is actually known today and is one annotation to replace.
*/
type WorkerProfileRecord = any;
/** One derived endorsement row, as the talent card and detail modal render it. */
export interface ClientEndorsement {
company: string;
role: string;
stars: number;
}
// ESAT — Employee Skills Assessment Transcript; derived from AI interview score // ESAT — Employee Skills Assessment Transcript; derived from AI interview score
export function esatTag(profile) { export function esatTag(profile: WorkerProfileRecord) {
const s = Math.round(profile?.ai_interview_score || 0); const s = Math.round(profile?.ai_interview_score || 0);
if (s > 0) return { label: `${s}/100`, cls: 'bg-[#FFF7ED] text-[#B45309]' }; if (s > 0) return { label: `${s}/100`, cls: 'bg-[#FFF7ED] text-[#B45309]' };
return { label: 'Pending', cls: 'bg-[#F3F4F6] text-[#6B7280]' }; return { label: 'Pending', cls: 'bg-[#F3F4F6] text-[#6B7280]' };
@@ -11,13 +30,13 @@ export function esatTag(profile) {
// Recent job history (last 3 years). experience entries carry a duration // Recent job history (last 3 years). experience entries carry a duration
// (`years`) rather than dates, so we surface the most recent up to 3. // (`years`) rather than dates, so we surface the most recent up to 3.
export function recentJobs(profile) { export function recentJobs(profile: WorkerProfileRecord) {
return (profile?.experience || []).slice(0, 3).filter((e) => e && (e.role || e.company)); return (profile?.experience || []).slice(0, 3).filter((e) => e && (e.role || e.company));
} }
// Up to 3 client endorsements, deterministically derived from companies the // Up to 3 client endorsements, deterministically derived from companies the
// worker has been placed at, rated off their aggregate client rating. // worker has been placed at, rated off their aggregate client rating.
export function clientEndorsements(profile) { export function clientEndorsements(profile: WorkerProfileRecord): ClientEndorsement[] {
const companies = (profile?.experience || []) const companies = (profile?.experience || [])
.map((e) => e?.company) .map((e) => e?.company)
.filter(Boolean); .filter(Boolean);

View File

@@ -1,6 +1,10 @@
import { base44 } from '@/api/base44Client'; import { base44 } from '@/api/base44Client';
import type { User } from '@/types/user';
let _cachedUser = null; /* The signed-in user, or null when nobody is or the lookup failed. Fetched
once per page load: this is best-effort logging, and a tracking call must
never become a second round trip on a user action. */
let _cachedUser: User | null = null;
let _userFetched = false; let _userFetched = false;
async function getCurrentUser() { async function getCurrentUser() {
@@ -14,8 +18,30 @@ async function getCurrentUser() {
return _cachedUser; return _cachedUser;
} }
/**
* The optional context an event may carry.
*
* Every field is optional because every call site supplies a different subset —
* the shape is "whatever this particular event knows". The id fields are the
* records the event is about; they are written onto the record only when
* supplied, so an event with no application does not get an
* `application_id: null` that later reads as a missing link.
*/
export interface ActivityExtra {
email?: string;
name?: string;
account_type?: string;
details?: string;
position_id?: string;
application_id?: string;
candidate_id?: string;
interview_id?: string;
worker_email?: string;
metadata?: unknown;
}
/** Best-effort activity logging — never throws, never blocks the main flow. */ /** Best-effort activity logging — never throws, never blocks the main flow. */
export async function logActivity(eventType, extra = {}) { export async function logActivity(eventType: string, extra: ActivityExtra = {}) {
try { try {
const me = await getCurrentUser(); const me = await getCurrentUser();
const record = { const record = {

View File

@@ -1,4 +1,4 @@
import { clsx } from "clsx" import { clsx, type ClassValue } from "clsx"
import { extendTailwindMerge } from "tailwind-merge" import { extendTailwindMerge } from "tailwind-merge"
/** /**
@@ -27,7 +27,18 @@ const twMerge = extendTailwindMerge({
}, },
}) })
export function cn(...inputs) { /**
* `ClassValue` is clsx's own type, so this accepts exactly what clsx accepts
* and nothing has to be kept in step by hand: strings, numbers, `null`,
* `undefined`, `false`, arrays of any of those, and `{ 'class-name': boolean }`
* objects, nested to any depth. That breadth is deliberate — the conditional
* forms are most of why callers reach for `cn` at all.
*
* The body is untouched. `clsx(inputs)` is passed the rest array rather than
* spread into it, which is not the usual shape but is what this has always
* done, and clsx flattens an array argument to the same string either way.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }

129
src/types/api.ts Normal file
View File

@@ -0,0 +1,129 @@
/**
* The shapes the Krow API speaks in.
*
* Types only — this module emits nothing. Import it with `import type` so it is
* erased at build time and never becomes a runtime dependency of the transport.
*
* Every declaration here is transcribed from the API's own contract
* (`krow-backend/docs/api-contract.md`, §1, §4 and §5) and cross-read against
* what `src/api/httpClient.js` actually does with the response today. Where the
* two could differ, the running code wins, because the callers were written
* against it.
*
* Nothing in `src` imports this yet. It exists so that the transport layer has
* a vocabulary to be converted INTO, rather than one invented halfway through
* converting it.
*/
/**
* Collection metadata, from the `meta` half of a list response.
*
* Present on `list` and `filter` only; a single record carries no `meta`.
*
* Worth knowing before anyone reaches for it: `httpClient.request` returns
* `payload.data` and **drops `meta` on the floor**. That is deliberate — §4.2
* records that nothing reads it, and surfacing it would change what the six
* entity methods return, which the transport swap was not allowed to do. So
* this type describes a value that is real on the wire and currently
* unreachable from application code. Typing it costs nothing and means the
* shape is already written down on the day someone wants `total`.
*
* `truncated` is `true` when `total > offset + returned`, which is the silent
* data-hiding bug §12.2 describes. It is not a bug this type fixes.
*/
export interface ApiMeta {
total: number;
limit: number;
offset: number;
returned: number;
truncated: boolean;
}
/**
* What comes back on the wire, before `request` unwraps it.
*
* A successful response is always `{ data: … }`; a collection adds `meta`. The
* envelope exists so `meta` has somewhere to live (§1.1) and is unwrapped in
* exactly one place, so no hook, page or component has ever seen it.
*/
export interface ApiEnvelope<T> {
data: T;
meta?: ApiMeta;
}
/**
* The eight codes the API is allowed to return (§5).
*
* A closed union rather than `string`, because these are a fixed vocabulary the
* server owns and the client branches on — `isUnauthenticated` tests for
* `'unauthorized'` by name.
*
* `'unreachable'` is NOT in this union and is deliberately kept out: it is not
* an API code. `httpClient` synthesises it when `fetch` itself rejects and
* there is no response, no status and no envelope to read a code from. It
* belongs to `KrowApiError` below, where the distinction between "the API said
* no" and "there was no API" stays visible.
*/
export type ApiErrorCode =
| 'unauthorized'
| 'forbidden'
| 'rate_limited'
| 'not_found'
| 'validation_failed'
| 'invalid_query'
| 'conflict'
| 'internal';
/**
* The body of a non-2xx response.
*
* `details` maps field → reason on `validation_failed` and is `{}` otherwise.
*/
export interface ApiErrorBody {
code: ApiErrorCode;
message: string;
details: Record<string, unknown>;
}
/** A non-2xx response, whole. */
export interface ApiErrorResponse {
error: ApiErrorBody;
}
/**
* The error a failed request becomes on the client.
*
* An **interface, not a class**, and that is the load-bearing decision in this
* file. `httpClient` builds these by constructing a plain `Error` and assigning
* four properties onto it:
*
* const error = new Error(body?.message || `Request failed with status ${status}`);
* error.name = 'KrowApiError';
* error.status = status;
* error.code = body?.code || 'internal';
* error.details = body?.details || {};
*
* Declaring a `class KrowApiError extends Error` would be the tidier-looking
* conversion and would change behaviour: it alters the prototype chain, so
* `instanceof` starts answering differently, and anything comparing
* `error.name === 'KrowApiError'` is relying on a string that a class would set
* by a different route. The migration's whole claim is that it changes nothing,
* so the shape is described rather than rebuilt — Phase 4 casts the constructed
* `Error` to this interface and the emitted JavaScript stays identical.
*
* `code` widens to include `'unreachable'` because `fetch` rejecting is not an
* API answer: that path sets `status: 0`, `details: {}` and a `cause`, and its
* message names the URL that did not respond rather than anything the server
* said.
*
* `status` is `0` for that transport failure and the HTTP status otherwise.
* Both `isUnauthenticated` and the 429 branch in `pages/admin/Login.jsx` read
* it, which is why it is required rather than optional.
*/
export interface KrowApiError extends Error {
name: 'KrowApiError';
status: number;
code: ApiErrorCode | 'unreachable';
details: Record<string, unknown>;
cause?: unknown;
}

135
src/types/entities.ts Normal file
View File

@@ -0,0 +1,135 @@
/**
* Which entities exist, and the surface each one presents.
*
* Types only — nothing here emits. Import with `import type`.
*
* This file exists because the list of entities is currently written down
* **twice**: as the keys of `RESOURCE_PATHS` in `src/api/httpClient.js`, and as
* the `ENTITY_NAMES` array in `src/api/base44Client.js`. They hold the same
* eighteen names in a different order, and nothing checks that they agree. They
* agree today; they are one careless edit from not doing so, and the symptom
* would be `base44.entities.Whatever` being `undefined` at runtime with no
* warning from anywhere.
*
* Note what this file does NOT do: it does not replace either list. Both stay
* exactly where they are, because collapsing them into one is a runtime
* refactor and this phase changes no runtime behaviour. What it adds is a
* single type the two can be checked against once they are TypeScript, which
* turns a silent divergence into a compile error.
*
* Verified against the backend on 2026-09-11: fifteen of these eighteen appear
* in `krow-backend/go-api/internal/domain/resources_gen.go` — a registry
* generated from `information_schema`, so it cannot drift from the migrations —
* and every one of those fifteen paths matches character for character. The
* three that are absent from it are served by dedicated handlers rather than
* the generic entity machinery: `AgentDefinition` and `SkillDefinition` by
* `httpserver/definitions.go`, and the user by `httpserver/me.go`.
*/
/**
* Every entity name the client knows, spelled as the frontend spells it.
*
* PascalCase and used verbatim in error messages — §5.1 of the API contract
* makes the entity name load-bearing in a `not_found` message, which reads
* `"<EntityName> <id> not found"` using this spelling rather than the table
* name.
*
* `'User'` is in this union because it is in both runtime lists, not because it
* works. There is no `/api/v1/users` route on the backend, and nothing in `src`
* calls `base44.entities.User` — a call would 404. It is recorded here as it
* is, dead entry and all, because removing it from the union while both runtime
* lists still carry it would make the type a description of something other
* than this codebase. Whether the entry should exist at all is a product
* question, not a migration one.
*/
export type EntityName =
| 'JobPosting'
| 'JobApplication'
| 'AIInterview'
| 'Staff'
| 'WorkerProfile'
| 'EmployeeRole'
| 'Course'
| 'Badge'
| 'LearningPath'
| 'Certification'
| 'RoleCategory'
| 'UserActivity'
| 'Evidence'
| 'User'
| 'Assignment'
| 'ShiftRecord'
| 'AgentDefinition'
| 'SkillDefinition';
/**
* The URL segment each entity lives under.
*
* Kebab-case plural, with mass nouns left singular — `staff`, `evidence`,
* `user-activity` (§1). Declared rather than derived, for the reason
* `httpClient` gives at the site of the real map: a rule that turns
* `AIInterview` into `ai-interviews` and `Staff` into `staff` and `UserActivity`
* into `user-activity` is three special cases wearing a trench coat, and a
* wrong guess is a 404 at run time instead of a mistake anyone can see.
*/
export type EntityResourcePath =
| 'job-postings'
| 'job-applications'
| 'ai-interviews'
| 'staff'
| 'worker-profiles'
| 'employee-roles'
| 'courses'
| 'badges'
| 'learning-paths'
| 'certifications'
| 'role-categories'
| 'user-activity'
| 'evidence'
| 'users'
| 'assignments'
| 'shift-records'
| 'agent-definitions'
| 'skill-definitions';
/**
* What `createEntity(name)` hands back: one entity's client surface.
*
* Eight members, transcribed from `src/api/httpClient.js` — the six methods
* `store.js` declared, plus `bulkCreate` and the `entityName` the object
* carries around.
*
* The defaults are part of the signature rather than incidental. `store.js`
* declared `list(sort = '-created_date', limit = 100)` and several call sites
* rely on them instead of passing their own, so a conversion that dropped them
* would change what those callers fetch.
*
* `T` is the record type, left open here. Phase 2 deliberately does not define
* the eighteen record shapes: they are derivable — `resources_gen.go` carries
* every column with its type, nullability and enum values — but nothing
* consumes them until the transport layer is converted, and transcribing some
* five hundred field declarations by hand ahead of a consumer is how a type
* becomes confidently wrong. They belong with Phase 4, generated from that
* registry rather than retyped from it.
*
* Until then `EntityClient<T>` describes the shape of the surface without
* asserting anything about the records that travel through it, which is exactly
* as much as is known today.
*/
export interface EntityClient<T = unknown> {
entityName: EntityName;
list(sort?: string, limit?: number): Promise<T[]>;
filter(query?: Record<string, unknown>, sort?: string, limit?: number): Promise<T[]>;
get(id: string): Promise<T>;
create(data: Partial<T>): Promise<T>;
update(id: string, data: Partial<T>): Promise<T>;
/** Resolves to `{ id }`, matching what `store.js` returned (§4.3). */
delete(id: string): Promise<{ id: string }>;
/**
* Sequential creates, not a batch endpoint and not `Promise.all`: there is no
* bulk write in the contract (§12.1), and doing them one at a time keeps the
* failure behaviour identical — the first rejection stops the run and the
* records written before it stay written.
*/
bulkCreate(records?: Partial<T>[]): Promise<T[]>;
}

26
src/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,26 @@
export {};
/**
* Globals this app adds to the browser's own.
*
* There is exactly one, and it is a custom event rather than anything hung off
* `window` — nothing in `src` ever assigns a property to `window`, and this
* should stay true.
*
* `krow:open-owliver` is how a component that is nowhere near the assistant
* asks for the assistant to open. The talent shell listens for it
* (`src/layouts/Layout.jsx`) and two buttons plus the employee dashboard
* dispatch it. Declaring it in `WindowEventMap` is what lets
* `addEventListener` and `dispatchEvent` agree on the name: without it both
* sides fall back to the untyped string overload, and a misspelling on either
* side is a listener that never fires and nothing that says why.
*
* The payload is an unparameterised `CustomEvent` because the event carries no
* detail — it is a signal, not a message. If it ever gains one, give it
* `CustomEvent<{ … }>` here and both ends are checked against it at once.
*/
declare global {
interface WindowEventMap {
'krow:open-owliver': CustomEvent;
}
}

132
src/types/user.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* The signed-in user, and the preferences that travel with the account.
*
* Types only — nothing here emits. Import with `import type`.
*
* Transcribed from the projection the server actually selects, not from the
* documentation of it. `krow-backend/go-api/internal/httpserver/me.go` builds
* every user record from one constant:
*
* const userColumns = `id::text AS id, legacy_id, full_name, email::text AS email,
* role, account_type, status,
* to_char(created_date …) AS created_date,
* to_char(updated_date …) AS updated_date`
*
* and then attaches `preferences`. That is ten fields. The contract document's
* worked example in §9 shows seven — it omits `status` and `updated_date`, and
* it is the older of the two. The code is what answers the request, so the code
* is what is written down here.
*/
/**
* What the account is allowed to do.
*
* A closed union because the database closes it: `users_role_check` is
* `CHECK (role IN ('admin', 'employer', 'talent'))`, so no other value can be
* stored and none can be returned.
*
* This is the authorization field. It is server-owned — `PATCH /me` ignores it,
* deliberately, because when it was writable any signed-in user could promote
* themselves to admin with a one-line request.
*/
export type UserRole = 'admin' | 'employer' | 'talent';
/**
* Whether the account may sign in.
*
* Closed for the same reason: `users_status_check` is
* `CHECK (status IN ('active', 'suspended'))`. Also server-owned.
*/
export type UserStatus = 'active' | 'suspended';
/**
* Product preferences, stored on the account.
*
* Three named keys and an open tail, and the open tail is not a hedge — it is
* the shape. The `user_preferences` table has exactly three boolean columns
* plus an `extra jsonb` blob, and the server merges that blob into the same
* flat object it returns. Everything account-authored lives in there:
* `customSkills` and `customAgents`, written by `WorkspaceSkills.jsx`,
* `SkillEditor.jsx`, `OwliverSkillEditor.jsx` and `useAgents.js`.
*
* The index signature is therefore a true statement about the runtime value,
* not a shortcut. Naming `customSkills` and `customAgents` explicitly was
* tempting and rejected: their contents are the authored-definition shapes,
* which belong to the agent layer, and this phase does not describe the agent
* layer.
*
* The three named keys are the one place field naming diverges from the
* database — camelCase here, snake_case in the columns. The server does that
* translation; nothing on this side should.
*/
export interface UserPreferences {
owliverDefault: boolean;
compactDensity: boolean;
emailDigest: boolean;
[key: string]: unknown;
}
/**
* The user as `GET /me` returns it.
*
* Four fields are optional, and the reason is worth stating because it is not
* laziness. `base44.auth.me()` does not always resolve to a server response: on
* the path through `readCachedUser` it returns
* `{ ...DEMO_USER, ...JSON.parse(raw) }`, and `DEMO_USER` — the default shape
* in `src/api/demoUser.js` that has to resolve during the first render, before
* the server has answered — carries only `id`, `full_name`, `email`, `role`,
* `account_type`, `created_date` and `preferences`.
*
* So a value of this type may genuinely lack `legacy_id`, `status` and
* `updated_date`. Marking them required would describe the server's answer
* accurately and the runtime value inaccurately, and the second is the one that
* reaches components.
*
* `legacy_id` is additionally nullable at the source — `legacy_id text UNIQUE`,
* no NOT NULL — so it is `string | null` when present. It holds the original
* seeded identifier (`user_demo`); `id` is the uuid and is the only addressable
* one.
*/
export interface User {
id: string;
full_name: string;
email: string;
role: UserRole;
/**
* Which product surface the person is looking at, Employer or Talent.
*
* Plain `string`, not a union, and that is checked rather than assumed: the
* column is `account_type text NOT NULL DEFAULT 'employer'` with **no CHECK
* constraint**, and the frontend reads it as open text — `|| 'applicant'`,
* `|| 'unknown'`, `|| '—'` all appear at call sites. There is no closed set
* to write down, so none is invented.
*
* Explicitly not an authorization field. `role` authorizes; this is display.
*/
account_type: string;
created_date: string;
preferences: UserPreferences;
legacy_id?: string | null;
status?: UserStatus;
updated_date?: string;
}
/**
* What `auth.updatePreferences()` resolves to.
*
* Not a bare user, and the reason is a bug rather than a preference: a
* swallowed `QuotaExceededError` once silently lost account-authored skills, so
* a caller storing something it needs back after a reload has to be able to
* tell a write that landed from one the browser refused. The cache is updated
* either way — the change is real for the session even when it could not be
* stored.
*
* Over HTTP a failed write is already a non-2xx, so the client synthesises
* `{ user, persisted: true, error: null }` on success and lets the throw path
* handle failure. `src/lib/skills/saveFeedback.js` is the only consumer.
*/
export interface PreferencesUpdateResult {
user: User;
persisted: boolean;
error: Error | null;
}

43
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,43 @@
/// <reference types="vite/client" />
/**
* Vite's build-time additions to `import.meta`, and the environment this app
* actually reads from it.
*
* The reference above is what supplies `import.meta.env`, `import.meta.glob`
* and the `?raw` module shape. Without it the two registries that discover
* agents and skills from the filesystem do not typecheck at all, and one of
* them carries a hand-written `@ts-ignore` saying so.
*
* The interface below names the two variables this codebase reads. It does not
* restrict `import.meta.env` to them — this declaration MERGES with Vite's,
* which carries an open `[key: string]: any`, so `import.meta.env.VITE_ANYTHING`
* still compiles and still comes back as `any`. Typing cannot close that hole;
* only reading this list can.
*
* Declaring them is therefore documentation as much as typing: these are
* inlined at build time, so a variable missing when the bundle is built is
* missing for the life of that bundle, and a typo is not a runtime error
* anywhere — it is a silent `undefined` and a fallback taken forever.
*
* VITE_API_BASE_URL src/api/httpClient.js — where the entity API lives.
* Defaults to the same-origin '/api/v1', which is the
* supported arrangement: the session cookie is HttpOnly
* and SameSite=Lax, so a cross-origin value breaks auth.
* VITE_AGENT_API src/components/ai-assistant/provider.js — the agent
* endpoint. Unset means the local engine answers.
*
* VITE_API_PROXY_TARGET is deliberately absent: it is read by `vite.config.js`
* through `loadEnv` at dev-server startup and never reaches `import.meta.env`.
*
* Both are optional because both have fallbacks, and typing them as required
* would claim a guarantee the build does not make.
*/
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_AGENT_API?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

84
tsconfig.json Normal file
View File

@@ -0,0 +1,84 @@
{
/* The TypeScript configuration, succeeding `jsconfig.json`.
Deliberately a translation of what was already there rather than a fresh
start: every option below that existed in jsconfig carries its old value,
so the first day of the migration checks exactly what the last day before
it checked. The four additions are marked.
`jsconfig.json` is left in place for now and is inert — TypeScript ignores
it wherever a `tsconfig.json` sits beside it. It is removed in the final
phase, once nothing is asking for it. */
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"jsx": "react-jsx",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["esnext", "dom", "dom.iterable"],
"target": "esnext",
/* Vite builds; this config only ever checks. Stating it means no stray
output can appear next to a source file if anyone runs `tsc` directly. */
"noEmit": true,
/* The two that make an incremental migration possible. `allowJs` lets a
`.ts` file and a `.js` file import each other with no ceremony, so files
can be converted one at a time instead of in one unreviewable commit;
`checkJs` keeps the JSDoc-typed JavaScript under the same scrutiny it
has had all along, so nothing is lost in the meantime. Both go away at
the end of the migration, not before. */
"allowJs": true,
"checkJs": true,
/* NEW. esbuild compiles each file on its own and cannot see across them, so
a construct that needs whole-program knowledge — re-exporting a type as
if it were a value, most often — works under `tsc` and breaks in the
bundle. This makes tsc refuse what esbuild could not have done anyway. */
"isolatedModules": true,
/* NEW. macOS does not distinguish `Button` from `button`; CI does. Without
this, a mis-cased import is a green local build and a red pipeline. */
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
/* CHANGED, from `[]`. The empty list suppressed every ambient type package,
which is why `import.meta.env` and `import.meta.glob` — Vite build-time
APIs, absent from the standard `ImportMeta` — were reported as errors in
eight places across the app.
Named explicitly rather than left to default, because the default pulls
in EVERYTHING under node_modules/@types, and `@types/node` is installed.
In browser code that would make `setTimeout` return a `NodeJS.Timeout`
instead of a number, which is wrong and quietly infectious: three
components store a timer handle in a `useRef` and would be typed against
a runtime they do not run on. Node's types belong to `vite.config.js` and
`scripts/`, neither of which this project includes. */
"types": ["vite/client"],
/* Off for now, and turned on a flag at a time later in the migration —
`noImplicitAny`, then `strictNullChecks`, then the rest. Enabling it here
would bury the ~70 real errors this project already has under several
hundred more, and the point of going file by file is that each step is
small enough to read. */
"strict": false
},
"include": ["src/**/*"],
/* Unchanged from jsconfig, and temporary. These three trees hold the densest
logic in the app — the transport layer, every hook, and the vendored UI
primitives — and none of it has ever been checked. Each exclusion is
removed by the phase that converts the tree behind it, so the error count
rises deliberately and in one identifiable place at a time.
Note that excluding a file only keeps it out of the root set: one that an
included file imports is still checked, which is why errors from `src/api`
and `src/lib` already show up today. */
"exclude": ["node_modules", "dist", "src/components/ui", "src/api", "src/lib"]
}