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,78 @@
import type { JobApplication } from '@/types/entities';
import React from 'react';
import { cn } from '@/lib/utils';
const TIERS = [
{ label: 'Top Talent', range: '80-100', color: 'bg-[#1E3A8A]', textColor: 'text-[#1E3A8A]' },
{ label: 'Strong Fit', range: '60-79', color: 'bg-[#60A5FA]', textColor: 'text-[#2563EB]' },
{ label: 'Possible Fit', range: '40-59', color: 'bg-[#93C5FD]', textColor: 'text-[#3B82F6]' },
{ label: 'Not a Fit', range: '0-39', color: 'bg-[#E5E7EB]', textColor: 'text-[#9CA3AF]' },
];
function getTier(score) {
if (score >= 80) return 0;
if (score >= 60) return 1;
if (score >= 40) return 2;
return 3;
}
interface KROWCandidateLeaderboardProps {
applications: JobApplication[];
}
export default function KROWCandidateLeaderboard({ applications }: KROWCandidateLeaderboardProps) {
const screened = applications.filter(a => a.ai_score > 0 || a.status !== 'applied');
const counts = TIERS.map((_, i) => screened.filter(a => getTier(a.ai_score || 0) === i).length);
const total = counts.reduce((a, b) => a + b, 0) || 1;
const sorted = [...screened].sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0));
return (
<div className="bg-white border border-[#E5E7EB] rounded-xl p-6">
<h3 className="font-semibold text-[#111827] text-[15px] mb-5">Candidate Pipeline</h3>
{/* Bar chart */}
<div className="space-y-3 mb-6">
{TIERS.map((tier, i) => {
const count = counts[i];
const pct = (count / total) * 100;
return (
<div key={tier.label}>
<div className="flex items-center justify-between mb-1">
<span className="text-[12px] font-medium text-[#374151]">{tier.label} <span className="text-[#9CA3AF]">({tier.range})</span></span>
<span className="text-[13px] font-bold text-[#111827]">{count}</span>
</div>
<div className="h-2.5 bg-[#F3F4F6] rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500', tier.color)}
style={{ width: `${Math.max(pct, count > 0 ? 4 : 0)}%` }}
/>
</div>
</div>
);
})}
</div>
{/* Ranked list */}
<div className="space-y-2">
{sorted.slice(0, 5).map((app, idx) => (
<div key={app.id} className="flex items-center gap-3 py-2">
<span className="text-[12px] font-bold text-[#9CA3AF] w-6">#{idx + 1}</span>
<div className="w-7 h-7 rounded-full bg-[#0838E0] flex items-center justify-center text-white text-[10px] font-semibold shrink-0">
{app.applicant_name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-[#111827] truncate">{app.applicant_name}</div>
<div className="text-[11px] text-[#6B7280] truncate">{app.job_title || 'Applicant'}</div>
</div>
<span className={cn('text-[13px] font-bold', TIERS[getTier(app.ai_score || 0)].textColor)}>
{app.ai_score || 0}
</span>
</div>
))}
{sorted.length === 0 && (
<div className="text-center py-8 text-[13px] text-[#9CA3AF]">No screened candidates yet</div>
)}
</div>
</div>
);
}