chore(ts-migration): migrate components/krow to TypeScript with real prop types

Phase 8, first batch: 56 files, plus three supporting edits outside the folder.
All 56 emit byte-identical JavaScript and the production bundle is byte-identical
to 543da9d.

This batch establishes the pattern for the remaining component work: real prop
interfaces built from actual call sites, reusing the generated entity types.
Fifty-four components now declare what they take -
`CandidateCard({ application: JobApplication, jobTitle?: string, rank: number })`,
`MatchedCandidates({ job: JobPosting, profiles: ProfileView[], ... })` - rather
than carrying a compatibility bag. Callbacks are optional because call sites
omit them; entity props are required because call sites always pass them. Where
a call site proved otherwise, the call site won: `ScheduleInterviewModal`'s
`position`, `CandidateCard`'s `jobTitle`, `TalentDetailModal`'s `matchScore` and
`matchReasons` are optional because real callers omit them.

Two view types absorb what the registry cannot describe. `ProfileView` and
`CourseView` narrow the `jsonb` columns these components read through -
`experience`, `completed_courses`, `earned_badges`, `capabilities`,
`score_breakdown`, `challenge`, `quiz` - to arrays and objects, while every
field backed by a real column keeps the registry's type. They are declared once
for the folder, and their narrowed fields stay REQUIRED: the registry has those
columns NOT NULL, and making them optional broke assignment back to the entity
where `ChallengeRunner` hands a course to a mutation.

TWO REAL DEFECTS FOUND, BOTH PRESERVED RATHER THAN FIXED:

  1. `SuggestedTalent` calls `matchTalent.mutate({...}).then(...)`. React
     Query's `mutate` returns `void`, so that `.then` throws at run time, and
     `runMatch` is reachable from a button. `mutateAsync` is what the code
     means. Swapping it turns a crash into a working feature, which is a
     product change, not a migration one - so it is cast to compile and left
     behaving exactly as it did. This one deserves a fix on its own terms.

  2. `TalentPoolCard` renders a location row behind `profile.location &&`, but
     `worker_profiles` has no `location` column - the registry has none and the
     API cannot send one, so the row has never rendered. Recorded as an
     optional field on `ProfileView` with a note, the same treatment as
     `user.avatar_url` in Phase 7.

Also fixed, all type-only: the Web Speech API declared as the optional `Window`
members `AIInterviewModal` already feature-detects; four `new Promise<void>`
where `resolve()` takes no argument; `toast`'s options bag made optional on
every method, which callers had always omitted; five `krowHooks` query
arguments made optional, which callers had always omitted.

One automated pass was reverted rather than shipped. A local-component
annotator captured words out of the preceding JSDoc as prop names - producing
`interface FrameProps { one?: any; every?: any }` from a sentence about
"one surface, one padding, every step" - the same class of regex error as
Phase 6's. The whole folder was restored from the index and the sound steps
re-run, then local components were annotated with an index-spliced rewrite that
reads only the destructuring.

Verified: tsc 35 -> 22, set-difference showing thirteen removed and none added;
zero errors in any of the 56 files; all 56 emit byte-identical JavaScript;
production bundle byte-identical; npm test 1684/1691 with the same seven
failures; Owliver baseline 59/59; lint 0 errors; baseline artifacts untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
2026-09-18 11:39:58 +05:30
parent 543da9d4e7
commit e7e1e9873f
60 changed files with 750 additions and 116 deletions

View File

