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:
330
src/components/krow/proving/ChallengeRunner.tsx
Normal file
330
src/components/krow/proving/ChallengeRunner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user