Compare commits
36 Commits
f522b6508e
...
e676d259b2
| Author | SHA1 | Date | |
|---|---|---|---|
| e676d259b2 | |||
| 34505d7cf6 | |||
| 878a23532a | |||
| 3ddacf269a | |||
| 2f9b3baa56 | |||
| e8207038dd | |||
| 21133a6064 | |||
| 78f1b44c2e | |||
| 34acd63a80 | |||
| 284a7e7671 | |||
| a0f3f77900 | |||
| b99dc7c576 | |||
| 7a95d95151 | |||
| 3f835eee93 | |||
| dde4ba62c6 | |||
| 446df7b37b | |||
| 55ddaa134a | |||
| fc8d7eec52 | |||
| e73929f47e | |||
| eaa677f815 | |||
| ecf5e75d76 | |||
| fcdaa32f4d | |||
| be49184006 | |||
| 3d5f54bd5b | |||
| e7e1e9873f | |||
| 543da9d4e7 | |||
| d440036211 | |||
| 2b8f5746bd | |||
| 3e654c2bf7 | |||
| 1133eca16a | |||
| 02a2ab05ef | |||
| dca184289e | |||
| 64140c7add | |||
| f96f128839 | |||
| d1425f974c | |||
| 1775395256 |
209
MIGRATION_BASELINE.md
Normal file
209
MIGRATION_BASELINE.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-20 — closing addendum: the migration is complete
|
||||
|
||||
Everything above this line is the record as measured on 2026-09-11 and is left
|
||||
exactly as it was written. The numbers in it describe `main` before the first
|
||||
rename; they are the thing the migration was checked against, so correcting them
|
||||
to today's figures would destroy the comparison rather than update it. What
|
||||
follows is the other end of that comparison.
|
||||
|
||||
### Final state, measured on the `ts-migration` branch
|
||||
|
||||
| Check | Command | 2026-09-11 baseline | Now |
|
||||
|---|---|---|---|
|
||||
| Types | `npm run typecheck` | FAILS — 71 errors / 27 files | **0 errors**, 321 files in the program |
|
||||
| Lint | `npm run lint` | PASSES, exit 0 | PASSES, exit 0 |
|
||||
| Lint (with warnings) | `npx eslint .` | 0 errors, 289 warnings | 0 errors, 289 warnings |
|
||||
| Behaviour | `npm test` | 1641 / 1642 | **1693 / 1693** |
|
||||
| Build | `npm run build` | succeeds | succeeds |
|
||||
| Bundle | `dist/assets/index-*.js` | — | `74d17e2d5cafdd6f88eaf6d89ffdab11` |
|
||||
| Owliver, standalone | `node scripts/owliver-baseline.mjs` | FAILS — pre-existing | **matches the baseline** |
|
||||
| Backend fixture | `npm run seed:check` | FAILS — stale | **in step** |
|
||||
|
||||
No `.js` or `.jsx` remains under `src/`: 103 `.ts`, 216 `.tsx` and 2 `.d.ts`.
|
||||
`src/agents/**/*.md` and `src/skills/**/*.md` are untouched, as promised.
|
||||
|
||||
### The three items this document left open, and where they were closed
|
||||
|
||||
**The standalone Owliver script.** Recorded above as failing before any of this
|
||||
began, verified by running it on a clean `main`. The byte-exact comparison was
|
||||
reporting one real difference: the baseline held 23 skills and the runtime
|
||||
resolved 24, because `create-employee-role` was added after the baseline was
|
||||
last written and nobody recaptured it. `npm test` tolerated it — its check
|
||||
asserts only that nothing was *removed*, and read "23 kept, 1 added since" — so
|
||||
the drift was visible only to the stricter script. Refreshed in `34505d7`:
|
||||
three inserted lines, no deletions, routes byte-identical at 18 and all eleven
|
||||
contexts unchanged. The check now reads "24 kept, 0 added since".
|
||||
|
||||
**The backend seed fixture.** Recorded above as predating the migration and
|
||||
deliberately left alone. It stayed that way for good reason: the generator was a
|
||||
version behind the seeder and emitted no `users` key, so regenerating would have
|
||||
dropped `employer@krow.app` from every fresh seed and left nobody to sign in as
|
||||
to reach the employer console. Fixed in `3ddacf2` by teaching the generator to
|
||||
emit the key, not by overwriting the fixture — with that change the generator
|
||||
reproduces the committed fixture byte for byte, so `seed.json` in the sibling
|
||||
repository was never written to at all.
|
||||
|
||||
**The HTML render baselines.** Recorded above as read-only for the duration of
|
||||
the migration, and that held: they were untouched through every phase. Three of
|
||||
them — Hired History, Analytics and Candidates — were recaptured in `878a235`,
|
||||
after the migration, because a parallel feature removed demo data those files had
|
||||
captured while it was still in effect. They are renamed `*.render.html`, since a
|
||||
file called `pre-migration` holding post-feature markup is a lie in the filename.
|
||||
The other five still hold genuine pre-migration markup, still pass, and keep the
|
||||
name. One check that could no longer be satisfied was replaced by three that read
|
||||
the render directly; `scripts/__baseline__/README.md` has the detail.
|
||||
|
||||
### Commits
|
||||
|
||||
3ddacf2 fix(seed): emit the users array the backend seeder reads
|
||||
878a235 test(baseline): recapture the three HTML baselines that held invented people
|
||||
34505d7 chore: refresh owliver baseline
|
||||
|
||||
Strictness remains as debt rather than a blocker: `strict: true` reports 4438
|
||||
errors, `noImplicitAny` 3317 and `strictNullChecks` 1794, while the committed
|
||||
configuration reports none. None of it affects emitted output — the production
|
||||
bundle hash is unchanged — so it is hardening work, not migration work.
|
||||
90
README.md
90
README.md
@@ -19,22 +19,22 @@ Recharts + MUI X Charts · react-hot-toast.
|
||||
|
||||
```
|
||||
src/
|
||||
main.jsx entry — mounts <App/>, imports index.css
|
||||
App.jsx providers + the entire route table
|
||||
main.tsx entry — mounts <App/>, imports index.css
|
||||
App.tsx providers + the entire route table
|
||||
index.css design tokens (HSL CSS variables) + utility layers
|
||||
|
||||
api/ the backend seam — see below
|
||||
base44Client.js the contract the production app talks to
|
||||
base44Client.ts the contract the production app talks to
|
||||
store.js in-memory entity store, mirrored to localStorage
|
||||
aiEngine.js local, deterministic InvokeLLM
|
||||
seed.js demo dataset
|
||||
attendanceSeed.js shift/attendance dataset
|
||||
aiEngine.ts local, deterministic InvokeLLM
|
||||
seed.ts demo dataset
|
||||
attendanceSeed.ts shift/attendance dataset
|
||||
|
||||
lib/ domain logic and data hooks
|
||||
krowHooks.js React Query hooks over the entity store
|
||||
krowAi.js AI workflows
|
||||
krowScore.js score engine
|
||||
AuthContext.jsx auth provider
|
||||
krowHooks.ts React Query hooks over the entity store
|
||||
krowAi.ts AI workflows
|
||||
krowScore.ts score engine
|
||||
AuthContext.tsx auth provider
|
||||
admin/ admin session, permissions, position insights
|
||||
skills/ the Owliver skill system (registry, resolver, flows)
|
||||
agents/ the agent system (registry, runtime, lifecycle)
|
||||
@@ -79,37 +79,37 @@ The app opens on **`/admin/login`**. Everything else lives under `/admin/*` behi
|
||||
| `/admin/workspace/skills`, `skills/new`, `skills/:id` | UI skill list and editor |
|
||||
| `/admin/workspace/skills/owliver/new`, `owliver/:id` | Owliver skill editor |
|
||||
| `/admin/workspace/skill-development` | Skill Development |
|
||||
| `*` | `lib/PageNotFound.jsx` |
|
||||
| `*` | `lib/PageNotFound.tsx` |
|
||||
|
||||
Legacy top-level paths (`/overview`, `/positions`, `/candidates`, `/hired`, `/talent-pool`,
|
||||
`/university`, `/analytics`, `/activity`, `/tracking`) all `Navigate` into their `/admin/*`
|
||||
equivalent, id preserved. Static route segments are declared before dynamic ones so
|
||||
`workspace/agents/new` cannot be read as an agent whose id is `"new"`.
|
||||
|
||||
> **Note.** `App.jsx` still imports a set of pre-redesign Employer/Talent pages
|
||||
> **Note.** `App.tsx` still imports a set of pre-redesign Employer/Talent pages
|
||||
> (`Overview`, `Apply`, `WorkerProfile`, `KrowIdentity`, `EmployeeDashboard`, `DesignSystem`, …)
|
||||
> and `layouts/Layout.jsx`. None of them are mounted on a route — they are unreachable at runtime
|
||||
> and `layouts/Layout.tsx`. None of them are mounted on a route — they are unreachable at runtime
|
||||
> and retained pending a decision on whether to re-route or remove them.
|
||||
|
||||
## The backend seam
|
||||
|
||||
The production app talks to a Base44 backend through `src/api/base44Client.js`. This demo keeps that
|
||||
The production app talks to a Base44 backend through `src/api/base44Client.ts`. This demo keeps that
|
||||
module path, export name and full method contract, and swaps only the transport:
|
||||
|
||||
| Contract | Demo implementation |
|
||||
| --- | --- |
|
||||
| `base44.entities.<Name>.list/filter/get/create/update/delete` | `api/store.js` — in-memory records, mirrored to `localStorage` |
|
||||
| `base44.integrations.Core.InvokeLLM` | `api/aiEngine.js` — local, deterministic |
|
||||
| `base44.integrations.Core.InvokeLLM` | `api/aiEngine.ts` — local, deterministic |
|
||||
| `base44.integrations.Core.UploadFile` | blob URLs, session-scoped |
|
||||
| `base44.auth.*` | seeded employer/admin user, always signed in |
|
||||
|
||||
Because the seam did not move, everything above it — `lib/krowHooks.js`, `lib/krowAi.js`,
|
||||
`lib/krowScore.js`, every page and component — is unchanged from the reference implementation.
|
||||
Because the seam did not move, everything above it — `lib/krowHooks.ts`, `lib/krowAi.ts`,
|
||||
`lib/krowScore.ts`, every page and component — is unchanged from the reference implementation.
|
||||
|
||||
## The AI engine
|
||||
|
||||
Every AI workflow funnels through one call, `InvokeLLM({ prompt, response_json_schema })`.
|
||||
`api/aiEngine.js` reimplements it locally: it recognises each workflow by the phrase its prompt opens
|
||||
`api/aiEngine.ts` reimplements it locally: it recognises each workflow by the phrase its prompt opens
|
||||
with, reads the structured fields the prompt already carries (`Years Experience: 6`,
|
||||
`Required Certifications: …`, the talent-pool JSON block) and scores them deterministically.
|
||||
|
||||
@@ -124,8 +124,8 @@ Two registries discover their definitions from markdown at build time:
|
||||
|
||||
| Registry | Glob | Files |
|
||||
| --- | --- | --- |
|
||||
| `lib/skills/registry.js` | `import.meta.glob('/src/skills/**/*.md')` | 18 Owliver skills, 5 workforce training skills |
|
||||
| `lib/agents/registry.js` | `import.meta.glob('/src/agents/**/*.md')` | 9 agent definitions |
|
||||
| `lib/skills/registry.ts` | `import.meta.glob('/src/skills/**/*.md')` | 19 Owliver skills, 5 workforce training skills |
|
||||
| `lib/agents/registry.ts` | `import.meta.glob('/src/agents/**/*.md')` | 9 agent definitions |
|
||||
|
||||
**These globs are absolute paths.** Moving or renaming `src/skills/` or `src/agents/` makes the
|
||||
registry silently return nothing — no build error, no import failure, just an empty registry. Add
|
||||
@@ -133,9 +133,9 @@ definitions by dropping a new `.md` file into the right folder; nothing else nee
|
||||
|
||||
## Demo data
|
||||
|
||||
Seeded in `src/api/seed.js` — positions, applicants, AI-scored candidates, interviews, hires,
|
||||
Seeded in `src/api/seed.ts` — positions, applicants, AI-scored candidates, interviews, hires,
|
||||
talent-pool profiles, Proving Ground courses and an activity log; shift data in
|
||||
`src/api/attendanceSeed.js`.
|
||||
`src/api/attendanceSeed.ts`.
|
||||
|
||||
Edits persist to `localStorage`. To restore the shipped dataset:
|
||||
|
||||
@@ -163,12 +163,12 @@ Tokens live in `src/index.css` as HSL CSS variables consumed by `tailwind.config
|
||||
| Body | Inter |
|
||||
|
||||
`src/components/ds/` is the vocabulary the app actually writes in — `Surface`, `KpiCard`,
|
||||
`DataTable`, `PageHeader`, `Modal`, `toast` and the rest, exported through `ds/index.js`.
|
||||
`DataTable`, `PageHeader`, `Modal`, `toast` and the rest, exported through `ds/index.ts`.
|
||||
`src/components/ui/` holds the shadcn primitives those are built on. Surfaces use `glass` and
|
||||
`glass-card`; the page background is the `gradient-mesh` utility.
|
||||
|
||||
Toasts go through `ds/toast` (a wrapper over `react-hot-toast`), rendered by `<HotToaster>` in
|
||||
`App.jsx`.
|
||||
`App.tsx`.
|
||||
|
||||
## Scripts
|
||||
|
||||
@@ -179,8 +179,8 @@ Toasts go through `ds/toast` (a wrapper over `react-hot-toast`), rendered by `<H
|
||||
| `npm run preview` | Serve the build |
|
||||
| `npm run lint` | ESLint — currently clean |
|
||||
| `npm run lint:fix` | ESLint with `--fix` |
|
||||
| `npm test` | `scripts/skill-check.mjs` — 835 assertions over the skill and agent systems |
|
||||
| `npm run typecheck` | `tsc -p ./jsconfig.json` with `checkJs` |
|
||||
| `npm test` | `scripts/skill-check.mjs` — 1693 assertions over the skill and agent systems |
|
||||
| `npm run typecheck` | `tsc -p ./tsconfig.json` — 0 errors across 321 TypeScript files |
|
||||
|
||||
### The test harness
|
||||
|
||||
@@ -191,17 +191,41 @@ mocks. It also diffs Owliver's behaviour across eleven page contexts against a c
|
||||
`node scripts/owliver-baseline.mjs --write`, never to turn a red check green.
|
||||
|
||||
Because it loads modules by absolute path, the harness pins the locations of `src/api/`, `src/lib/`,
|
||||
`src/lib/skills/`, `src/lib/agents/`, `src/components/ai-assistant/`, `src/App.jsx`,
|
||||
`src/layouts/AdminLayout.jsx` and a handful of agent files. Move any of them and update
|
||||
`src/lib/skills/`, `src/lib/agents/`, `src/components/ai-assistant/`, `src/App.tsx`,
|
||||
`src/layouts/AdminLayout.tsx` and a handful of agent files. Move any of them and update
|
||||
`scripts/owliver-capture.mjs` and `scripts/skill-check.mjs` in the same change.
|
||||
|
||||
### Known-failing checks
|
||||
### Check status
|
||||
|
||||
Two checks fail on a clean checkout and are tracked as separate work, not regressions:
|
||||
Everything above passes on a clean checkout: `npm test` is 1693/1693 and
|
||||
`npm run typecheck` reports no errors.
|
||||
|
||||
- `npm test` — 834/835 pass; `the seeded overtime climb is found` fails.
|
||||
- `npm run typecheck` — 59 errors, all pre-existing JSDoc/inference gaps in `lib/krowAi.js`,
|
||||
`lib/skills/*`, `lib/positionModel.js` and a few components.
|
||||
This section used to list two known failures — 834/835 on the suite, and 59 type
|
||||
errors across `lib/krowAi`, `lib/skills/*` and `lib/positionModel`. Both are
|
||||
gone, and they were unrelated to each other.
|
||||
|
||||
The suite failure was `the seeded overtime climb is found`. It asserted against
|
||||
the live calendar: the shifts are generated by counting back from the day the
|
||||
suite runs, so the oldest week in the window thinned as the week wore on and
|
||||
inflated the baseline every later week was compared against. The climb was
|
||||
reported Sunday through Thursday and vanished on Friday and Saturday. That was a
|
||||
product defect rather than a flaky assertion, and it was fixed in
|
||||
`src/lib/attendance.ts` (commit `88c412f`) by dropping a leading week rostered
|
||||
well below the usual — only from the front, so a genuine collapse in the middle
|
||||
is still a finding. `the seeded overtime climb is detectable on every day of the
|
||||
week` re-runs the detector against 28 consecutive anchors and is what keeps it
|
||||
fixed.
|
||||
|
||||
The type errors were resolved by the TypeScript migration.
|
||||
|
||||
A third check, `the backend fixture is in step with this seed`, failed for its
|
||||
own reasons and is recorded here because it is easily confused with the above:
|
||||
`seed.json` in the sibling backend had drifted from this seed module. Fixed in
|
||||
commit `3ddacf2` by teaching the generator to emit the `users` array the Go
|
||||
seeder reads, rather than by overwriting the fixture. All three pass.
|
||||
|
||||
The heading is kept rather than deleted so that the absence of failures is
|
||||
stated rather than merely implied.
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
|
||||
136
eslint.config.js
136
eslint.config.js
@@ -3,16 +3,68 @@ import pluginJs from "@eslint/js";
|
||||
import pluginReact from "eslint-plugin-react";
|
||||
import pluginReactHooks from "eslint-plugin-react-hooks";
|
||||
import pluginUnusedImports from "eslint-plugin-unused-imports";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
/**
|
||||
* The rule set, written once and applied to JavaScript and TypeScript alike.
|
||||
*
|
||||
* Shared rather than duplicated because the two blocks below differ in exactly
|
||||
* one thing — which parser reads the file — and a rule that applied to `.jsx`
|
||||
* but not to its `.tsx` successor would make the TypeScript migration look like
|
||||
* it was tidying the code up. It is not; it renames files and adds types.
|
||||
*/
|
||||
const rules = {
|
||||
"no-unused-vars": "off",
|
||||
"react/jsx-uses-vars": "error",
|
||||
"react/jsx-uses-react": "error",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
vars: "all",
|
||||
varsIgnorePattern: "^_",
|
||||
args: "after-used",
|
||||
argsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"react/prop-types": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/no-unknown-property": [
|
||||
"error",
|
||||
{ ignore: ["cmdk-input-wrapper", "toast-close"] },
|
||||
],
|
||||
"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: [
|
||||
"src/components/**/*.{js,mjs,cjs,jsx}",
|
||||
"src/pages/**/*.{js,mjs,cjs,jsx}",
|
||||
"src/layouts/**/*.{js,mjs,cjs,jsx}",
|
||||
"src/hooks/**/*.{js,mjs,cjs,jsx}",
|
||||
],
|
||||
ignores: ["src/lib/**/*", "src/components/ui/**/*"],
|
||||
files: filesWith("js,mjs,cjs,jsx"),
|
||||
ignores,
|
||||
...pluginJs.configs.recommended,
|
||||
...pluginReact.configs.flat.recommended,
|
||||
languageOptions: {
|
||||
@@ -20,42 +72,50 @@ export default [
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "module",
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
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 },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
react: pluginReact,
|
||||
"react-hooks": pluginReactHooks,
|
||||
"unused-imports": pluginUnusedImports,
|
||||
},
|
||||
settings,
|
||||
plugins: { ...plugins, "@typescript-eslint": tseslint.plugin },
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"react/jsx-uses-vars": "error",
|
||||
"react/jsx-uses-react": "error",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
vars: "all",
|
||||
varsIgnorePattern: "^_",
|
||||
args: "after-used",
|
||||
argsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"react/prop-types": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/no-unknown-property": [
|
||||
"error",
|
||||
{ ignore: ["cmdk-input-wrapper", "toast-close"] },
|
||||
],
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
...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",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"jsx": "react-jsx",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["esnext", "dom"],
|
||||
"target": "esnext",
|
||||
"checkJs": true,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/components/ui", "src/api", "src/lib"]
|
||||
}
|
||||
333
package-lock.json
generated
333
package-lock.json
generated
@@ -57,6 +57,7 @@
|
||||
"puppeteer-core": "^23.11.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
},
|
||||
@@ -3378,6 +3379,301 @@
|
||||
"@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": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
|
||||
@@ -9650,6 +9946,19 @@
|
||||
"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": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
@@ -9774,6 +10083,30 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"test": "node scripts/skill-check.mjs",
|
||||
"seed:fixture": "node scripts/seed-fixture.mjs --write",
|
||||
"seed:check": "node scripts/seed-fixture.mjs",
|
||||
"typecheck": "tsc -p ./jsconfig.json",
|
||||
"typecheck": "tsc -p ./tsconfig.json",
|
||||
"preview": "vite preview",
|
||||
"test:browser": "node scripts/browser-check.mjs"
|
||||
},
|
||||
@@ -65,6 +65,7 @@
|
||||
"puppeteer-core": "^23.11.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,79 @@ agent layer existed. `skill-check.mjs` asserts against it on every run.
|
||||
|
||||
Regenerating it is a deliberate act, and the reason belongs here.
|
||||
|
||||
## 2026-09-20 — the three HTML baselines that held invented people were recaptured
|
||||
|
||||
Settled. The section that stood here said six checks failed on purpose and
|
||||
listed the evidence; this is that debt being paid.
|
||||
|
||||
`hiringRecords.js` used to pad the hires list with five invented people
|
||||
(`DEMO_FILL`) so Hired History read as a history rather than as three rows. The
|
||||
padding reached Analytics too, where "total hires" counted eight against a
|
||||
database holding three. It was removed, and because these baselines render with
|
||||
queries disabled, the padding was *all* they contained. Candidates separately
|
||||
became the final-selection queue, which changed what its empty state says.
|
||||
|
||||
Verified before regenerating, so the drift was known rather than assumed. Tag
|
||||
counts for Hired History, baseline -> now:
|
||||
|
||||
<td 40 -> 0 five table rows of people who were never hired
|
||||
<span 51 -> 13
|
||||
<div 78 -> 39
|
||||
<p 26 -> 7
|
||||
<svg 20 -> 8
|
||||
|
||||
Every difference was content that had been fabricated. Analytics lost 269 class
|
||||
attributes and 238 words to the same cause. Candidates gained exactly one class
|
||||
— the description paragraph under "Nobody is awaiting a decision". No styling
|
||||
rule, ordering rule or structural rule changed on any of the three.
|
||||
|
||||
**Recaptured and renamed**, because a file called `pre-migration` that holds
|
||||
post-`dca1842` markup is a lie in the filename:
|
||||
|
||||
hired-history.pre-migration.html -> hired-history.render.html
|
||||
analytics.pre-migration.html -> analytics.render.html
|
||||
candidates.pre-migration.html -> candidates.render.html
|
||||
|
||||
`activity-page`, `candidates-analysis`, `control-center`, `positions` and
|
||||
`talent-pool` still hold genuine pre-migration markup, still pass, and keep the
|
||||
name that says so. The `PAGES` table in `skill-check.mjs` now carries each
|
||||
baseline's FILENAME rather than deriving one suffix for all of them.
|
||||
|
||||
**The migration proof was not thrown away with the baseline.** One check —
|
||||
`Hired History added only identity wrappers` — compared tag tallies to assert
|
||||
that migrating the page added exactly two `<div>`s and changed nothing else.
|
||||
That was true, and it was checkable only while the DATA was frozen as well: the
|
||||
predicate subtracts one whole render from another, so removing the invented
|
||||
hires moved every count and the arithmetic stopped describing wrappers at all.
|
||||
Recapturing would not have rescued it — with the baseline equal to the render
|
||||
the delta is zero, and a predicate demanding two can never hold again. Left in
|
||||
place it would have stayed red for a new reason, which is worse than failing
|
||||
for the old one.
|
||||
|
||||
It is replaced by three checks that read the render itself and need no frozen
|
||||
file, so they keep holding as the page's content changes:
|
||||
|
||||
Hired History wraps exactly the node types registered to wrap
|
||||
Hired History identities are unique and name composed nodes
|
||||
Hired History identity wrappers carry no styling
|
||||
|
||||
Together these say what the tally said — `UiTreeRenderer` encloses a type
|
||||
registered `wrap: true` in `<div {...attrs}>`, every other type takes the
|
||||
attributes on its own root element, and a wrapper contributes identity and no
|
||||
styling. Hired History composes five nodes; two are registered to wrap and
|
||||
produce the two divs, two carry their identity on their own `<section>`, and
|
||||
`hired-extensions-top` is an extension slot that renders nothing while no skill
|
||||
is attached to it. The second check therefore asserts uniqueness and no strays
|
||||
rather than one identity per composed node — a one-to-one rule would be
|
||||
asserting that every extension slot is always filled.
|
||||
|
||||
`carries node identity in the DOM` was described here as needing pre-migration
|
||||
markup. It does not: it reads only the live render, names `chronology` and
|
||||
`records` directly, and is unaffected by any of this. It still passes.
|
||||
|
||||
`npm test` is 1693/1693.
|
||||
|
||||
|
||||
## 2026-08-27 — the seed gained the three statuses nothing exercised
|
||||
|
||||
`application_status` has seven values. The fixture produced four: `applied`,
|
||||
@@ -63,3 +136,48 @@ with it. It now names the page's topics in a sentence.
|
||||
Every intent `kind` was unchanged. No routing moved, no skill matching changed.
|
||||
That is why this regeneration was safe: the diff was read first, and it was one
|
||||
cosmetic change on a path that only runs when no agent is configured at all.
|
||||
|
||||
## 2026-09-11 — Candidates became the final-selection queue
|
||||
|
||||
`candidates: shows the same words` now fails, and it is the only check this
|
||||
change breaks.
|
||||
|
||||
The page used to list every application the org had ever taken — all seven
|
||||
statuses at once — which made it a second Talent Pool rather than the queue of
|
||||
hiring decisions waiting on a human. It now opens on final selection: interview
|
||||
completed, not hired, not rejected. That is a change to what the page *says*, so
|
||||
a check asserting the page says exactly what it said before was always going to
|
||||
fail. There is no version of this work that leaves those words alone.
|
||||
|
||||
**What drifted, verified before leaving it failing.** The rendered delta is the
|
||||
empty state and nothing else:
|
||||
|
||||
before "…0 of 0 candidates No candidates match your filters"
|
||||
now "…0 of 0 candidates Nobody is awaiting a decision
|
||||
Candidates arrive here once their interview is completed,
|
||||
and leave once they are hired or declined."
|
||||
|
||||
Title, subtitle and toolbar meta are byte-identical. The old copy was not merely
|
||||
different, it was untrue: with no filters applied there is nothing to clear, and
|
||||
"no candidates match your filters" describes a filter that was never set.
|
||||
|
||||
Tag and class counts, baseline → now:
|
||||
|
||||
class="…" 26 -> 27 one inserted: the description paragraph
|
||||
|
||||
One insertion, nothing changed and nothing dropped — which is why
|
||||
`candidates: paints the same styled elements, in the same order` and
|
||||
`candidates: carries node identity in the DOM` both still pass. Those two are
|
||||
the migration proof; only the words moved.
|
||||
|
||||
The new stage filter options cost nothing here. `FilterSelect` is a Radix
|
||||
`Select`, so its options live in a portal that is closed in static markup, and
|
||||
`SelectValue` renders empty on the server — the baseline contains neither the
|
||||
old option labels nor the new ones.
|
||||
|
||||
**Regenerated on 2026-09-20**, with the other two — see the entry at the top of
|
||||
this file. It was held until then because `candidates.pre-migration.html` was
|
||||
load-bearing for the UI node tree proof, and recapturing it earlier would have
|
||||
written post-migration markup into a file named `pre-migration`. The file is now
|
||||
`candidates.render.html` and the proof it was holding up has been replaced by
|
||||
three checks that read the render directly.
|
||||
|
||||
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/analytics.render.html
Normal file
1
scripts/__baseline__/analytics.render.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/candidates.render.html
Normal file
1
scripts/__baseline__/candidates.render.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/hired-history.render.html
Normal file
1
scripts/__baseline__/hired-history.render.html
Normal file
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@
|
||||
"bartending-training",
|
||||
"candidate-analysis",
|
||||
"candidate-search",
|
||||
"create-employee-role",
|
||||
"create-position",
|
||||
"customer-service-training",
|
||||
"executive-summary",
|
||||
@@ -161,6 +162,7 @@
|
||||
"pageKey": "positions",
|
||||
"route": "/admin/positions",
|
||||
"skills": [
|
||||
"create-employee-role",
|
||||
"create-position",
|
||||
"hiring-activity-assistant",
|
||||
"staffing-risk"
|
||||
@@ -707,6 +709,7 @@
|
||||
"pageKey": "talent-pool",
|
||||
"route": "/admin/talent-pool",
|
||||
"skills": [
|
||||
"create-employee-role",
|
||||
"talent-pool-analysis"
|
||||
],
|
||||
"suggestions": [
|
||||
|
||||
209
scripts/gen-entity-types.mjs
Normal file
209
scripts/gen-entity-types.mjs
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Generates `src/types/entities.generated.ts` from the backend's resource
|
||||
* registry.
|
||||
*
|
||||
* node scripts/gen-entity-types.mjs # check: exits non-zero if stale
|
||||
* node scripts/gen-entity-types.mjs --write # regenerate
|
||||
*
|
||||
* The same arrangement as `seed-fixture.mjs`, and for the same reason: two
|
||||
* descriptions of one shape, maintained by hand, only ever fail quietly. Here
|
||||
* the two are the columns Postgres actually has and the fields this app
|
||||
* believes it will be sent.
|
||||
*
|
||||
* ## Why generate rather than transcribe
|
||||
*
|
||||
* `krow-backend/go-api/internal/domain/resources_gen.go` is itself generated,
|
||||
* out of `information_schema`, and says so: "Column names, types, enum values
|
||||
* and nullability are read out of information_schema so they cannot drift from
|
||||
* the migrations." It is the closest thing to the database that can be read
|
||||
* without connecting to one — 15 resources, 298 columns, every one carrying its
|
||||
* kind, its nullability and its permitted values.
|
||||
*
|
||||
* Two hundred and ninety-eight fields retyped by hand would be two hundred and
|
||||
* ninety-eight chances to be confidently wrong, and a type that is wrong is
|
||||
* worse than no type: it is a claim the compiler will defend.
|
||||
*
|
||||
* ## Where the TypeScript types come from
|
||||
*
|
||||
* Not from the Postgres type — from `Column.SelectExpr()` in
|
||||
* `domain/resource.go`, which is what the read projection actually emits. The
|
||||
* two differ, and the differences are the whole point of reading the code
|
||||
* rather than the schema:
|
||||
*
|
||||
* uuid ::text -> string (never a byte array)
|
||||
* numeric ::float8 -> number (never pgtype.Numeric)
|
||||
* date to_char(…,'YYYY-MM-DD') -> string (a date, not a timestamp)
|
||||
* timestamptz to_char(…ISO with ms…) -> string
|
||||
* citext ::text -> string
|
||||
* bigint ::text -> string ← user_activity.id only
|
||||
* int (uncast) -> number
|
||||
*
|
||||
* That last one is the case worth the whole exercise: `user_activity.id` is an
|
||||
* identity bigint and arrives as a STRING, because every id the frontend
|
||||
* handles is an opaque string. A hand-written interface would have called it a
|
||||
* number, and nothing would have contradicted that until a comparison silently
|
||||
* stopped matching.
|
||||
*
|
||||
* Nullability is the column's: `NotNull: true` becomes a required field, its
|
||||
* absence becomes `| null`. Every column is emitted because the projection
|
||||
* emits every column — `Repo.selectList()` maps over `res.Columns` with no
|
||||
* filter, and §4.1 of the API contract states the record is complete.
|
||||
*
|
||||
* ## What this does NOT generate
|
||||
*
|
||||
* Three of the eighteen entities the client knows are absent from the registry
|
||||
* because they are not served by the generic entity machinery: AgentDefinition
|
||||
* and SkillDefinition have dedicated handlers in `httpserver/definitions.go`,
|
||||
* and the user is `httpserver/me.go`. Their shapes are hand-written in
|
||||
* `src/types/user.ts` and `src/types/entities.ts`. This file does not invent
|
||||
* them.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
/** The registry, read from the sibling checkout. */
|
||||
export const REGISTRY_PATH = join(
|
||||
process.cwd(), '..', 'krow-backend', 'go-api', 'internal', 'domain', 'resources_gen.go');
|
||||
|
||||
export const OUTPUT_PATH = join(process.cwd(), 'src', 'types', 'entities.generated.ts');
|
||||
|
||||
/**
|
||||
* Kind -> TypeScript, following `Column.SelectExpr()` rather than the column's
|
||||
* Postgres type. `pgType` is needed because two kinds cast conditionally.
|
||||
*/
|
||||
function tsTypeFor(kind, pgType, enumValues) {
|
||||
switch (kind) {
|
||||
case 'KindUUID': return 'string'; // ::text
|
||||
case 'KindString': return 'string'; // citext also ::text
|
||||
case 'KindBool': return 'boolean';
|
||||
case 'KindFloat': return 'number'; // ::float8
|
||||
case 'KindInt': return pgType === 'bigint' ? 'string' : 'number';
|
||||
case 'KindDate': return 'string'; // 'YYYY-MM-DD'
|
||||
case 'KindTimestamp': return 'string'; // ISO-8601 with ms
|
||||
case 'KindTextArray': return 'string[]';
|
||||
case 'KindEnum': return enumValues.map((v) => `'${v}'`).join(' | ');
|
||||
case 'KindJSON': return 'unknown'; // jsonb: shape is the column's own
|
||||
default: throw new Error(`unmapped Kind: ${kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses the Go registry into `[{ name, path, table, columns }]`. */
|
||||
export function parseRegistry(source) {
|
||||
const resources = [];
|
||||
/* Each resource opens with a lone `{` one tab in, then a `Name:` line two
|
||||
tabs in. Splitting on that opener gives one block per resource; the header
|
||||
match rejects anything that is not one. */
|
||||
const blocks = source.split(/\n\t\{\n/).slice(1);
|
||||
for (const block of blocks) {
|
||||
const head = block.match(/^\t\tName: "(\w+)", Path: "([\w-]+)", Table: "(\w+)",/);
|
||||
if (!head) continue;
|
||||
const [, name, path, table] = head;
|
||||
const columns = [];
|
||||
const colRe = /\{Name: "(\w+)", Kind: (Kind\w+), PGType: "([^"]+)"([^}]*)\}/g;
|
||||
let m;
|
||||
while ((m = colRe.exec(block)) !== null) {
|
||||
const [, colName, kind, pgType, rest] = m;
|
||||
/* `rest` is already cut at the first `}`, which for an enum column is
|
||||
the one closing `[]string{...}` — so the values are in it but the
|
||||
brace is not. Requiring a closing brace here matched nothing and
|
||||
silently produced empty unions. */
|
||||
const enumMatch = rest.match(/Enum: \[\]string\{([^}]*)/);
|
||||
const enumValues = enumMatch
|
||||
? [...enumMatch[1].matchAll(/"([^"]*)"/g)].map((e) => e[1])
|
||||
: [];
|
||||
columns.push({
|
||||
name: colName,
|
||||
kind,
|
||||
pgType,
|
||||
notNull: /NotNull: true/.test(rest),
|
||||
readOnly: /ReadOnly: true/.test(rest),
|
||||
required: /Required: true/.test(rest),
|
||||
enumValues,
|
||||
});
|
||||
}
|
||||
if (columns.length) resources.push({ name, path, table, columns });
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
const FLAGS = (c) => {
|
||||
const notes = [];
|
||||
if (c.readOnly) notes.push('server-owned');
|
||||
if (c.required) notes.push('required on create');
|
||||
return notes.length ? ` /** ${notes.join('; ')}. */\n` : '';
|
||||
};
|
||||
|
||||
/** One resource -> one exported interface. */
|
||||
function renderInterface(res) {
|
||||
const fields = res.columns.map((c) => {
|
||||
const ts = tsTypeFor(c.kind, c.pgType, c.enumValues);
|
||||
/* A nullable column returns JSON null, so the field is present and null
|
||||
rather than absent. `?:` would describe a key that can be missing, which
|
||||
is not what the projection does. */
|
||||
const type = c.notNull ? ts : `${ts} | null`;
|
||||
return `${FLAGS(c)} ${c.name}: ${type};`;
|
||||
}).join('\n');
|
||||
return `/** \`${res.path}\` — the \`${res.table}\` table, every column the projection returns. */\nexport interface ${res.name} {\n${fields}\n}`;
|
||||
}
|
||||
|
||||
export function render(resources) {
|
||||
const banner = `/**
|
||||
* GENERATED FILE — DO NOT EDIT BY HAND.
|
||||
*
|
||||
* Regenerate with: node scripts/gen-entity-types.mjs --write
|
||||
* Source of truth: krow-backend/go-api/internal/domain/resources_gen.go
|
||||
* (itself generated from information_schema)
|
||||
*
|
||||
* Field types follow \`Column.SelectExpr()\` in \`domain/resource.go\` — what the
|
||||
* read projection emits — not the raw Postgres type. See the generator's header
|
||||
* for the mapping and for why the two differ.
|
||||
*
|
||||
* ${resources.length} resources, ${resources.reduce((n, r) => n + r.columns.length, 0)} columns.
|
||||
*/
|
||||
`;
|
||||
const interfaces = resources.map(renderInterface).join('\n\n');
|
||||
const names = resources.map((r) => ` | '${r.name}'`).join('\n');
|
||||
const mapEntries = resources.map((r) => ` ${r.name}: ${r.name};`).join('\n');
|
||||
const tail = `/** The entity names the generic registry serves. Three more exist — see \`entities.ts\`. */
|
||||
export type GeneratedEntityName =
|
||||
${names};
|
||||
|
||||
/** Entity name -> its record type, for looking a record up by name. */
|
||||
export interface GeneratedEntityRecords {
|
||||
${mapEntries}
|
||||
}
|
||||
`;
|
||||
return `${banner}\n${interfaces}\n\n${tail}`;
|
||||
}
|
||||
|
||||
/* ── CLI ──────────────────────────────────────────────────────────────────── */
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
if (!existsSync(REGISTRY_PATH)) {
|
||||
console.log('krow-backend is not checked out beside this repo; entity types not checked here.');
|
||||
console.log('The types in src/types/entities.generated.ts are committed, so this is not fatal.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const resources = parseRegistry(readFileSync(REGISTRY_PATH, 'utf8'));
|
||||
if (!resources.length) {
|
||||
console.error('parsed no resources out of resources_gen.go — has its shape changed?');
|
||||
process.exit(1);
|
||||
}
|
||||
const generated = render(resources);
|
||||
|
||||
if (process.argv.includes('--write')) {
|
||||
writeFileSync(OUTPUT_PATH, generated);
|
||||
const cols = resources.reduce((n, r) => n + r.columns.length, 0);
|
||||
console.log(`entities.generated.ts written — ${resources.length} resources, ${cols} columns.`);
|
||||
} else if (!existsSync(OUTPUT_PATH)) {
|
||||
console.error('entities.generated.ts is missing. Run: node scripts/gen-entity-types.mjs --write');
|
||||
process.exit(1);
|
||||
} else if (readFileSync(OUTPUT_PATH, 'utf8') !== generated) {
|
||||
console.error('entities.generated.ts is stale — it no longer matches the backend registry.');
|
||||
console.error('Run: node scripts/gen-entity-types.mjs --write');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('entities.generated.ts is in step with the backend registry.');
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { createServer } from 'vite';
|
||||
import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs';
|
||||
import { withSourceResolution } from './ssr-resolve.mjs';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
@@ -26,6 +27,11 @@ const server = await createServer({
|
||||
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);
|
||||
await server.close();
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { join } from 'node:path';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { withSourceResolution } from './ssr-resolve.mjs';
|
||||
|
||||
/**
|
||||
* 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',
|
||||
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 {
|
||||
const html = await renderPage(server, `/${modulePath.replace(/^\//, '')}`, { route: route || '/' });
|
||||
writeFileSync(out, html);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { createServer } from 'vite';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { withSourceResolution } from './ssr-resolve.mjs';
|
||||
|
||||
export const FIXTURE_PATH = join(process.cwd(), '..', 'krow-backend', 'seed', 'fixtures', 'seed.json');
|
||||
|
||||
@@ -30,10 +31,31 @@ export const GENERATED_NOTE =
|
||||
|
||||
export function fixtureFrom(seedModule) {
|
||||
const { ShiftRecord, ...entities } = seedModule.seedData;
|
||||
/* First key, so it is the first thing anyone opening the file reads. The Go
|
||||
loader unmarshals into a struct of demoUser + entities and ignores the
|
||||
rest, so this costs nothing on the reading side. */
|
||||
return { _generated: GENERATED_NOTE, demoUser: seedModule.DEMO_USER, entities };
|
||||
/* `_generated` first, so it is the first thing anyone opening the file reads.
|
||||
|
||||
`users` is the channel the seeder actually reads — `internal/seeder`
|
||||
unmarshals into `{demoUser, users, entities}` and writes every account in
|
||||
`users`, falling back to `demoUser` alone when the key is absent. That
|
||||
fallback is why `demoUser` stays beside it rather than being replaced: a
|
||||
fixture written here still seeds correctly against a backend that predates
|
||||
the list, it simply seeds one account instead of two.
|
||||
|
||||
Emitting only `demoUser` is what this generator used to do, and it was a
|
||||
version behind: the fixture on disk carries the employer account, the
|
||||
seeder reads it from `users`, and regenerating without this key would drop
|
||||
`employer@krow.app` from every fresh seed — leaving nobody to sign in as
|
||||
to reach the employer console, which is the exact gap the account was
|
||||
added to close.
|
||||
|
||||
`entities.User` carries the same list. The seeder ignores it, and it is
|
||||
emitted because `seedData` is copied wholesale; the two are the same array
|
||||
rather than two places to keep in step. */
|
||||
return {
|
||||
_generated: GENERATED_NOTE,
|
||||
demoUser: seedModule.DEMO_USER,
|
||||
users: seedModule.seedData.User,
|
||||
entities,
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialised exactly as the committed file is: 2-space indent, no trailing newline. */
|
||||
@@ -51,6 +73,9 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const server = await createServer({
|
||||
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);
|
||||
await server.close();
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs';
|
||||
import { buildFixture, FIXTURE_PATH } from './seed-fixture.mjs';
|
||||
import { resolveSourcePath, withSourceResolution } from './ssr-resolve.mjs';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const results = [];
|
||||
@@ -41,6 +42,12 @@ const server = await createServer({
|
||||
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 placement = await server.ssrLoadModule('/src/components/ai-assistant/placement.js');
|
||||
|
||||
@@ -1611,10 +1618,10 @@ record('...and stops asking a backend that does not have the route',
|
||||
now does — and the failure mode of it returning is not an error but a second
|
||||
opinion silently outranking the server's. */
|
||||
record('the panel imports no local suggestion ranker',
|
||||
!existsSync(join(ROOT, 'src/components/ai-assistant/matchPrompts.js')),
|
||||
!existsSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/matchPrompts.js'))),
|
||||
'matchPrompts.js removed');
|
||||
|
||||
const panelSource = readFileSync(join(ROOT, 'src/components/ai-assistant/KrowAssistant.jsx'), 'utf8');
|
||||
const panelSource = readFileSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/KrowAssistant.jsx')), 'utf8');
|
||||
record('...and does not rank, score or sort suggestions itself',
|
||||
!/rankPrompts|\.sort\(|score\(/.test(panelSource),
|
||||
'no ranking in the panel');
|
||||
@@ -1623,10 +1630,10 @@ record('...it asks the API instead',
|
||||
|
||||
/* The production data client must not carry the fixture. */
|
||||
record('the app does not import the seed fixture',
|
||||
!/from '\.\/seed'/.test(readFileSync(join(ROOT, 'src/api/base44Client.js'), 'utf8')),
|
||||
!/from '\.\/seed'/.test(readFileSync(join(ROOT, resolveSourcePath('src/api/base44Client.js')), 'utf8')),
|
||||
'base44Client reads demoUser.js, not seed.js');
|
||||
record('the local entity store is gone',
|
||||
!existsSync(join(ROOT, 'src/api/store.js')),
|
||||
!existsSync(join(ROOT, resolveSourcePath('src/api/store.js'))),
|
||||
'store.js removed');
|
||||
|
||||
/* ── 11. A fresh Add Owliver Skill screen ─────────────────────────────────
|
||||
@@ -2951,7 +2958,7 @@ const runtime = await server.ssrLoadModule('/src/lib/agents/runtime.js');
|
||||
})().pass);
|
||||
|
||||
record('ownership: the rule is the one the page actually uses', (() => {
|
||||
const source = readFileSync(join(ROOT, 'src/components/skills/SkillSurface.jsx'), 'utf8');
|
||||
const source = readFileSync(join(ROOT, resolveSourcePath('src/components/skills/SkillSurface.jsx')), 'utf8');
|
||||
return {
|
||||
pass: /agentPermitsSkill\(skill\.id, scope\)/.test(source)
|
||||
&& /from '@\/lib\/agents\/runtime'/.test(source),
|
||||
@@ -2962,7 +2969,7 @@ const runtime = await server.ssrLoadModule('/src/lib/agents/runtime.js');
|
||||
record('ownership: the panel reads agents from the definitions store', (() => {
|
||||
/* The live failure: this file read `preferences.customAgents`, which authored
|
||||
agents had already moved out of, so no attachment was ever visible here. */
|
||||
const source = readFileSync(join(ROOT, 'src/components/ai-assistant/AgentContext.jsx'), 'utf8');
|
||||
const source = readFileSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/AgentContext.jsx')), 'utf8');
|
||||
return {
|
||||
pass: /useAgentDefinitions\(\)/.test(source)
|
||||
&& /sourcesFrom\(/.test(source)
|
||||
@@ -3781,7 +3788,7 @@ record('agent starters carry no capability, so they cannot address an unoffered
|
||||
* Read out of `App.jsx` rather than asserted against a written list, so a route
|
||||
* that is renamed or removed fails here rather than in someone's browser.
|
||||
*/
|
||||
const appSource = readFileSync(join(ROOT, 'src/App.jsx'), 'utf8');
|
||||
const appSource = readFileSync(join(ROOT, resolveSourcePath('src/App.jsx')), 'utf8');
|
||||
|
||||
/**
|
||||
* The switcher, if there still is one.
|
||||
@@ -3793,7 +3800,7 @@ const appSource = readFileSync(join(ROOT, 'src/App.jsx'), 'utf8');
|
||||
* ENOENT and stopped the whole run here, taking every section after it with it.
|
||||
* Absent is a state to report, not to crash on.
|
||||
*/
|
||||
const SWITCHER_PATH = join(ROOT, 'src/components/ai-assistant/AgentSwitcher.jsx');
|
||||
const SWITCHER_PATH = join(ROOT, resolveSourcePath('src/components/ai-assistant/AgentSwitcher.jsx'));
|
||||
const switcherSource = existsSync(SWITCHER_PATH) ? readFileSync(SWITCHER_PATH, 'utf8') : null;
|
||||
|
||||
/* Only live navigations count: a disabled control goes nowhere by design. */
|
||||
@@ -3829,7 +3836,7 @@ record('every route the agent switcher navigates to exists',
|
||||
* keeping — offered somewhere, and the route resolves — so it is asserted
|
||||
* against where the control actually is.
|
||||
*/
|
||||
const agentsListSource = readFileSync(join(ROOT, 'src/pages/admin/WorkspaceAgents.jsx'), 'utf8');
|
||||
const agentsListSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/WorkspaceAgents.jsx')), 'utf8');
|
||||
record('creating an agent is reachable, and its screen exists',
|
||||
/navigate\('\/admin\/workspace\/agents\/new'\)/.test(agentsListSource)
|
||||
&& routed.has('/admin/workspace/agents/new'),
|
||||
@@ -4490,7 +4497,7 @@ record('every category offered by the picker matches at least one skill',
|
||||
|
||||
const managementRoutes = ['/admin/workspace/agents', '/admin/workspace/agents/new'];
|
||||
const appRoutes = new Set(
|
||||
[...readFileSync(join(ROOT, 'src/App.jsx'), 'utf8').matchAll(/<Route\s+path="([^"]+)"/g)]
|
||||
[...readFileSync(join(ROOT, resolveSourcePath('src/App.jsx')), 'utf8').matchAll(/<Route\s+path="([^"]+)"/g)]
|
||||
.map((m) => m[1])
|
||||
.map((path) => (path.startsWith('/') ? path : `/admin/${path}`))
|
||||
);
|
||||
@@ -4516,7 +4523,7 @@ record('every surface route is registered in the router',
|
||||
|
||||
record('the dynamic agent route is registered after the static one',
|
||||
(() => {
|
||||
const source = readFileSync(join(ROOT, 'src/App.jsx'), 'utf8');
|
||||
const source = readFileSync(join(ROOT, resolveSourcePath('src/App.jsx')), 'utf8');
|
||||
return source.indexOf('workspace/agents/new') < source.indexOf('workspace/agents/:id');
|
||||
})(),
|
||||
'so `agents/new` cannot be read as an agent whose id is "new"');
|
||||
@@ -4703,8 +4710,8 @@ record('...and never answered from the configure page itself',
|
||||
* Asserted structurally: the configure screen must not import or define a chat.
|
||||
* The panel it gets is the one the Admin shell already mounts.
|
||||
*/
|
||||
const detailSource = readFileSync(join(ROOT, 'src/pages/admin/AgentDetail.jsx'), 'utf8');
|
||||
const configureSource = readFileSync(join(ROOT, 'src/components/agents/AgentConfigure.jsx'), 'utf8');
|
||||
const detailSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/AgentDetail.jsx')), 'utf8');
|
||||
const configureSource = readFileSync(join(ROOT, resolveSourcePath('src/components/agents/AgentConfigure.jsx')), 'utf8');
|
||||
|
||||
/* The property is right and the old pattern was too blunt. It matched any
|
||||
occurrence of the substring, so `useAssistantPanel` — the hook for talking to
|
||||
@@ -4728,18 +4735,18 @@ record('the configure screen defines no chat of its own',
|
||||
record('the panel is mounted once, by the shell',
|
||||
/* `<AssistantPanel` alone also matches `<AssistantPanelProvider`, which is a
|
||||
different component and legitimately present. */
|
||||
(readFileSync(join(ROOT, 'src/layouts/AdminLayout.jsx'), 'utf8').match(/<AssistantPanel\b(?!Provider)/g) || []).length === 1);
|
||||
(readFileSync(join(ROOT, resolveSourcePath('src/layouts/AdminLayout.jsx')), 'utf8').match(/<AssistantPanel\b(?!Provider)/g) || []).length === 1);
|
||||
|
||||
/* The switcher is gone — see the note above. What replaced it is a label, and
|
||||
the property worth keeping is that it stayed one: a header that can be
|
||||
clicked is a second place to choose an agent. */
|
||||
const badgeSource = readFileSync(join(ROOT, 'src/components/ai-assistant/AgentBadge.jsx'), 'utf8');
|
||||
const badgeSource = readFileSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/AgentBadge.jsx')), 'utf8');
|
||||
record('the panel header names the agent and does not switch it',
|
||||
!/<button|onClick=|navigate\(/.test(badgeSource),
|
||||
'a label, not a control');
|
||||
|
||||
record('PageContext is untouched',
|
||||
!/agentConfigure/.test(readFileSync(join(ROOT, 'src/components/ai-assistant/PageContext.jsx'), 'utf8')));
|
||||
!/agentConfigure/.test(readFileSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/PageContext.jsx')), 'utf8')));
|
||||
|
||||
/* ── The configure surface declares no placements ───────────────────────── */
|
||||
|
||||
@@ -4774,7 +4781,7 @@ record('no skill declares the configure page',
|
||||
* **It stays Krow.** The palette is the tokens, and nothing else.
|
||||
*/
|
||||
|
||||
const canvasSource = readFileSync(join(ROOT, 'src/components/agents/AgentCanvas.jsx'), 'utf8');
|
||||
const canvasSource = readFileSync(join(ROOT, resolveSourcePath('src/components/agents/AgentCanvas.jsx')), 'utf8');
|
||||
const agentUi = detailSource + configureSource + canvasSource;
|
||||
|
||||
/* One surface with sections inside it, not four cards side by side. */
|
||||
@@ -4915,10 +4922,10 @@ const canvasImporters = [
|
||||
...readdirSync(join(ROOT, 'src/pages/admin')).map((f) => ['src/pages/admin', f]),
|
||||
...readdirSync(join(ROOT, 'src/components/agents')).map((f) => ['src/components/agents', f]),
|
||||
]
|
||||
.filter(([, f]) => /\.jsx?$/.test(f))
|
||||
.filter(([, f]) => /\.[jt]sx?$/.test(f))
|
||||
.filter(([, f]) => !/^(AgentConfigure|AgentDetail|AgentCanvas)\./.test(f))
|
||||
.filter(([dir, f]) => /agents\/AgentCanvas|from '\.\/AgentCanvas'/
|
||||
.test(readFileSync(join(ROOT, dir, f), 'utf8')))
|
||||
.test(readFileSync(join(ROOT, resolveSourcePath(`${dir}/${f}`)), 'utf8')))
|
||||
.map(([dir, f]) => `${dir}/${f}`);
|
||||
record('the workspace treatment is used by the configure screen alone',
|
||||
canvasImporters.length === 0,
|
||||
@@ -5952,6 +5959,354 @@ record('an assigned candidate counts as hired',
|
||||
records.HIRED_STATUSES.includes('assigned') &&
|
||||
records.rankOf('assigned') >= records.STAGE_ORDER.indexOf('hired'));
|
||||
|
||||
/* ── Final selection, and seats ──────────────────────────────────────────
|
||||
*
|
||||
* Final selection is DERIVED — there is no `final_selection` value in the
|
||||
* `application_status` enum and none is added. The fact it reads is the
|
||||
* existence of an interview row, which is a NOT NULL foreign key, rather than
|
||||
* `job_applications.interview_id`, which the schema keeps as an unconstrained
|
||||
* soft reference precisely so it may dangle.
|
||||
*
|
||||
* That distinction is the whole point: `status = 'interview'` is set both when
|
||||
* an interview is arranged and when one is completed, so status alone cannot
|
||||
* tell a queue of people who have been interviewed from a queue of people who
|
||||
* have merely been booked in.
|
||||
*/
|
||||
const fsApps = [
|
||||
{ id: 'a-applied', status: 'applied' },
|
||||
{ id: 'a-booked', status: 'interview' }, // arranged, never sat
|
||||
{ id: 'a-done', status: 'interview' }, // sat it
|
||||
{ id: 'a-hired', status: 'hired' },
|
||||
{ id: 'a-assigned', status: 'assigned' },
|
||||
{ id: 'a-rejected', status: 'rejected' },
|
||||
];
|
||||
const fsInterviews = [
|
||||
{ id: 'i1', application_id: 'a-done' },
|
||||
{ id: 'i2', application_id: 'a-hired' },
|
||||
{ id: 'i3', application_id: 'a-rejected' },
|
||||
];
|
||||
const queue = records.finalSelection(fsApps, fsInterviews).map((a) => a.id);
|
||||
|
||||
record('final selection holds only the interviewed and undecided',
|
||||
queue.length === 1 && queue[0] === 'a-done', queue.join(', ') || 'empty');
|
||||
record('...so an interview merely arranged does not qualify',
|
||||
!records.isFinalSelection(fsApps[1], fsInterviews),
|
||||
'status interview with no interview row');
|
||||
record('...and a decided application never reappears in the queue',
|
||||
['a-hired', 'a-assigned', 'a-rejected']
|
||||
.every((id) => !queue.includes(id)),
|
||||
'hired, assigned and rejected all excluded');
|
||||
record('...while the interview record, not interview_id, is what is read',
|
||||
!records.isFinalSelection({ id: 'x', status: 'interview', interview_id: 'dangling' }, []),
|
||||
'a soft reference alone proves nothing');
|
||||
|
||||
/* Seats are counted from the employment records. A `filled` column would be a
|
||||
second source of truth, and nothing could say which one was right. */
|
||||
const seatPosting = { id: 'p1', headcount: 2 };
|
||||
const seatStaff = [
|
||||
{ id: 's1', job_posting_id: 'p1', status: 'active' },
|
||||
{ id: 's2', job_posting_id: 'p1', status: 'onboarding' },
|
||||
{ id: 's3', job_posting_id: 'p1', status: 'inactive' }, // left; seat reopens
|
||||
{ id: 's4', job_posting_id: 'p2', status: 'active' }, // another position
|
||||
];
|
||||
record('seats filled are counted from the staff records',
|
||||
records.filledFor('p1', seatStaff) === 2, `${records.filledFor('p1', seatStaff)} of 2`);
|
||||
record('...someone who has left frees their seat',
|
||||
records.remainingFor({ id: 'p1', headcount: 3 }, seatStaff) === 1,
|
||||
'inactive staff do not hold a seat');
|
||||
record('...a full position reports no remaining seats, never a negative number',
|
||||
records.isFullyStaffed(seatPosting, seatStaff)
|
||||
&& records.remainingFor({ id: 'p1', headcount: 1 }, seatStaff) === 0,
|
||||
'over-hiring floors at zero');
|
||||
|
||||
/* ── The Candidates page IS the final-selection queue ─────────────────────
|
||||
*
|
||||
* The derivation above is only worth having if the page that decides hires
|
||||
* actually opens on it. Candidates used to list every application the org had
|
||||
* ever taken — all seven statuses at once — which made it a second Talent Pool
|
||||
* rather than a queue of decisions waiting.
|
||||
*
|
||||
* Two rules these assertions hold in place, both of them safety rules rather
|
||||
* than presentation ones:
|
||||
*
|
||||
* The evidence is the interview ROW. `interview_id` is a soft reference the
|
||||
* schema deliberately leaves unconstrained, and it dangles in real data.
|
||||
*
|
||||
* The score never gates. `ai_score`, `verdict` and `hire_recommendation`
|
||||
* assist a recruiter; a human makes the selection. A candidate who scored
|
||||
* zero and sat the interview is still a decision somebody owes them.
|
||||
*/
|
||||
record('a candidate who scored zero still reaches the queue',
|
||||
records.isFinalSelection({ id: 'a-zero', status: 'interview', ai_score: 0 },
|
||||
[{ id: 'i9', application_id: 'a-zero' }]),
|
||||
'screening is not a gate on the decision');
|
||||
|
||||
{
|
||||
/* Comments are stripped first: these assert what the page DOES, and a
|
||||
comment explaining the bug that was fixed must not read as the bug. */
|
||||
const decomment = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
const candidatesSrc = decomment(readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/Candidates.jsx')), 'utf8'));
|
||||
const nodesSrc = decomment(readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/candidates/nodes.jsx')), 'utf8'));
|
||||
/* Read from source rather than imported: the page pulls in the interview
|
||||
modal, which touches `window` at module scope, and standing up a DOM to
|
||||
read one string constant would be a worse test than reading the string. */
|
||||
const defaultStage = candidatesSrc.match(/export const DEFAULT_STAGE = '([a-z_]+)'/)?.[1];
|
||||
|
||||
record('the candidate page opens on the final-selection queue',
|
||||
defaultStage === 'final' && /useState\(DEFAULT_STAGE\)/.test(candidatesSrc),
|
||||
String(defaultStage));
|
||||
|
||||
/* The interview rows were fetched and then thrown away — `useInterviews();`
|
||||
on its own line, its result unbound — which is why the queue could not be
|
||||
derived on the one page that needed it. */
|
||||
record('the candidate page keeps the interview rows it fetches',
|
||||
/(const|let)\s*\{[^}]*\}\s*=\s*useInterviews\(\)/.test(candidatesSrc),
|
||||
'useInterviews() is bound, not discarded');
|
||||
|
||||
/* The filter compared `a.stage`. Applications carry `status`; no application
|
||||
has ever had a `stage` property, so every stage selection emptied the
|
||||
page. */
|
||||
record('the stage filter reads a field applications actually carry',
|
||||
!/\ba\.stage\b/.test(candidatesSrc) && /matchesStage\(/.test(candidatesSrc),
|
||||
'status, not an undefined stage');
|
||||
|
||||
/* Narrowing the default view is only safe if nothing becomes unreachable.
|
||||
`shortlisted` and `assigned` were absent from these options, so the rows
|
||||
holding them could not be found by any selection. */
|
||||
const offered = [...nodesSrc.matchAll(/\{ value: '([a-z_]+)', label: '[^']+' \}/g)]
|
||||
.map((m) => m[1]);
|
||||
const unreachable = records.APPLICATION_STATUSES.filter((st) => !offered.includes(st));
|
||||
record('every application status is still reachable from the stage filter',
|
||||
unreachable.length === 0, unreachable.join(', ') || 'all seven offered');
|
||||
|
||||
record('...and the queue itself is offered alongside them',
|
||||
offered.includes('final') && offered.includes('all'),
|
||||
'final selection and all stages');
|
||||
}
|
||||
|
||||
/* ── Human interviews leave a trace, and only completion counts ───────────
|
||||
*
|
||||
* A human interview used to persist nothing at all: the modal collected a
|
||||
* type, a date, a time and notes, announced "Interview scheduled!", and dropped
|
||||
* every one of them. So the final-selection queue could only ever hold people
|
||||
* an AI had interviewed, and anyone seen by a person was invisible to it.
|
||||
*
|
||||
* What changed is where each fact lives, and the split is the safety property:
|
||||
*
|
||||
* booking → a `user_activity` row. Advances nobody.
|
||||
* completion→ an `ai_interviews` row. The evidence, unchanged since Step 6.
|
||||
*
|
||||
* `isFinalSelection` is not touched by any of this and is not expected to be.
|
||||
* These assertions exist to prove that a booking cannot masquerade as an
|
||||
* interview, which is the one way this work could put a candidate in front of a
|
||||
* hiring decision that nobody has actually met.
|
||||
*/
|
||||
const human = await server.ssrLoadModule('/src/lib/humanInterviews.js');
|
||||
|
||||
{
|
||||
const app = { id: 'a-human', status: 'interview', job_posting_id: 'p1', applicant_name: 'A' };
|
||||
const at = (n) => new Date(Date.UTC(2026, 0, n)).toISOString();
|
||||
|
||||
const bookedEvent = {
|
||||
event_type: human.SCHEDULED, application_id: 'a-human', created_date: at(1),
|
||||
metadata: { interview_type: 'video', scheduled_date: '2026-01-09', scheduled_time: '10:00', notes: 'prep' },
|
||||
};
|
||||
const completedRow = { id: 'i-human', application_id: 'a-human' };
|
||||
|
||||
/* Booked, and nothing else. */
|
||||
record('a booked human interview is recorded',
|
||||
human.humanInterviewState(app, [bookedEvent], []) === 'scheduled');
|
||||
record('...and the booking keeps what was entered',
|
||||
(() => {
|
||||
const d = human.scheduleDetails(human.scheduledInterview(app, [bookedEvent]));
|
||||
return d.type === 'video' && d.date === '2026-01-09' && d.time === '10:00' && d.notes === 'prep';
|
||||
})(), 'type, date, time and notes survive');
|
||||
record('...but booking alone is NOT final selection',
|
||||
!records.isFinalSelection(app, []),
|
||||
'no interview row, no decision to make');
|
||||
|
||||
/* Held. */
|
||||
record('a completed human interview is recorded',
|
||||
human.humanInterviewState(app, [bookedEvent], [completedRow]) === 'completed');
|
||||
record('...and a completed human interview DOES reach final selection',
|
||||
records.isFinalSelection(app, [completedRow]));
|
||||
record('...even with no score, because a human interview measures none',
|
||||
records.isFinalSelection({ ...app, ai_score: 0 }, [completedRow]),
|
||||
'absence of an AI score is not absence of an interview');
|
||||
|
||||
/* Not held. Both outcomes are events, and neither is an interview. */
|
||||
for (const [label, type] of [['cancelled', human.CANCELLED], ['not attended', human.NO_SHOW]]) {
|
||||
const events = [bookedEvent, { event_type: type, application_id: 'a-human', created_date: at(2) }];
|
||||
record(`an interview ${label} leaves no completion record`,
|
||||
human.humanInterviewState(app, events, []) === (type === human.CANCELLED ? 'cancelled' : 'no_show'));
|
||||
record(`...so ${label} never reaches final selection`,
|
||||
!records.isFinalSelection(app, []));
|
||||
}
|
||||
|
||||
/* A decided application is out regardless of how it was interviewed. */
|
||||
for (const status of ['hired', 'assigned', 'rejected']) {
|
||||
record(`a ${status} application leaves the queue even with a human interview`,
|
||||
!records.isFinalSelection({ ...app, status }, [completedRow]));
|
||||
}
|
||||
|
||||
/* The Step 6 guard, restated against a human booking: a booking may well set
|
||||
interview_id one day, and it must still prove nothing on its own. */
|
||||
record('a dangling interview_id still does not qualify a human interview',
|
||||
!records.isFinalSelection({ ...app, interview_id: 'nope' }, []),
|
||||
'the row is the evidence, not the reference');
|
||||
|
||||
/* An event claiming completion with no row behind it. The log is not the
|
||||
record, and treating it as one would reintroduce exactly the soft-reference
|
||||
problem interview_id already demonstrates. */
|
||||
record('a completion EVENT without an interview row is not completion',
|
||||
human.humanInterviewState(app,
|
||||
[{ event_type: human.COMPLETED, application_id: 'a-human', created_date: at(3) }], []) === 'none',
|
||||
'user_activity is a log, not the evidence');
|
||||
}
|
||||
|
||||
/* ── Nothing about AI is invented for a human interview ───────────────────
|
||||
*
|
||||
* The completed-interview record lives in `ai_interviews`, which is currently
|
||||
* reused as the durable completion record for both flows. Reusing the table is
|
||||
* a deliberate decision; filling in its AI columns with plausible numbers no
|
||||
* machine produced would not be. A fabricated score or verdict would be read by
|
||||
* analytics, by the candidate profile and by a recruiter as a measurement.
|
||||
*
|
||||
* `overall_interview_score` matters twice over: the server copies it onto the
|
||||
* application only when the request mentions it, so sending a 0 would overwrite
|
||||
* a real screening score with one this interview never took.
|
||||
*/
|
||||
{
|
||||
const hooksSrc = readFileSync(join(ROOT, resolveSourcePath('src/lib/krowHooks.js')), 'utf8');
|
||||
const complete = hooksSrc.slice(
|
||||
hooksSrc.indexOf('export function useCompleteHumanInterview'),
|
||||
hooksSrc.indexOf('export function useRecordInterviewNotHeld')
|
||||
);
|
||||
const schedule = hooksSrc.slice(
|
||||
hooksSrc.indexOf('export function useScheduleHumanInterview'),
|
||||
hooksSrc.indexOf('export function useCompleteHumanInterview')
|
||||
);
|
||||
|
||||
record('the human completion path exists at all', complete.length > 0 && schedule.length > 0);
|
||||
|
||||
const fabricated = ['overall_interview_score', 'integrity_score', 'ai_flags', 'category_scores',
|
||||
'hire_recommendation', 'messages', 'ai_score']
|
||||
.filter((f) => new RegExp(`^\\s*${f}\\s*:`, 'm').test(complete));
|
||||
record('a human interview fabricates no AI data',
|
||||
fabricated.length === 0, fabricated.join(', ') || 'no AI column is written');
|
||||
|
||||
record('...and the interviewer\'s own assessment is only sent when given',
|
||||
/if \(assessment\) record\.verdict = assessment;/.test(complete),
|
||||
'verdict is human-entered or left to the schema');
|
||||
|
||||
record('...while the facts it does write are the ones it knows',
|
||||
/application_id: application\.id/.test(complete)
|
||||
&& /job_posting_id: application\.job_posting_id/.test(complete));
|
||||
|
||||
/* The rule the whole step turns on. */
|
||||
record('scheduling never writes an interview record',
|
||||
!/AIInterview|useCreateInterview|createInterview/.test(schedule),
|
||||
'a booking touches user_activity only');
|
||||
|
||||
record('...and completion goes through the existing interview client',
|
||||
/createInterview\.mutateAsync/.test(complete),
|
||||
'no second interview API');
|
||||
|
||||
/* Not held writes an event and nothing else — no row, and no invented
|
||||
application status to go with it. */
|
||||
const notHeld = hooksSrc.slice(hooksSrc.indexOf('export function useRecordInterviewNotHeld'));
|
||||
record('a cancelled or unattended interview writes no record and no new status',
|
||||
!/AIInterview|createInterview/.test(notHeld) && !/JobApplication\.update/.test(notHeld),
|
||||
'an event, and nothing else');
|
||||
}
|
||||
|
||||
/* The AI flow is untouched: it still writes its own record at completion, and
|
||||
still does it through the same client this now shares. */
|
||||
{
|
||||
const aiSrc = readFileSync(join(ROOT, resolveSourcePath('src/components/krow/AIInterviewModal.jsx')), 'utf8');
|
||||
record('the AI interview still records itself on completion',
|
||||
/const finishInterview = useCallback/.test(aiSrc)
|
||||
&& /createInterview\.mutateAsync\(\{/.test(aiSrc)
|
||||
&& /messages: finalMessages/.test(aiSrc),
|
||||
'unchanged by the human path');
|
||||
}
|
||||
|
||||
/* The modal is no longer theatre. */
|
||||
{
|
||||
const modalSrc = readFileSync(join(ROOT, resolveSourcePath('src/components/krow/ScheduleInterviewModal.jsx')), 'utf8');
|
||||
record('scheduling an interview persists it',
|
||||
/useScheduleHumanInterview/.test(modalSrc) && /schedule\.mutateAsync/.test(modalSrc));
|
||||
record('...the props its six call sites pass are unchanged',
|
||||
/\{ open, onClose, application, position \}/.test(modalSrc),
|
||||
'open, onClose, application — position optional');
|
||||
record('...and a failed save is reported rather than celebrated',
|
||||
/setError\(/.test(modalSrc) && !/setScheduled\(true\)/.test(modalSrc),
|
||||
'the old version always claimed success');
|
||||
}
|
||||
|
||||
/* Every path that files an application on somebody's behalf must carry the
|
||||
link back to the talent-pool record. The column is nullable, so omitting it
|
||||
saves cleanly and fails silently: the application belongs to an email address
|
||||
instead of to a person, and the hire it becomes cannot be traced back to the
|
||||
profile it came from. Both call sites are asserted because they were written
|
||||
at different times and only one of them is on the position page. */
|
||||
const positionDetailSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/PositionDetail.jsx')), 'utf8');
|
||||
const hooksSource = readFileSync(join(ROOT, resolveSourcePath('src/lib/krowHooks.js')), 'utf8');
|
||||
record('admitting talent to a position links the application to the profile',
|
||||
/worker_profile_id: profile\.id/.test(positionDetailSource),
|
||||
'PositionDetail.admitTalent');
|
||||
record('...and so does assigning a worker straight from the pool',
|
||||
/worker_profile_id: worker\.profile\?\.id/.test(hooksSource),
|
||||
'useAssignWorkers filing an application');
|
||||
|
||||
/* ── One person, many positions ──────────────────────────────────────────
|
||||
*
|
||||
* The two profile routes answer different questions and must stay separate.
|
||||
* `/admin/talent/:id` is the PERSON — a `worker_profiles` row, and every
|
||||
* position they are up for. `/admin/candidates/:id` is that person FOR ONE
|
||||
* POSITION — a `job_applications` row, with its score, interview and decision.
|
||||
*
|
||||
* One screen doing both is how a product ends up with a candidate record per
|
||||
* vacancy, which is exactly what `worker_profiles` exists to prevent.
|
||||
*/
|
||||
record('the person and the application have separate routes',
|
||||
routed.has('/admin/talent/:id') && routed.has('/admin/candidates/:id'),
|
||||
[...routed].filter((r) => r.includes('talent') || r.includes('candidates')).join(', '));
|
||||
|
||||
/* Which applications are this person's. The foreign key is the answer; email is
|
||||
the fallback for rows written before the key was being set, and it has to be
|
||||
there or every application already in the database detaches from its person. */
|
||||
const whoProfile = { id: 'wp-1', email: 'Maria@Example.com' };
|
||||
const whoApps = [
|
||||
{ id: 'by-key', worker_profile_id: 'wp-1', email: 'nothing@else.com' },
|
||||
{ id: 'by-email', worker_profile_id: null, email: 'maria@example.com' },
|
||||
{ id: 'by-email-case', email: 'MARIA@EXAMPLE.COM' },
|
||||
{ id: 'somebody-else', worker_profile_id: 'wp-2', email: 'other@example.com' },
|
||||
];
|
||||
const whoIds = records.applicationsForProfile(whoApps, whoProfile).map((a) => a.id);
|
||||
record('an application finds its person by foreign key',
|
||||
whoIds.includes('by-key'), 'worker_profile_id');
|
||||
record('...and falls back to email, case-insensitively, for older rows',
|
||||
whoIds.includes('by-email') && whoIds.includes('by-email-case'),
|
||||
'citext identity, as worker_profiles already asserts');
|
||||
record('...without collecting somebody else',
|
||||
!whoIds.includes('somebody-else') && whoIds.length === 3, whoIds.join(', '));
|
||||
record('...and asks for nobody when there is no profile',
|
||||
records.applicationsForProfile(whoApps, null).length === 0, 'empty, not everything');
|
||||
|
||||
/* The reported symptom: a talent pool you could read and not use. */
|
||||
const talentPoolSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/talent-pool/nodes.jsx')), 'utf8');
|
||||
record('a talent pool row opens that person',
|
||||
/onRowClick=\{\(p\) => navigate\(`\/admin\/talent\/\$\{p\.id\}`\)\}/.test(talentPoolSource),
|
||||
'DataTable wires the handler only when it is given one');
|
||||
record('...and no action pretends to open something it does not',
|
||||
!/toast\.info\(`Opening/.test(talentPoolSource),
|
||||
'the "view profile" toast is gone');
|
||||
record('...and a person can be put in front of a vacancy from the pool',
|
||||
/setConsidering\(p\)/.test(talentPoolSource)
|
||||
&& /AddToPositionModal/.test(talentPoolSource),
|
||||
'the step that was missing between pool and position');
|
||||
|
||||
/* "Screened and waiting on a decision" includes the shortlisted. This counted
|
||||
`ai_screened` alone, which read as correct only while nothing was ever
|
||||
shortlisted — the first shortlisted candidate dropped out of the count in
|
||||
@@ -7340,7 +7695,7 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
...registryMod.nodeRegistry.list(),
|
||||
];
|
||||
const engine = ['node.js', 'registry.js', 'operations.js', 'validate.js', 'patch.js', 'inspect.js']
|
||||
.map((f) => readFileSync(join(ROOT, 'src/lib/ui', f), 'utf8'));
|
||||
.map((f) => readFileSync(join(ROOT, resolveSourcePath(`src/lib/ui/${f}`)), 'utf8'));
|
||||
|
||||
const offences = [];
|
||||
engine.forEach((source, i) => {
|
||||
@@ -7567,7 +7922,7 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
entry is only created by a `register` call that was handed a reference.
|
||||
A node naming a type nobody registered draws nothing — asserted above —
|
||||
so there is no string that can become code. */
|
||||
const source = readFileSync(join(ROOT, 'src/components/ui-tree/UiTreeRenderer.jsx'), 'utf8')
|
||||
const source = readFileSync(join(ROOT, resolveSourcePath('src/components/ui-tree/UiTreeRenderer.jsx')), 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/^\s*\/\/.*$/gm, '');
|
||||
return !/\bimport\s*\(/.test(source)
|
||||
@@ -7752,7 +8107,7 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
record('applying writes only the uiLayouts key, so no other preference moves', (() => {
|
||||
/* `useUiLayouts.save` sends `{ uiLayouts }` and nothing else; the endpoint
|
||||
shallow-merges, so `customSkills` and the rest survive untouched. */
|
||||
const source = readFileSync(join(ROOT, 'src/lib/krowHooks.js'), 'utf8');
|
||||
const source = readFileSync(join(ROOT, resolveSourcePath('src/lib/krowHooks.js')), 'utf8');
|
||||
const fn = source.slice(source.indexOf('export function useUiLayouts'), source.indexOf('export function useJobPostings'));
|
||||
const writes = [...fn.matchAll(/mutateAsync\(\{([^}]*)\}/g)].map((m) => m[1].trim());
|
||||
return writes.length === 1 && writes[0].startsWith('uiLayouts:');
|
||||
@@ -7852,7 +8207,7 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
record('an operation that would be refused is never stored', (() => {
|
||||
/* `propose` validates before it previews, so a refused change never becomes
|
||||
a preview and therefore never reaches an apply. */
|
||||
const provider = readFileSync(join(ROOT, 'src/components/ui-tree/UiEditingProvider.jsx'), 'utf8');
|
||||
const provider = readFileSync(join(ROOT, resolveSourcePath('src/components/ui-tree/UiEditingProvider.jsx')), 'utf8');
|
||||
const proposeBody = provider.slice(provider.indexOf('const propose'), provider.indexOf('/** Throw the experiment away'));
|
||||
return /applyOperation\(/.test(proposeBody)
|
||||
&& /if \(!result\.ok\)/.test(proposeBody)
|
||||
@@ -7870,7 +8225,7 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
];
|
||||
const offences = [];
|
||||
for (const f of files) {
|
||||
const code = readFileSync(join(ROOT, f), 'utf8')
|
||||
const code = readFileSync(join(ROOT, resolveSourcePath(f)), 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
for (const bad of ['writeFile', 'customSkills', 'skillDefinition', 'skill-definitions', '.md']) {
|
||||
if (code.includes(bad)) offences.push(`${f}: ${bad}`);
|
||||
@@ -8124,7 +8479,7 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
})().pass);
|
||||
|
||||
record('the language layer names no page and no component', (() => {
|
||||
const code = readFileSync(join(ROOT, 'src/lib/ui/intent.js'), 'utf8')
|
||||
const code = readFileSync(join(ROOT, resolveSourcePath('src/lib/ui/intent.js')), 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
const banned = [...surfaces.SUPPORTED_SKILL_PAGES, ...reg4.list()];
|
||||
const hits = banned.filter((w) => new RegExp(`['"\`]${w}['"\`]`).test(code));
|
||||
@@ -8474,22 +8829,69 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
|
||||
const classes = (html) => (html.match(/class="[^"]*"/g) || []).join('\n');
|
||||
const words = (html) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const tally = (html) => (html.match(/<\/?[a-z][a-z0-9-]*/gi) || [])
|
||||
.map((t) => t.toLowerCase())
|
||||
.reduce((acc, t) => ({ ...acc, [t]: (acc[t] || 0) + 1 }), {});
|
||||
|
||||
const hiredNow = stripControls(await draw('/src/pages/admin/HiredHistory.jsx', 'hired-history', '/admin/hired'));
|
||||
const hiredBefore = readFileSync(join(ROOT, 'scripts/__baseline__/hired-history.pre-migration.html'), 'utf8');
|
||||
const hiredBefore = readFileSync(join(ROOT, 'scripts/__baseline__/hired-history.render.html'), 'utf8');
|
||||
|
||||
record('Hired History paints the same styled elements, in the same order',
|
||||
classes(hiredNow) === classes(hiredBefore));
|
||||
record('Hired History shows the same words', words(hiredNow) === words(hiredBefore));
|
||||
record('Hired History added only identity wrappers', (() => {
|
||||
const b = tally(hiredBefore);
|
||||
const n = tally(hiredNow);
|
||||
const changed = [...new Set([...Object.keys(b), ...Object.keys(n)])].filter((t) => (b[t] || 0) !== (n[t] || 0));
|
||||
return { pass: changed.join(',') === '<div,</div' && n['<div'] - b['<div'] === 2, detail: changed.join(', ') };
|
||||
})().pass);
|
||||
/**
|
||||
* What the node tree adds to a page's markup, proved from the render itself.
|
||||
*
|
||||
* This replaces a tag tally against the captured baseline, which asserted
|
||||
* that migrating Hired History added exactly two `<div>`s and changed
|
||||
* nothing else. That was true, and it was only checkable while the page's
|
||||
* DATA was frozen too: the comparison subtracts one whole render from
|
||||
* another, so removing the invented hires moved every count and the
|
||||
* arithmetic stopped describing the wrappers at all. Recapturing does not
|
||||
* rescue it either — with the baseline equal to the render the tag delta is
|
||||
* zero, and a predicate demanding a delta of two can never hold again.
|
||||
*
|
||||
* So the property is asserted directly instead of inferred from a
|
||||
* subtraction. `UiTreeRenderer` gives a node its identity one of two ways:
|
||||
* a type registered `wrap: true` is enclosed in `<div {...attrs}>`, and
|
||||
* every other type takes the attributes on its own root element. The three
|
||||
* checks below say exactly that, and they keep saying it as the page's
|
||||
* content changes — which the tally could not.
|
||||
*/
|
||||
const hiredComposed = compMod.composePage('hired-history', ctx5).tree;
|
||||
const hiredNodes = nodeMod5.walk(hiredComposed);
|
||||
const hiredWrapped = hiredNodes.filter((n) => reg5.get(n.type)?.wrap === true);
|
||||
/* The attribute list of every `<div>` that carries an identity. */
|
||||
const hiredWrappers = [...hiredNow.matchAll(/<div([^>]*\bdata-ui-node="[^"]+"[^>]*)>/g)]
|
||||
.map((m) => m[1]);
|
||||
const hiredIds = [...hiredNow.matchAll(/data-ui-node="([^"]+)"/g)].map((m) => m[1]);
|
||||
|
||||
record('Hired History wraps exactly the node types registered to wrap',
|
||||
hiredWrappers.length === hiredWrapped.length,
|
||||
`${hiredWrappers.length} wrapper div(s) for ${hiredWrapped.length} wrapping type(s): `
|
||||
+ `${hiredWrapped.map((n) => n.id).join(', ') || 'none'}`);
|
||||
|
||||
/* Unique, and every one a node the page actually composed.
|
||||
NOT "one per composed node": an extension slot with no skills attached
|
||||
renders nothing at all, which is correct and is why `hired-extensions-top`
|
||||
has no identity here. Asserting a one-to-one mapping would be asserting
|
||||
that every slot is always filled. What must hold is that nothing is
|
||||
addressed twice and nothing is addressed that the page did not compose —
|
||||
either would break the editor's and Owliver's ability to name a node. */
|
||||
const hiredComposedIds = new Set(hiredNodes.map((n) => n.id));
|
||||
const hiredDuplicated = hiredIds.filter((id, i) => hiredIds.indexOf(id) !== i);
|
||||
const hiredStrays = hiredIds.filter((id) => !hiredComposedIds.has(id));
|
||||
const hiredSilent = hiredNodes.filter((n) => !hiredIds.includes(n.id)).map((n) => `${n.id} (${n.type})`);
|
||||
|
||||
record('Hired History identities are unique and name composed nodes',
|
||||
hiredDuplicated.length === 0 && hiredStrays.length === 0,
|
||||
`${hiredIds.length} of ${hiredNodes.length} composed node(s) addressed`
|
||||
+ `${hiredSilent.length ? `; rendering nothing: ${hiredSilent.join(', ')}` : ''}`
|
||||
+ `${hiredDuplicated.length ? `; DUPLICATED: ${hiredDuplicated.join(', ')}` : ''}`
|
||||
+ `${hiredStrays.length ? `; STRAY: ${hiredStrays.join(', ')}` : ''}`);
|
||||
|
||||
/* Identity, not presentation. A wrapper that picked up a class would be
|
||||
styling the page from the tree, which is the one thing wrapping a node
|
||||
must never do — it is why `nodeAttrs` emits `data-` attributes only. */
|
||||
record('Hired History identity wrappers carry no styling',
|
||||
hiredWrappers.every((attrs) => !/\bclass=/.test(attrs)),
|
||||
hiredWrappers.map((a) => a.trim()).join(' | ') || 'no wrappers');
|
||||
record('Hired History carries node identity in the DOM', (() => {
|
||||
const ids = [...hiredNow.matchAll(/data-ui-node="([^"]+)"/g)].map((m) => m[1]);
|
||||
return ids.includes('chronology') && ids.includes('records');
|
||||
@@ -8718,11 +9120,17 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
const words = (h) => h.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const PAGES = [
|
||||
['candidates', '/src/pages/admin/Candidates.jsx', '/admin/candidates', 'candidates'],
|
||||
['talent-pool', '/src/pages/admin/TalentPool.jsx', '/admin/talent-pool', 'talent-pool'],
|
||||
['analytics', '/src/pages/admin/Analytics.jsx', '/admin/analytics', 'analytics'],
|
||||
['candidates-analysis', '/src/pages/admin/CandidatesAnalysis.jsx', '/admin/candidates-analysis', 'candidates-analysis'],
|
||||
['control-center', '/src/pages/admin/ControlCenter.jsx', '/admin', 'control-center'],
|
||||
/* The fourth column is the baseline FILE, not a stem. Three pages were
|
||||
recaptured after the final-selection work changed what they render and
|
||||
are named `.render.html`; the other two still hold genuine
|
||||
pre-migration markup and keep the name that says so. Deriving the
|
||||
filename from the page would force both sets to share one suffix, and
|
||||
whichever suffix won would be a lie about half of them. */
|
||||
['candidates', '/src/pages/admin/Candidates.jsx', '/admin/candidates', 'candidates.render.html'],
|
||||
['talent-pool', '/src/pages/admin/TalentPool.jsx', '/admin/talent-pool', 'talent-pool.pre-migration.html'],
|
||||
['analytics', '/src/pages/admin/Analytics.jsx', '/admin/analytics', 'analytics.render.html'],
|
||||
['candidates-analysis', '/src/pages/admin/CandidatesAnalysis.jsx', '/admin/candidates-analysis', 'candidates-analysis.pre-migration.html'],
|
||||
['control-center', '/src/pages/admin/ControlCenter.jsx', '/admin', 'control-center.pre-migration.html'],
|
||||
];
|
||||
|
||||
for (const [page, modulePath, route, baseline] of PAGES) {
|
||||
@@ -8734,7 +9142,7 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
React.createElement(UiEditingProvider, { page },
|
||||
React.createElement(Page))))
|
||||
));
|
||||
const before = readFileSync(join(ROOT, `scripts/__baseline__/${baseline}.pre-migration.html`), 'utf8');
|
||||
const before = readFileSync(join(ROOT, `scripts/__baseline__/${baseline}`), 'utf8');
|
||||
|
||||
/**
|
||||
* A migration may INSERT a wrapper class — grouping three siblings under
|
||||
@@ -9163,7 +9571,7 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
|
||||
record('the editor never evaluates a string as code', (() => {
|
||||
const files = ['UiEditor.jsx', 'TreePanel.jsx', 'NodeInspector.jsx', 'NodePicker.jsx', 'ops.js']
|
||||
.map((f) => readFileSync(join(ROOT, 'src/components/ui-editor', f), 'utf8'))
|
||||
.map((f) => readFileSync(join(ROOT, resolveSourcePath(`src/components/ui-editor/${f}`)), 'utf8'))
|
||||
.join('\n')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
return !/\beval\s*\(/.test(files) && !/new\s+Function/.test(files)
|
||||
@@ -9172,7 +9580,7 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
|
||||
record('the editor contains no page name and no component branch', (() => {
|
||||
const files = ['UiEditor.jsx', 'TreePanel.jsx', 'NodeInspector.jsx', 'NodePicker.jsx', 'ops.js']
|
||||
.map((f) => readFileSync(join(ROOT, 'src/components/ui-editor', f), 'utf8'))
|
||||
.map((f) => readFileSync(join(ROOT, resolveSourcePath(`src/components/ui-editor/${f}`)), 'utf8'))
|
||||
.join('\n')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
const banned = [...surfaces.SUPPORTED_SKILL_PAGES, ...nreg.list()];
|
||||
@@ -9385,7 +9793,7 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
* `scripts/browser-flows.js`, and only held to its contract here.
|
||||
*/
|
||||
record('every node is rendered inside a boundary', (() => {
|
||||
const source = readFileSync(join(ROOT, 'src/components/ui-tree/UiTreeRenderer.jsx'), 'utf8');
|
||||
const source = readFileSync(join(ROOT, resolveSourcePath('src/components/ui-tree/UiTreeRenderer.jsx')), 'utf8');
|
||||
return {
|
||||
pass: /<UiNodeBoundary node=\{node\}>/.test(source)
|
||||
&& /import \{ UiNodeBoundary \}/.test(source),
|
||||
|
||||
108
scripts/ssr-resolve.mjs
Normal file
108
scripts/ssr-resolve.mjs
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same resolution, for source read as TEXT rather than loaded as a module.
|
||||
*
|
||||
* `skill-check.mjs` asserts structural facts by reading source files and
|
||||
* matching against their contents — "the panel imports no local suggestion
|
||||
* ranker", "no runtime path writes a definition". Those reads go through
|
||||
* `readFileSync`, not `ssrLoadModule`, so `withSourceResolution` above never
|
||||
* sees them: it wraps the loader, and this is a second, entirely separate
|
||||
* channel. Renaming `base44Client.js` to `.ts` is what surfaced the difference,
|
||||
* as an ENOENT in the middle of a suite that had been passing.
|
||||
*
|
||||
* Takes and returns a path RELATIVE to the project root, so the call site keeps
|
||||
* its `join(ROOT, …)` exactly as it was:
|
||||
*
|
||||
* readFileSync(join(ROOT, resolveSourcePath('src/api/base44Client.js')), 'utf8')
|
||||
*
|
||||
* Unresolvable paths come back unchanged, so the resulting error still names
|
||||
* the file the caller asked for rather than a candidate it invented. That also
|
||||
* keeps the deliberately absent ones honest: a check asserting a file is GONE
|
||||
* gets the path it asked about, and `existsSync` still answers false.
|
||||
*/
|
||||
export function resolveSourcePath(path, root = process.cwd()) {
|
||||
for (const candidate of candidatesFor(path)) {
|
||||
if (existsSync(join(root, candidate))) return candidate;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import AdminCandidateProfile from '@/pages/admin/CandidateProfile';
|
||||
import AdminCandidatesAnalysis from '@/pages/admin/CandidatesAnalysis';
|
||||
import AdminHiredHistory from '@/pages/admin/HiredHistory';
|
||||
import AdminTalentPool from '@/pages/admin/TalentPool';
|
||||
import AdminTalentProfile from '@/pages/admin/TalentProfile';
|
||||
import AdminAnalytics from '@/pages/admin/Analytics';
|
||||
import AdminActivity from '@/pages/admin/Activity';
|
||||
import AdminProfile from '@/pages/admin/Profile';
|
||||
@@ -107,6 +108,12 @@ const AuthenticatedApp = () => {
|
||||
<Route path="candidates-analysis" element={<AdminCandidatesAnalysis />} />
|
||||
<Route path="hired" element={<AdminHiredHistory />} />
|
||||
<Route path="talent-pool" element={<AdminTalentPool />} />
|
||||
{/* The person, keyed by worker profile — distinct from
|
||||
`candidates/:id`, which is one person FOR ONE POSITION. Two
|
||||
routes because they answer different questions; one screen
|
||||
would push the product back towards a candidate record per
|
||||
vacancy, which is the thing `worker_profiles` exists to stop. */}
|
||||
<Route path="talent/:id" element={<AdminTalentProfile />} />
|
||||
<Route path="university" element={<University />} />
|
||||
<Route path="university/:id" element={<CourseDetail />} />
|
||||
<Route path="analytics" element={<AdminAnalytics />} />
|
||||
@@ -13,7 +13,7 @@
|
||||
* and results that actually respond to the data instead of being canned.
|
||||
*/
|
||||
|
||||
const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
|
||||
const clamp = (n: unknown, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
|
||||
|
||||
/** Simulated model latency so streaming/pending UI behaves as designed. */
|
||||
const think = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -21,39 +21,39 @@ const think = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
/* ── Prompt field readers ──────────────────────────────────────────────── */
|
||||
|
||||
/** Reads a `Label: value` line out of a prompt. */
|
||||
function field(prompt, label) {
|
||||
function field(prompt: string, label: string): string {
|
||||
const match = prompt.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'mi'));
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
function numField(prompt, label) {
|
||||
function numField(prompt: string, label: string): number {
|
||||
const raw = field(prompt, label);
|
||||
const match = raw.match(/-?\d+(\.\d+)?/);
|
||||
return match ? Number(match[0]) : 0;
|
||||
}
|
||||
|
||||
/** Reads a comma-separated line, treating the app's "None" sentinel as empty. */
|
||||
function listField(prompt, label) {
|
||||
function listField(prompt: string, label: string): string[] {
|
||||
const raw = field(prompt, label);
|
||||
if (!raw || /^(none|not specified|not provided)$/i.test(raw)) return [];
|
||||
return raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Pulls the first triple-quoted block (transcripts, free-text resumes). */
|
||||
function quotedBlock(prompt) {
|
||||
function quotedBlock(prompt: string): string {
|
||||
const match = prompt.match(/"""\s*([\s\S]*?)\s*"""/);
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
const ENGLISH_RANK = { basic: 1, conversational: 2, fluent: 3, native: 4 };
|
||||
const ENGLISH_RANK: Record<string, number> = { basic: 1, conversational: 2, fluent: 3, native: 4 };
|
||||
|
||||
const isSpanish = (prompt) => /Spanish \(español\)/.test(prompt) && !/default to English/.test(prompt);
|
||||
const isSpanish = (prompt: string) => /Spanish \(español\)/.test(prompt) && !/default to English/.test(prompt);
|
||||
|
||||
/* ── 1. Candidate screening ────────────────────────────────────────────── */
|
||||
|
||||
function screenCandidate(prompt) {
|
||||
function screenCandidate(prompt: string) {
|
||||
// Weights are stated inline: "experience 25%, english 20%, …"
|
||||
const weightOf = (name) => {
|
||||
const weightOf = (name: string) => {
|
||||
const match = prompt.match(new RegExp(`${name} (\\d+)%`));
|
||||
return match ? Number(match[1]) : 20;
|
||||
};
|
||||
@@ -184,7 +184,7 @@ function screenCandidate(prompt) {
|
||||
|
||||
/* ── 2. Job description generator ──────────────────────────────────────── */
|
||||
|
||||
const RESPONSIBILITY_LIBRARY = {
|
||||
const RESPONSIBILITY_LIBRARY: Record<string, string[]> = {
|
||||
Bartender: [
|
||||
'Set up and break down a full service bar',
|
||||
'Pour to spec at event pace without sacrificing presentation',
|
||||
@@ -236,7 +236,7 @@ const DEFAULT_RESPONSIBILITIES = [
|
||||
'Complete close-out duties before leaving',
|
||||
];
|
||||
|
||||
function generateJobDescription(prompt) {
|
||||
function generateJobDescription(prompt: string) {
|
||||
const title = field(prompt, 'Role Title');
|
||||
const category = field(prompt, 'Role Category');
|
||||
const minYears = numField(prompt, 'Min Experience');
|
||||
@@ -301,7 +301,7 @@ const CERT_KEYWORDS = [
|
||||
['food handler', 'Food Handler Card'], ['forklift', 'Forklift Operator'], ['osha', 'OSHA 10'],
|
||||
];
|
||||
|
||||
function buildResume(prompt) {
|
||||
function buildResume(prompt: string) {
|
||||
const text = quotedBlock(prompt);
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
@@ -343,14 +343,14 @@ function buildResume(prompt) {
|
||||
|
||||
const INTERVIEW_QUESTIONS = {
|
||||
en: [
|
||||
(role) => `Thanks for joining. To start — what drew you to ${role} work, and how long have you been doing it?`,
|
||||
(role: string) => `Thanks for joining. To start — what drew you to ${role} work, and how long have you been doing it?`,
|
||||
() => `Walk me through your busiest shift. What actually happened, and what did you do about it?`,
|
||||
() => `Something goes wrong mid-service and your lead is unreachable. Talk me through your next three moves.`,
|
||||
() => `Tell me about feedback that changed how you work. What did you do differently afterward?`,
|
||||
() => `Last one — what does your schedule realistically look like, and what do you want to be doing a year from now?`,
|
||||
],
|
||||
es: [
|
||||
(role) => `Gracias por acompañarme. Para empezar — ¿qué te atrajo al trabajo de ${role}, y cuánto tiempo llevas haciéndolo?`,
|
||||
(role: string) => `Gracias por acompañarme. Para empezar — ¿qué te atrajo al trabajo de ${role}, y cuánto tiempo llevas haciéndolo?`,
|
||||
() => `Cuéntame de tu turno más ocupado. ¿Qué pasó realmente, y qué hiciste al respecto?`,
|
||||
() => `Algo sale mal a mitad del servicio y no puedes contactar a tu supervisor. Explícame tus siguientes tres pasos.`,
|
||||
() => `Háblame de algún comentario que cambió tu manera de trabajar. ¿Qué hiciste diferente después?`,
|
||||
@@ -358,7 +358,7 @@ const INTERVIEW_QUESTIONS = {
|
||||
],
|
||||
};
|
||||
|
||||
function interviewQuestion(prompt) {
|
||||
function interviewQuestion(prompt: string) {
|
||||
const lang = isSpanish(prompt) ? 'es' : 'en';
|
||||
const numberMatch = prompt.match(/Ask question (\d+) of 5/);
|
||||
const index = Math.min(4, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
|
||||
@@ -369,7 +369,7 @@ function interviewQuestion(prompt) {
|
||||
|
||||
const OWLIVER_QUESTIONS = {
|
||||
en: [
|
||||
(name) => `Hey ${name} — good to meet you. Tell me about yourself, in your own words.`,
|
||||
(name: string) => `Hey ${name} — good to meet you. Tell me about yourself, in your own words.`,
|
||||
() => `I like that. Tell me about something you got through that you're genuinely proud of.`,
|
||||
() => `Picture this: you're the only one on shift, 300 guests arrive early, and the kitchen is behind. What do you do?`,
|
||||
() => `What's a piece of feedback that stuck with you — and what changed after it?`,
|
||||
@@ -377,7 +377,7 @@ const OWLIVER_QUESTIONS = {
|
||||
() => `That's everything I needed. Anything you want to add before I put this together?`,
|
||||
],
|
||||
es: [
|
||||
(name) => `Hola ${name} — un gusto conocerte. Cuéntame de ti, con tus propias palabras.`,
|
||||
(name: string) => `Hola ${name} — un gusto conocerte. Cuéntame de ti, con tus propias palabras.`,
|
||||
() => `Me gusta eso. Cuéntame de algo que superaste y de lo que estés realmente orgulloso.`,
|
||||
() => `Imagina: estás solo en el turno, llegan 300 invitados antes de tiempo y la cocina va atrasada. ¿Qué haces?`,
|
||||
() => `¿Qué comentario se te quedó grabado — y qué cambió después?`,
|
||||
@@ -386,7 +386,7 @@ const OWLIVER_QUESTIONS = {
|
||||
],
|
||||
};
|
||||
|
||||
function owliverQuestion(prompt) {
|
||||
function owliverQuestion(prompt: string) {
|
||||
const lang = isSpanish(prompt) ? 'es' : 'en';
|
||||
const numberMatch = prompt.match(/you are on question (\d+)/);
|
||||
const index = Math.min(5, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
|
||||
@@ -398,7 +398,7 @@ function owliverQuestion(prompt) {
|
||||
/* ── 5. Interview evaluation ───────────────────────────────────────────── */
|
||||
|
||||
/** Scores a transcript on how substantive the candidate's own answers are. */
|
||||
function readTranscript(prompt) {
|
||||
function readTranscript(prompt: string) {
|
||||
const transcript = prompt.split('TRANSCRIPT:')[1] || '';
|
||||
const answers = transcript
|
||||
.split('\n')
|
||||
@@ -412,7 +412,7 @@ function readTranscript(prompt) {
|
||||
return { answers, words, avgWords, specifics };
|
||||
}
|
||||
|
||||
function evaluateInterview(prompt) {
|
||||
function evaluateInterview(prompt: string) {
|
||||
const { answers, avgWords, specifics } = readTranscript(prompt);
|
||||
const fastMatch = prompt.match(/(\d+) candidate response\(s\) were suspiciously fast/);
|
||||
const fast = fastMatch ? Number(fastMatch[1]) : 0;
|
||||
@@ -489,9 +489,14 @@ function evaluateInterview(prompt) {
|
||||
|
||||
/* ── 6. Talent matching ────────────────────────────────────────────────── */
|
||||
|
||||
function matchTalent(prompt) {
|
||||
function matchTalent(prompt: string) {
|
||||
const block = prompt.match(/AVAILABLE TALENT \(JSON\):\s*(\[[\s\S]*?\])\s*\n\nYou are NEVER/);
|
||||
let pool = [];
|
||||
/* Whatever the caller embedded in the prompt's talent-pool block, as
|
||||
`JSON.parse` hands it back. Not `WorkerProfile[]`: this is a simulated
|
||||
engine reading a blob out of free text, and the rows carry computed
|
||||
fields like `match_score` that no entity declares. Typing it as a
|
||||
record would assert a shape nothing validates. */
|
||||
let pool: any[] = [];
|
||||
try {
|
||||
pool = block ? JSON.parse(block[1]) : [];
|
||||
} catch {
|
||||
@@ -513,9 +518,9 @@ function matchTalent(prompt) {
|
||||
? Math.min(20, ((p.experience_years || 0) / minYears) * 20)
|
||||
: Math.min(20, (p.experience_years || 0) * 3);
|
||||
|
||||
const certs = (p.certifications || []).map((c) => c.toLowerCase());
|
||||
const certs = (p.certifications || []).map((c: string) => c.toLowerCase());
|
||||
const certFit = requiredCerts.length
|
||||
? (requiredCerts.filter((r) => certs.some((c) => c.includes(r) || r.includes(c))).length / requiredCerts.length) * 15
|
||||
? (requiredCerts.filter((r) => certs.some((c: string) => c.includes(r) || r.includes(c))).length / requiredCerts.length) * 15
|
||||
: (certs.length ? 12 : 5);
|
||||
|
||||
const scoreFit = ((p.krow_score || 0) / 100) * 20;
|
||||
@@ -547,7 +552,11 @@ function matchTalent(prompt) {
|
||||
/* ── 7. Owliver profile builder ────────────────────────────────────────── */
|
||||
|
||||
/** Job titles a worker might name for themselves, most specific first. */
|
||||
const ROLE_TITLES = [
|
||||
/* Explicit tuple, not `as const`: the entries are read as a pair — `.find(([re]) => re.test(…))`
|
||||
then `role[1]` — and without it the element widens to `string | RegExp`, which has no `.test`.
|
||||
`as const` would also work and would additionally make the array and its entries `readonly`,
|
||||
a change to the type this module publishes for no benefit it uses. */
|
||||
const ROLE_TITLES: [RegExp, string][] = [
|
||||
[/banquet captain|captain/, 'Banquet Captain'],
|
||||
[/executive chef/, 'Executive Chef'],
|
||||
[/sous chef/, 'Sous Chef'],
|
||||
@@ -563,7 +572,8 @@ const ROLE_TITLES = [
|
||||
[/manager/, 'Manager'],
|
||||
];
|
||||
|
||||
const AVAILABILITY_TOKENS = [
|
||||
/** Same pairing, same reason — matched by regex, collected by label. */
|
||||
const AVAILABILITY_TOKENS: [RegExp, string][] = [
|
||||
[/weekend|saturday|sunday|sábado|domingo/i, 'Weekends'],
|
||||
[/weekday|monday|tuesday|wednesday|thursday|friday|semana/i, 'Weekdays'],
|
||||
[/evening|night|noche|tarde/i, 'Evenings'],
|
||||
@@ -572,7 +582,7 @@ const AVAILABILITY_TOKENS = [
|
||||
[/on.?call|flexible/i, 'On-Call'],
|
||||
];
|
||||
|
||||
function buildCareerDna(prompt) {
|
||||
function buildCareerDna(prompt: string) {
|
||||
const transcript = quotedBlock(prompt);
|
||||
const lower = transcript.toLowerCase();
|
||||
const { answers, avgWords, specifics } = (() => {
|
||||
@@ -696,7 +706,7 @@ function buildCareerDna(prompt) {
|
||||
|
||||
/* ── 8. Proving Ground evaluator ───────────────────────────────────────── */
|
||||
|
||||
function evaluateChallenge(prompt) {
|
||||
function evaluateChallenge(prompt: string) {
|
||||
const criteriaMatch = prompt.match(/rubric" object: (.+?)\.\n/);
|
||||
const criteria = criteriaMatch
|
||||
? criteriaMatch[1].split(',').map((c) => c.trim()).filter(Boolean)
|
||||
@@ -743,7 +753,7 @@ function evaluateChallenge(prompt) {
|
||||
const verdict = base >= 70 ? 'verified' : base >= 50 ? 'needs_work' : 'failed';
|
||||
|
||||
// Spread the overall score across the rubric so criteria are not identical.
|
||||
const rubric = {};
|
||||
const rubric: Record<string, number> = {};
|
||||
criteria.forEach((criterion, i) => {
|
||||
rubric[criterion] = clamp(base + (i % 3 === 0 ? 4 : i % 3 === 1 ? -3 : 1));
|
||||
});
|
||||
@@ -768,7 +778,15 @@ function evaluateChallenge(prompt) {
|
||||
* Recognizes each workflow by the phrase its prompt opens with. Order matters
|
||||
* only in that every branch is mutually exclusive by design.
|
||||
*/
|
||||
const ROUTES = [
|
||||
/* `[pattern, handler, latencyMs]`. Spelled out because all three positions are used for what
|
||||
they are: `.test()` on the first, a call on the second, and `think(ms)` on the third. Left to
|
||||
inference the element becomes the union of all three and none of those work.
|
||||
|
||||
The handler returns `any` deliberately. The ten handlers below return ten different shapes,
|
||||
which is the point of a router — and `krowAi.js` relies on it: one caller does
|
||||
`typeof res === 'string' ? res : res.text || String(res)`, which a precise union would
|
||||
reject on every branch that has no `.text`. */
|
||||
const ROUTES: [RegExp, (prompt: string) => any, number][] = [
|
||||
[/^You are KROW's AI screening engine/m, screenCandidate, 1100],
|
||||
[/^You are an expert hiring copywriter/m, generateJobDescription, 1400],
|
||||
[/Extract and infer a structured resume/m, buildResume, 1200],
|
||||
@@ -782,8 +800,32 @@ const ROUTES = [
|
||||
() => 'Walk me through the exact order you would do that in — what happens first, and who do you tell?', 700],
|
||||
];
|
||||
|
||||
/** Drop-in replacement for `integrations.Core.InvokeLLM`. */
|
||||
export async function invokeLLM({ prompt = '', response_json_schema: schema } = {}) {
|
||||
/**
|
||||
* What a caller may send. Every field optional, because the destructuring default `= {}`
|
||||
* says a call with no argument at all is legal, and it is one this module answers.
|
||||
*
|
||||
* `model` and `file_urls` are not read here — the local engine routes on the prompt and
|
||||
* ignores both — but callers send them, and a parameter type that omitted them would reject
|
||||
* eight call sites in `krowAi.js` and one in `provingGround.ts` for passing a field the real
|
||||
* integration accepts. Declaring them describes the call this function is a drop-in for.
|
||||
*/
|
||||
export interface InvokeLLMOptions {
|
||||
prompt?: string;
|
||||
response_json_schema?: any;
|
||||
model?: string;
|
||||
file_urls?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for `integrations.Core.InvokeLLM`.
|
||||
*
|
||||
* The return stays `any` on purpose. See the note on `ROUTES`: the shape depends on which
|
||||
* handler matched, and narrowing it here would break callers that branch on the result at
|
||||
* run time rather than by type.
|
||||
*/
|
||||
export async function invokeLLM(
|
||||
{ prompt = '', response_json_schema: schema }: InvokeLLMOptions = {},
|
||||
): Promise<any> {
|
||||
const route = ROUTES.find(([pattern]) => pattern.test(prompt));
|
||||
|
||||
if (!route) {
|
||||
@@ -800,7 +842,7 @@ export async function invokeLLM({ prompt = '', response_json_schema: schema } =
|
||||
}
|
||||
|
||||
/** Drop-in replacement for `integrations.Core.UploadFile`. */
|
||||
export async function uploadFile({ file } = {}) {
|
||||
export async function uploadFile({ file }: { file?: File } = {}) {
|
||||
await think(500);
|
||||
if (!file) return { file_url: '' };
|
||||
// A blob URL keeps uploaded media viewable for the rest of the session
|
||||
@@ -39,15 +39,15 @@ const WINDOW_DAYS = 56;
|
||||
* Local rather than UTC because a shift belongs to the day it was worked in the
|
||||
* place it was worked, and `periodRange` windows on local day boundaries too.
|
||||
*/
|
||||
function daysAgo(n, hour = 9, minute = 0, anchor = new Date()) {
|
||||
function daysAgo(n: number, hour = 9, minute = 0, anchor = new Date()) {
|
||||
const d = new Date(anchor.getTime());
|
||||
d.setDate(d.getDate() - n);
|
||||
d.setHours(hour, minute, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
const round1 = (n) => Math.round(n * 10) / 10;
|
||||
const round2 = (n) => Math.round(n * 100) / 100;
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
@@ -105,10 +105,21 @@ const ROSTER = [
|
||||
* Returning a plain record keeps every rule visible in one place instead of
|
||||
* spread across the generator.
|
||||
*/
|
||||
const BEHAVIOUR = {
|
||||
/**
|
||||
* One worker's shift outcome, as the rules below return it.
|
||||
*
|
||||
* `i` is the index of the shift counting back from the anchor, `weekday` the
|
||||
* day it falls on. Rules that ignore the weekday take one argument, which is
|
||||
* why the second is optional here.
|
||||
*/
|
||||
type ShiftBehaviour = (i: number, weekday?: number) => {
|
||||
status: string; minutesLate: number; overtime: number; notes: string;
|
||||
};
|
||||
|
||||
const BEHAVIOUR: Record<string, ShiftBehaviour> = {
|
||||
/* Reliable. One late arrival every couple of months, and overtime only on
|
||||
the nights events actually overrun. */
|
||||
staff_marco: (i, weekday) => ({
|
||||
staff_marco: (i: number, weekday?: number) => ({
|
||||
status: i === 14 ? 'late' : 'present',
|
||||
minutesLate: i === 14 ? 9 : 0,
|
||||
/* Friday and Saturday events overrun; midweek ones do not. */
|
||||
@@ -124,7 +135,7 @@ const BEHAVIOUR = {
|
||||
* the six weeks before it — a change big enough to be worth surfacing and
|
||||
* specific enough to act on.
|
||||
*/
|
||||
staff_marcus: (i) => {
|
||||
staff_marcus: (i: number) => {
|
||||
if (i === 2 || i === 7) {
|
||||
return { status: 'absent', minutesLate: 0, overtime: 0, notes: 'Called in sick' };
|
||||
}
|
||||
@@ -145,7 +156,7 @@ const BEHAVIOUR = {
|
||||
* covers, on the three busiest shifts of each week. A steady climb rather
|
||||
* than a spike, which is exactly the shape that hides in a table of totals.
|
||||
*/
|
||||
staff_antoine: (i, weekday) => {
|
||||
staff_antoine: (i: number, weekday?: number) => {
|
||||
const weekIndex = Math.floor(i / 5);
|
||||
const busy = weekday === 4 || weekday === 5 || weekday === 6;
|
||||
const overtime = busy ? Math.max(0.5, round1(3.5 - weekIndex * 0.45)) : 0;
|
||||
@@ -154,8 +165,8 @@ const BEHAVIOUR = {
|
||||
};
|
||||
|
||||
/** Every shift date for one worker, most recent first. */
|
||||
function shiftOffsets(weekdays, anchor) {
|
||||
const offsets = [];
|
||||
function shiftOffsets(weekdays: number[], anchor: Date) {
|
||||
const offsets: number[] = [];
|
||||
for (let offset = 0; offset <= WINDOW_DAYS; offset += 1) {
|
||||
const day = daysAgo(offset, 9, 0, anchor).getDay();
|
||||
if (weekdays.includes(day)) offsets.push(offset);
|
||||
@@ -163,16 +174,16 @@ function shiftOffsets(weekdays, anchor) {
|
||||
return offsets;
|
||||
}
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const localDate = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
|
||||
/**
|
||||
* @param {Date} [anchor] the day to count back from. Defaults to now, which is
|
||||
* the point of this collection; a caller passes one only to hold the window
|
||||
* still — see buildShiftsAt.
|
||||
*/
|
||||
function buildShifts(anchor = new Date()) {
|
||||
const records = [];
|
||||
function buildShifts(anchor: Date = new Date()) {
|
||||
const records: any[] = [];
|
||||
|
||||
for (const worker of ROSTER) {
|
||||
const offsets = shiftOffsets(worker.weekdays, anchor);
|
||||
@@ -251,6 +262,6 @@ export const SHIFT_RECORDS = buildShifts();
|
||||
* shifts built around one day and bucketed around another describe two
|
||||
* different windows.
|
||||
*/
|
||||
export function buildShiftsAt(anchor) {
|
||||
export function buildShiftsAt(anchor: Date) {
|
||||
return buildShifts(anchor);
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
*/
|
||||
|
||||
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
|
||||
import type { PreferencesUpdateResult } from '@/types/user';
|
||||
import { invokeLLM, uploadFile } from './aiEngine';
|
||||
import { DEMO_USER } from './demoUser';
|
||||
|
||||
@@ -143,14 +144,18 @@ let authenticated = false;
|
||||
* A 401 here is the ordinary state of a signed-out visitor, not a failure: the
|
||||
* app opens on the login page and this request is how it finds that out.
|
||||
*/
|
||||
let hydration = request('GET', '/me')
|
||||
/* `Promise<any> | null`, because the runtime already assigns both: the shared
|
||||
first request, then `null` once it has been consumed or superseded by a
|
||||
login. Inference alone would fix the type at `Promise<any>` and reject the
|
||||
two `hydration = null` assignments that make the sharing work. */
|
||||
let hydration: Promise<any> | null = request('GET', '/me')
|
||||
.then((user) => {
|
||||
currentUser = user;
|
||||
authenticated = true;
|
||||
cacheUser();
|
||||
return user;
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((): null => {
|
||||
authenticated = false;
|
||||
return null;
|
||||
});
|
||||
@@ -204,7 +209,11 @@ const auth = {
|
||||
* by design: telling the two apart would say whether an address is
|
||||
* registered. The caller shows that message as-is.
|
||||
*/
|
||||
async login({ email, password, rememberMe = false }) {
|
||||
async login({ email, password, rememberMe = false }: {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe?: boolean;
|
||||
}) {
|
||||
const user = await request('POST', '/auth/login', {
|
||||
body: { email, password, remember_me: Boolean(rememberMe) },
|
||||
});
|
||||
@@ -215,7 +224,7 @@ const auth = {
|
||||
return { ...user };
|
||||
},
|
||||
|
||||
async updateMe(patch) {
|
||||
async updateMe(patch: any) {
|
||||
const user = await request('PATCH', '/me', { body: patch });
|
||||
currentUser = user;
|
||||
cacheUser();
|
||||
@@ -247,7 +256,7 @@ const auth = {
|
||||
* question the shape exists to answer is now answered by whether this
|
||||
* function resolved at all.
|
||||
*/
|
||||
async updatePreferences(patch) {
|
||||
async updatePreferences(patch: any): Promise<PreferencesUpdateResult> {
|
||||
const preferences = await request('PATCH', '/me/preferences', { body: patch });
|
||||
currentUser = { ...currentUser, preferences };
|
||||
cacheUser();
|
||||
@@ -274,7 +283,7 @@ const auth = {
|
||||
* reach the API must still leave the browser signed out locally, and the
|
||||
* cookie it keeps will be refused by every request it is sent on.
|
||||
*/
|
||||
async logout(redirectTo = '/admin/login') {
|
||||
async logout(redirectTo: string = '/admin/login') {
|
||||
try {
|
||||
await request('POST', '/auth/logout');
|
||||
} catch {
|
||||
@@ -320,7 +329,7 @@ const workflows = {
|
||||
* across from the application by the server, because it is already the truth
|
||||
* about this person and retyping it here is how the two records drift apart.
|
||||
*/
|
||||
async hire(applicationId, body = {}) {
|
||||
async hire(applicationId: string, body: any = {}) {
|
||||
return request('POST', `/job-applications/${encodeURIComponent(applicationId)}/hire`, {
|
||||
body,
|
||||
});
|
||||
@@ -336,7 +345,7 @@ const workflows = {
|
||||
* inside the same transaction; a worker taken straight from the talent pool
|
||||
* with neither is placed without one rather than given an invented one.
|
||||
*/
|
||||
async assign(jobPostingId, workers = []) {
|
||||
async assign(jobPostingId: string, workers: any[] = []) {
|
||||
return request('POST', `/job-postings/${encodeURIComponent(jobPostingId)}/assignments`, {
|
||||
body: { workers },
|
||||
});
|
||||
@@ -368,7 +377,7 @@ const workflows = {
|
||||
*/
|
||||
const owliver = {
|
||||
/** @param {any} request */
|
||||
async suggestions({ page, query = '' } = {}) {
|
||||
async suggestions({ page, query = '' }: { page?: string; query?: string } = {}) {
|
||||
if (!page) return [];
|
||||
const typed = String(query || '').trim();
|
||||
const data = await request('GET', '/owliver/suggestions', {
|
||||
@@ -388,7 +397,7 @@ const integrations = {
|
||||
};
|
||||
|
||||
const analytics = {
|
||||
track({ eventName, properties } = {}) {
|
||||
track({ eventName, properties }: { eventName?: string; properties?: any } = {}) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('[krow-demo] analytics', eventName, properties || {});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { User } from '@/types/user';
|
||||
|
||||
/**
|
||||
* The shape of a signed-in user, before the server has answered.
|
||||
*
|
||||
@@ -22,7 +24,7 @@
|
||||
* the server has never stored still has to resolve to something rather than to
|
||||
* `undefined`.
|
||||
*/
|
||||
export const DEMO_USER = {
|
||||
export const DEMO_USER: User = {
|
||||
id: 'user_demo',
|
||||
full_name: 'Alex Rivera',
|
||||
email: 'demo@krow.app',
|
||||
@@ -37,3 +39,29 @@ export const DEMO_USER = {
|
||||
emailDigest: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The employer account, and the reason there is a second one at all.
|
||||
*
|
||||
* `policy.go` has always had three roles — admin, employer, talent — and the
|
||||
* fixture had one administrator, so two thirds of the authorization table was
|
||||
* never exercised and there was nobody to sign in as to reach the employer
|
||||
* console. This account is what makes that path reachable.
|
||||
*
|
||||
* Unlike `DEMO_USER` this is not a default shape for the first render: nothing
|
||||
* in the running app reads it. It exists so that `seedData.User` names both
|
||||
* accounts, which is what the backend seeder writes. See `seed.ts`.
|
||||
*/
|
||||
export const EMPLOYER_USER: User = {
|
||||
id: 'user_employer',
|
||||
full_name: 'Jordan Blake',
|
||||
email: 'employer@krow.app',
|
||||
role: 'employer',
|
||||
account_type: 'employer',
|
||||
created_date: '2026-06-01T09:00:00.000Z',
|
||||
preferences: {
|
||||
owliverDefault: true,
|
||||
compactDensity: false,
|
||||
emailDigest: true,
|
||||
},
|
||||
};
|
||||
@@ -27,6 +27,9 @@
|
||||
* did not answer.
|
||||
*/
|
||||
|
||||
import type { ApiErrorResponse, KrowApiError } from '@/types/api';
|
||||
import type { EntityName, EntityResourcePath } from '@/types/entities';
|
||||
|
||||
/**
|
||||
* Where the API lives.
|
||||
*
|
||||
@@ -93,7 +96,13 @@ if (import.meta.env?.DEV && /^https?:\/\//i.test(API_BASE_URL)) {
|
||||
* `user-activity` is three special cases wearing a trench coat, and a wrong
|
||||
* guess here is a 404 at runtime instead of a mistake anyone can see.
|
||||
*/
|
||||
const RESOURCE_PATHS = {
|
||||
/* `Record<EntityName, EntityResourcePath>` is doing two checks at once, and
|
||||
both were previously unavailable. Every one of the eighteen names must be
|
||||
present as a key — so this map and `ENTITY_NAMES` in `base44Client.js` can no
|
||||
longer drift apart silently — and every value must be one of the declared
|
||||
paths, so a typo in a resource segment is a compile error rather than a 404
|
||||
at run time. The object itself is unchanged, key for key. */
|
||||
const RESOURCE_PATHS: Record<EntityName, EntityResourcePath> = {
|
||||
JobPosting: 'job-postings',
|
||||
JobApplication: 'job-applications',
|
||||
AIInterview: 'ai-interviews',
|
||||
@@ -128,7 +137,7 @@ const RESOURCE_PATHS = {
|
||||
* `got === undefined` against real values and matched nothing, and sending the
|
||||
* string "undefined" would be a filter on a value no column holds.
|
||||
*/
|
||||
function queryString(params) {
|
||||
function queryString(params: Record<string, unknown>) {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined) continue;
|
||||
@@ -153,9 +162,14 @@ function queryString(params) {
|
||||
* ride along as properties — new information a local store never had, and
|
||||
* additive, so nothing that only reads `.message` notices.
|
||||
*/
|
||||
function apiError(status, payload) {
|
||||
function apiError(status: number, payload: ApiErrorResponse | null): KrowApiError {
|
||||
const body = payload?.error;
|
||||
const error = new Error(body?.message || `Request failed with status ${status}`);
|
||||
/* Cast, not `class KrowApiError extends Error`. The properties below are
|
||||
assigned onto a plain Error exactly as they always were, so the prototype
|
||||
chain, `instanceof Error`, and the `error.name === 'KrowApiError'` test
|
||||
callers use all behave identically. A subclass would change all three, and
|
||||
the emitted JavaScript with it. */
|
||||
const error = new Error(body?.message || `Request failed with status ${status}`) as KrowApiError;
|
||||
error.name = 'KrowApiError';
|
||||
error.status = status;
|
||||
error.code = body?.code || 'internal';
|
||||
@@ -171,10 +185,14 @@ function apiError(status, payload) {
|
||||
* reads it (§4.2), and surfacing it would mean changing what the six methods
|
||||
* return, which is the one thing Phase 2D must not do.
|
||||
*/
|
||||
async function request(method, path, { query, body } = {}) {
|
||||
async function request(
|
||||
method: string,
|
||||
path: string,
|
||||
{ query, body }: { query?: Record<string, unknown>; body?: unknown } = {},
|
||||
) {
|
||||
const url = `${API_BASE_URL}${path}${query ? queryString(query) : ''}`;
|
||||
|
||||
let response;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
@@ -199,7 +217,7 @@ async function request(method, path, { query, body } = {}) {
|
||||
`Cannot reach the Krow API at ${API_BASE_URL}. Is the Go API running on ` +
|
||||
`127.0.0.1:8080, and is the Vite dev server proxying /api to it? ` +
|
||||
`(${method} ${path})`
|
||||
);
|
||||
) as KrowApiError;
|
||||
error.name = 'KrowApiError';
|
||||
error.status = 0;
|
||||
error.code = 'unreachable';
|
||||
@@ -221,7 +239,7 @@ async function request(method, path, { query, body } = {}) {
|
||||
if (!response.ok) throw apiError(response.status, payload);
|
||||
|
||||
if (payload === null) {
|
||||
const error = new Error(`${method} ${path} returned no JSON body`);
|
||||
const error = new Error(`${method} ${path} returned no JSON body`) as KrowApiError;
|
||||
error.name = 'KrowApiError';
|
||||
error.status = response.status;
|
||||
error.code = 'internal';
|
||||
@@ -242,7 +260,7 @@ export { request };
|
||||
* before the first login and after a session expires — so callers need to tell
|
||||
* it apart from a real failure rather than treating both as "something broke".
|
||||
*/
|
||||
export function isUnauthenticated(error) {
|
||||
export function isUnauthenticated(error: any) {
|
||||
return Boolean(error) && (error.status === 401 || error.code === 'unauthorized');
|
||||
}
|
||||
|
||||
@@ -256,13 +274,33 @@ export function isUnauthenticated(error) {
|
||||
* `list(sort = '-created_date', limit = 100)`, and several call sites rely on
|
||||
* them rather than passing their own.
|
||||
*/
|
||||
export function createEntity(name) {
|
||||
const path = RESOURCE_PATHS[name];
|
||||
export function createEntity(name: string) {
|
||||
/* `name` stays `string`, and the cast below is deliberate. Typing the
|
||||
parameter as `EntityName` would be tighter and would immediately break the
|
||||
one caller — `base44Client.js` builds the entity map by mapping over a
|
||||
plain string array — which is a file this phase does not touch.
|
||||
|
||||
The return type is left inferred, and that is a decision rather than an
|
||||
omission. Annotating it `EntityClient<any>` was tried and reverted: it
|
||||
makes `list()` return `Promise<any[]>` where inference gives
|
||||
`Promise<any>`, and two call sites in `src/lib/agents/agentStore.js` do
|
||||
|
||||
const rows = qc.getQueryData(KEY) || (await …list('-created_date', 200));
|
||||
|
||||
React Query types `getQueryData` as `unknown`, and `unknown || any`
|
||||
collapses to `any` while `unknown || any[]` stays `unknown` — so `.find`
|
||||
on the next line stopped compiling. Two new errors in a file this phase is
|
||||
not migrating, for no gain: the surface is structurally identical either
|
||||
way, and the emitted JavaScript is byte-for-byte the same.
|
||||
|
||||
`EntityClientFor<K>` and the generated record types are ready for the phase
|
||||
that migrates those consumers. Nothing here has to change then. */
|
||||
const path = RESOURCE_PATHS[name as EntityName];
|
||||
if (!path) throw new Error(`No API resource path is declared for entity ${name}`);
|
||||
const base = `/${path}`;
|
||||
|
||||
return {
|
||||
entityName: name,
|
||||
entityName: name as EntityName,
|
||||
|
||||
/**
|
||||
* `sort` is always sent, even when empty. `?sort=` is not the same as
|
||||
@@ -281,19 +319,19 @@ export function createEntity(name) {
|
||||
return request('GET', base, { query: { ...query, sort, limit } });
|
||||
},
|
||||
|
||||
async get(id) {
|
||||
async get(id: string) {
|
||||
return request('GET', `${base}/${encodeURIComponent(id)}`);
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
async create(data: any) {
|
||||
return request('POST', base, { body: data });
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
async update(id: string, data: any) {
|
||||
return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data });
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
async delete(id: string) {
|
||||
return request('DELETE', `${base}/${encodeURIComponent(id)}`);
|
||||
},
|
||||
|
||||
@@ -305,8 +343,8 @@ export function createEntity(name) {
|
||||
* identical — the first rejection stops the run and the records before it
|
||||
* are already written.
|
||||
*/
|
||||
async bulkCreate(records = []) {
|
||||
const created = [];
|
||||
async bulkCreate(records: any[] = []) {
|
||||
const created: any[] = [];
|
||||
for (const record of records) created.push(await this.create(record));
|
||||
return created;
|
||||
},
|
||||
@@ -9,9 +9,9 @@
|
||||
*/
|
||||
|
||||
import { SHIFT_RECORDS } from './attendanceSeed';
|
||||
import { DEMO_USER } from './demoUser';
|
||||
import { DEMO_USER, EMPLOYER_USER } from './demoUser';
|
||||
|
||||
const iso = (date) => new Date(`${date}T09:00:00.000Z`).toISOString();
|
||||
const iso = (date: string) => new Date(`${date}T09:00:00.000Z`).toISOString();
|
||||
|
||||
/* ── Reference data ────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -347,7 +347,7 @@ const JOB_POSTINGS = [
|
||||
|
||||
/* ── Applications ──────────────────────────────────────────────────────── */
|
||||
|
||||
const breakdown = (over) => ({
|
||||
const breakdown = (over?: Record<string, number>) => ({
|
||||
experience: 0, english: 0, reliability: 0, certifications: 0, availability: 0,
|
||||
personality: 0, culture_fit: 0, communication_style: 0, attendance_expectations: 0,
|
||||
physical_requirements: 0, leadership_expectations: 0, job_related_answers: 0,
|
||||
@@ -356,13 +356,13 @@ const breakdown = (over) => ({
|
||||
});
|
||||
|
||||
/** An applicant who has not been screened yet — every AI field stays empty. */
|
||||
const unscreened = (over) => ({
|
||||
const unscreened = (over?: Record<string, any>) => ({
|
||||
status: 'applied',
|
||||
ai_score: 0,
|
||||
certifications: [],
|
||||
availability: [],
|
||||
skills: [],
|
||||
companies_worked: [],
|
||||
certifications: [] as string[],
|
||||
availability: [] as string[],
|
||||
skills: [] as string[],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 0,
|
||||
created_date: iso('2026-08-04'),
|
||||
updated_date: iso('2026-08-04'),
|
||||
@@ -428,7 +428,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['ServSafe', 'CPR / First Aid'],
|
||||
availability: ['Weekdays', 'Weekends', 'Evenings'],
|
||||
skills: ['Menu design', 'Brigade leadership', 'Food costing', 'Plated dinners', 'Offsite catering'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 4.9,
|
||||
professional_summary:
|
||||
'Executive chef with 12 years across hotel banquets and high-end offsite catering. Has run brigades of 20+ and holds plate standards at 400 covers.',
|
||||
@@ -480,7 +480,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['Guard Card', 'CPR / First Aid'],
|
||||
availability: ['Weekends', 'Evenings', 'Overnight'],
|
||||
skills: ['Crowd management', 'De-escalation', 'Incident reporting', 'Access control'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 4.8,
|
||||
professional_summary:
|
||||
'Licensed event security officer with five years across concerts and corporate activations. Known for defusing situations before they escalate.',
|
||||
@@ -514,7 +514,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['Guard Card'],
|
||||
availability: ['Weekends', 'Evenings'],
|
||||
skills: ['Access control', 'Incident reporting'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 0,
|
||||
professional_summary: 'Venue security officer transitioning into private event work.',
|
||||
job_posting_id: 'job_security',
|
||||
@@ -562,7 +562,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['RBS Alcohol Server', 'TIPS Certified'],
|
||||
availability: ['Weekdays', 'Evenings'],
|
||||
skills: ['Batch cocktails', 'Craft cocktails', 'Bar setup', 'Inventory control'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 4.7,
|
||||
professional_summary:
|
||||
'Corporate event bartender with six years on Bay Area tech campuses. Builds batched programs that hold quality at volume.',
|
||||
@@ -605,7 +605,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['Food Handler Card'],
|
||||
availability: ['Weekends', 'Evenings'],
|
||||
skills: ['Synchronized service', 'Tray service', 'Wine service', 'Guest relations'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 4.6,
|
||||
professional_summary:
|
||||
'Fine dining event server with four years of coursed banquet service and wine knowledge.',
|
||||
@@ -639,7 +639,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['Food Handler Card'],
|
||||
availability: ['Weekends'],
|
||||
skills: ['Banquet service', 'Tray service', 'Table resets'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 4.4,
|
||||
professional_summary: 'Banquet server with three years of seated dinner service.',
|
||||
selfie_url: 'https://i.pravatar.cc/240?img=44',
|
||||
@@ -673,7 +673,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: [],
|
||||
availability: ['Weekends', 'Evenings'],
|
||||
skills: ['Table service'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 0,
|
||||
professional_summary: 'Restaurant server moving into event work.',
|
||||
job_posting_id: 'job_server_fine',
|
||||
@@ -695,7 +695,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['TIPS Certified'],
|
||||
availability: ['Weekends', 'Evenings'],
|
||||
skills: ['Classic cocktails', 'Bar setup', 'Speed pouring'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 4.2,
|
||||
professional_summary: 'Event bartender with three years of high-volume weekend service.',
|
||||
job_posting_id: 'job_bartender',
|
||||
@@ -728,7 +728,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['TIPS Certified'],
|
||||
availability: ['Evenings'],
|
||||
skills: ['Bar setup', 'Wine service'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 4.0,
|
||||
professional_summary: 'Barback stepping up to bartending at private events.',
|
||||
job_posting_id: 'job_bartender',
|
||||
@@ -761,7 +761,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: [],
|
||||
availability: ['Weekends'],
|
||||
skills: [],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 0,
|
||||
professional_summary: 'Looking for a first hospitality role.',
|
||||
job_posting_id: 'job_bartender',
|
||||
@@ -795,7 +795,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['Forklift Operator'],
|
||||
availability: ['Weekdays', 'Early mornings'],
|
||||
skills: ['Order picking', 'Scanner operation', 'Pallet staging'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 3.9,
|
||||
professional_summary: 'Warehouse picker with forklift certification.',
|
||||
job_posting_id: 'job_picker',
|
||||
@@ -827,7 +827,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: [],
|
||||
availability: ['Weekdays'],
|
||||
skills: ['Order picking'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 0,
|
||||
professional_summary: 'Fulfillment associate seeking steady warehouse shifts.',
|
||||
job_posting_id: 'job_picker',
|
||||
@@ -854,7 +854,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['Food Handler Card'],
|
||||
availability: ['Weekdays', 'Weekends'],
|
||||
skills: ['Banquet leadership', 'BEO reading', 'Floor coordination'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 0,
|
||||
professional_summary: 'Banquet server with lead-shift experience in hotel events.',
|
||||
job_posting_id: 'job_banquet',
|
||||
@@ -874,7 +874,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['Food Handler Card'],
|
||||
availability: ['Weekdays', 'Weekends', 'Evenings'],
|
||||
skills: ['Floor leadership', 'Client relations', 'Event reporting'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 0,
|
||||
professional_summary: 'Hotel banquet captain with six years directing event floors.',
|
||||
job_posting_id: 'job_banquet',
|
||||
@@ -900,7 +900,7 @@ const JOB_APPLICATIONS = [
|
||||
|
||||
/* ── AI interviews ─────────────────────────────────────────────────────── */
|
||||
|
||||
const transcript = (candidate, role) => [
|
||||
const transcript = (candidate: string, role: string) => [
|
||||
{ role: 'assistant', content: `Hi ${candidate.split(' ')[0]} — thanks for making time. Tell me about the busiest ${role} shift you've worked and how you handled it.`, timestamp: iso('2026-08-04') },
|
||||
{ role: 'user', content: 'We were short two people on a 300-guest event. I re-sectioned the floor, moved the strongest server to the head tables, and pulled the rest into a runner rotation so nothing sat under the lamps.', timestamp: iso('2026-08-04'), response_time_seconds: 11 },
|
||||
{ role: 'assistant', content: 'What broke first, and what did you do the moment you noticed?', timestamp: iso('2026-08-04') },
|
||||
@@ -919,7 +919,7 @@ const AI_INTERVIEWS = [
|
||||
verdict: 'maybe',
|
||||
hire_recommendation: 'Worth a trial shift on a coursed dinner before committing to the season.',
|
||||
integrity_score: 100,
|
||||
ai_flags: [],
|
||||
ai_flags: [] as string[],
|
||||
category_scores: {
|
||||
communication: 78, confidence: 71, experience_relevance: 74, culture_fit: 76,
|
||||
problem_solving: 69, personality: 77, communication_style: 76,
|
||||
@@ -947,11 +947,11 @@ const AI_INTERVIEWS = [
|
||||
verdict: 'maybe',
|
||||
hire_recommendation: '',
|
||||
integrity_score: 100,
|
||||
ai_flags: [],
|
||||
ai_flags: [] as string[],
|
||||
category_scores: {},
|
||||
strengths: [],
|
||||
concerns: [],
|
||||
best_fit_roles: [],
|
||||
strengths: [] as string[],
|
||||
concerns: [] as string[],
|
||||
best_fit_roles: [] as string[],
|
||||
summary: '',
|
||||
reasoning: '',
|
||||
created_date: iso('2026-08-05'),
|
||||
@@ -967,11 +967,11 @@ const AI_INTERVIEWS = [
|
||||
verdict: 'maybe',
|
||||
hire_recommendation: '',
|
||||
integrity_score: 100,
|
||||
ai_flags: [],
|
||||
ai_flags: [] as string[],
|
||||
category_scores: {},
|
||||
strengths: [],
|
||||
concerns: [],
|
||||
best_fit_roles: [],
|
||||
strengths: [] as string[],
|
||||
concerns: [] as string[],
|
||||
best_fit_roles: [] as string[],
|
||||
summary: '',
|
||||
reasoning: '',
|
||||
created_date: iso('2026-08-05'),
|
||||
@@ -987,11 +987,11 @@ const AI_INTERVIEWS = [
|
||||
verdict: 'maybe',
|
||||
hire_recommendation: '',
|
||||
integrity_score: 100,
|
||||
ai_flags: [],
|
||||
ai_flags: [] as string[],
|
||||
category_scores: {},
|
||||
strengths: [],
|
||||
concerns: [],
|
||||
best_fit_roles: [],
|
||||
strengths: [] as string[],
|
||||
concerns: [] as string[],
|
||||
best_fit_roles: [] as string[],
|
||||
summary: '',
|
||||
reasoning: '',
|
||||
created_date: iso('2026-08-03'),
|
||||
@@ -1015,7 +1015,7 @@ const STAFF = [
|
||||
ai_score: 92,
|
||||
status: 'onboarding',
|
||||
client_rating: 0,
|
||||
endorsed_skills: [],
|
||||
endorsed_skills: [] as string[],
|
||||
created_date: iso('2026-07-25'),
|
||||
},
|
||||
{
|
||||
@@ -1031,7 +1031,7 @@ const STAFF = [
|
||||
ai_score: 94,
|
||||
status: 'onboarding',
|
||||
client_rating: 0,
|
||||
endorsed_skills: [],
|
||||
endorsed_skills: [] as string[],
|
||||
created_date: iso('2026-07-25'),
|
||||
},
|
||||
{
|
||||
@@ -1047,7 +1047,7 @@ const STAFF = [
|
||||
ai_score: 97,
|
||||
status: 'onboarding',
|
||||
client_rating: 0,
|
||||
endorsed_skills: [],
|
||||
endorsed_skills: [] as string[],
|
||||
created_date: iso('2026-07-25'),
|
||||
},
|
||||
];
|
||||
@@ -1085,7 +1085,7 @@ const COURSES = [
|
||||
{ criterion: 'Protects the table experience', weight: 30 },
|
||||
],
|
||||
},
|
||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
|
||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
|
||||
quiz: [
|
||||
{
|
||||
question: 'A guest flags an allergy mid-service. What happens first?',
|
||||
@@ -1127,7 +1127,7 @@ const COURSES = [
|
||||
{ criterion: 'Escalates appropriately', weight: 30 },
|
||||
],
|
||||
},
|
||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
|
||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
|
||||
quiz: [
|
||||
{
|
||||
question: 'A guest shows clear signs of intoxication. What do you do?',
|
||||
@@ -1151,7 +1151,7 @@ const COURSES = [
|
||||
proof_skill: 'Hazard Identification',
|
||||
skill_id: 'food_safety',
|
||||
target_level: 'beginner',
|
||||
required_level: null,
|
||||
required_level: null as string | null,
|
||||
completion_criteria: ['Work through the training material', 'Submit evidence for the challenge'],
|
||||
verification_criteria: ['Identifies temperature violations', 'Spots cross-contamination risk', 'Notes obstruction hazards'],
|
||||
challenge: {
|
||||
@@ -1164,7 +1164,7 @@ const COURSES = [
|
||||
{ criterion: 'Notes obstruction hazards', weight: 20 },
|
||||
],
|
||||
},
|
||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
|
||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
|
||||
quiz: [
|
||||
{
|
||||
question: 'What is the danger zone for cold-held food?',
|
||||
@@ -1201,7 +1201,7 @@ const COURSES = [
|
||||
],
|
||||
},
|
||||
unlock_requirements: { min_shifts: 5, min_reliability: 70, required_badges: ['Service Fundamentals'] },
|
||||
quiz: [],
|
||||
quiz: [] as unknown[],
|
||||
pass_score: 80,
|
||||
status: 'active',
|
||||
created_date: iso('2026-06-16'),
|
||||
@@ -1222,7 +1222,17 @@ const COURSES = [
|
||||
* what Owliver checks. `required_level` is the rung below, which is what makes
|
||||
* the ladder a ladder rather than a menu.
|
||||
*/
|
||||
const MODULE_TABLE = [
|
||||
/**
|
||||
* One training module per row: skill, rung, title, minutes, challenge type,
|
||||
* description, and the criteria it is marked against.
|
||||
*
|
||||
* Written out as a tuple because a bare array of mixed literals infers as an
|
||||
* array of the UNION of its column types, so destructuring a row below gives
|
||||
* every field `string | number | string[]` — and `SKILL_CATEGORY[skill_id]`
|
||||
* then refuses a key that might be an array. The tuple says which column is
|
||||
* which, which is what the reader already assumes.
|
||||
*/
|
||||
const MODULE_TABLE: [string, string, string, number, string, string, string[]][] = [
|
||||
/* Server — the full four-rung ladder. */
|
||||
['server', 'beginner', 'Server Fundamentals', 18, 'roleplay', 'Sequence of service, tray discipline, and the first ninety seconds at a table.', ['Greets and sets expectations', 'Follows the service sequence', 'Handles the tray safely']],
|
||||
['server', 'beginner', 'Workplace Safety', 14, 'photo_identify', 'Spot the hazard before it becomes an incident — spills, traffic, hot pass.', ['Identifies floor hazards', 'Spots obstruction risks', 'Knows when to stop service']],
|
||||
@@ -1283,7 +1293,7 @@ const MODULE_TABLE = [
|
||||
['food_safety', 'advanced', 'Incident Response & Reporting', 18, 'roleplay', 'A suspected foodborne incident, in the hour it matters.', ['Isolates the product', 'Documents accurately', 'Notifies the right people']],
|
||||
];
|
||||
|
||||
const SKILL_CATEGORY = {
|
||||
const SKILL_CATEGORY: Record<string, string> = {
|
||||
server: 'Service',
|
||||
bartending: 'Bar',
|
||||
leadership: 'Leadership',
|
||||
@@ -1294,20 +1304,20 @@ const SKILL_CATEGORY = {
|
||||
const LEVEL_ORDER = ['beginner', 'intermediate', 'advanced', 'expert'];
|
||||
|
||||
/** Level → the rung below it, which is what a module requires before it opens. */
|
||||
const requiredLevelFor = (level) => LEVEL_ORDER[LEVEL_ORDER.indexOf(level) - 1] || null;
|
||||
const requiredLevelFor = (level: string) => LEVEL_ORDER[LEVEL_ORDER.indexOf(level) - 1] || null;
|
||||
|
||||
/** Difficulty is the existing field; level drives it so the two never disagree. */
|
||||
const DIFFICULTY_FOR = { beginner: 'beginner', intermediate: 'intermediate', advanced: 'advanced', expert: 'advanced' };
|
||||
const DIFFICULTY_FOR: Record<string, string> = { beginner: 'beginner', intermediate: 'intermediate', advanced: 'advanced', expert: 'advanced' };
|
||||
|
||||
const XP_FOR = { beginner: 100, intermediate: 150, advanced: 220, expert: 300 };
|
||||
const XP_FOR: Record<string, number> = { beginner: 100, intermediate: 150, advanced: 220, expert: 300 };
|
||||
|
||||
const PROMPT_FOR = {
|
||||
const PROMPT_FOR: Record<string, (title: string) => string> = {
|
||||
roleplay: (title) => `Work through a ${title.toLowerCase()} scenario with Owliver, start to finish.`,
|
||||
video: (title) => `Record yourself demonstrating ${title.toLowerCase()} as you would on a live shift.`,
|
||||
photo_identify: (title) => `Mark everything a ${title.toLowerCase()} check should catch in this photo.`,
|
||||
};
|
||||
|
||||
const slug = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
||||
const slug = (title: string) => title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
||||
|
||||
const TRAINING_MODULES = MODULE_TABLE.map(
|
||||
([skill_id, target_level, title, minutes, type, description, criteria], i) => ({
|
||||
@@ -1331,8 +1341,8 @@ const TRAINING_MODULES = MODULE_TABLE.map(
|
||||
ai_persona: type === 'roleplay' ? 'A guest or teammate in a live service situation.' : undefined,
|
||||
rubric: (Array.isArray(criteria) ? criteria : []).map((criterion, n) => ({ criterion, weight: n === 0 ? 40 : 30 })),
|
||||
},
|
||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
|
||||
quiz: [],
|
||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
|
||||
quiz: [] as unknown[],
|
||||
pass_score: 70,
|
||||
status: 'active',
|
||||
created_date: iso('2026-06-20'),
|
||||
@@ -1344,7 +1354,7 @@ const TRAINING_MODULES = MODULE_TABLE.map(
|
||||
const COURSE_LIBRARY = [...COURSES, ...TRAINING_MODULES];
|
||||
|
||||
/** A module id from its skill and title, so profiles read like the ladder does. */
|
||||
const mod = (skill, title) => `mod_${skill}_${slug(title)}`;
|
||||
const mod = (skill: string, title: string) => `mod_${skill}_${slug(title)}`;
|
||||
|
||||
/**
|
||||
* Completed training on a worker profile.
|
||||
@@ -1352,7 +1362,7 @@ const mod = (skill, title) => `mod_${skill}_${slug(title)}`;
|
||||
* Titles are looked up rather than repeated, so a renamed module cannot leave a
|
||||
* profile claiming a course that no longer goes by that name.
|
||||
*/
|
||||
const completions = (ids, date, score = 88) =>
|
||||
const completions = (ids: string[], date: string, score = 88) =>
|
||||
ids.map((id) => ({
|
||||
course_id: id,
|
||||
title: COURSE_LIBRARY.find((c) => c.id === id)?.title || id,
|
||||
@@ -1525,7 +1535,7 @@ const WORKER_PROFILES = [
|
||||
earned_badges: [
|
||||
{ name: 'Service Fundamentals', level: 'bronze', course_id: 'course_service_basics', earned_date: iso('2026-07-28') },
|
||||
],
|
||||
capabilities: [],
|
||||
capabilities: [] as unknown[],
|
||||
shifts_completed: 6,
|
||||
attendance_score: 92,
|
||||
performance_score: 0,
|
||||
@@ -1595,8 +1605,8 @@ const WORKER_PROFILES = [
|
||||
'2026-08-02',
|
||||
89
|
||||
),
|
||||
earned_badges: [],
|
||||
capabilities: [],
|
||||
earned_badges: [] as unknown[],
|
||||
capabilities: [] as unknown[],
|
||||
shifts_completed: 38,
|
||||
attendance_score: 96,
|
||||
performance_score: 88,
|
||||
@@ -1643,8 +1653,8 @@ const WORKER_PROFILES = [
|
||||
'2026-07-30',
|
||||
85
|
||||
),
|
||||
earned_badges: [],
|
||||
capabilities: [],
|
||||
earned_badges: [] as unknown[],
|
||||
capabilities: [] as unknown[],
|
||||
shifts_completed: 21,
|
||||
attendance_score: 93,
|
||||
performance_score: 81,
|
||||
@@ -1691,8 +1701,8 @@ const WORKER_PROFILES = [
|
||||
'2026-08-05',
|
||||
76
|
||||
),
|
||||
earned_badges: [],
|
||||
capabilities: [],
|
||||
earned_badges: [] as unknown[],
|
||||
capabilities: [] as unknown[],
|
||||
shifts_completed: 4,
|
||||
attendance_score: 100,
|
||||
performance_score: 0,
|
||||
@@ -1735,8 +1745,8 @@ const WORKER_PROFILES = [
|
||||
},
|
||||
xp: 0,
|
||||
completed_courses: [],
|
||||
earned_badges: [],
|
||||
capabilities: [],
|
||||
earned_badges: [] as unknown[],
|
||||
capabilities: [] as unknown[],
|
||||
shifts_completed: 0,
|
||||
attendance_score: 100,
|
||||
performance_score: 0,
|
||||
@@ -1779,8 +1789,8 @@ const WORKER_PROFILES = [
|
||||
},
|
||||
xp: 0,
|
||||
completed_courses: [],
|
||||
earned_badges: [],
|
||||
capabilities: [],
|
||||
earned_badges: [] as unknown[],
|
||||
capabilities: [] as unknown[],
|
||||
shifts_completed: 0,
|
||||
attendance_score: 100,
|
||||
performance_score: 0,
|
||||
@@ -1823,8 +1833,8 @@ const WORKER_PROFILES = [
|
||||
},
|
||||
xp: 0,
|
||||
completed_courses: [],
|
||||
earned_badges: [],
|
||||
capabilities: [],
|
||||
earned_badges: [] as unknown[],
|
||||
capabilities: [] as unknown[],
|
||||
shifts_completed: 0,
|
||||
attendance_score: 100,
|
||||
performance_score: 0,
|
||||
@@ -1867,8 +1877,8 @@ const WORKER_PROFILES = [
|
||||
},
|
||||
xp: 0,
|
||||
completed_courses: [],
|
||||
earned_badges: [],
|
||||
capabilities: [],
|
||||
earned_badges: [] as unknown[],
|
||||
capabilities: [] as unknown[],
|
||||
shifts_completed: 0,
|
||||
attendance_score: 100,
|
||||
performance_score: 0,
|
||||
@@ -1980,8 +1990,12 @@ const USER_ACTIVITY = ACTIVITY_EVENTS.map(([event_type, user_email, user_name, a
|
||||
* candidate, interview and shift record below — into the production bundle.
|
||||
* The fixtures here are for the test scripts, and they still read
|
||||
* `seedData.User` and `DEMO_USER` from this module, so both keep working.
|
||||
*
|
||||
* `EMPLOYER_USER` travels the same way. It is seeded, never rendered: the
|
||||
* backend writes both accounts so the employer console has somebody to sign
|
||||
* in as, and nothing in the running app imports it.
|
||||
*/
|
||||
export { DEMO_USER } from './demoUser';
|
||||
export { DEMO_USER, EMPLOYER_USER } from './demoUser';
|
||||
|
||||
/* ── Assignments ───────────────────────────────────────────────────────────
|
||||
Who is on which position, and until when — the record that turns a hire into
|
||||
@@ -2014,7 +2028,7 @@ const ASSIGNMENTS = [
|
||||
worker_email: 'marco.rivera@email.com',
|
||||
worker_name: 'Marco Rivera',
|
||||
starts_at: iso('2026-07-01'),
|
||||
ends_at: null,
|
||||
ends_at: null as string | null,
|
||||
status: 'active',
|
||||
source: 'seed',
|
||||
match_score: 92,
|
||||
@@ -2041,5 +2055,5 @@ export const seedData = {
|
||||
Assignment: ASSIGNMENTS,
|
||||
ShiftRecord: SHIFT_RECORDS,
|
||||
Evidence: EVIDENCE,
|
||||
User: [DEMO_USER],
|
||||
User: [DEMO_USER, EMPLOYER_USER],
|
||||
};
|
||||
@@ -21,7 +21,12 @@ const DefaultFallback = () => (
|
||||
* The attempted path travels in location state so signing in returns the
|
||||
* visitor to where they were going.
|
||||
*/
|
||||
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement }) {
|
||||
interface ProtectedRouteProps {
|
||||
fallback?: any;
|
||||
unauthenticatedElement?: any;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement }: ProtectedRouteProps) {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
|
||||
const signedOut = unauthenticatedElement ?? (
|
||||
@@ -17,8 +17,17 @@ import { cn } from '@/lib/utils';
|
||||
* Controls live in the page rather than in the global chrome, which is why the
|
||||
* Admin header only needs one 56px bar.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }) {
|
||||
interface AdminPageProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
meta?: any;
|
||||
actions?: any;
|
||||
tabs?: any;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }: AdminPageProps) {
|
||||
return (
|
||||
<div className={cn('space-y-6', className)}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
|
||||
@@ -57,8 +66,15 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
|
||||
* Sentence-case and small rather than a heavy all-caps overline: on a page with
|
||||
* four or five sections, loud labels compete with the data they introduce.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function SectionTitle({ id, title, meta, action, className }) {
|
||||
interface SectionTitleProps {
|
||||
id?: any;
|
||||
title?: string;
|
||||
meta?: any;
|
||||
action?: any;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionTitle({ id, title, meta, action, className }: SectionTitleProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
|
||||
{/* The count wraps under the section name rather than competing with it
|
||||
@@ -91,8 +107,15 @@ export function SectionTitle({ id, title, meta, action, className }) {
|
||||
* primary action is pushed to the end, so every management page has the same
|
||||
* control geometry.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }) {
|
||||
interface ToolbarProps {
|
||||
search?: any;
|
||||
filters?: any;
|
||||
actions?: any;
|
||||
meta?: any;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }: ToolbarProps) {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import {
|
||||
Briefcase, ChefHat, ConciergeBell, HardHat, PackageSearch, ShieldCheck, Sparkles, Truck, Wine,
|
||||
} from 'lucide-react';
|
||||
@@ -19,8 +20,10 @@ import { cn } from '@/lib/utils';
|
||||
*/
|
||||
|
||||
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
|
||||
/** @type {[RegExp, React.ComponentType<any>][]} */
|
||||
const GLYPHS = [
|
||||
/* `[pattern, icon]` pairs. Without the tuple the element widens to the union of
|
||||
both positions, so `pattern.test` and `<Icon />` both stop working — the same
|
||||
shape as `aiEngine`'s route table. */
|
||||
const GLYPHS: [RegExp, ComponentType<any>][] = [
|
||||
[/chef|cook|kitchen/i, ChefHat],
|
||||
[/bartend|bar|mixolog/i, Wine],
|
||||
[/server|wait|host|banquet|concierge/i, ConciergeBell],
|
||||
@@ -42,8 +45,15 @@ const SIZES = {
|
||||
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' },
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }) {
|
||||
interface RoleGlyphProps {
|
||||
category?: any;
|
||||
title?: string;
|
||||
size?: any;
|
||||
aiGenerated?: any;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }: RoleGlyphProps) {
|
||||
const Icon = iconForRole(category, title);
|
||||
const s = SIZES[size] || SIZES.md;
|
||||
|
||||
@@ -44,7 +44,7 @@ import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
* holds a ~380px track on the right of every Admin page, so below `xl` the
|
||||
* remaining width belongs to the editor and the rail becomes a strip above it.
|
||||
*/
|
||||
export function Workspace({ rail, children, className }) {
|
||||
export function Workspace({ rail, children, className }: any) {
|
||||
return (
|
||||
<div className={cn('w-full rounded-2xl border border-border bg-surface shadow-xs overflow-hidden', className)}>
|
||||
<div className="border-b border-border/80 bg-surface-subtle/40 px-4 py-3 sm:px-6">
|
||||
@@ -64,8 +64,7 @@ export function Workspace({ rail, children, className }) {
|
||||
* height without measuring it in JavaScript. Content stays mounted, which is
|
||||
* what keeps a section addressable by the rail while it is closed.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Collapse({ open, children, className }) {
|
||||
export function Collapse({ open, children, className }: any) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -100,8 +99,7 @@ export function Collapse({ open, children, className }) {
|
||||
* Provides clear section navigation and leaf anchor shortcuts across the
|
||||
* configuration workspace.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Rail({ sections, activeId, onSelect }) {
|
||||
export function Rail({ sections, activeId, onSelect }: any) {
|
||||
const activeSection = sections.find((s) => s.id === activeId);
|
||||
|
||||
return (
|
||||
@@ -181,11 +179,10 @@ export function Rail({ sections, activeId, onSelect }) {
|
||||
/**
|
||||
* One section of the document.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function DocSection({
|
||||
id, icon: Icon, title, description, meta, open, onToggle, emphasis = false, last = false,
|
||||
children,
|
||||
}) {
|
||||
}: any) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
@@ -257,8 +254,7 @@ export function DocSection({
|
||||
}
|
||||
|
||||
/** A labelled field in the document body. */
|
||||
/** @param {any} props */
|
||||
export function DocField({ id, label, hint, children, className }) {
|
||||
export function DocField({ id, label, hint, children, className }: any) {
|
||||
const reactId = React.useId();
|
||||
const controlId = id ? `${id}-control` : reactId;
|
||||
|
||||
@@ -270,7 +266,7 @@ export function DocField({ id, label, hint, children, className }) {
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{React.isValidElement(children)
|
||||
{React.isValidElement<any>(children)
|
||||
? React.cloneElement(children, { id: children.props.id || controlId })
|
||||
: children}
|
||||
{hint && <p className="text-caption leading-relaxed text-ink-4">{hint}</p>}
|
||||
@@ -284,8 +280,7 @@ export function DocField({ id, label, hint, children, className }) {
|
||||
* One notch quieter than a section title and one louder than a row, which is
|
||||
* exactly the level of the thing it names.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function GroupHead({ id, icon: Icon, title, count, action, className }) {
|
||||
export function GroupHead({ id, icon: Icon, title, count, action, className }: any) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
@@ -310,8 +305,7 @@ export function GroupHead({ id, icon: Icon, title, count, action, className }) {
|
||||
* competing with the section that holds them; as rows they read as a list,
|
||||
* which is what they are.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLabel }) {
|
||||
export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLabel }: any) {
|
||||
return (
|
||||
<li
|
||||
className={`group/item -mx-2 flex items-start gap-2.5 rounded-lg px-2 py-2
|
||||
@@ -352,10 +346,9 @@ export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLa
|
||||
* a switch in a button makes one press mean two things. A setting that needs
|
||||
* space gets a chevron and opens underneath.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function SettingRow({
|
||||
id, icon: Icon, title, value, description, control, children, open, onToggle, last = false,
|
||||
}) {
|
||||
}: any) {
|
||||
const expandable = Boolean(children);
|
||||
|
||||
const body = (
|
||||
@@ -412,8 +405,7 @@ export function SettingRow({
|
||||
}
|
||||
|
||||
/** A page an agent covers, or one it could. */
|
||||
/** @param {any} props */
|
||||
export function ScopeChip({ active, children, ...props }) {
|
||||
export function ScopeChip({ active, children, ...props }: any) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -443,8 +435,7 @@ export function ScopeChip({ active, children, ...props }) {
|
||||
/* ── Identity ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The icons an agent may wear, as a picker. */
|
||||
/** @param {any} props */
|
||||
export function IconPicker({ icons, value, onChange }) {
|
||||
export function IconPicker({ icons, value, onChange }: any) {
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{icons.map((icon) => {
|
||||
@@ -486,8 +477,7 @@ export function IconPicker({ icons, value, onChange }) {
|
||||
* same three things the switcher renders — icon, name, description — and
|
||||
* nothing it does not.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function AgentPreview({ name, description, icon }) {
|
||||
export function AgentPreview({ name, description, icon }: any) {
|
||||
const Icon = agentIconFor(icon);
|
||||
|
||||
return (
|
||||
@@ -94,7 +94,6 @@ function useActiveSection(ids) {
|
||||
return [active, setActive];
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentConfigure({
|
||||
fields, agents, customSkills = [], onChange, onOpenSkills, scopeLocked = false,
|
||||
/* Attaching and detaching a skill goes through the caller's existing write
|
||||
@@ -107,7 +106,7 @@ export function AgentConfigure({
|
||||
/* The tools this deployment registers, from GET /api/v1/tools. Passed in
|
||||
rather than fetched here so this component stays a form over `fields`. */
|
||||
toolCatalogue = [],
|
||||
}) {
|
||||
}: any) {
|
||||
/* Sections are open by default: this is a document, and one that greets its
|
||||
author with four closed headers hides the thing they came to write. Closing
|
||||
is for focus, not for the initial reading. */
|
||||
@@ -22,8 +22,7 @@ import { cn } from '@/lib/utils';
|
||||
* Real-time usage analytics computed directly from the local telemetry log.
|
||||
*/
|
||||
|
||||
/** @param {any} props */
|
||||
function MetricCard({ icon: Icon, label, value, detail, highlight }) {
|
||||
function MetricCard({ icon: Icon, label, value, detail, highlight }: any) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -45,8 +44,7 @@ function MetricCard({ icon: Icon, label, value, detail, highlight }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentInsightsPanel({ agentId, agentName }) {
|
||||
export function AgentInsightsPanel({ agentId, agentName }: any) {
|
||||
const preferences = usePreferences();
|
||||
|
||||
const [records] = React.useState(() => readHistory());
|
||||
@@ -46,8 +46,7 @@ const CLASSIFICATION_COPY = {
|
||||
combined: 'Requires both structured records and knowledge retrieval.',
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }) {
|
||||
export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }: any) {
|
||||
const [contextId, setContextId] = React.useState(
|
||||
() => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId
|
||||
|| CONTEXT_OPTIONS[0].contextId
|
||||
@@ -43,7 +43,6 @@ import { SkillDetails } from './SkillDetails';
|
||||
const OWLIVER = 'owliver';
|
||||
const BOARD = 'board';
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentSkillWorkspace({
|
||||
fields,
|
||||
customSkills = [],
|
||||
@@ -55,7 +54,7 @@ export function AgentSkillWorkspace({
|
||||
busy = false,
|
||||
pendingId = null,
|
||||
dirty = false,
|
||||
}) {
|
||||
}: any) {
|
||||
const [kind, setKind] = React.useState(OWLIVER);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [boardQuery, setBoardQuery] = React.useState('');
|
||||
@@ -174,7 +174,6 @@ function StaticLeaf({ icon: Icon, label, detail = null }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentTree({
|
||||
agentName,
|
||||
agentIcon,
|
||||
@@ -191,7 +190,7 @@ export function AgentTree({
|
||||
onToggleBranch,
|
||||
busy = false,
|
||||
className,
|
||||
}) {
|
||||
}: any) {
|
||||
const Glyph = agentIconFor(agentIcon);
|
||||
|
||||
return (
|
||||
@@ -159,11 +159,10 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = fals
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function BoardSkillList({
|
||||
entries, agentPages = [], disabledIds, onToggle, busy = false, query, onQueryChange, total,
|
||||
attachedIds = null, onAttach = null,
|
||||
}) {
|
||||
}: any) {
|
||||
const searchId = React.useId();
|
||||
const onSurfaces = React.useMemo(() => new Set(agentPages), [agentPages]);
|
||||
|
||||
@@ -25,9 +25,9 @@ import { glyphForGroup } from './glyphs';
|
||||
*/
|
||||
|
||||
/** @type {any} */
|
||||
export const SkillCard = React.memo(/** @param {any} props */ ({
|
||||
export const SkillCard = React.memo(({
|
||||
entry, enabled, onOpen, onAddAgent, onTest, canTest = false, busy = false, isPending = false,
|
||||
}) => {
|
||||
}: any) => {
|
||||
const Glyph = glyphForGroup(entry.group);
|
||||
const samplePrompt = entry.suggestions?.[0]?.label;
|
||||
|
||||
@@ -23,8 +23,7 @@ import { SkillCard } from './SkillCard';
|
||||
* has, at every width, with no media query to get wrong.
|
||||
*/
|
||||
|
||||
/** @param {any} props */
|
||||
function CategoryFilter({ groups, value, onChange, total }) {
|
||||
function CategoryFilter({ groups, value, onChange, total }: any) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
@@ -60,7 +59,6 @@ function CategoryFilter({ groups, value, onChange, total }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function SkillCatalog({
|
||||
entries,
|
||||
groups,
|
||||
@@ -76,7 +74,7 @@ export function SkillCatalog({
|
||||
testableIds,
|
||||
busy = false,
|
||||
pendingId = null,
|
||||
}) {
|
||||
}: any) {
|
||||
const searchId = React.useId();
|
||||
|
||||
return (
|
||||
@@ -82,11 +82,10 @@ function TestState({ test, entryId }) {
|
||||
* button needs the same `surface` this renders, and two copies of that would be
|
||||
* two answers to "what am I about to enable".
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function CapabilityBrief({
|
||||
entry, attached, surface, onSurfaceChange, question, onQuestionChange,
|
||||
canTest, target, onTest, test, busy = false, showTest = true,
|
||||
}) {
|
||||
}: any) {
|
||||
const { shapes, described } = entry.capabilities;
|
||||
|
||||
return (
|
||||
@@ -252,11 +251,10 @@ export function CapabilityBrief({
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function SkillDetails({
|
||||
entry, open, onOpenChange, isEnabledOn, onToggle, busy = false,
|
||||
fields = null, onRunTest = null, test = null,
|
||||
}) {
|
||||
}: any) {
|
||||
/**
|
||||
* The surface this would run on.
|
||||
*
|
||||
@@ -45,8 +45,7 @@ const OPTIONS = [
|
||||
export const supportedSurfaces = (type) =>
|
||||
OPTIONS.filter((o) => o.supported(type)).map((o) => o.id);
|
||||
|
||||
/** @param {any} props */
|
||||
export function SurfaceSelect({ type, value, onChange, name }) {
|
||||
export function SurfaceSelect({ type, value, onChange, name }: any) {
|
||||
return (
|
||||
<div role="radiogroup" aria-label={`Run ${name} as`} className="space-y-1.5">
|
||||
{OPTIONS.map((option) => {
|
||||
@@ -57,8 +57,7 @@ function ConstrainedTag() {
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentBadge({ page }) {
|
||||
export function AgentBadge({ page }: any) {
|
||||
const { agent, covers } = useActiveAgent();
|
||||
|
||||
return (
|
||||
@@ -86,9 +86,8 @@ function FeedbackControls({ feedback, onFeedback }) {
|
||||
}
|
||||
|
||||
export const Message = React.memo(
|
||||
/** @param {any} props */
|
||||
({ role, text, blocks, streaming, stopped, onPrompt, onConfirm = null,
|
||||
feedback = null, onFeedback = null }) => {
|
||||
feedback = null, onFeedback = null }: any) => {
|
||||
if (role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
@@ -268,7 +268,7 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
|
||||
/* Width is written on every drag frame, so persistence is debounced rather
|
||||
than hitting sessionStorage sixty times a second. */
|
||||
const setWidthState = React.useCallback((next) => {
|
||||
const setWidthState: any = React.useCallback((next) => {
|
||||
const clamped = clampWidth(next);
|
||||
setWidth(clamped);
|
||||
clearTimeout(setWidthState.timer);
|
||||
@@ -15,8 +15,7 @@ import { useAssistantPanel } from './AssistantPanelContext';
|
||||
* It self-hides on pages with no assistant, so a page can render it
|
||||
* unconditionally and never needs to know the placement rules.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function AssistantTrigger({ className = '', size = 'default' }) {
|
||||
export function AssistantTrigger({ className = '', size = 'default' }: any) {
|
||||
const { supported, isOpen, toggle, context } = useAssistantPanel();
|
||||
|
||||
if (!supported) return null;
|
||||
@@ -1080,9 +1080,6 @@ export default function KrowAssistant({
|
||||
>
|
||||
<SuggestedQuestions prompts={prompts} open={showSuggestions} onSelect={runPrompt} />
|
||||
{composer}
|
||||
<p className="px-1 text-[10px] leading-tight text-ink-4">
|
||||
Owliver reads this page's data. Check anything you act on.
|
||||
</p>
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
@@ -23,14 +23,13 @@ import { cn } from '@/lib/utils';
|
||||
*
|
||||
* Arrow keys move between chips, so the whole set is one tab stop.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function PromptChips({
|
||||
prompts = [], onSelect, max = 0, align = 'center', className = '',
|
||||
/* Taken out of the tab order while the row is collapsed but still mounted:
|
||||
a chip inside a zero-height container is invisible, and a Tab that lands on
|
||||
something invisible is a keyboard user losing their place. */
|
||||
focusable = true,
|
||||
}) {
|
||||
}: any) {
|
||||
const chipRefs = React.useRef([]);
|
||||
const visible = max ? prompts.slice(0, max) : prompts;
|
||||
|
||||
@@ -17,7 +17,6 @@ import { cn } from '@/lib/utils';
|
||||
* gain — the suggestion chips already teach the range of what can be asked, and
|
||||
* they do it without a timer or a rerender every four seconds.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function PromptInput({
|
||||
value,
|
||||
onChange,
|
||||
@@ -28,7 +27,7 @@ export function PromptInput({
|
||||
autoFocus = false,
|
||||
align = 'left',
|
||||
className = '',
|
||||
}) {
|
||||
}: any) {
|
||||
const textareaRef = React.useRef(null);
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
|
||||
@@ -121,14 +121,14 @@ const TONE_BADGE = {
|
||||
|
||||
/* ── Individual blocks ──────────────────────────────────────────────────── */
|
||||
|
||||
const TextBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
const TextBlock = React.memo(({ block }: any) => (
|
||||
<p className="text-body-sm leading-relaxed text-ink-2">
|
||||
<Inline value={block.text} />
|
||||
</p>
|
||||
));
|
||||
TextBlock.displayName = 'TextBlock';
|
||||
|
||||
const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
const HeadingBlock = React.memo(({ block }: any) => (
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="font-heading text-body-sm font-semibold text-ink-1">{block.text}</h4>
|
||||
{block.sub && <p className="text-caption text-ink-3">{block.sub}</p>}
|
||||
@@ -137,7 +137,7 @@ const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
HeadingBlock.displayName = 'HeadingBlock';
|
||||
|
||||
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
|
||||
const KpisBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
const KpisBlock = React.memo(({ block }: any) => (
|
||||
<div className="grid grid-cols-2 gap-2 [[data-wide]_&]:grid-cols-3">
|
||||
{block.items.map((item) => (
|
||||
<div
|
||||
@@ -172,7 +172,7 @@ const KpisBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
KpisBlock.displayName = 'KpisBlock';
|
||||
|
||||
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
|
||||
const StatusBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
const StatusBlock = React.memo(({ block }: any) => (
|
||||
<ul className="space-y-1.5">
|
||||
{block.items.map((item) => (
|
||||
<li key={item.label} className="flex items-start gap-2">
|
||||
@@ -204,7 +204,7 @@ const StatusBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
));
|
||||
StatusBlock.displayName = 'StatusBlock';
|
||||
|
||||
const MetersBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
const MetersBlock = React.memo(({ block }: any) => (
|
||||
<div className="space-y-2.5">
|
||||
{block.items.map((item) => (
|
||||
<div key={item.label} className="space-y-1">
|
||||
@@ -226,7 +226,7 @@ MetersBlock.displayName = 'MetersBlock';
|
||||
* A table. Scrolls horizontally inside its own container so a wide comparison
|
||||
* never widens the panel.
|
||||
*/
|
||||
const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const TableBlock = React.memo(({ block }: any) => {
|
||||
const cell = (value) => {
|
||||
if (value == null || value === '') return <span className="text-ink-4">—</span>;
|
||||
if (typeof value === 'object') {
|
||||
@@ -287,7 +287,7 @@ const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
TableBlock.displayName = 'TableBlock';
|
||||
|
||||
/** Stage progression, widths relative to the largest stage. */
|
||||
const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const FunnelBlock = React.memo(({ block }: any) => {
|
||||
const max = Math.max(...block.steps.map((s) => s.count), 1);
|
||||
|
||||
return (
|
||||
@@ -314,7 +314,7 @@ const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
});
|
||||
FunnelBlock.displayName = 'FunnelBlock';
|
||||
|
||||
const ListBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const ListBlock = React.memo(({ block }: any) => {
|
||||
const Tag = block.ordered ? 'ol' : 'ul';
|
||||
return (
|
||||
<Tag className="space-y-1.5">
|
||||
@@ -357,7 +357,7 @@ ListBlock.displayName = 'ListBlock';
|
||||
* clickable and doing nothing. An item may carry a hint *and* an action: the
|
||||
* hint explains the record, the action opens it.
|
||||
*/
|
||||
const InsightsBlock = React.memo(/** @param {any} props */ ({ block, onPrompt }) => (
|
||||
const InsightsBlock = React.memo(({ block, onPrompt }: any) => (
|
||||
<div className="space-y-2">
|
||||
{block.items.map((item, i) => {
|
||||
const surface = cn(
|
||||
@@ -434,7 +434,7 @@ const InsightsBlock = React.memo(/** @param {any} props */ ({ block, onPrompt })
|
||||
InsightsBlock.displayName = 'InsightsBlock';
|
||||
|
||||
/** Recommended steps — numbered, because order is the recommendation. */
|
||||
const ActionsBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
const ActionsBlock = React.memo(({ block }: any) => (
|
||||
<ol className="space-y-2">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-2.5 rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs">
|
||||
@@ -453,7 +453,7 @@ const ActionsBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
));
|
||||
ActionsBlock.displayName = 'ActionsBlock';
|
||||
|
||||
const BadgesBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
const BadgesBlock = React.memo(({ block }: any) => (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{block.items.map((item) => (
|
||||
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
|
||||
@@ -464,7 +464,7 @@ const BadgesBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
));
|
||||
BadgesBlock.displayName = 'BadgesBlock';
|
||||
|
||||
const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
const TimelineBlock = React.memo(({ block }: any) => (
|
||||
<ol className="relative space-y-3">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-3">
|
||||
@@ -497,7 +497,7 @@ const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
));
|
||||
TimelineBlock.displayName = 'TimelineBlock';
|
||||
|
||||
const NoteBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
const NoteBlock = React.memo(({ block }: any) => (
|
||||
<p className="border-l-2 border-border pl-2.5 text-caption italic leading-relaxed text-ink-3">
|
||||
<Inline value={block.text} />
|
||||
</p>
|
||||
@@ -514,7 +514,7 @@ NoteBlock.displayName = 'NoteBlock';
|
||||
* shape the table does not carry renders nothing rather than something
|
||||
* improvised.
|
||||
*/
|
||||
const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const SkillSectionBlock = React.memo(({ block }: any) => {
|
||||
const section = block.section;
|
||||
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
|
||||
|
||||
@@ -564,7 +564,7 @@ SkillSectionBlock.displayName = 'SkillSectionBlock';
|
||||
* - **Not pretend to be finished.** Once approved, the block stays visible
|
||||
* and says so. Replacing it with a tick would lose what was agreed to.
|
||||
*/
|
||||
const ConfirmationBlock = React.memo(/** @param {any} props */ ({ block, onConfirm }) => {
|
||||
const ConfirmationBlock = React.memo(({ block, onConfirm }: any) => {
|
||||
const [state, setState] = React.useState('pending');
|
||||
|
||||
const approve = React.useCallback(() => {
|
||||
@@ -673,7 +673,7 @@ const RENDERERS = {
|
||||
* blocks has consistent rhythm — a heading hugs what follows it, everything
|
||||
* else breathes.
|
||||
*/
|
||||
export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt, onConfirm }) => (
|
||||
export const ResponseDocument = React.memo(({ blocks = [], streaming = false, onPrompt, onConfirm }: any) => (
|
||||
<div className="space-y-3">
|
||||
{blocks.map((block, i) => {
|
||||
const Renderer = RENDERERS[block.type];
|
||||
@@ -20,7 +20,7 @@ export const doc = (...blocks) => ({
|
||||
export const text = (value) => value && { type: 'text', text: value };
|
||||
|
||||
/** A section label inside a response. */
|
||||
export const heading = (value, sub) => value && { type: 'heading', text: value, sub };
|
||||
export const heading = (value, sub?) => value && { type: 'heading', text: value, sub };
|
||||
|
||||
/** Headline figures. `items: [{ label, value, delta?, sub?, tone? }]` */
|
||||
export const kpis = (items) => items?.length && { type: 'kpis', items };
|
||||
@@ -15,7 +15,7 @@ import { PRIVILEGED_EVENTS, activitySignals } from '@/lib/activitySignals';
|
||||
|
||||
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
|
||||
|
||||
export const plural = (n, word, irregular) =>
|
||||
export const plural = (n, word, irregular?) =>
|
||||
`${n} ${n === 1 ? word : irregular || `${word}s`}`;
|
||||
|
||||
export const verb = (n, singular, plural_) => (n === 1 ? singular : plural_);
|
||||
@@ -215,7 +215,7 @@ export function buildFacts({
|
||||
),
|
||||
experienced: profiles.filter((p) => (p.experience_years || 0) >= 3),
|
||||
avgScore: avg(profiles.filter((p) => p.krow_score > 0).map((p) => p.krow_score)),
|
||||
bands: Object.values(profiles.reduce((acc, p) => {
|
||||
bands: Object.values(profiles.reduce<Record<string, { label: string; count: number }>>((acc, p) => {
|
||||
const { label } = getScoreBand(p.krow_score || 0);
|
||||
acc[label] ||= { label, count: 0 };
|
||||
acc[label].count += 1;
|
||||
@@ -241,7 +241,9 @@ export function buildFacts({
|
||||
};
|
||||
});
|
||||
|
||||
const groupHires = (key) => Object.values(hires.reduce((acc, h) => {
|
||||
const groupHires = (key) => Object.values(hires.reduce<Record<string, {
|
||||
name: string; count: number; scores: any[]; days: any[]; ratings: any[];
|
||||
}>>((acc, h) => {
|
||||
const name = h[key] || 'Unspecified';
|
||||
acc[name] ||= { name, count: 0, scores: [], days: [], ratings: [] };
|
||||
acc[name].count += 1;
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from '@/components/ds/props';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
@@ -104,7 +105,7 @@ function getDeptColor(index, avgScore) {
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function DepartmentPerformance({ items = [], className = '' }) {
|
||||
export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
const [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table'
|
||||
const [activeDept, setActiveDept] = useState(null);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from '@/components/ds/props';
|
||||
import React from 'react';
|
||||
import { BarChart } from '@mui/x-charts';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
@@ -19,7 +20,7 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
||||
* component derives no figures of its own.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }) {
|
||||
export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }: DsProps) {
|
||||
if (!stages.length) return null;
|
||||
|
||||
const total = stages[0]?.count || 1;
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from '@/components/ds/props';
|
||||
import React from 'react';
|
||||
import { LineChart } from '@mui/x-charts';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -15,7 +16,7 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
||||
* `points: [{ label, hires, cumulative }]` — supplied by the page.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function HiringTrendChart({ points = [], emptyState, className = '' }) {
|
||||
export function HiringTrendChart({ points = [], emptyState, className = '' }: DsProps) {
|
||||
const enough = points.length >= 2;
|
||||
|
||||
return (
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar } from '@/components/ds/Avatar';
|
||||
@@ -30,7 +31,7 @@ export function ActivityCard({
|
||||
variant = 'row',
|
||||
onClick,
|
||||
className,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const iconTones = {
|
||||
soft: 'bg-krow-blue-tint text-krow-blue',
|
||||
brand: 'bg-krow-blue text-white',
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from 'lucide-react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
@@ -40,7 +41,7 @@ const ICON_COLORS = {
|
||||
* that persists is noise.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }) {
|
||||
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }: DsProps) {
|
||||
const Icon = icon || ICONS[tone];
|
||||
|
||||
return (
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -41,8 +42,7 @@ export function initialsFrom(name = '') {
|
||||
* first-class state rather than a fallback afterthought. A failed image load
|
||||
* degrades to initials instead of a broken-image icon.
|
||||
*/
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
export const Avatar = React.forwardRef(
|
||||
export const Avatar: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
|
||||
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
|
||||
const [failed, setFailed] = React.useState(false);
|
||||
const showImage = src && !failed;
|
||||
@@ -91,7 +91,7 @@ Avatar.displayName = 'Avatar';
|
||||
* people in a tight row (applicants on a position, endorsers on a skill).
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function AvatarGroup({ people = [], max = 4, size = 'sm', className }) {
|
||||
export function AvatarGroup({ people = [], max = 4, size = 'sm', className }: DsProps) {
|
||||
const visible = people.slice(0, max);
|
||||
const overflow = people.length - visible.length;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { ResponsiveContainer } from 'recharts';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -38,7 +39,7 @@ export const AXIS_PROPS = {
|
||||
* white box that ignores the token set.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }) {
|
||||
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }: DsProps) {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
return (
|
||||
@@ -71,7 +72,7 @@ export function ChartTooltip({ active, payload, label, valueFormatter, labelForm
|
||||
* A legend that reads as part of the card rather than as chart furniture.
|
||||
* Items: `{ label, color, value? }`.
|
||||
*/
|
||||
export function ChartLegend({ items = [], className }) {
|
||||
export function ChartLegend({ items = [], className }: DsProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-center gap-x-4 gap-y-2', className)}>
|
||||
{items.map((item) => (
|
||||
@@ -118,7 +119,7 @@ export function ChartContainer({
|
||||
raw = false,
|
||||
footer,
|
||||
className,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
if (loading) return <SkeletonChart className={className} height={height} />;
|
||||
|
||||
return (
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { ArrowDown, ArrowUp, ChevronsUpDown, Inbox } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -60,7 +61,7 @@ export function DataTable({
|
||||
className,
|
||||
rowClassName,
|
||||
stickyHeader = false,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
/* ── Sorting ─────────────────────────────────────────────────────────── */
|
||||
/* Row height follows the account's density preference. Only the vertical
|
||||
padding changes — same columns, same type, same behaviour. */
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
@@ -41,7 +42,7 @@ export function Drawer({
|
||||
className,
|
||||
/** Hides the header for a fully custom panel (the mobile nav does this). */
|
||||
bare = false,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const isVertical = side === 'top' || side === 'bottom';
|
||||
|
||||
return (
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -25,7 +26,7 @@ export function EmptyState({
|
||||
variant = 'empty',
|
||||
size = 'default',
|
||||
className,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const renderAction = (spec, buttonVariant) => {
|
||||
if (!spec) return null;
|
||||
if (React.isValidElement(spec)) return spec;
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -19,7 +20,7 @@ export function Field({
|
||||
inline = false,
|
||||
className,
|
||||
children,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const reactId = React.useId();
|
||||
const id = htmlFor || reactId;
|
||||
const hintId = hint ? `${id}-hint` : undefined;
|
||||
@@ -27,11 +28,15 @@ export function Field({
|
||||
const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined;
|
||||
|
||||
// Only clone when the child is a single element that can accept the wiring.
|
||||
/* `isValidElement` narrows to `ReactElement<unknown>`, whose `props` is
|
||||
`unknown` — so reading `children.props.id` to preserve a caller's own id
|
||||
does not compile. The cast says what the guard has already established and
|
||||
what the runtime relies on: this is an element with props. */
|
||||
const control = React.isValidElement(children)
|
||||
? React.cloneElement(children, {
|
||||
id: children.props.id || id,
|
||||
'aria-describedby': children.props['aria-describedby'] || describedBy,
|
||||
'aria-invalid': error ? true : children.props['aria-invalid'],
|
||||
? React.cloneElement(children as React.ReactElement<Record<string, any>>, {
|
||||
id: (children.props as Record<string, any>).id || id,
|
||||
'aria-describedby': (children.props as Record<string, any>)['aria-describedby'] || describedBy,
|
||||
'aria-invalid': error ? true : (children.props as Record<string, any>)['aria-invalid'],
|
||||
})
|
||||
: children;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { SlidersHorizontal, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -37,7 +38,7 @@ export function FilterBar({
|
||||
/** Value that means "no filter" for select-type filters. */
|
||||
allValue = 'all',
|
||||
className = '',
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
const isActive = (filter) => {
|
||||
@@ -9,8 +9,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
* `label`: an icon-only control with no accessible name is a bug, so the API
|
||||
* makes it impossible to omit. The label doubles as the tooltip.
|
||||
*/
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
export const IconButton = React.forwardRef(
|
||||
export const IconButton: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
|
||||
({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => {
|
||||
const sizeMap = {
|
||||
xs: 'icon-xs',
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { AlertTriangle, ChevronRight, Info, ShieldAlert, TrendingDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -58,7 +59,7 @@ export function InsightRow({
|
||||
onClick = null,
|
||||
actionLabel = 'Review',
|
||||
className = '',
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const s = SEVERITY[severity] || SEVERITY.info;
|
||||
const Comp = onClick ? 'button' : 'div';
|
||||
|
||||
@@ -107,6 +108,6 @@ export function InsightRow({
|
||||
|
||||
/** The list container — dividers between rows, nothing else. */
|
||||
/** @param {any} props */
|
||||
export function InsightList({ children, className = '' }) {
|
||||
export function InsightList({ children, className = '' }: DsProps) {
|
||||
return <div className={cn('divide-y divide-border', className)}>{children}</div>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
|
||||
@@ -32,7 +33,7 @@ export function KpiCard({
|
||||
loading = false,
|
||||
onClick,
|
||||
className,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const t = TONES[tone] || TONES.brand;
|
||||
|
||||
if (loading) {
|
||||
@@ -112,7 +113,7 @@ export function MetricCard({
|
||||
loading = false,
|
||||
onClick,
|
||||
className,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const t = TONES[tone] || TONES.neutral;
|
||||
|
||||
if (loading) {
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -19,7 +20,7 @@ const GAPS = { sm: 'gap-3', default: 'gap-4', lg: 'gap-6' };
|
||||
* chosen to avoid orphan cards (6 → 2/3/6 rather than 1/3/6).
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Grid({ cols = 3, gap = 'default', className, children, ...props }) {
|
||||
export function Grid({ cols = 3, gap = 'default', className, children, ...props }: DsProps) {
|
||||
const columns = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 sm:grid-cols-2',
|
||||
@@ -38,7 +39,7 @@ export function Grid({ cols = 3, gap = 'default', className, children, ...props
|
||||
|
||||
/** Stack — vertical rhythm. `space` maps to the two spacings pages should use. */
|
||||
/** @param {any} props */
|
||||
export function Stack({ space = 'default', className, children, ...props }) {
|
||||
export function Stack({ space = 'default', className, children, ...props }: DsProps) {
|
||||
const spacing = {
|
||||
xs: 'space-y-2',
|
||||
sm: 'space-y-3',
|
||||
@@ -59,7 +60,7 @@ export function Stack({ space = 'default', className, children, ...props }) {
|
||||
* with its content at consistent spacing.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }) {
|
||||
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }: DsProps) {
|
||||
return (
|
||||
<section className={cn('space-y-3', className)}>
|
||||
{title && (
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -6,7 +7,7 @@ import { cn } from '@/lib/utils';
|
||||
* Spinner — the only spinner in the system.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Spinner({ size = 'default', className = '', label = 'Loading' }) {
|
||||
export function Spinner({ size = 'default', className = '', label = 'Loading' }: DsProps) {
|
||||
const sizes = { xs: 'w-3.5 h-3.5', sm: 'w-4 h-4', default: 'w-6 h-6', lg: 'w-8 h-8' };
|
||||
return (
|
||||
<Loader2
|
||||
@@ -25,7 +26,7 @@ export function Spinner({ size = 'default', className = '', label = 'Loading' })
|
||||
* final layout prevents the content jump a spinner always causes.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function LoadingState({ message = '', size = 'default', className = '' }) {
|
||||
export function LoadingState({ message = '', size = 'default', className = '' }: DsProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -45,7 +46,7 @@ export function LoadingState({ message = '', size = 'default', className = '' })
|
||||
* card headers, or beside a control that is refreshing.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function InlineLoading({ message = 'Loading…', className = '' }) {
|
||||
export function InlineLoading({ message = 'Loading…', className = '' }: DsProps) {
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}>
|
||||
<Spinner size="sm" />
|
||||
@@ -59,7 +60,7 @@ export function InlineLoading({ message = 'Loading…', className = '' }) {
|
||||
* height stable so the page does not jump. Used by DataTable when refetching.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function LoadingOverlay({ show = false, message = '', className = '' }) {
|
||||
export function LoadingOverlay({ show = false, message = '', className = '' }: DsProps) {
|
||||
if (!show) return null;
|
||||
return (
|
||||
<div
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -58,7 +59,7 @@ function Delta({ delta, label, invert }) {
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function MetricStrip({ items = [], columns, className, loading = false }) {
|
||||
export function MetricStrip({ items = [], columns, className, loading = false }: DsProps) {
|
||||
const count = columns ?? Math.min(items.length, 6);
|
||||
|
||||
const gridCols = {
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
@@ -39,7 +40,7 @@ export function Modal({
|
||||
className,
|
||||
/** Blocks closing — use while a submit is in flight. */
|
||||
busy = false,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={busy ? undefined : onOpenChange}>
|
||||
<DialogContent
|
||||
@@ -100,7 +101,7 @@ export function ConfirmModal({
|
||||
tone = 'destructive',
|
||||
icon = null,
|
||||
busy = false,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronDown, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -25,7 +26,7 @@ export function MultiSelect({
|
||||
className = '',
|
||||
align = 'start',
|
||||
emptyMessage = 'No options',
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState('');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -22,7 +23,7 @@ export function PageHeader({
|
||||
size = 'title',
|
||||
animate = true,
|
||||
className,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const Wrapper = animate ? motion.div : 'div';
|
||||
const motionProps = animate
|
||||
? {
|
||||
@@ -68,7 +69,7 @@ export function PageHeader({
|
||||
* A dashboard-style greeting header where part of the title is emphasized —
|
||||
* "Good day. Let's hire smarter."
|
||||
*/
|
||||
export function HeroHeader({ lead, highlight, subtitle, actions, className }) {
|
||||
export function HeroHeader({ lead, highlight, subtitle, actions, className }: DsProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -12 }}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -24,7 +25,10 @@ export function buildPageRange(current, total, siblings = 1) {
|
||||
const showLeftEllipsis = left > 2;
|
||||
const showRightEllipsis = right < total - 1;
|
||||
|
||||
const pages = [1];
|
||||
/* Page numbers with '…' markers between them — a real mixed array, which is
|
||||
why the renderer tests `entry === '…'` before treating it as a number.
|
||||
Inference took the seed `[1]` as `number[]` and rejected the markers. */
|
||||
const pages: (number | string)[] = [1];
|
||||
if (showLeftEllipsis) pages.push('…');
|
||||
for (let p = Math.max(2, left); p <= Math.min(total - 1, right); p += 1) pages.push(p);
|
||||
if (showRightEllipsis) pages.push('…');
|
||||
@@ -47,7 +51,7 @@ export function Pagination({
|
||||
onPageSizeChange,
|
||||
pageSizeOptions = [10, 25, 50, 100],
|
||||
className,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
if (pageCount <= 1 && !onPageSizeChange) return null;
|
||||
|
||||
const from = totalItems != null && pageSize ? (page - 1) * pageSize + 1 : null;
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -45,7 +46,7 @@ export function ProgressBar({
|
||||
valueSuffix = '',
|
||||
className,
|
||||
trackClassName,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const pct = clamp((Number(value) / max) * 100);
|
||||
const resolvedTone = tone === 'score' ? toneForScore(pct) : tone;
|
||||
|
||||
@@ -110,7 +111,7 @@ export function ProgressRing({
|
||||
/** Small text below the label. */
|
||||
footnote,
|
||||
className,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const ratio = Math.max(0, Math.min(1, (Number(value) - min) / (max - min)));
|
||||
@@ -178,7 +179,7 @@ export function ProgressRing({
|
||||
* at a glance without relying on colour.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function ProgressSteps({ steps = [], current = 0, className = '', onStepClick = null }) {
|
||||
export function ProgressSteps({ steps = [], current = 0, className = '', onStepClick = null }: DsProps) {
|
||||
const normalized = steps.map((s) => (typeof s === 'string' ? { label: s } : s));
|
||||
|
||||
return (
|
||||
@@ -243,7 +244,7 @@ export function ProgressSteps({ steps = [], current = 0, className = '', onStepC
|
||||
* dimensions are listed with a weight and a value.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function ScoreMeter({ label, value, weight, tone = 'score', className }) {
|
||||
export function ScoreMeter({ label, value, weight, tone = 'score', className }: DsProps) {
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
@@ -10,8 +10,7 @@ import { inputVariants } from '@/components/ui/input';
|
||||
* without re-running expensive work, while the field itself stays instantly
|
||||
* responsive. Pass `debounce={0}` for immediate updates.
|
||||
*/
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
export const SearchInput = React.forwardRef(
|
||||
export const SearchInput: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -21,7 +22,7 @@ export function SectionHeader({
|
||||
onViewAll,
|
||||
viewAllLabel = 'View all',
|
||||
className,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
if (level === 'overline') {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between gap-3 mb-3', className)}>
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -10,7 +11,7 @@ import { cn } from '@/lib/utils';
|
||||
* `prefers-reduced-motion` (see index.css).
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Skeleton({ className = '', radius = 'md', ...props }) {
|
||||
export function Skeleton({ className = '', radius = 'md', ...props }: DsProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
@@ -33,7 +34,7 @@ export function Skeleton({ className = '', radius = 'md', ...props }) {
|
||||
|
||||
/** Paragraph placeholder. The last line is short, as real text is. */
|
||||
/** @param {any} props */
|
||||
export function SkeletonText({ lines = 3, className = '' }) {
|
||||
export function SkeletonText({ lines = 3, className = '' }: DsProps) {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)} role="status" aria-label="Loading content">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
@@ -48,7 +49,7 @@ export function SkeletonText({ lines = 3, className = '' }) {
|
||||
|
||||
/** Matches KpiCard: icon tile, big number, caption. */
|
||||
/** @param {any} props */
|
||||
export function SkeletonKpi({ className = '' }) {
|
||||
export function SkeletonKpi({ className = '' }: DsProps) {
|
||||
return (
|
||||
<div className={cn('glass-card border border-white/60 rounded-2xl p-4 shadow-sm', className)}>
|
||||
<Skeleton className="w-10 h-10 rounded-xl mb-3" />
|
||||
@@ -60,7 +61,7 @@ export function SkeletonKpi({ className = '' }) {
|
||||
|
||||
/** Matches a list row: avatar, two lines of text, trailing value. */
|
||||
/** @param {any} props */
|
||||
export function SkeletonRow({ className = '' }) {
|
||||
export function SkeletonRow({ className = '' }: DsProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-3 py-3', className)}>
|
||||
<Skeleton className="w-9 h-9" radius="full" />
|
||||
@@ -75,7 +76,7 @@ export function SkeletonRow({ className = '' }) {
|
||||
|
||||
/** Matches a card in a grid: title, meta, stat row. */
|
||||
/** @param {any} props */
|
||||
export function SkeletonCard({ className = '' }) {
|
||||
export function SkeletonCard({ className = '' }: DsProps) {
|
||||
return (
|
||||
<div className={cn('glass-card border border-white/60 rounded-2xl p-5 shadow-sm', className)}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
@@ -100,7 +101,7 @@ export function SkeletonCard({ className = '' }) {
|
||||
|
||||
/** Matches DataTable: header row plus n body rows. */
|
||||
/** @param {any} props */
|
||||
export function SkeletonTable({ rows = 5, columns = 4, className = '' }) {
|
||||
export function SkeletonTable({ rows = 5, columns = 4, className = '' }: DsProps) {
|
||||
return (
|
||||
<div className={cn('overflow-hidden', className)} role="status" aria-label="Loading table">
|
||||
<div className="flex items-center gap-4 px-4 py-3 border-b border-border bg-surface-subtle">
|
||||
@@ -121,7 +122,7 @@ export function SkeletonTable({ rows = 5, columns = 4, className = '' }) {
|
||||
|
||||
/** Matches ChartContainer: title, legend, plot area. */
|
||||
/** @param {any} props */
|
||||
export function SkeletonChart({ className = '', height = 240 }) {
|
||||
export function SkeletonChart({ className = '', height = 240 }: DsProps) {
|
||||
return (
|
||||
<div className={cn('glass-card border border-white/60 rounded-2xl p-6 shadow-sm', className)}>
|
||||
<Skeleton className="h-3.5 w-40 mb-2" />
|
||||
@@ -137,7 +138,7 @@ export function SkeletonChart({ className = '', height = 240 }) {
|
||||
|
||||
/** n SkeletonRows inside a surface — the common "loading a list" case. */
|
||||
/** @param {any} props */
|
||||
export function SkeletonList({ rows = 5, className = '' }) {
|
||||
export function SkeletonList({ rows = 5, className = '' }: DsProps) {
|
||||
return (
|
||||
<div className={cn('divide-y divide-border', className)} role="status" aria-label="Loading list">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -78,7 +79,7 @@ const DOT_COLOR = {
|
||||
* much work; it also gives colour-blind users a second cue alongside the label.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function StatusBadge({ status, label, tone, dot = false, size = 'default', className, ...props }) {
|
||||
export function StatusBadge({ status, label, tone, dot = false, size = 'default', className, ...props }: DsProps) {
|
||||
const entry = STATUS_MAP[status] || {};
|
||||
const resolvedTone = tone || entry.tone || 'neutral';
|
||||
const resolvedLabel = label || entry.label || humanize(status);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user