@@ -0,0 +1,330 @@
import type { ProfileView, CourseView } from '../types';
import React, { useState } from 'react';
import { Lock, Video, Camera, MessageSquare, ScanLine, XCircle, CheckCircle2, ShieldCheck, Award, Eye, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button, Spinner, Surface } from '@/components/ds';
import { isUnlocked } from '@/lib/provingGround';
import { useSubmitChallenge, useVerifyEvidence } from '@/lib/krowHooks';
import RoleplayChallenge from './RoleplayChallenge';
import MediaChallenge from './MediaChallenge';
import VideoRecorder from './VideoRecorder';
import HazardIdentifier from './HazardIdentifier';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
/**
* The challenge itself: learn, demonstrate, verify, proof.
*
* Presentation only — every step, transition and mutation below is the original
* flow. Styling comes from the design system so the runner reads as part of the
* Forge workspace it is embedded in rather than a widget dropped into it.
*/
const STEPS = ['Learn', 'Demonstrate', 'Verify', 'Proof'];
const TYPE_ICON = { roleplay: MessageSquare, photo: Camera, video: Video, photo_identify: ScanLine };
const TYPE_LABEL = { roleplay: 'Chat with Owliver', photo: 'Photo Proof', video: 'Record Yourself', photo_identify: 'Identify Hazards' };
/** Props for the local `Stepper` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface StepperProps {
active?: any;
}
function Stepper({ active }: StepperProps) {
return (
<div className="mb-5 flex items-center gap-1.5">
{STEPS.map((s, i) => {
const done = i < active;
const cur = i === active;
return (
<React.Fragment key={s}>
<div className="flex items-center gap-1.5">
<span
className={cn(
'grid h-5 w-5 place-items-center rounded-full text-[10px] font-bold',
done || cur ? 'bg-krow-blue text-white' : 'bg-surface-sunken text-ink-4',
cur && 'ring-4 ring-krow-blue-tint'
)}
>
{done ? '✓' : i + 1}
</span>
<span className={cn('text-caption font-medium', done || cur ? 'text-ink-1' : 'text-ink-4')}>
{s}
</span>
</div>
{i < STEPS.length - 1 && (
<span className={cn('h-px flex-1', done ? 'bg-krow-blue' : 'bg-border')} aria-hidden="true" />
)}
</React.Fragment>
);
})}
</div>
);
}
/** The runner's shell — one surface, one padding, every step. */
/** Props for the local `Frame` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface FrameProps {
children?: any;
className?: any;
}
function Frame({ children, className }: FrameProps) {
return (
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className={cn('px-5 py-4', className)}>
{children}
</Surface>
);
}
interface ChallengeRunnerProps {
course: CourseView;
profile: ProfileView;
onPassed?: (...args: any[]) => void;
}
export default function ChallengeRunner({ course, profile, onPassed }: ChallengeRunnerProps) {
const [step, setStep] = useState(0);
const [evaluating, setEvaluating] = useState(false);
const [result, setResult] = useState(null);
const [evidence, setEvidence] = useState(null);
const [supervisorVerified, setSupervisorVerified] = useState(false);
const submit = useSubmitChallenge();
const verify = useVerifyEvidence();
const ch = course?.challenge || {};
const type = ch.type || 'roleplay';
const Icon = TYPE_ICON[type] || MessageSquare;
const lock = isUnlocked(course, profile);
const handleSubmit = async (payload) => {
setEvaluating(true);
let mediaUrl = null, transcript = '', identified = null;
if (type === 'roleplay') transcript = payload;
else if (type === 'photo_identify') { mediaUrl = ch.image_url; identified = payload; }
else mediaUrl = payload;
try {
const res = await submit.mutateAsync({ profile, course, type, mediaUrl, transcript, identified });
setResult(res.result);
setEvidence(res.evidence);
setSupervisorVerified(false);
setStep(2);
if (res.passed && onPassed) onPassed();
} catch {
setStep(1);
} finally {
setEvaluating(false);
}
};
const doVerify = async () => {
if (!evidence) return;
try {
await verify.mutateAsync({ evidenceId: evidence.id });
setSupervisorVerified(true);
} catch {}
};
const passed = evidence?.ai_verdict === 'verified';
// 0 — Learn
if (step === 0) {
return (
<Frame>
<Stepper active={0} />
{!lock.unlocked ? (
<>
<p className="flex items-center gap-2 text-body font-semibold text-ink-1">
<Lock className="h-4 w-4 text-warning" aria-hidden="true" /> Locked — earn this one
</p>
<p className="mt-1 text-body-sm text-ink-3">Real work unlocks learning. Complete these first:</p>
<ul className="mt-2.5 space-y-1.5">
{lock.reasons.map((r, i) => (
<li key={i} className="flex items-start gap-2 text-body-sm text-ink-2">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-warning" aria-hidden="true" />
{r}
</li>
))}
</ul>
</>
) : (
<>
<div className="flex items-center gap-3">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-krow-blue-tint text-krow-blue">
<Icon className="h-5 w-5" aria-hidden="true" />
</span>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wide text-ink-4">Demonstration</p>
<p className="text-body font-semibold text-ink-1">{TYPE_LABEL[type]}</p>
</div>
</div>
<p className="mt-3 max-w-prose text-body-sm leading-relaxed text-ink-2">
{ch.prompt || course?.description}
</p>
<Button shape="rounded" className="mt-4 w-full" onClick={() => setStep(1)}>
Show me <ArrowRight aria-hidden="true" />
</Button>
</>
)}
</Frame>
);
}
// Evaluating
if (evaluating) {
return (
<Frame className="text-center">
<Stepper active={1} />
<div className="flex flex-col items-center gap-2 py-6">
<Spinner size="lg" />
<p className="text-body-sm font-medium text-ink-1">Owliver is evaluating your proof…</p>
<p className="text-caption text-ink-3">Measuring whether you can actually perform.</p>
</div>
</Frame>
);
}
// 1 — Demonstrate
if (step === 1) {
return (
<Frame>
<Stepper active={1} />
{/* The roleplay widget opens with this same prompt as its first message,
so repeating it above the transcript would say it twice. */}
{type !== 'roleplay' && (
<div className="mb-4 flex items-start gap-2.5 rounded-xl border border-border bg-surface-subtle p-3">
<OwliverAvatar className="h-7 w-7 shrink-0" rounded="rounded-full" />
<div className="min-w-0">
<p className="text-[11px] font-semibold text-krow-blue">Owliver</p>
<p className="text-body-sm leading-relaxed text-ink-2">{ch.prompt || course?.description}</p>
</div>
</div>
)}
{type === 'roleplay' && <RoleplayChallenge course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
{type === 'photo' && <MediaChallenge course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
{type === 'video' && <VideoRecorder onSubmit={handleSubmit} submitting={submit.isPending} />}
{type === 'photo_identify' && <HazardIdentifier course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
</Frame>
);
}
// 2 — Verify
if (step === 2) {
const rubric = result?.rubric || {};
return (
<Frame>
<Stepper active={2} />
<div className="flex items-center gap-3">
{passed
? <CheckCircle2 className="h-8 w-8 shrink-0 text-success" aria-hidden="true" />
: <XCircle className="h-8 w-8 shrink-0 text-destructive" aria-hidden="true" />}
<div className="min-w-0">
<p className="font-heading text-title text-ink-1">{passed ? 'Verified by Owliver' : 'Needs work'}</p>
<p className="text-caption tabular-nums text-ink-3">
AI confidence: {Math.round(evidence?.ai_score || 0)}/100
</p>
</div>
</div>
{result?.feedback && (
<p className="mt-3 max-w-prose text-body-sm leading-relaxed text-ink-2">{result.feedback}</p>
)}
{Object.keys(rubric).length > 0 && (
<div className="mt-4 space-y-2">
{Object.entries(rubric).map(([k, v]) => (
<div key={k}>
<div className="mb-1 flex justify-between text-caption">
<span className="capitalize text-ink-3">{k.replace(/_/g, ' ')}</span>
<span className="font-semibold tabular-nums text-ink-1">{Math.round(v as number)}/100</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-surface-sunken">
<div className="h-full rounded-full bg-krow-blue" style={{ width: `${Math.min(100, Math.round(v as number))}%` }} />
</div>
</div>
))}
</div>
)}
<div
className={cn(
'mt-4 flex items-center gap-3 rounded-xl border px-3.5 py-3',
supervisorVerified ? 'border-success/30 bg-success-muted' : 'border-warning/30 bg-warning-muted'
)}
>
<ShieldCheck
className={cn('h-4 w-4 shrink-0', supervisorVerified ? 'text-success' : 'text-warning')}
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<p className="text-body-sm font-medium text-ink-1">
{supervisorVerified ? 'Supervisor verified' : 'Supervisor verification'}
</p>
<p className="text-caption text-ink-3">
{supervisorVerified
? 'A supervisor confirmed this demonstration in person.'
: 'A supervisor can confirm this demonstration on the floor.'}
</p>
</div>
{!supervisorVerified ? (
<Button variant="navy" size="sm" shape="rounded" onClick={doVerify} disabled={verify.isPending}>
{verify.isPending ? '…' : 'Verify'}
</Button>
) : (
<CheckCircle2 className="h-4 w-4 text-success" aria-hidden="true" />
)}
</div>
{passed ? (
<Button shape="rounded" className="mt-3 w-full" onClick={() => setStep(3)}>
See your proof <ArrowRight aria-hidden="true" />
</Button>
) : (
<Button variant="outline" shape="rounded" className="mt-3 w-full" onClick={() => setStep(1)}>
Try the demonstration again
</Button>
)}
</Frame>
);
}
// 3 — Proof
return (
<Frame>
<Stepper active={3} />
<div className="flex items-center gap-3">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-krow-yellow text-krow-blue">
<Award className="h-5 w-5" aria-hidden="true" />
</span>
<div className="min-w-0">
<p className="text-body font-semibold text-ink-1">Proof stored</p>
<p className="text-caption text-ink-3">Capability: {course.proof_skill || course.title}</p>
</div>
</div>
<dl className="mt-4 divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface-subtle">
{[
{ label: 'AI verdict', value: evidence?.ai_verdict, tone: 'text-success', capitalize: true },
{ label: 'Score', value: `${Math.round(evidence?.ai_score || 0)}/100` },
{ label: 'Supervisor', value: supervisorVerified ? 'Verified' : 'Pending' },
{ label: 'Proof type', value: TYPE_LABEL[type] },
].map((row) => (
<div key={row.label} className="flex items-baseline justify-between gap-3 px-3.5 py-2">
<dt className="text-caption text-ink-3">{row.label}</dt>
<dd className={cn('text-caption font-semibold', row.capitalize && 'capitalize', row.tone || 'text-ink-1')}>
{row.value}
</dd>
</div>
))}
</dl>
<p className="mt-3 flex items-start gap-2 rounded-xl border border-border bg-krow-blue-tint px-3.5 py-3 text-caption leading-relaxed text-krow-blue">
<Eye className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
Employers can now see this proof in your Career Wallet. Every skill is backed by evidence.
</p>
<Button variant="outline" shape="rounded" className="mt-3 w-full" onClick={() => setStep(0)}>
Back to start
</Button>
</Frame>
);
}