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:
117
src/components/krow/RetentionMetrics.tsx
Normal file
117
src/components/krow/RetentionMetrics.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { Staff } from '@/types/entities';
|
||||
import React, { useMemo } from 'react';
|
||||
import { TrendingUp, TrendingDown, Clock, Activity } from 'lucide-react';
|
||||
|
||||
const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||
|
||||
function tenureDays(hireDate, status, now) {
|
||||
if (!hireDate) return 0;
|
||||
const start = new Date(hireDate).getTime();
|
||||
const end = status === 'inactive' && now ? now : Date.now();
|
||||
return Math.max(0, Math.floor((end - start) / MS_PER_DAY));
|
||||
}
|
||||
|
||||
interface RetentionMetricsProps {
|
||||
staff: Staff[];
|
||||
}
|
||||
|
||||
export default function RetentionMetrics({ staff }: RetentionMetricsProps) {
|
||||
const stats = useMemo(() => {
|
||||
const total = staff.length;
|
||||
const active = staff.filter((s) => s.status === 'active').length;
|
||||
const onboarding = staff.filter((s) => s.status === 'onboarding').length;
|
||||
const churned = staff.filter((s) => s.status === 'inactive').length;
|
||||
|
||||
// Retention rate: of hires whose outcome is decided (active + inactive),
|
||||
// what share are still active. Onboarding hires are excluded from the denominator
|
||||
// since they haven't had time to churn.
|
||||
const decided = active + churned;
|
||||
const retentionRate = decided > 0 ? Math.round((active / decided) * 100) : null;
|
||||
|
||||
// Cohort retention: % of hires still on board after N days from hire_date.
|
||||
// A hire counts as retained past day N if (status !== 'inactive') OR (tenure >= N before churning).
|
||||
const now = Date.now();
|
||||
const withHireDate = staff.filter((s) => s.hire_date);
|
||||
const cohort = (days) => {
|
||||
if (withHireDate.length === 0) return null;
|
||||
const retained = withHireDate.filter((s) => {
|
||||
const tenure = tenureDays(s.hire_date, s.status, now);
|
||||
if (s.status === 'inactive') return tenure >= days;
|
||||
return true; // still active/onboarding — retained
|
||||
}).length;
|
||||
return Math.round((retained / withHireDate.length) * 100);
|
||||
};
|
||||
|
||||
const avgTenure = withHireDate.length > 0
|
||||
? Math.round(withHireDate.reduce((sum, s) => sum + tenureDays(s.hire_date, s.status, now), 0) / withHireDate.length)
|
||||
: 0;
|
||||
|
||||
return { total, active, onboarding, churned, retentionRate, c30: cohort(30), c60: cohort(60), c90: cohort(90), avgTenure };
|
||||
}, [staff]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Metric
|
||||
icon={<TrendingUp className="w-4 h-4" />}
|
||||
label="Retention Rate"
|
||||
value={stats.retentionRate == null ? '—' : `${stats.retentionRate}%`}
|
||||
tone={stats.retentionRate == null ? 'neutral' : stats.retentionRate >= 70 ? 'good' : stats.retentionRate >= 40 ? 'warn' : 'bad'}
|
||||
sub={`${stats.active} active · ${stats.churned} churned`}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Activity className="w-4 h-4" />}
|
||||
label="Still Active"
|
||||
value={stats.active}
|
||||
tone="good"
|
||||
sub={stats.onboarding > 0 ? `+ ${stats.onboarding} onboarding` : 'On track'}
|
||||
/>
|
||||
<Metric
|
||||
icon={<TrendingDown className="w-4 h-4" />}
|
||||
label="Churned"
|
||||
value={stats.churned}
|
||||
tone={stats.churned === 0 ? 'good' : 'bad'}
|
||||
sub={`${stats.total} total hires`}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Clock className="w-4 h-4" />}
|
||||
label="Avg Tenure"
|
||||
value={stats.avgTenure > 0 ? `${stats.avgTenure}d` : '—'}
|
||||
tone="neutral"
|
||||
sub={`30d: ${stats.c30 ?? '—'} · 90d: ${stats.c90 ?? '—'}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TONES = {
|
||||
good: { bg: 'bg-[#ECFDF5]', text: 'text-[#047857]', icon: 'bg-[#047857]' },
|
||||
warn: { bg: 'bg-[#FFF7ED]', text: 'text-[#B45309]', icon: 'bg-[#B45309]' },
|
||||
bad: { bg: 'bg-[#FEF2F2]', text: 'text-[#B91C1C]', icon: 'bg-[#B91C1C]' },
|
||||
neutral: { bg: 'bg-[#F0F4F8]', text: 'text-[#1E56C1]', icon: 'bg-[#1E56C1]' },
|
||||
};
|
||||
|
||||
/** Props for the local `Metric` helper in this file. Optional throughout:
|
||||
the JavaScript validated none of them and callers omit freely. */
|
||||
interface MetricProps {
|
||||
icon?: any;
|
||||
label?: any;
|
||||
value?: any;
|
||||
sub?: any;
|
||||
tone?: any;
|
||||
}
|
||||
|
||||
function Metric({ icon, label, value, sub, tone = 'neutral' }: MetricProps) {
|
||||
const t = TONES[tone] || TONES.neutral;
|
||||
return (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-xl p-4 flex items-start gap-3">
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${t.bg}`}>
|
||||
<span className={t.text}>{icon}</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold text-[#6B7280] tracking-wide">{label}</p>
|
||||
<p className="text-[20px] font-bold text-[#111827] leading-tight mt-0.5">{value}</p>
|
||||
<p className="text-[10px] text-[#9CA3AF] mt-0.5 truncate">{sub}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user