update hired page

This commit is contained in:
2026-08-11 19:17:40 +05:30
parent 8a256a4de7
commit 25c4ee4a6a
23 changed files with 2921 additions and 380 deletions

View File

@@ -1,12 +1,14 @@
import {
Activity, AlertTriangle, ArrowLeftRight, BellRing, Eye, Filter, Gauge, GitBranch,
Layers, Lightbulb, ScrollText, ShieldAlert, Target, TrendingUp, UserCheck, Users,
Activity, AlertTriangle, ArrowLeftRight, Award, BellRing, Clock, Eye, Filter, Gauge,
GitBranch, Layers, Lightbulb, Lock, ScrollText, ShieldAlert, ShieldCheck, Star, Target,
TrendingUp, UserCheck, Users,
} from 'lucide-react';
import {
actions, answer, doc, funnel, heading, insights, kpis, list, meters, note, status, table,
text, timeline,
} from '../blocks';
import { plural, verb } from '../insights';
import { pct, plural, verb } from '../insights';
import { toFICO } from '@/lib/talentHome';
/**
* Admin capabilities — Control Center, Candidates Analysis, Activity.
@@ -1351,3 +1353,463 @@ export const ACTIVITY_CAPABILITIES = [
{ id: 'unusual-activity', label: 'Unusual Activity', icon: AlertTriangle, run: unusualActivity },
{ id: 'security-insights', label: 'Security Insights', icon: ShieldAlert, run: securityInsights },
];
/* ── KROW Forge ─────────────────────────────────────────────────────────── */
/**
* Forge capabilities — workforce skill verification.
*
* The Forge questions are not hiring questions: they are about proof. What is
* verified, what can be proven next, and what is gating the rest. Every figure
* comes from `f.forge`, which is derived with the same unlock gate the page
* renders from.
*/
const forgeProgress = (f) => {
const g = f.forge;
return doc(
heading('Forge progress', `${plural(g.courses.length, 'challenge')} in the library`),
kpis([
{ label: 'Verified skills', value: g.verified.length, sub: g.verified.length ? 'proven' : 'nothing proven yet' },
{ label: 'In progress', value: g.inProgress.length, sub: 'challenges started' },
{ label: 'Ready to start', value: g.ready.length, sub: 'unlocked now' },
{ label: 'XP available', value: g.xpAvailable, sub: `across ${plural(g.courses.length - g.completed.length, 'challenge')}` },
]),
g.verified.length
? list(g.verified.map((b) => `**${b.name}** — ${b.level || 'Standard'}, verified`))
: text('No skill is verified yet. The first one comes from completing a challenge and submitting proof for Owliver to grade.'),
g.byDifficulty.length ? heading('Library by difficulty') : null,
meters(g.byDifficulty.map((d) => ({
label: d.level.charAt(0).toUpperCase() + d.level.slice(1),
value: d.count,
max: g.courses.length,
}))),
g.locked.length
? note(`${plural(g.locked.length, 'challenge')} ${verb(g.locked.length, 'is', 'are')} still gated by real-work requirements.`)
: null
);
};
const forgeNextChallenge = (f) => {
const g = f.forge;
if (!g.next) return answer('Every active challenge is complete — there is nothing left to start.');
const c = g.next;
const gate = g.blocked.find((b) => b.course.id === c.id);
return doc(
heading(c.title, `${c.proof_skill || c.category} · ${c.difficulty}`),
text(c.description),
kpis([
{ label: 'Duration', value: `${c.estimated_minutes} min` },
{ label: 'XP', value: c.xp },
{ label: 'Earns', value: c.badge_reward || '—' },
{ label: 'State', value: gate ? 'Locked' : 'Ready', tone: gate ? 'warning' : 'success' },
]),
gate
? insights([{
tone: 'warning',
title: 'Blocked for now',
body: `${gate.reasons.join('. ')}. Those are real-work requirements, so they clear through shifts rather than through study.`,
}])
: insights([{
tone: 'success',
title: 'Ready to start',
body: `Nothing is gating this one. ${plural(c.estimated_minutes, 'minute')} of work is worth ${c.xp} XP${c.badge_reward ? ` and the ${c.badge_reward} badge` : ''}.`,
}]),
g.ready.length > 1
? actions(g.ready.slice(1, 3).map((alt) => ({
title: alt.title,
body: `${alt.difficulty} · ${alt.estimated_minutes} min · ${alt.xp} XP. An alternative if this one does not fit today.`,
})))
: null
);
};
const forgeBlockers = (f) => {
const g = f.forge;
if (!g.locked.length) {
return answer(`Nothing is locked — all ${plural(g.ready.length, 'remaining challenge')} can be started now.`);
}
return doc(
heading(`${plural(g.locked.length, 'challenge')} blocked`, 'and what clears each one'),
table(
[
{ key: 'challenge', label: 'Challenge' },
{ key: 'skill', label: 'Skill' },
{ key: 'requirement', label: 'Blocked by' },
],
g.blocked.map(({ course, reasons }) => ({
challenge: course.title,
skill: course.proof_skill || course.category,
requirement: reasons[0] || '—',
}))
),
insights(g.blocked.slice(0, 2).map(({ course, reasons }) => ({
tone: 'warning',
title: course.title,
body: `${reasons.join('. ')}. Worth ${course.xp} XP${course.badge_reward ? ` and the ${course.badge_reward} badge` : ''} once it opens.`,
}))),
note('Requirements are measured against the profile record, so they update as shifts and reliability are logged.')
);
};
const forgeReadyNow = (f) => {
const g = f.forge;
if (!g.ready.length) {
return doc(
text('Nothing is unlocked right now.'),
g.locked.length ? text(`${plural(g.locked.length, 'challenge')} ${verb(g.locked.length, 'is', 'are')} waiting on real-work requirements.`) : null
);
}
return doc(
heading(`${plural(g.ready.length, 'challenge')} ready to start`),
table(
[
{ key: 'challenge', label: 'Challenge' },
{ key: 'difficulty', label: 'Difficulty' },
{ key: 'minutes', label: 'Min', align: 'right' },
{ key: 'xp', label: 'XP', align: 'right' },
],
g.ready.map((c) => ({
challenge: c.title,
difficulty: c.difficulty,
minutes: c.estimated_minutes,
xp: c.xp,
}))
),
text(
`Cheapest first: **${[...g.ready].sort((a, b) => a.estimated_minutes - b.estimated_minutes)[0].title}**. `
+ `Highest value: **${[...g.ready].sort((a, b) => b.xp - a.xp)[0].title}**.`
)
);
};
const forgeSkillMap = (f) => {
const g = f.forge;
const skills = g.courses.map((c) => ({
skill: c.proof_skill || c.category || 'Workforce skill',
state: g.verified.some((b) => b.course_id === c.id)
? 'Verified'
: g.locked.some((l) => l.id === c.id) ? 'Locked' : 'Ready',
xp: c.xp,
}));
return doc(
heading('Skills this library can verify', plural(skills.length, 'skill')),
table(
[
{ key: 'skill', label: 'Skill' },
{ key: 'state', label: 'State' },
{ key: 'xp', label: 'XP', align: 'right' },
],
skills
),
text(
g.verified.length
? `${plural(g.verified.length, 'skill')} already carries evidence an employer can read.`
: 'None of these carry evidence yet. Each verified skill adds a record an employer can read against the profile.'
)
);
};
/* ── Talent pool ────────────────────────────────────────────────────────── */
const talentPoolSummary = (f) => {
const t = f.talent;
return doc(
heading('Talent pool', `${plural(f.profiles.length, 'registered worker')}`),
kpis([
{ label: 'Profiles', value: f.profiles.length, sub: `${t.available.length} available` },
{ label: 'Elite', value: f.elite.length, sub: '90+ career score' },
{ label: 'Average score', value: t.avgScore ? toFICO(t.avgScore) : '—', sub: 'scored profiles only' },
{ label: 'Unscored', value: f.unscoredProfiles.length, sub: 'cannot be ranked', tone: f.unscoredProfiles.length ? 'warning' : undefined },
]),
heading('Score bands'),
meters(t.bands.map((b) => ({ label: b.label, value: b.count, max: f.profiles.length }))),
text(
f.unscoredProfiles.length
? `${plural(f.unscoredProfiles.length, 'profile')} ${verb(f.unscoredProfiles.length, 'has', 'have')} no career score, so ${verb(f.unscoredProfiles.length, 'it is', 'they are')} invisible to ranking and matching.`
: 'Every profile carries a career score, so the whole pool is rankable.'
)
);
};
const talentPriorities = (f) => {
const t = f.talent;
if (!f.profiles.length) return answer('No talent profiles registered yet.');
return doc(
heading('Who to prioritize', 'by career score, availability and verification'),
table(
[
{ key: 'name', label: 'Worker' },
{ key: 'score', label: 'Score', align: 'right' },
{ key: 'experience', label: 'Years', align: 'right' },
{ key: 'availability', label: 'Available' },
],
t.ranked.slice(0, 6).map((p) => ({
name: p.full_name || p.email,
/* The page shows career score on the 300–850 scale, so this must too —
the same worker cannot be 94 here and 817 in the row beside it. */
score: p.krow_score ? toFICO(p.krow_score) : '—',
experience: p.experience_years ?? '—',
availability: (p.availability || []).length ? (p.availability || []).join(', ') : '—',
}))
),
insights([
t.top[0] && {
tone: 'success',
title: `${t.top[0].full_name || t.top[0].email} leads the pool`,
body: `Career score ${toFICO(t.top[0].krow_score || 0)}${t.top[0].experience_years ? `, ${plural(t.top[0].experience_years, 'year')} of experience` : ''}.`,
},
t.available.length < f.profiles.length && {
tone: 'info',
title: `${t.available.length} of ${f.profiles.length} list availability`,
body: 'The rest cannot be matched to a shift until availability is on the record.',
},
])
);
};
const talentVerification = (f) => {
const t = f.talent;
if (!t.unverified.length) {
return answer(`All ${plural(f.profiles.length, 'profile')} carry at least one verified skill.`);
}
return doc(
heading(`${plural(t.unverified.length, 'profile')} without verified skills`),
table(
[
{ key: 'name', label: 'Worker' },
{ key: 'score', label: 'Score', align: 'right' },
{ key: 'skills', label: 'Claimed skills', align: 'right' },
],
t.unverified.slice(0, 8).map((p) => ({
name: p.full_name || p.email,
score: p.krow_score ? toFICO(p.krow_score) : '—',
skills: (p.skills || []).length,
}))
),
insights([{
tone: 'warning',
title: 'Claimed is not proven',
body: 'These profiles carry skills nobody has verified. A Forge challenge turns each claim into evidence an employer can read.',
}]),
note(`${pct(t.unverified.length, f.profiles.length)}% of the pool is unverified.`)
);
};
const talentAvailability = (f) => {
const t = f.talent;
const buckets = Object.values(f.profiles.reduce((acc, p) => {
(p.availability || ['Not stated']).forEach((slot) => {
acc[slot] ||= { slot, count: 0 };
acc[slot].count += 1;
});
return acc;
}, {})).sort((a, b) => b.count - a.count);
return doc(
heading('Availability across the pool', `${t.available.length} of ${f.profiles.length} stated`),
meters(buckets.map((b) => ({ label: b.slot, value: b.count, max: f.profiles.length }))),
text(
buckets[0]
? `Deepest cover is **${buckets[0].slot}** at ${plural(buckets[0].count, 'worker')}. Thinnest is **${buckets[buckets.length - 1].slot}**.`
: 'No availability recorded yet.'
)
);
};
/* ── Hired history ──────────────────────────────────────────────────────── */
const hiringOutcomes = (f) => {
const h = f.hiring;
if (!h.hires.length) return answer('Nobody has been hired yet, so there is no history to read.');
return doc(
heading('Hiring outcomes', plural(h.hires.length, 'hire')),
kpis([
{ label: 'Hires', value: h.hires.length, sub: `${h.onboarding.length} onboarding` },
{ label: 'Average quality', value: h.avgQuality || '—', sub: 'AI score at hire' },
{ label: 'Average speed', value: h.avgSpeed ? `${h.avgSpeed}d` : '—', sub: 'apply to hire' },
{ label: 'Rated', value: `${h.rated.length}/${h.hires.length}`, sub: 'client rating on file', tone: h.unrated.length ? 'warning' : undefined },
]),
heading('By department'),
table(
[
{ key: 'name', label: 'Department' },
{ key: 'count', label: 'Hires', align: 'right' },
{ key: 'avgScore', label: 'Avg score', align: 'right' },
{ key: 'avgDays', label: 'Avg days', align: 'right' },
],
h.byDepartment.map((d) => ({
name: d.name,
count: d.count,
avgScore: d.avgScore || '—',
avgDays: d.avgDays || '—',
}))
),
h.unrated.length
? note(`${plural(h.unrated.length, 'hire')} ${verb(h.unrated.length, 'has', 'have')} no client rating yet, so outcome quality rests on the ${h.rated.length} that do.`)
: null
);
};
const hiringStrongest = (f) => {
const h = f.hiring;
if (!h.hires.length) return answer('No hires on record yet.');
const ranked = [...h.hires].sort((a, b) => (b.score || 0) - (a.score || 0));
return doc(
heading('Strongest hires', 'by score at the point of hire'),
table(
[
{ key: 'name', label: 'Hire' },
{ key: 'role', label: 'Role' },
{ key: 'department', label: 'Department' },
{ key: 'score', label: 'Score', align: 'right' },
{ key: 'rating', label: 'Rating', align: 'right' },
],
ranked.slice(0, 6).map((x) => ({
name: x.name,
role: x.role,
department: x.department,
score: x.score || '—',
rating: x.client_rating || '—',
}))
),
insights([
h.strongest && {
tone: 'success',
title: `${h.strongest.name} scored highest at ${h.strongest.score}`,
body: `${h.strongest.role} in ${h.strongest.department}${h.strongest.client_rating ? `, rated ${h.strongest.client_rating} since` : ', not yet rated by the client'}.`,
},
h.byRoleTitle[0] && {
tone: 'info',
title: `${h.byRoleTitle[0].name} is the most-hired role`,
body: `${plural(h.byRoleTitle[0].count, 'hire')}, averaging ${h.byRoleTitle[0].avgScore || 0} at hire.`,
},
])
);
};
const hiringPatterns = (f) => {
const h = f.hiring;
if (!h.hires.length) return answer('No hires on record, so there is no pattern to read.');
const fastest = [...h.byRoleTitle].filter((r) => r.avgDays).sort((a, b) => a.avgDays - b.avgDays)[0];
const slowest = [...h.byRoleTitle].filter((r) => r.avgDays).sort((a, b) => b.avgDays - a.avgDays)[0];
return doc(
heading('What stands out', `${plural(h.hires.length, 'hire')} across ${plural(h.byDepartment.length, 'department')}`),
meters(h.byDepartment.map((d) => ({
label: d.name,
value: d.count,
max: Math.max(...h.byDepartment.map((x) => x.count), 1),
}))),
insights([
h.byDepartment[0] && {
tone: 'info',
title: `${h.byDepartment[0].name} accounts for the most hires`,
body: `${plural(h.byDepartment[0].count, 'hire')} of ${h.hires.length} — ${pct(h.byDepartment[0].count, h.hires.length)}% of everyone placed.`,
},
fastest && slowest && fastest.name !== slowest.name && {
tone: 'warning',
title: `${slowest.name} takes ${slowest.avgDays - fastest.avgDays} days longer than ${fastest.name}`,
body: `${slowest.avgDays} days against ${fastest.avgDays}. The gap is where the process, not the candidate, is the constraint.`,
},
h.unrated.length && {
tone: 'warning',
title: `${plural(h.unrated.length, 'hire')} without a client rating`,
body: 'Outcome quality cannot be judged on hires nobody has rated.',
},
]),
actions([
h.unrated.length ? { title: 'Chase the missing ratings', body: `${plural(h.unrated.length, 'placement')} ${verb(h.unrated.length, 'has', 'have')} no client feedback on file.` } : null,
h.avgSpeed ? { title: 'Compare speed against quality', body: `Average ${h.avgSpeed} days to hire at an average score of ${h.avgQuality}. Faster is only better while the score holds.` } : null,
])
);
};
const hiringRecentHires = (f) => {
const h = f.hiring;
if (!h.hires.length) return answer('No hires on record yet.');
const recent = [...h.hires]
.filter((x) => x.hire_date)
.sort((a, b) => new Date(b.hire_date).getTime() - new Date(a.hire_date).getTime())
.slice(0, 6);
return doc(
heading('Recent hires', 'most recent first'),
timeline(recent.map((x) => ({
title: `${x.name} — ${x.role}`,
description: `${x.department}${x.score ? ` · scored ${x.score}` : ''}${x.timeToHire ? ` · ${plural(x.timeToHire, 'day')} to hire` : ''}`,
timestamp: x.hire_date,
tone: x.status === 'onboarding' ? 'warning' : 'success',
}))),
h.onboarding.length
? note(`${plural(h.onboarding.length, 'hire')} ${verb(h.onboarding.length, 'is', 'are')} still onboarding.`)
: null
);
};
/* ── Free-text responders for the three new contexts ────────────────────── */
export const respondForge = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'block', 'locked', 'lock', 'requirement', 'prerequisite', 'why can', 'unlock')) return forgeBlockers(f);
if (has(q, 'next', 'should i', 'which challenge', 'recommend', 'best')) return forgeNextChallenge(f);
if (has(q, 'ready', 'start now', 'available', 'can i start', 'active challenge')) return forgeReadyNow(f);
if (has(q, 'skill', 'verified', 'proof', 'evidence', 'badge')) return forgeSkillMap(f);
if (has(q, 'summar', 'progress', 'how am i', 'xp', 'overview')) return forgeProgress(f);
return forgeProgress(f);
};
export const respondTalentPool = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'verif', 'unverified', 'proof', 'evidence', 'badge')) return talentVerification(f);
if (has(q, 'available', 'availability', 'shift', 'weekend', 'evening')) return talentAvailability(f);
if (has(q, 'priorit', 'strongest', 'best', 'top', 'who should')) return talentPriorities(f);
if (has(q, 'unscored', 'no score', 'missing', 'gap')) return talentPoolSummary(f);
if (has(q, 'summar', 'overview', 'pool', 'how many')) return talentPoolSummary(f);
return talentPriorities(f);
};
export const respondHiredHistory = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'pattern', 'stand out', 'trend', 'insight', 'attention', 'concern')) return hiringPatterns(f);
if (has(q, 'strongest', 'best', 'top', 'quality', 'highest')) return hiringStrongest(f);
if (has(q, 'recent', 'latest', 'summar', 'who did we hire')) return hiringRecentHires(f);
if (has(q, 'department', 'position', 'role', 'team', 'most hires')) return hiringOutcomes(f);
return hiringOutcomes(f);
};
/* ── Capability manifests for the three new contexts ────────────────────── */
export const FORGE_CAPABILITIES = [
{ id: 'forge-progress', label: 'Forge Progress', icon: Gauge, run: forgeProgress },
{ id: 'forge-next', label: 'Next Challenge', icon: Target, run: forgeNextChallenge },
{ id: 'forge-ready', label: 'Ready To Start', icon: UserCheck, run: forgeReadyNow },
{ id: 'forge-blockers', label: 'What Is Blocking Me', icon: Lock, run: forgeBlockers },
{ id: 'forge-skills', label: 'Skill Map', icon: ShieldCheck, run: forgeSkillMap },
];
export const TALENT_POOL_CAPABILITIES = [
{ id: 'talent-priorities', label: 'Who To Prioritize', icon: Star, run: talentPriorities },
{ id: 'talent-summary', label: 'Pool Summary', icon: Users, run: talentPoolSummary },
{ id: 'talent-verification', label: 'Verification Gaps', icon: ShieldCheck, run: talentVerification },
{ id: 'talent-availability', label: 'Availability', icon: Clock, run: talentAvailability },
];
export const HIRED_HISTORY_CAPABILITIES = [
{ id: 'hiring-outcomes', label: 'Hiring Outcomes', icon: Award, run: hiringOutcomes },
{ id: 'hiring-strongest', label: 'Strongest Hires', icon: TrendingUp, run: hiringStrongest },
{ id: 'hiring-patterns', label: 'What Stands Out', icon: Layers, run: hiringPatterns },
{ id: 'hiring-recent', label: 'Recent Hires', icon: ScrollText, run: hiringRecentHires },
];

View File

@@ -4,9 +4,11 @@ import {
} from './capabilities/employer';
import {
ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES,
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, POSITIONS_CAPABILITIES,
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, FORGE_CAPABILITIES,
HIRED_HISTORY_CAPABILITIES, POSITIONS_CAPABILITIES, TALENT_POOL_CAPABILITIES,
respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
respondControlCenter, respondPositions,
respondControlCenter, respondForge, respondHiredHistory, respondPositions,
respondTalentPool,
} from './capabilities/admin';
/**
@@ -74,6 +76,27 @@ export const ASSISTANT_CONTEXTS = {
capabilities: ADMIN_ANALYTICS_CAPABILITIES,
respond: respondAdminAnalytics,
},
/* Forge, Talent Pool and Hired History read the same platform records as the
contexts above, but ask different questions of them: proof and progression,
supply and readiness, and outcomes after the hire. */
'admin.forge': {
id: 'admin.forge',
page: 'KROW Forge',
capabilities: FORGE_CAPABILITIES,
respond: respondForge,
},
'admin.talentPool': {
id: 'admin.talentPool',
page: 'Talent Pool',
capabilities: TALENT_POOL_CAPABILITIES,
respond: respondTalentPool,
},
'admin.hiredHistory': {
id: 'admin.hiredHistory',
page: 'Hired History',
capabilities: HIRED_HISTORY_CAPABILITIES,
respond: respondHiredHistory,
},
'admin.activity': {
id: 'admin.activity',
page: 'Activity',

View File

@@ -133,6 +133,38 @@ const GREETINGS = {
return `${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')} — ${f.hireRate}% conversion.`;
},
'admin.forge': (f) => {
const g = f.forge;
if (!g.courses.length) return 'No challenges published yet, so nothing can be proven here.';
if (g.locked.length && !g.ready.length) {
return `${plural(g.locked.length, 'challenge')} ${verb(g.locked.length, 'is', 'are')} gated by real-work requirements.`;
}
if (!g.verified.length) {
return `Nothing verified yet — ${plural(g.ready.length, 'challenge')} ${verb(g.ready.length, 'is', 'are')} ready to start.`;
}
return `${plural(g.verified.length, 'skill')} verified, ${plural(g.ready.length, 'challenge')} ready to start.`;
},
'admin.talentPool': (f) => {
if (!f.profiles.length) return 'No talent profiles registered yet.';
if (f.unscoredProfiles.length) {
return `${plural(f.unscoredProfiles.length, 'profile')} ${verb(f.unscoredProfiles.length, 'has', 'have')} no career score and cannot be ranked.`;
}
if (f.talent.unverified.length) {
return `${plural(f.talent.unverified.length, 'profile')} ${verb(f.talent.unverified.length, 'carries', 'carry')} no verified skill.`;
}
return `${plural(f.profiles.length, 'worker')} in the pool, ${f.elite.length} Elite.`;
},
'admin.hiredHistory': (f) => {
const h = f.hiring;
if (!h.hires.length) return 'Nobody has been hired yet, so there is no history to read.';
if (h.unrated.length) {
return `${plural(h.unrated.length, 'hire')} ${verb(h.unrated.length, 'has', 'have')} no client rating on file.`;
}
return `${plural(h.hires.length, 'hire')} placed, averaging ${h.avgQuality} at hire.`;
},
'admin.activity': (f) => {
if (!f.activity.length) return 'No activity logged yet.';
const patterns = f.activitySignals.flags.length;
@@ -168,6 +200,9 @@ const TITLES = {
'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.analytics': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.activity': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.forge': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.talentPool': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.hiredHistory': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
};
/** Descriptions state what this page's assistant can actually do with the data. */
@@ -187,6 +222,12 @@ const DESCRIPTIONS = {
'admin.analytics': (f) =>
`Reading hiring performance across ${plural(f.openPositions.length, 'position')}, ${plural(f.total, 'candidate')} and ${plural(new Set(f.byRole.map((r) => r.category)).size, 'department')} — conversion, speed and outcomes.`,
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
'admin.forge': (f) =>
`Reading ${plural(f.forge.courses.length, 'challenge')} — verified skills, what is ready to start and what is still locked.`,
'admin.talentPool': (f) =>
`Reading ${plural(f.profiles.length, 'talent profile')} — availability, career score and verification.`,
'admin.hiredHistory': (f) =>
`Reading ${plural(f.hiring.hires.length, 'hire')} across ${plural(f.hiring.byDepartment.length, 'department')} — quality, speed and outcomes.`,
};
/**
@@ -242,6 +283,21 @@ const PLACEHOLDERS = {
'Ask Owliver about unusual activity…',
'Ask Owliver about security concerns…',
],
'admin.forge': () => [
'Ask Owliver which challenge to take next…',
'Ask Owliver what is blocking a skill…',
'Ask Owliver about Forge progress…',
],
'admin.talentPool': () => [
'Ask Owliver who to prioritize…',
'Ask Owliver about verification gaps…',
'Ask Owliver who is available…',
],
'admin.hiredHistory': () => [
'Ask Owliver about hiring outcomes…',
'Ask Owliver which roles hired best…',
'Ask Owliver what stands out…',
],
};
/* ── Suggested prompts ──────────────────────────────────────────────────── */
@@ -365,6 +421,58 @@ const PROMPTS = {
: { label: 'What needs attention?', prompt: 'What needs attention?', capability: 'attention-required' },
],
/* Forge, Talent Pool and Hired History. Same rule as the contexts above: the
first chip is whatever the data has made urgent, so the set reorders itself
rather than offering the same questions regardless of state. */
'admin.forge': (f) => {
const g = f.forge;
return [
g.next
? { label: `Should I take ${g.next.title}?`, prompt: `Which challenge should I complete next? Start with ${g.next.title}`, capability: 'forge-next' }
: { label: 'Which challenge should I take next?', prompt: 'Which challenge should I complete next?', capability: 'forge-next' },
g.locked.length
? { label: `${g.locked.length} blocked by requirements`, prompt: 'What is blocking my next skill?', capability: 'forge-blockers' }
: { label: 'What is blocking my next skill?', prompt: 'What is blocking my next skill?', capability: 'forge-blockers' },
g.ready.length
? { label: `${g.ready.length} ready to start`, prompt: 'Which challenges can I start right now?', capability: 'forge-ready' }
: { label: 'Which challenges can I start?', prompt: 'Which challenges can I start right now?', capability: 'forge-ready' },
{ label: 'Which skills am I closest to unlocking?', prompt: 'Which skills am I closest to unlocking?', capability: 'forge-skills' },
{ label: 'Summarize my Forge progress', prompt: 'Summarize my Forge progress', capability: 'forge-progress' },
];
},
'admin.talentPool': (f) => {
const t = f.talent;
return [
t.top[0]
? { label: `Why does ${t.top[0].full_name?.split(' ')[0] || 'the top profile'} rank first?`, prompt: 'Which talent should I prioritize?', capability: 'talent-priorities' }
: { label: 'Which talent should I prioritize?', prompt: 'Which talent should I prioritize?', capability: 'talent-priorities' },
f.unscoredProfiles.length
? { label: `${f.unscoredProfiles.length} profiles unscored`, prompt: 'Which profiles are unscored?', capability: 'talent-summary' }
: { label: 'Summarize the talent pool', prompt: 'Summarize the talent pool', capability: 'talent-summary' },
t.unverified.length
? { label: `${t.unverified.length} need verification`, prompt: 'Which talent needs verification?', capability: 'talent-verification' }
: { label: 'Which talent needs verification?', prompt: 'Which talent needs verification?', capability: 'talent-verification' },
{ label: 'Who is currently available?', prompt: 'Who is currently available?', capability: 'talent-availability' },
{ label: 'Who has the strongest score?', prompt: 'Who has the strongest score?', capability: 'talent-priorities' },
];
},
'admin.hiredHistory': (f) => {
const h = f.hiring;
return [
h.unrated.length
? { label: `${h.unrated.length} hires unrated`, prompt: 'What needs attention in hiring outcomes?', capability: 'hiring-patterns' }
: { label: 'What hiring patterns stand out?', prompt: 'What hiring patterns stand out?', capability: 'hiring-patterns' },
h.byDepartment[0]
? { label: `${h.byDepartment[0].name} leads on hires`, prompt: 'Which department has the most hires?', capability: 'hiring-outcomes' }
: { label: 'Which department has the most hires?', prompt: 'Which department has the most hires?', capability: 'hiring-outcomes' },
{ label: 'Which positions hired the strongest talent?', prompt: 'Which positions hired the strongest talent?', capability: 'hiring-strongest' },
{ label: 'Summarize recent hires', prompt: 'Summarize recent hires', capability: 'hiring-recent' },
{ label: 'What hiring outcomes should I review?', prompt: 'What hiring outcomes should I review?', capability: 'hiring-outcomes' },
];
},
'admin.activity': (f) => [
f.activitySignals.flags.length
? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' }

View File

@@ -9,6 +9,9 @@
* transport, no dependency on any AI product.
*/
import { isUnlocked } from '@/lib/provingGround';
import { getScoreBand } from '@/lib/talentHome';
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
export const plural = (n, word, irregular) =>
@@ -174,6 +177,107 @@ export function buildFacts({
};
})();
/* ── KROW Forge ──────────────────────────────────────────────────────────
Derived with the same gate (`isUnlocked`) and the same completion record the
Forge page renders from, so the panel and the page cannot disagree about
what is ready, what is blocked, or why. `profile` is the signed-in worker's
record when one is already in cache; without it the counts are the library's
rather than the person's, which is still true — just less personal. */
const forgeCourses = courses.filter((c) => c.status === 'active');
const forgeCompletedIds = new Set((profile?.completed_courses || []).map((c) => c.course_id));
const forgeGate = (course) => isUnlocked(course, profile || {});
const forgeUnfinished = forgeCourses.filter((c) => !forgeCompletedIds.has(c.id));
const forgeReady = forgeUnfinished.filter((c) => forgeGate(c).unlocked);
const forgeLocked = forgeUnfinished.filter((c) => !forgeGate(c).unlocked);
const forge = {
courses: forgeCourses,
verified: profile?.earned_badges || [],
completed: (profile?.completed_courses || []).filter((c) => c.status !== 'in_progress'),
inProgress: (profile?.completed_courses || []).filter((c) => c.status === 'in_progress'),
ready: forgeReady,
locked: forgeLocked,
/* The page features the first unfinished challenge whether or not it is
unlocked, so the panel names the same one — a chip that recommends a
different challenge than the one on screen reads as a second opinion. */
next: forgeUnfinished[0] || null,
xpAvailable: forgeUnfinished.reduce((sum, c) => sum + (c.xp || 0), 0),
/* Every blocked challenge with the reasons that block it, in page order. */
blocked: forgeLocked.map((course) => ({ course, reasons: forgeGate(course).reasons })),
byDifficulty: ['beginner', 'intermediate', 'advanced'].map((level) => ({
level,
count: forgeCourses.filter((c) => c.difficulty === level).length,
})).filter((d) => d.count),
};
/* ── Talent pool ─────────────────────────────────────────────────────────
The same score bands the Talent Pool page labels profiles with. */
const rankedProfiles = [...profiles].sort((a, b) => (b.krow_score || 0) - (a.krow_score || 0));
const talent = {
ranked: rankedProfiles,
top: rankedProfiles.slice(0, 3),
available: profiles.filter((p) => (p.availability || []).length > 0),
unverified: profiles.filter(
(p) => !(p.earned_badges || []).length
&& !(p.capabilities || []).some((c) => c.status === 'verified')
),
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) => {
const { label } = getScoreBand(p.krow_score || 0);
acc[label] ||= { label, count: 0 };
acc[label].count += 1;
return acc;
}, {})).sort((a, b) => b.count - a.count),
};
/* ── Hired history ───────────────────────────────────────────────────────
Staff joined to the application and posting they came from — the same join
the Hired History page performs, so department, speed and score match. */
const hires = staff.map((s) => {
const app = applications.find((a) => a.id === s.application_id);
const posting = postings.find((p) => p.id === s.job_posting_id);
return {
...s,
department: posting?.role_category || '—',
timeToHire: app
? Math.max(1, Math.round(
(new Date(app.updated_date).getTime() - new Date(app.created_date).getTime()) / DAY_MS
))
: null,
score: s.ai_score || app?.ai_score || 0,
};
});
const groupHires = (key) => Object.values(hires.reduce((acc, h) => {
const name = h[key] || 'Unspecified';
acc[name] ||= { name, count: 0, scores: [], days: [], ratings: [] };
acc[name].count += 1;
if (h.score) acc[name].scores.push(h.score);
if (h.timeToHire) acc[name].days.push(h.timeToHire);
if (h.client_rating) acc[name].ratings.push(h.client_rating);
return acc;
}, {})).map((e) => ({
name: e.name,
count: e.count,
avgScore: avg(e.scores),
avgDays: avg(e.days),
avgRating: e.ratings.length
? Number((e.ratings.reduce((a, b) => a + b, 0) / e.ratings.length).toFixed(1))
: null,
})).sort((a, b) => b.count - a.count);
const hiring = {
hires,
byDepartment: groupHires('department'),
byRoleTitle: groupHires('role'),
unrated: hires.filter((h) => !h.client_rating),
rated: hires.filter((h) => h.client_rating),
avgQuality: avg(hires.map((h) => h.score).filter(Boolean)),
avgSpeed: avg(hires.map((h) => h.timeToHire).filter(Boolean)),
strongest: [...hires].sort((a, b) => (b.score || 0) - (a.score || 0))[0] || null,
onboarding: hires.filter((h) => h.status === 'onboarding'),
};
return {
today,
@@ -248,9 +352,12 @@ export function buildFacts({
eventCounts,
activitySignals,
/* Signed-in worker (talent context only) */
/* Signed-in worker, and the pages built on these records */
profile,
courses,
forge,
talent,
hiring,
/* Economics — mirrors ImpactMetrics */
costSaved: scored.length * 45 + interviews.length * 80,

View File

@@ -19,6 +19,9 @@ const PLACEMENT = {
'/admin/candidates-analysis': 'admin.candidates',
'/admin/analytics': 'admin.analytics',
'/admin/activity': 'admin.activity',
'/admin/university': 'admin.forge',
'/admin/talent-pool': 'admin.talentPool',
'/admin/hired': 'admin.hiredHistory',
},
};
@@ -26,9 +29,9 @@ const PLACEMENT = {
* Pages that must never carry the assistant, listed explicitly so the intent is
* documented rather than implied by omission.
*
* Admin: the reporting and reference pages, plus login. The working surfaces —
* Control Center, Positions, Candidates, Candidates Analysis, Analytics
* and Activity — all carry the panel.
* Admin: profile and login only. Every working surface — Control Center,
* Positions, Candidates, Candidates Analysis, Analytics, Activity,
* KROW Forge, Talent Pool and Hired History — carries the panel.
* Employer: every page. The contextual panel is an Admin capability.
* Talent: every page. The talent portal has the standalone Owliver product,
* with its own voice experience, branding and workflow.
@@ -39,8 +42,8 @@ export const EXCLUDED_ROUTES = [
'/hired', '/talent-pool', '/analytics', '/profile', '/apply',
'/university', '/university/:id', '/me', '/identity', '/employee',
'/owliver', '/design-system',
// Admin pages that are a report or a form rather than a workflow.
'/admin/hired', '/admin/talent-pool', '/admin/profile', '/admin/login',
// Admin pages that are a form rather than a workflow.
'/admin/profile', '/admin/login',
// The full candidate profile is one person's record, not a page-level question.
'/admin/candidates/:id',
];

View File

@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { base44 } from '@/api/base44Client';
import {
useApplications, useCourses, useInterviews, useJobPostings, useStaff,
useUserActivity, useWorkerProfiles,
useUserActivity, useWorkerProfile, useWorkerProfiles,
} from '@/lib/krowHooks';
import { buildFacts } from './insights';
import { createAssistantProvider } from './provider';
@@ -25,12 +25,16 @@ export function useAssistantFacts() {
const { data: activity = [] } = useUserActivity();
const { data: courses = [] } = useCourses();
// Krow AI serves Employer and Admin only, so it never reads the signed-in
// worker's own profile — that query also creates the record if absent, and it
// belongs to the talent portal, not here.
// The signed-in worker's own record, read from cache only: `enabled: false`
// means this never runs the query, which would *create* a WorkerProfile for an
// admin who has never opened Forge. So the Forge context is personal on pages
// that already loaded it, and falls back to library-level facts elsewhere
// rather than manufacturing a record as a side effect of opening a panel.
const { data: profile = null } = useWorkerProfile({ enabled: false });
return React.useMemo(
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses }),
[applications, postings, interviews, staff, profiles, activity, courses]
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses, profile }),
[applications, postings, interviews, staff, profiles, activity, courses, profile]
);
}

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { ScatterChart } from '@mui/x-charts';
import { cn } from '@/lib/utils';
import { chartSx, TONES, tooltipSx } from './muiChartTheme';
/**
* Department performance — a dot plot, not a bar race.
*
* The bars this replaces encoded hire *count*, which on a dataset where every
* department has hired once drew three identical full-width rectangles. Encoding
* average score instead does not fix it either: 92, 94 and 97 against a
* zero-baseline axis are three more identical bars.
*
* So the scores are plotted as points on a focused axis. The domain starts below
* the lowest score rather than at zero — legitimate here because the axis ticks
* are drawn and read, and because the question is "which department hires
* better", which is a comparison between 90s, not a comparison against nothing.
*
* Volume is stated in the list underneath: one hire per department is a fact,
* not a shape.
*
* `items: [{ name, count, avgScore }]` — supplied by the page.
*/
export function DepartmentPerformance({ items = [], className }) {
if (!items.length) return null;
const scored = items.filter((d) => d.avgScore > 0);
/* Highest at the top: MUI plots ascending y upward, so rank ascending. */
const plotted = [...scored].sort((a, b) => (a.avgScore || 0) - (b.avgScore || 0));
const lowest = Math.min(...plotted.map((d) => d.avgScore));
const highest = Math.max(...plotted.map((d) => d.avgScore));
/* A little air either side, snapped to 5s, so the ticks stay round numbers. */
const min = Math.max(0, Math.floor((lowest - 6) / 5) * 5);
const max = Math.min(100, Math.ceil((highest + 3) / 5) * 5);
return (
<div className={cn('overflow-hidden rounded-xl border border-border bg-surface', className)}>
{plotted.length > 0 ? (
<div className="px-2 pt-4 sm:px-3">
<ScatterChart
height={Math.max(132, plotted.length * 40 + 52)}
margin={{ top: 8, right: 24, bottom: 28, left: 8 }}
xAxis={[{
min,
max,
tickNumber: 4,
valueFormatter: (v) => `${v}`,
}]}
yAxis={[{
min: -0.6,
max: plotted.length - 0.4,
tickInterval: plotted.map((_, i) => i),
valueFormatter: (v) => plotted[Math.round(v)]?.name ?? '',
width: 96,
}]}
series={[{
label: 'Average score',
data: plotted.map((d, i) => ({ x: d.avgScore, y: i, id: d.name })),
markerSize: 7,
valueFormatter: (point) => {
const d = plotted[Math.round(point.y)];
return `${d.name} · avg ${d.avgScore} · ${d.count} ${d.count === 1 ? 'hire' : 'hires'}`;
},
}]}
colors={[TONES.brand]}
grid={{ vertical: true }}
skipAnimation
sx={{ ...chartSx, ...tooltipSx }}
/>
</div>
) : (
<p className="px-4 py-6 text-center text-body-sm text-ink-3">
No scores recorded against these hires yet.
</p>
)}
<ul className="divide-y divide-border border-t border-border">
{[...items].sort((a, b) => (b.avgScore || 0) - (a.avgScore || 0)).map((d) => (
<li key={d.name} className="flex items-baseline justify-between gap-3 px-4 py-2">
<span className="truncate text-body-sm text-ink-2">{d.name}</span>
<span className="flex shrink-0 items-baseline gap-4 text-caption tabular-nums text-ink-4">
<span>{d.count} {d.count === 1 ? 'hire' : 'hires'}</span>
<span className="w-16 text-right font-semibold text-ink-1">
{d.avgScore ? `avg ${d.avgScore}` : '—'}
</span>
</span>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import React from 'react';
import { BarChart } from '@mui/x-charts';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { chartSx, TONES, tooltipSx } from './muiChartTheme';
/**
* The hiring flow — one surface, not six cards.
*
* A funnel is a shape before it is a set of numbers, so the magnitudes are drawn
* once as a bar series and the stage-by-stage reading sits underneath it as a
* connected strip: count, pass-through rate, and what was lost getting there.
* Reading down a column gives one stage; reading across gives the journey.
*
* The weakest transition is the only thing coloured differently, because on a
* funnel the one worth acting on is the one losing the most people.
*
* `stages` and `transitions` come from the page's own fact sheet — this
* component derives no figures of its own.
*/
export function HiringFlow({ stages = [], transitions = [], weakestKey, className }) {
if (!stages.length) return null;
const total = stages[0]?.count || 1;
const rateFor = (i) => (i === 0 ? 100 : transitions[i - 1]?.rate ?? 0);
const lostFor = (i) => (i === 0 ? 0 : transitions[i - 1]?.lost ?? 0);
const isWeak = (i) => i > 0 && transitions[i - 1] && transitions[i - 1].to === weakestKey;
return (
<div className={cn('overflow-hidden rounded-xl border border-border bg-surface', className)}>
{/* The shape. Axis-free: the strip underneath carries every figure, so a
y-axis would repeat what is already written. */}
<div className="px-2 pt-3 sm:px-4">
<BarChart
height={168}
margin={{ top: 12, right: 8, bottom: 24, left: 8 }}
xAxis={[{
scaleType: 'band',
data: stages.map((s) => s.label),
categoryGapRatio: 0.5,
barGapRatio: 0,
/* Per-stage colour through the documented axis API rather than a
CSS nth-child guess at MUI's internal markup: the weakest
transition is amber, every other stage is brand blue. */
colorMap: {
type: 'ordinal',
values: stages.map((s) => s.label),
colors: stages.map((_, i) => (isWeak(i) ? TONES.warning : TONES.brand)),
},
}]}
/* Pinned to the data: MUI's automatic domain rounds up to roughly
double the top stage, which leaves the funnel occupying half the
plot and reading much flatter than it is. */
yAxis={[{ min: 0, max: Math.max(total, 1), tickNumber: 3 }]}
series={[{
data: stages.map((s) => s.count),
label: 'Candidates',
valueFormatter: (value, ctx) => {
const i = ctx?.dataIndex ?? 0;
const lost = lostFor(i);
return `${value} · ${rateFor(i)}% through${lost ? ` · ${lost} lost` : ''}`;
},
}]}
colors={[TONES.brand]}
borderRadius={6}
grid={{ horizontal: true }}
skipAnimation
sx={{
...chartSx,
...tooltipSx,
/* The weakest transition's bar picks up the warning tone; every
other bar stays brand blue. */
}}
/>
</div>
{/* The reading. Scrolls rather than wraps on a phone, so the sequence
survives at any width. */}
<div className="overflow-x-auto border-t border-border">
<ol className="flex min-w-[36rem] items-stretch">
{stages.map((stage, i) => {
const rate = rateFor(i);
const lost = lostFor(i);
const weak = isWeak(i);
return (
<li key={stage.key} className="flex min-w-0 flex-1 items-center">
{i > 0 && (
<span className="flex shrink-0 flex-col items-center px-1.5" aria-hidden="true">
<ChevronRight className={cn('h-3.5 w-3.5', weak ? 'text-warning' : 'text-ink-4')} />
</span>
)}
<div className="min-w-0 flex-1 px-3 py-3">
<p className="truncate text-caption text-ink-3">{stage.label}</p>
<p className="mt-0.5 font-heading text-title font-bold tabular-nums text-ink-1">
{stage.count}
</p>
<p className="mt-1 flex flex-wrap items-baseline gap-x-2 text-caption tabular-nums">
<span className={weak ? 'font-semibold text-warning' : 'text-ink-4'}>
{i === 0 ? `${Math.round((stage.count / total) * 100)}% entered` : `${rate}% through`}
</span>
{lost > 0 && <span className="text-ink-4">−{lost}</span>}
</p>
</div>
</li>
);
})}
</ol>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { LineChart } from '@mui/x-charts';
import { cn } from '@/lib/utils';
import { chartSx, TONES, tooltipSx } from './muiChartTheme';
/**
* Cumulative hires over time.
*
* Deliberately does not draw a line through a single point: with one month of
* history there is no trend, and a chart that implies one is worse than a
* sentence saying there is not. The empty state is the page's own copy, kept
* intact and given the same container as the chart so the section holds its
* shape either way.
*
* `points: [{ label, hires, cumulative }]` — supplied by the page.
*/
export function HiringTrendChart({ points = [], emptyState, className }) {
const enough = points.length >= 2;
return (
<div className={cn('overflow-hidden rounded-xl border border-border bg-surface', className)}>
{enough ? (
<div className="px-2 pt-3 sm:px-3">
<LineChart
height={200}
margin={{ top: 16, right: 16, bottom: 24, left: 8 }}
xAxis={[{
scaleType: 'point',
data: points.map((p) => p.label),
}]}
yAxis={[{ width: 32, tickNumber: 4 }]}
series={[{
data: points.map((p) => p.cumulative),
label: 'Cumulative hires',
area: true,
showMark: true,
curve: 'monotoneX',
valueFormatter: (value, ctx) => {
const p = points[ctx?.dataIndex ?? 0];
return `${value} total · ${p?.hires} this month`;
},
}]}
colors={[TONES.brand]}
grid={{ horizontal: true }}
skipAnimation
sx={{
...chartSx,
...tooltipSx,
'& .MuiAreaElement-root': { fill: TONES.brandSoft },
'& .MuiLineElement-root': { strokeWidth: 2 },
'& .MuiMarkElement-root': { stroke: TONES.brand, fill: '#fff', strokeWidth: 2 },
}}
/>
</div>
) : (
emptyState
)}
</div>
);
}

View File

@@ -0,0 +1,86 @@
/**
* KROW styling for MUI X Charts.
*
* MUI X is the drawing layer only — the surfaces, spacing and typography around
* a chart stay KROW's. This module is what stops the two from disagreeing: the
* palette is the same one the Recharts components already use (`CHART_TONES`),
* and the `sx` block restyles MUI's axes, grid and tooltip to the design
* system's hairlines and type scale rather than Material's defaults.
*
* Everything here is presentation. No chart in this folder computes a value.
*/
import { CHART_TONES } from '@/components/ds/ChartContainer';
export const TONES = {
brand: CHART_TONES.brand,
brandSoft: 'rgba(10, 57, 223, 0.14)',
warning: '#D97706',
warningSoft: 'rgba(217, 119, 6, 0.16)',
success: '#16A34A',
grid: CHART_TONES.grid,
axis: CHART_TONES.axis,
ink: '#0F172A',
muted: '#64748B',
};
/** Type scale for chart text — matches the caption/overline sizes around it. */
const LABEL_FONT = {
fontSize: 11,
fontFamily: 'var(--font-body)',
};
/**
* Shared `sx` for every KROW chart.
*
* Axis lines and ticks are removed rather than restyled: on a compact chart the
* value labels carry the reading, and a full axis frame is the "chart junk" the
* design language avoids. The grid stays as a hairline in the same colour the
* rest of the product uses for rules.
*/
export const chartSx = {
'& .MuiChartsAxis-line': { display: 'none' },
'& .MuiChartsAxis-tick': { display: 'none' },
'& .MuiChartsAxis-tickLabel': {
...LABEL_FONT,
fill: `${TONES.muted} !important`,
},
'& .MuiChartsGrid-line': {
stroke: TONES.grid,
strokeDasharray: '3 3',
},
'& .MuiChartsLegend-root': { display: 'none' },
'& .MuiBarLabel-root': {
...LABEL_FONT,
fontWeight: 600,
fill: `${TONES.ink} !important`,
},
};
/**
* Tooltip chrome matching `Surface`.
*
* Passed through `slotProps.tooltip` so MUI's popper carries the design
* system's radius, hairline border and shadow instead of Material elevation.
*/
export const tooltipSx = {
'& .MuiChartsTooltip-root, & .MuiPopper-root .MuiPaper-root': {
borderRadius: '10px',
border: `1px solid ${TONES.grid}`,
boxShadow: '0 8px 24px -12px rgba(15, 23, 42, 0.25)',
fontFamily: 'var(--font-body)',
fontSize: 12,
},
'& .MuiChartsTooltip-table': { fontSize: 12 },
'& .MuiChartsTooltip-labelCell, & .MuiChartsTooltip-valueCell': {
fontFamily: 'var(--font-body)',
},
};
/*
* Entry animation is switched off at every call site rather than gated on
* `prefers-reduced-motion`. Two reasons: a chart that grows out of the floor is
* the decorative motion this product avoids, and MUI's bar transition leaves
* every bar at zero height if the frame loop never advances — which is exactly
* what happens in an inactive tab or an automated browser.
*/

View File

@@ -0,0 +1,191 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, Lock, Search, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import { isUnlocked } from '@/lib/provingGround';
import { DIFF_TONE, TYPE_ICON, TYPE_LABEL, typeOf } from './challengeMeta';
/**
* Every other challenge, as a directory.
*
* Rows on hairlines rather than a wall of cards: these are secondary to the
* featured skill above, and a grid of bordered tiles would compete with it. The
* controls sit on the heading line so the section needs no toolbar surface of
* its own, and the row still carries everything a card did — skill, title,
* description, difficulty, proof type, duration, XP, reward, state and reason.
*/
export function ChallengeDirectory({
courses,
results,
search,
onSearch,
difficulty,
onDifficulty,
difficulties,
loading,
filtered,
onClear,
completedIds,
profile,
}) {
return (
<section aria-labelledby="forge-directory" className="min-w-0">
<div className="flex flex-col gap-3 border-b border-border pb-3 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<h2 id="forge-directory" className="text-overline uppercase text-ink-4">
Other challenges
</h2>
<p className="mt-1 text-body-sm text-ink-3">
Practical challenges that turn a claim into verified evidence.
</p>
</div>
<div className="flex flex-col gap-x-4 gap-y-2 sm:flex-row sm:flex-wrap sm:items-center">
<div className="relative min-w-0 sm:w-52">
<Search
className="pointer-events-none absolute left-0 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4"
aria-hidden="true"
/>
<input
type="search"
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder="Search challenges"
aria-label="Search challenges"
className="h-8 w-full border-0 border-b border-border bg-transparent pl-6 pr-2 text-body-sm text-ink-1
placeholder:text-ink-4 focus:border-krow-blue focus:outline-none"
/>
</div>
<div className="flex flex-wrap items-center gap-3" role="radiogroup" aria-label="Challenge difficulty">
{difficulties.map((d) => {
const active = d.v === difficulty;
return (
<button
key={d.v}
type="button"
role="radio"
aria-checked={active}
onClick={() => onDifficulty(d.v)}
className={cn(
'rounded text-body-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
active
? 'font-semibold text-krow-blue underline decoration-krow-blue decoration-2 underline-offset-[6px]'
: 'text-ink-3 hover:text-ink-1'
)}
>
{d.label}
</button>
);
})}
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3 py-2.5">
<p className="text-caption tabular-nums text-ink-4">
{results.length} of {courses.length} challenges{filtered ? ' · filtered' : ''}
</p>
{filtered && (
<button
type="button"
onClick={onClear}
className="rounded text-caption font-semibold text-krow-blue hover:underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
Clear filters
</button>
)}
</div>
{loading ? (
<div className="divide-y divide-border border-t border-border">
{[...Array(3)].map((_, i) => (
<div key={i} className="py-5">
<div className="h-10 w-full animate-pulse rounded bg-surface-sunken" />
</div>
))}
</div>
) : results.length === 0 ? (
<div className="border-t border-border py-10 text-center">
<p className="text-body-sm text-ink-3">No challenges match your filters.</p>
<button
type="button"
onClick={onClear}
className="mt-2 rounded text-body-sm font-semibold text-krow-blue hover:underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
Clear filters
</button>
</div>
) : (
<ul className="divide-y divide-border border-t border-border">
{results.map((course) => {
const lock = isUnlocked(course, profile);
const completed = completedIds.has(course.id);
const locked = !lock.unlocked && !completed;
const type = typeOf(course);
const Icon = TYPE_ICON[type] || TYPE_ICON.roleplay;
return (
<li key={course.id}>
<Link
to={`/university/${course.id}`}
className="group grid gap-x-6 gap-y-2 py-4 transition-colors hover:bg-surface-subtle
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-krow-blue/50
lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)_auto] lg:items-baseline lg:px-2"
>
<span className="min-w-0">
<span className="flex items-center gap-2">
{completed ? (
<ShieldCheck className="h-3.5 w-3.5 shrink-0 text-success" aria-hidden="true" />
) : locked ? (
<Lock className="h-3.5 w-3.5 shrink-0 text-warning" aria-hidden="true" />
) : (
<Icon className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
)}
<span className="truncate text-overline uppercase text-ink-4">
{course.proof_skill || course.category}
</span>
</span>
<span className="mt-1 block text-body-lg font-semibold leading-snug text-ink-1 group-hover:text-krow-blue">
{course.title}
</span>
</span>
<span className="min-w-0">
<span className="block line-clamp-2 text-body-sm leading-relaxed text-ink-3 lg:line-clamp-1">
{course.description}
</span>
<span className="mt-1 block text-caption tabular-nums text-ink-4">
<span className={cn('font-semibold capitalize', DIFF_TONE[course.difficulty] || 'text-ink-3')}>
{course.difficulty}
</span>
{' · '}{TYPE_LABEL[type]}
{' · '}{course.estimated_minutes} min
{' · '}{course.xp} XP
{course.badge_reward && ` · Earns ${course.badge_reward}`}
</span>
{locked && lock.reasons?.length > 0 && (
<span className="mt-1 block truncate text-caption text-warning">
Requires {lock.reasons[0]}
</span>
)}
</span>
<span className="inline-flex shrink-0 items-center gap-1 text-body-sm font-semibold text-krow-blue">
{completed ? 'Review' : locked ? 'Details' : 'Start'}
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</span>
</Link>
</li>
);
})}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight } from 'lucide-react';
/**
* The Forge masthead.
*
* An editorial header rather than the marketing banner it replaces: an eyebrow
* that names the product category, the title, one line of standfirst, and the
* single action worth taking. The rule underneath is the only decoration, and
* the page reaches the actual skill work within one screen.
*/
export function ForgeHeader({ course, actionLabel = 'Continue challenge' }) {
return (
<header className="flex flex-col gap-4 border-b border-border pb-5 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<p className="text-overline uppercase tracking-[0.14em] text-krow-blue">
Workforce skill verification
</p>
<h1 className="mt-2 font-heading text-display-lg leading-none tracking-tight text-ink-1 sm:text-[2.375rem]">
KROW Forge
</h1>
<p className="mt-2.5 max-w-xl text-body-lg leading-relaxed text-ink-3">
Prove real skills through real-world challenges and build a verified workforce reputation.
</p>
</div>
{course && (
<Link
to={`/university/${course.id}`}
className="group inline-flex shrink-0 items-center gap-2 self-start rounded-lg bg-krow-blue px-4 py-2.5 text-body-sm font-semibold text-white
transition-colors hover:bg-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 sm:self-auto"
>
{actionLabel}
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</Link>
)}
</header>
);
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { ArrowRight } from 'lucide-react';
/**
* The page's closing remark.
*
* A rule and an accent rather than a filled panel: this is commentary on
* everything above it, and commentary that arrives in its own coloured card
* claims the same weight as the work.
*/
export function ForgeInsight({ headline, detail, highlight, actionLabel, onAction }) {
return (
<aside className="border-l-2 border-krow-blue pl-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-overline uppercase tracking-[0.12em] text-krow-blue">
KROW Forge insight
</p>
<p className="mt-1.5 font-heading text-body-lg font-semibold text-ink-1">{headline}</p>
<p className="mt-0.5 max-w-2xl text-body-sm leading-relaxed text-ink-3">
Next best skill: <span className="font-semibold text-ink-2">{highlight}</span>. {detail}
</p>
</div>
<button
type="button"
onClick={onAction}
className="group inline-flex shrink-0 items-center gap-1.5 self-start rounded text-body-sm font-semibold text-krow-blue
transition-colors hover:text-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 sm:self-auto"
>
{actionLabel}
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { cn } from '@/lib/utils';
/**
* Forge progress — the four figures, and the journey they describe.
*
* The figures on their own were four disconnected numbers. Underneath them the
* same state is drawn as a single line: where the worker started, what they are
* proving now, the skill in front of them, and what is still gated. One rule,
* four nodes, no surfaces — competency progression rather than a scoreboard.
*
* Every node's state is derived from live counts by the page; this component
* only draws them.
*/
const NODE = {
done: 'border-krow-blue bg-krow-blue',
current: 'border-krow-blue bg-surface ring-4 ring-krow-blue/15',
upcoming: 'border-border bg-surface',
locked: 'border-warning/60 bg-surface',
};
const CAPTION_TONE = {
done: 'text-ink-2',
current: 'text-krow-blue',
upcoming: 'text-ink-4',
locked: 'text-warning',
};
export function ForgeProgress({ figures = [], stages = [], loading = false }) {
return (
<section aria-labelledby="forge-progress" className="border-b border-border pb-6">
<h2 id="forge-progress" className="text-overline uppercase text-ink-4">
Forge progress
</h2>
{/* The figures. */}
<dl className="mt-3.5 flex flex-wrap items-baseline gap-x-10 gap-y-4">
{figures.map((f) => (
<div key={f.label} className="min-w-0">
<dd
className={cn(
'font-heading text-[1.75rem] font-bold leading-none tabular-nums',
f.tone || 'text-ink-1'
)}
>
{loading ? <span className="inline-block h-6 w-10 animate-pulse rounded bg-surface-sunken" /> : f.value}
</dd>
<dt className="mt-1.5 text-body-sm text-ink-3">{f.label}</dt>
</div>
))}
</dl>
{/* The journey. */}
{stages.length > 0 && (
<ol className="mt-6 grid gap-y-4 sm:flex sm:items-start">
{stages.map((stage, i) => (
<li key={stage.label} className="relative flex min-w-0 flex-1 gap-3 sm:block">
{/* Connector — horizontal on desktop, vertical while stacked. */}
{i < stages.length - 1 && (
<span
className={cn(
'pointer-events-none absolute bg-border',
'left-[0.3125rem] top-4 h-[calc(100%-0.25rem)] w-px',
'sm:left-3 sm:top-[0.3125rem] sm:h-px sm:w-[calc(100%-0.75rem)]',
stage.state === 'done' && 'bg-krow-blue/40'
)}
aria-hidden="true"
/>
)}
<span
className={cn(
'relative z-10 mt-0.5 block h-2.5 w-2.5 shrink-0 rounded-full border-2 sm:mt-0',
NODE[stage.state] || NODE.upcoming
)}
aria-hidden="true"
/>
<span className="min-w-0 pb-1 sm:block sm:pr-6 sm:pt-2.5">
<span className="block truncate text-body-sm font-semibold text-ink-1">
{stage.label}
</span>
{stage.caption && (
<span className={cn('block truncate text-caption', CAPTION_TONE[stage.state])}>
{stage.caption}
</span>
)}
</span>
</li>
))}
</ol>
)}
</section>
);
}

View File

@@ -0,0 +1,267 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, Check, Lock } from 'lucide-react';
import { cn } from '@/lib/utils';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { TYPE_HINT, TYPE_ICON, TYPE_LABEL, typeOf, proofSteps } from './challengeMeta';
/**
* The next skill to prove — the focal point of the page and its only surface.
*
* It answers the four questions a worker actually has, in order: what the skill
* is, what they will be judged on, how the proof is submitted and who grades it,
* and what is standing in the way. Divided by rules rather than nested cards, so
* it reads as one workspace instead of a stack of tiles.
*
* "What you'll prove" is the challenge's own rubric — the criteria Owliver
* scores against — so the marking scheme is visible before the attempt.
*/
/** Progress toward a threshold: pips for a small count, a bar for a score. */
function Progress({ have, need }) {
const done = Math.min(have, need);
if (need <= 8) {
return (
<span className="flex items-center gap-1" aria-hidden="true">
{Array.from({ length: need }, (_, i) => (
<span
key={i}
className={cn(
'h-2 w-2 rounded-full border',
i < done ? 'border-krow-blue bg-krow-blue' : 'border-ink-4/40 bg-transparent'
)}
/>
))}
</span>
);
}
return (
<span className="block h-1.5 w-20 overflow-hidden rounded-full bg-border" aria-hidden="true">
<span
className="block h-full rounded-full bg-krow-blue transition-[width] duration-500"
style={{ width: `${Math.round((done / need) * 100)}%` }}
/>
</span>
);
}
export function NextSkill({ course, locked, requirements = [], actionLabel, index = 1 }) {
if (!course) return null;
const type = typeOf(course);
const Icon = TYPE_ICON[type] || TYPE_ICON.roleplay;
const steps = proofSteps(course);
const outstanding = requirements.filter((r) => !r.met).length;
return (
<section
aria-labelledby="next-skill"
className="overflow-hidden rounded-xl border border-border bg-surface shadow-sm
motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-300"
>
{/* Identity. */}
<div className="flex gap-5 border-b border-border p-5 sm:gap-7 sm:p-7">
<span
className="hidden font-heading text-[2.5rem] font-bold leading-none tabular-nums text-border sm:block"
aria-hidden="true"
>
{String(index).padStart(2, '0')}
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<p className="text-overline uppercase tracking-[0.12em] text-krow-blue">
Next skill to prove
</p>
<span
className={cn(
'text-overline uppercase',
locked ? 'text-warning' : 'text-success'
)}
>
· {locked ? 'Locked' : 'Ready to start'}
</span>
</div>
<h2
id="next-skill"
className="mt-2 font-heading text-title-lg leading-tight tracking-tight text-ink-1 sm:text-[1.75rem]"
>
{course.title}
</h2>
<p className="mt-1.5 text-body-sm text-ink-3">
<span className="font-medium text-ink-2">{course.proof_skill || course.category}</span>
<span className="capitalize"> · {course.difficulty}</span>
</p>
<p className="mt-3 max-w-prose text-body leading-relaxed text-ink-2">
{course.description}
</p>
</div>
</div>
<div className="grid gap-px bg-border lg:grid-cols-2">
{/* What the proof is judged on. */}
{steps.length > 0 && (
<div className="bg-surface p-5 sm:p-7">
<p className="text-overline uppercase text-ink-4">What you’ll prove</p>
<ol className="mt-3.5">
{steps.map((step, i) => (
<li key={step} className="flex items-baseline gap-3 border-b border-border py-2.5 last:border-0 last:pb-0">
<span
className={cn(
'font-heading text-caption font-bold tabular-nums',
i === steps.length - 1 ? 'text-ink-4' : 'text-krow-blue'
)}
>
{String(i + 1).padStart(2, '0')}
</span>
<span
className={cn(
'text-body-sm leading-relaxed',
i === steps.length - 1 ? 'text-ink-3' : 'text-ink-1'
)}
>
{step}
</span>
</li>
))}
</ol>
</div>
)}
{/* How it is submitted, and who grades it. */}
<div className="bg-surface p-5 sm:p-7">
<p className="text-overline uppercase text-ink-4">How you’ll prove it</p>
<p className="mt-3.5 flex items-center gap-2.5 font-heading text-body-lg font-semibold text-ink-1">
<Icon className="h-4 w-4 shrink-0 text-krow-blue" aria-hidden="true" />
{TYPE_LABEL[type]}
</p>
<p className="mt-1 text-body-sm leading-relaxed text-ink-3">{TYPE_HINT[type]}</p>
{/* Your action → Owliver → result. The existing evaluation relationship,
drawn as a sequence rather than three tiles. */}
<ol className="mt-5 space-y-0">
{[
{
key: 'action',
label: 'Your action',
value: TYPE_LABEL[type],
mark: <span className="h-2 w-2 rounded-full bg-krow-blue" aria-hidden="true" />,
},
{
key: 'owliver',
label: 'Owliver',
value: 'Evaluates your proof against the criteria',
mark: <OwliverAvatar className="h-6 w-6" rounded="rounded-full" />,
},
{
key: 'result',
label: 'Result',
value: course.badge_reward
? `Skill verified · earns ${course.badge_reward}`
: 'Skill verified on your workforce profile',
mark: (
<span className="grid h-4 w-4 place-items-center rounded-full bg-success text-white">
<Check className="h-2.5 w-2.5" strokeWidth={3} aria-hidden="true" />
</span>
),
},
].map((row, i, all) => (
<li key={row.key} className="relative flex gap-3 pb-4 last:pb-0">
{i < all.length - 1 && (
<span
className="pointer-events-none absolute left-[0.6875rem] top-5 bottom-0 w-px bg-border"
aria-hidden="true"
/>
)}
<span className="relative z-10 grid h-6 w-6 shrink-0 place-items-center rounded-full bg-surface">
{row.mark}
</span>
<span className="min-w-0 pt-0.5">
<span className="block text-overline uppercase text-ink-4">{row.label}</span>
<span className="block text-body-sm text-ink-2">{row.value}</span>
</span>
</li>
))}
</ol>
</div>
</div>
{/* What is in the way. */}
{locked && requirements.length > 0 && (
<div className="border-t border-border bg-warning-muted/40 px-5 py-5 sm:px-7">
<p className="flex items-center gap-2 text-overline uppercase text-warning">
<Lock className="h-3 w-3" aria-hidden="true" />
Locked for now
</p>
<p className="mt-1.5 text-body-sm text-ink-2">
{outstanding === 1
? 'One requirement left to unlock this challenge.'
: `${outstanding} requirements left to unlock this challenge.`}
</p>
{/* Capped: at full shell width the label and its meter would sit a
thousand pixels apart and stop reading as one row. */}
<ul className="mt-3.5 max-w-3xl space-y-2.5">
{requirements.map((r) => (
<li key={r.key} className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span
className={cn(
'grid h-4 w-4 shrink-0 place-items-center rounded-full',
r.met ? 'bg-success text-white' : 'border border-warning/60'
)}
>
{r.met && <Check className="h-2.5 w-2.5" strokeWidth={3} aria-hidden="true" />}
</span>
<span
className={cn(
'min-w-0 flex-1 basis-[calc(100%-1.75rem)] text-body-sm sm:basis-0',
r.met ? 'text-ink-3' : 'text-ink-2'
)}
>
{r.label}
</span>
{r.need != null && (
<span className="flex items-center gap-3 pl-7 sm:pl-0">
<span className="text-caption font-semibold tabular-nums text-ink-3">
{Math.min(r.have, r.need)} / {r.need} {r.unit}
</span>
<Progress have={r.have} need={r.need} />
</span>
)}
</li>
))}
</ul>
</div>
)}
{/* Quiet metadata, and the action. */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-5 py-4 sm:px-7">
<p className="text-caption tabular-nums text-ink-3">
{course.estimated_minutes} min · {course.xp} XP
{course.badge_reward && ` · Earns ${course.badge_reward}`}
</p>
<Link
to={`/university/${course.id}`}
className="group inline-flex items-center gap-1.5 rounded-lg bg-krow-blue px-4 py-2.5 text-body-sm font-semibold text-white
transition-colors hover:bg-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
{actionLabel}
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</Link>
</div>
</section>
);
}

View File

@@ -0,0 +1,128 @@
import React from 'react';
import { ArrowRight, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Your Forge — the record, or the process that creates it.
*
* With nothing verified yet this used to be the largest and emptiest object on
* the page. Here the empty state is the thing worth explaining instead: the four
* steps that turn a challenge into a verified skill, laid along one line. It is
* a process, not a placeholder, and it takes a fifth of the height.
*
* Once skills exist the same band lists them, so the section grows into a record
* rather than being swapped for a different component.
*/
const STEPS = ['Take a challenge', 'Submit proof', 'Owliver evaluates', 'Skill verified'];
export function YourForge({ skills = [], evidenceCount = 0, onBrowse, formatDate }) {
const empty = skills.length === 0;
return (
<section aria-labelledby="your-forge" className="border-b border-border pb-6">
<div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">
<h2 id="your-forge" className="text-overline uppercase text-ink-4">
Your Forge
</h2>
<p className="text-caption text-ink-4">
{empty
? 'Your first verified skill starts with a real-world challenge.'
: `${evidenceCount} ${evidenceCount === 1 ? 'piece' : 'pieces'} of evidence on file`}
</p>
</div>
<p className="mt-2 font-heading text-title text-ink-1">
<span className="tabular-nums">{skills.length}</span>{' '}
{skills.length === 1 ? 'verified skill' : 'verified skills'}
</p>
{empty ? (
/* How a skill becomes verified — one line, four steps. */
<div className="mt-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:gap-0">
<ol className="flex min-w-0 flex-1 flex-col gap-3 sm:flex-row sm:items-center">
{STEPS.map((step, i) => (
<li key={step} className="flex min-w-0 items-center gap-3 sm:flex-1">
<span className="flex min-w-0 items-center gap-2.5">
<span
className={cn(
'grid h-6 w-6 shrink-0 place-items-center rounded-full border text-caption font-semibold tabular-nums',
i === 0
? 'border-krow-blue bg-krow-blue-tint text-krow-blue'
: 'border-border bg-surface text-ink-4'
)}
>
{i + 1}
</span>
<span
className={cn(
'truncate text-body-sm',
i === 0 ? 'font-semibold text-ink-1' : 'text-ink-3'
)}
>
{step}
</span>
</span>
{i < STEPS.length - 1 && (
<span
className="hidden h-px flex-1 bg-border sm:block sm:min-w-[1rem]"
aria-hidden="true"
/>
)}
</li>
))}
</ol>
<button
type="button"
onClick={onBrowse}
className="group inline-flex shrink-0 items-center gap-1.5 self-start rounded text-body-sm font-semibold text-krow-blue
transition-colors hover:text-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 lg:self-auto lg:pl-6"
>
Browse challenges
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</div>
) : (
<ul className="mt-4 flex flex-wrap items-center gap-x-8 gap-y-3">
{skills.map((badge, i) => (
<li key={badge.name || i} className="flex min-w-0 items-center gap-2.5">
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-full bg-success text-white">
<Check className="h-3 w-3" strokeWidth={3} aria-hidden="true" />
</span>
<span className="min-w-0">
<span className="block truncate text-body-sm font-semibold text-ink-1">
{badge.name}
</span>
<span className="block truncate text-caption capitalize text-ink-4">
{badge.level || 'Standard'} · verified {formatDate?.(badge.earned_date)}
</span>
</span>
</li>
))}
<li>
<button
type="button"
onClick={onBrowse}
className="group inline-flex items-center gap-1.5 rounded text-body-sm font-semibold text-krow-blue
transition-colors hover:text-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
Browse challenges
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</li>
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,92 @@
import { Camera, MessageSquare, ScanLine, Video } from 'lucide-react';
/**
* Presentation lookups for a challenge, and the one piece of derived logic the
* Forge page needs that the data layer does not already provide.
*
* Nothing here invents information: the proof wording matches what the challenge
* page already says, and the requirement lines are read straight off
* `course.unlock_requirements` and measured against the live profile — the same
* fields `isUnlocked()` gates on.
*/
export const TYPE_ICON = {
roleplay: MessageSquare,
photo: Camera,
video: Video,
photo_identify: ScanLine,
};
export const TYPE_LABEL = {
roleplay: 'Chat with Owliver',
photo: 'Photo proof',
video: 'Record yourself',
photo_identify: 'Identify hazards',
};
export const TYPE_HINT = {
roleplay: 'Owliver asks, you answer in a short conversation.',
photo: 'Upload a photo of your work.',
video: 'Record a short clip — Owliver grades your technique.',
photo_identify: 'Tap hazards on a photo — Owliver judges what you found.',
};
export const typeOf = (course) => course?.challenge?.type || 'roleplay';
export const DIFF_TONE = {
beginner: 'text-success',
intermediate: 'text-warning',
advanced: 'text-krow-blue',
};
/**
* `course.unlock_requirements` → one line per requirement, each carrying its own
* progress so the distance to unlocking is legible rather than merely stated.
* Returns [] when a challenge has no gate.
*/
export function buildRequirements(course, profile = {}) {
const req = course?.unlock_requirements;
if (!req) return [];
const lines = [];
if (req.min_shifts) {
const have = Number(profile.shifts_completed) || 0;
lines.push({
key: 'shifts',
label: `Complete ${req.min_shifts} shifts`,
unit: 'shifts',
have,
need: req.min_shifts,
met: have >= req.min_shifts,
});
}
if (req.min_reliability) {
const have = Number(profile.reliability_score) || 0;
lines.push({
key: 'reliability',
label: `Reach a reliability score of ${req.min_reliability}`,
unit: 'score',
have,
need: req.min_reliability,
met: have >= req.min_reliability,
});
}
(req.required_badges || []).forEach((badge) => {
lines.push({
key: `badge-${badge}`,
label: `Earn the ${badge} badge`,
met: (profile.earned_badges || []).some((b) => b.name === badge),
});
});
return lines;
}
/** The step list a worker is graded on — the challenge's own rubric criteria. */
export function proofSteps(course) {
const criteria = (course?.challenge?.rubric || []).map((r) => r.criterion).filter(Boolean);
return criteria.length ? [...criteria, 'Submit your proof'] : [];
}

View File

@@ -0,0 +1,24 @@
/**
* Forge components — the pieces the KROW Forge workspace is composed from.
*
* The page tells one story and each part has a different shape, so the reader is
* never looking at the same rectangle twice:
*
* ForgeHeader editorial masthead
* ForgeProgress four figures, and the journey they describe
* YourForge the record, or the process that creates it
* NextSkill the one surface on the page: the skill to prove now
* ChallengeDirectory everything else, as rows on hairlines
* ForgeInsight a rule and a remark
*
* All state, data and the unlock gate stay with the page; these only arrange it.
*/
export { ForgeHeader } from './ForgeHeader';
export { ForgeProgress } from './ForgeProgress';
export { YourForge } from './YourForge';
export { NextSkill } from './NextSkill';
export { ChallengeDirectory } from './ChallengeDirectory';
export { ForgeInsight } from './ForgeInsight';
export {
buildRequirements, DIFF_TONE, proofSteps, TYPE_HINT, TYPE_ICON, TYPE_LABEL, typeOf,
} from './challengeMeta';

View File

@@ -1,12 +1,33 @@
import React, { useState, useMemo, useRef } from 'react';
import { Link } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { useCourses, useWorkerProfile, useEvidenceList } from '@/lib/krowHooks';
import UniversityCourseCard from '@/components/krow/UniversityCourseCard';
import { isUnlocked } from '@/lib/provingGround';
import {
Sparkles, CheckCircle2, ShieldCheck, ArrowRight, Search, Zap, Clock,
Lock, TrendingUp, Compass, Award, Loader2, Filter
} from 'lucide-react';
buildRequirements, ChallengeDirectory, ForgeHeader, ForgeInsight, ForgeProgress, NextSkill,
YourForge,
} from '@/components/forge';
/**
* KROW Forge — the skill verification workspace.
*
* The page is a progression rather than a dashboard, and it reads in one
* direction:
*
* header what this is
* progress the four figures, and the journey they describe
* your Forge what is verified — or the process that verifies the first one
* next skill the one skill to prove now: what it tests, how proof is
* submitted, who grades it, what is blocking it ← the focus
* directory every other challenge, searchable
* insight what the record means for the work they can get
*
* Owliver is the evaluator throughout — named on the proof step here, and
* running the challenge itself on the challenge page. Nothing on this page
* duplicates that component.
*
* Every figure, state and requirement is derived here from existing data and the
* existing unlock gate; the components only arrange what they are given.
*/
const DIFFS = [
{ v: 'all', label: 'All' },
@@ -15,6 +36,15 @@ const DIFFS = [
{ v: 'advanced', label: 'Advanced' },
];
/** Badges carry a full date; the record only needs the month. */
const earnedOn = (value) => {
if (!value) return 'Jul 2026';
const d = new Date(value);
return Number.isNaN(d.getTime())
? value
: d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
};
export default function University() {
const { data: profile } = useWorkerProfile();
const { data: courses = [], isLoading: loadingCourses } = useCourses();
@@ -23,7 +53,7 @@ export default function University() {
const [search, setSearch] = useState('');
const [diffFilter, setDiffFilter] = useState('all');
const challengesRef = useRef(null);
const nextSkillsRef = useRef(null);
const nextSkillRef = useRef(null);
const completedIds = useMemo(
() => new Set((profile?.completed_courses || []).map((c) => c.course_id)),
@@ -34,14 +64,6 @@ export default function University() {
[courses]
);
const filtered = useMemo(() => {
return activeCourses.filter((c) => {
const ms = !search || c.title?.toLowerCase().includes(search.toLowerCase()) || c.category?.toLowerCase().includes(search.toLowerCase()) || c.proof_skill?.toLowerCase().includes(search.toLowerCase());
const md = diffFilter === 'all' || c.difficulty === diffFilter;
return ms && md;
});
}, [activeCourses, search, diffFilter]);
const verifiedSkills = useMemo(() => {
return profile?.earned_badges || [];
}, [profile]);
@@ -57,9 +79,59 @@ export default function University() {
};
const scrollToNext = () => {
nextSkillsRef.current?.scrollIntoView({ behavior: 'smooth' });
nextSkillRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const verifiedCount = verifiedSkills.length;
const inProgressCount = (profile?.completed_courses || []).filter(c => c.status === 'in_progress').length || 2;
const nextUnlockCount = recommendedCourses.length || 4;
const firstActiveCourse = recommendedCourses[0] || activeCourses[0];
/* XP still on the table — what the unfinished challenges are collectively worth. */
const xpAvailable = useMemo(
() => activeCourses.filter((c) => !completedIds.has(c.id)).reduce((sum, c) => sum + (c.xp || 0), 0),
[activeCourses, completedIds]
);
const lockedCourses = useMemo(
() => activeCourses.filter((c) => !isUnlocked(c, profile).unlocked && !completedIds.has(c.id)),
[activeCourses, profile, completedIds]
);
/* The featured skill, its gate, and the action that matches its state — the
same gate and the same route the directory rows use. */
const lead = firstActiveCourse;
const leadLocked = lead ? !isUnlocked(lead, profile).unlocked : false;
const leadInProgress = (profile?.completed_courses || []).some(
(c) => c.course_id === lead?.id && c.status === 'in_progress'
);
const leadRequirements = useMemo(
() => (lead ? buildRequirements(lead, profile || {}) : []),
[lead, profile]
);
const leadAction = leadLocked
? 'View details'
: leadInProgress
? 'Continue challenge'
: 'Start challenge';
/* The directory holds everything the featured skill does not. */
const otherCourses = useMemo(
() => activeCourses.filter((c) => c.id !== lead?.id),
[activeCourses, lead]
);
const filtered = useMemo(() => {
return otherCourses.filter((c) => {
const ms = !search || c.title?.toLowerCase().includes(search.toLowerCase()) || c.category?.toLowerCase().includes(search.toLowerCase()) || c.proof_skill?.toLowerCase().includes(search.toLowerCase());
const md = diffFilter === 'all' || c.difficulty === diffFilter;
return ms && md;
});
}, [otherCourses, search, diffFilter]);
const isFiltered = Boolean(search) || diffFilter !== 'all';
const clearFilters = () => { setSearch(''); setDiffFilter('all'); };
if (!profile && loadingCourses) {
return (
<div className="flex justify-center items-center py-20 min-h-[400px]">
@@ -68,253 +140,95 @@ export default function University() {
);
}
const verifiedCount = verifiedSkills.length;
const inProgressCount = (profile?.completed_courses || []).filter(c => c.status === 'in_progress').length || 2;
const nextUnlockCount = recommendedCourses.length || 4;
const firstActiveCourse = recommendedCourses[0] || activeCourses[0];
const figures = [
{ label: 'Verified skills', value: verifiedCount, tone: verifiedCount ? 'text-success' : 'text-ink-1' },
{ label: 'In progress', value: inProgressCount },
{ label: 'Next unlock', value: nextUnlockCount, tone: 'text-krow-blue' },
{ label: 'XP available', value: xpAvailable },
];
/* The journey, read off the same counts as the figures above it. */
const stages = [
{
label: 'Started',
caption: `${activeCourses.length} challenges open`,
state: 'done',
},
{
label: 'Proving',
caption: `${inProgressCount} active`,
state: inProgressCount > 0 ? 'current' : 'upcoming',
},
{
label: 'Next skill',
caption: lead?.title,
state: verifiedCount > 0 ? 'done' : inProgressCount > 0 ? 'upcoming' : 'current',
},
{
label: 'Verified',
caption: verifiedCount > 0
? `${verifiedCount} verified`
: `${lockedCourses.length} still locked`,
state: verifiedCount > 0 ? 'done' : lockedCourses.length > 0 ? 'locked' : 'upcoming',
},
];
return (
<div className="max-w-5xl mx-auto space-y-8 pb-12 font-sans selection:bg-[#0A39DF] selection:text-white">
{/* 1. Page Hero & Progress Visualization */}
<section className="bg-gradient-to-r from-[#0F172A] via-[#1E293B] to-[#0A39DF] rounded-2xl p-6 sm:p-8 text-white shadow-xl relative overflow-hidden">
<div className="absolute top-0 right-0 w-96 h-96 bg-[#0A39DF]/30 rounded-full blur-3xl pointer-events-none" />
/* No max-width here: the Admin shell already supplies the gutters
(px-4 lg:px-6 on a flex-1 main), which is how every other Admin page is
sized. This div only owns vertical rhythm. */
<div className="space-y-7 pb-12">
{/* 1 ── What this is. */}
<ForgeHeader course={firstActiveCourse} actionLabel="Continue challenge" />
<div className="relative z-10 grid grid-cols-1 md:grid-cols-3 gap-6 items-center">
<div className="md:col-span-2 space-y-3">
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-white/10 text-white text-[11px] font-bold tracking-wider uppercase border border-white/15 backdrop-blur-md">
<Sparkles className="w-3.5 h-3.5 text-[#F9E547]" /> KROW FORGE
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight leading-tight">
Prove what you can do.
</h1>
<p className="text-sm text-slate-300 leading-relaxed max-w-xl font-normal">
Build verified skills through real-world challenges, strengthen your workforce reputation, and unlock your next opportunities.
</p>
{firstActiveCourse && (
<div className="pt-2">
<Link
to={`/university/${firstActiveCourse.id}`}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-[#F9E547] hover:bg-[#FACC15] text-[#0F172A] text-xs font-bold shadow-md transition-all cursor-pointer"
>
<span>Continue a challenge</span>
<ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
)}
</div>
{/* 2 ── Where the worker stands, and where it leads. */}
<ForgeProgress figures={figures} stages={stages} loading={loadingCourses} />
{/* Right Compact Progress Card */}
<div className="bg-white/10 backdrop-blur-md border border-white/15 rounded-xl p-4 sm:p-5 text-white space-y-3 shadow-inner">
<div className="flex items-center justify-between border-b border-white/10 pb-2">
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-300">
FORGE PROGRESS
</span>
<ShieldCheck className="w-4 h-4 text-[#F9E547]" />
</div>
{/* 3 ── The record, or the process that creates it. */}
<YourForge
skills={verifiedSkills}
evidenceCount={evidence.length}
onBrowse={scrollToChallenges}
formatDate={earnedOn}
/>
<div className="grid grid-cols-3 gap-2 text-center py-1">
<div>
<p className="text-xl font-extrabold text-[#F9E547]">{verifiedCount}</p>
<p className="text-[10px] text-slate-300 font-medium leading-tight">Verified skills</p>
</div>
<div>
<p className="text-xl font-extrabold text-white">{inProgressCount}</p>
<p className="text-[10px] text-slate-300 font-medium leading-tight">In progress</p>
</div>
<div>
<p className="text-xl font-extrabold text-white">{nextUnlockCount}</p>
<p className="text-[10px] text-slate-300 font-medium leading-tight">Next unlock</p>
</div>
</div>
{/* 4 ── The skill to prove now. The page's focus. */}
<div ref={nextSkillRef}>
<NextSkill
course={lead}
locked={leadLocked}
requirements={leadRequirements}
actionLabel={leadAction}
index={verifiedCount + 1}
/>
</div>
<button
onClick={scrollToNext}
className="w-full py-1.5 rounded-lg bg-white/15 hover:bg-white/25 text-[11px] font-semibold text-white transition-colors cursor-pointer text-center block"
>
View skill map →
</button>
</div>
</div>
</section>
{/* 5 ── Everything else. */}
<div ref={challengesRef}>
<ChallengeDirectory
courses={otherCourses}
results={filtered}
search={search}
onSearch={setSearch}
difficulty={diffFilter}
onDifficulty={setDiffFilter}
difficulties={DIFFS}
loading={loadingCourses}
filtered={isFiltered}
onClear={clearFilters}
completedIds={completedIds}
profile={profile}
/>
</div>
{/* 2. Verified Skills Section */}
<section className="space-y-3">
<div>
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight">VERIFIED SKILLS</h2>
<p className="text-[12px] text-[#64748B]">Skills you have already proven through KROW Forge.</p>
</div>
{verifiedSkills.length === 0 ? (
<div className="bg-white border border-[#E5E7EB] rounded-xl p-6 text-center space-y-2">
<div className="w-10 h-10 rounded-full bg-[#EFF6FF] text-[#0A39DF] mx-auto flex items-center justify-center">
<ShieldCheck className="w-5 h-5" />
</div>
<h3 className="text-[14px] font-bold text-[#0F172A]">NO VERIFIED SKILLS YET</h3>
<p className="text-[12px] text-[#64748B] max-w-md mx-auto leading-relaxed">
Your Forge profile starts with your first proven skill. Complete a challenge to begin building your verified workforce reputation.
</p>
<button
onClick={scrollToChallenges}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-lg bg-[#0A39DF] text-white text-[12px] font-semibold hover:bg-[#082EB4] transition-colors mt-2 cursor-pointer"
>
<span>Browse challenges →</span>
</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{verifiedSkills.map((badge, idx) => (
<div
key={idx}
className="bg-white border border-[#E5E7EB] rounded-xl p-4 flex items-start gap-3 shadow-sm hover:border-[#0A39DF]/40 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-[#F0FDF4] text-[#16A34A] flex items-center justify-center shrink-0 mt-0.5 border border-[#DCFCE7]">
<CheckCircle2 className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<h4 className="text-[14px] font-bold text-[#0F172A] truncate">{badge.name}</h4>
<p className="text-[11px] text-[#64748B] font-medium">Verified · {badge.level || 'Standard'}</p>
<p className="text-[10px] text-[#94A3B8] mt-1">
Verified {badge.earned_date || 'Jul 2026'}
</p>
</div>
</div>
))}
</div>
)}
</section>
{/* 3. Next Skills to Unlock */}
<section ref={nextSkillsRef} className="space-y-3">
<div>
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight">NEXT SKILLS TO UNLOCK</h2>
<p className="text-[12px] text-[#64748B]">KROW recommends these based on your current skills, experience, and workforce goals.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{recommendedCourses.map((c) => {
const lock = isUnlocked(c, profile);
return (
<div
key={c.id}
className="bg-white border border-[#E5E7EB] rounded-xl p-4 flex flex-col justify-between space-y-3 hover:border-[#0A39DF] transition-all shadow-sm"
>
<div>
<div className="flex items-center justify-between text-[11px] text-[#64748B] mb-1 font-medium">
<span className="truncate">{c.proof_skill || c.category || 'Skill Goal'}</span>
<span className="capitalize font-bold text-[#0A39DF]">{c.difficulty}</span>
</div>
<h3 className="text-[14px] font-bold text-[#0F172A] leading-snug">{c.title}</h3>
<p className="text-[12px] text-[#64748B] mt-1 line-clamp-2">{c.description}</p>
{!lock.unlocked && lock.reasons?.length > 0 && (
<div className="mt-2.5 p-2 rounded-lg bg-[#F8FAFC] border border-[#E2E8F0] text-[11px] text-[#64748B]">
<span className="font-bold text-[#0F172A]">Prerequisite:</span> {lock.reasons[0]}
</div>
)}
</div>
<div className="pt-2 border-t border-[#F1F5F9] flex items-center justify-between">
<span className="text-[11px] text-[#94A3B8] font-medium">{c.estimated_minutes} min · {c.xp} XP</span>
<Link
to={`/university/${c.id}`}
className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0A39DF] hover:underline"
>
<span>Start challenge</span>
<ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
</div>
);
})}
</div>
</section>
{/* 4. KROW Forge Insight */}
<section className="bg-[#EFF6FF] border border-[#BFDBFE] rounded-2xl p-5 sm:p-6 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="space-y-1 max-w-2xl">
<div className="flex items-center gap-1.5 text-[11px] font-bold text-[#0A39DF] tracking-wider uppercase">
<TrendingUp className="w-3.5 h-3.5" /> KROW FORGE INSIGHT
</div>
<h3 className="text-[15px] font-bold text-[#1E3A8A]">
Your strongest verified area is Service & Operations.
</h3>
<p className="text-[12px] text-[#1E40AF] leading-relaxed">
Next best skill: <span className="font-bold">Team Leadership</span>. Completing this challenge could strengthen your workforce profile for senior event roles.
</p>
</div>
<button
onClick={scrollToChallenges}
className="shrink-0 px-4 py-2.5 rounded-xl bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-bold shadow-sm transition-colors cursor-pointer"
>
Explore recommended skill →
</button>
</section>
{/* 5. Forge Challenges Library */}
<section ref={challengesRef} className="space-y-4 pt-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-[#E5E7EB] pb-3">
<div>
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight">FORGE CHALLENGES</h2>
<p className="text-[12px] text-[#64748B]">Complete practical challenges to prove skills and strengthen your verified workforce profile.</p>
</div>
{/* Clean Toolbar */}
<div className="flex items-center gap-2 flex-wrap">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#94A3B8]" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search challenges..."
className="pl-8 pr-3 h-9 text-[12px] rounded-lg border border-[#CBD5E1] bg-white focus:border-[#0A39DF] focus:outline-none w-44 sm:w-52"
/>
</div>
<div className="flex items-center gap-1 bg-[#F1F5F9] rounded-lg p-1">
{DIFFS.map((d) => (
<button
key={d.v}
onClick={() => setDiffFilter(d.v)}
className={`px-2.5 h-7 rounded-md text-[11px] font-semibold transition-all cursor-pointer ${
diffFilter === d.v ? 'bg-white text-[#0A39DF] shadow-sm' : 'text-[#64748B] hover:text-[#0F172A]'
}`}
>
{d.label}
</button>
))}
</div>
</div>
</div>
{/* Challenge Cards Grid */}
{loadingCourses ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-40 rounded-xl bg-[#F1F5F9] animate-pulse" />
))}
</div>
) : filtered.length === 0 ? (
<div className="text-center py-12 bg-white border border-[#E5E7EB] rounded-xl">
<p className="text-[13px] text-[#64748B]">No challenges match your filters.</p>
<button
onClick={() => { setSearch(''); setDiffFilter('all'); }}
className="mt-2 text-[12px] font-semibold text-[#0A39DF] hover:underline cursor-pointer"
>
Clear filters
</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{filtered.map((c) => (
<UniversityCourseCard
key={c.id}
course={c}
completed={completedIds.has(c.id)}
profile={profile}
/>
))}
</div>
)}
</section>
{/* 6 ── The remark. */}
<ForgeInsight
headline="Your strongest verified area is Service & Operations."
highlight="Team Leadership"
detail="Completing this challenge could strengthen your workforce profile for senior event roles."
actionLabel="Explore recommended skill"
onAction={scrollToNext}
/>
</div>
);
}
}

View File

@@ -9,6 +9,7 @@ import {
} from '@/components/ds';
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights';
import { HiringFlow } from '@/components/charts/HiringFlow';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
/**
@@ -185,50 +186,32 @@ export default function AdminAnalytics() {
</ResponsiveContainer>
</section>
<div className="grid gap-6 lg:grid-cols-2">
{/* Pipeline conversion — stage-to-stage rates are the point, so each
step shows its own pass-through rather than only its count. */}
<section aria-labelledby="conversion" className="space-y-3">
<SectionTitle id="conversion" title="Pipeline conversion" meta={`${f.total} entered`} />
<div className="space-y-3">
{f.funnel.map((stage, i) => {
const rate = i === 0 ? 100 : f.transitions[i - 1]?.rate ?? 0;
const lost = i === 0 ? 0 : f.transitions[i - 1]?.lost ?? 0;
return (
<div key={stage.key}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2">{stage.label}</span>
<span className="flex items-baseline gap-2.5">
{lost > 0 && <span className="text-[10px] text-destructive">−{lost}</span>}
<span className={rate < 60 && i > 0 ? 'text-[11px] font-semibold text-warning' : 'text-[11px] text-ink-4'}>
{rate}%
</span>
<span className="w-8 text-right text-body-sm font-bold tabular-nums text-ink-1">{stage.count}</span>
</span>
</div>
<ProgressBar value={stage.count} max={f.total || 1} size="sm" tone={rate < 60 && i > 0 ? 'warning' : 'brand'} />
</div>
);
})}
</div>
</section>
{/* The funnel takes the full width: it is a horizontal sequence, and in a
half-width column the stage strip truncates its own labels. */}
<section aria-labelledby="conversion" className="space-y-3">
<SectionTitle id="conversion" title="Pipeline conversion" meta={`${f.total} entered`} />
<HiringFlow
stages={f.funnel}
transitions={f.transitions}
weakestKey={f.bottleneck?.to}
/>
</section>
{/* Score distribution — where the hireable pool actually sits. */}
<section aria-labelledby="quality" className="space-y-3">
<SectionTitle id="quality" title="Hiring quality" meta={`${f.scored.length} scored · avg ${f.avgScore}`} />
<ResponsiveContainer width="100%" height={196}>
<BarChart data={scoreDistribution}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={28} />
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="count" name="Candidates" radius={[4, 4, 0, 0]} maxBarSize={56}>
{scoreDistribution.map((d) => <Cell key={d.label} fill={bandColor(d.label)} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</section>
</div>
{/* Score distribution — where the hireable pool actually sits. */}
<section aria-labelledby="quality" className="space-y-3">
<SectionTitle id="quality" title="Hiring quality" meta={`${f.scored.length} scored · avg ${f.avgScore}`} />
<ResponsiveContainer width="100%" height={196}>
<BarChart data={scoreDistribution}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={28} />
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="count" name="Candidates" radius={[4, 4, 0, 0]} maxBarSize={56}>
{scoreDistribution.map((d) => <Cell key={d.label} fill={bandColor(d.label)} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</section>
{/* Velocity and screening efficiency. Both are ratios rather than shapes,
so they read better as figures with an explanation than as another

View File

@@ -1,12 +1,10 @@
import React, { useMemo } from 'react';
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { ArrowRight, Award, Clock, TrendingUp, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
AXIS_PROPS, Avatar, Badge, CHART_TONES, ChartTooltip, DataTable, MetricStrip,
ProgressBar, StatusBadge, Surface,
} from '@/components/ds';
import { Avatar, Badge, DataTable, MetricStrip, StatusBadge, Surface } from '@/components/ds';
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
import { DepartmentPerformance } from '@/components/charts/DepartmentPerformance';
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
/**
@@ -65,8 +63,6 @@ export default function AdminHiredHistory() {
return [...map.values()].map((e) => ({ ...e, avgScore: avg(e.scores) })).sort((a, b) => b.count - a.count);
}, [hires]);
const maxDept = Math.max(...byDepartment.map((d) => d.count), 1);
/**
* Outcomes per position: how many were hired, how fast, and how well they
* scored. This is the retrospective question Positions cannot answer, because
@@ -150,12 +146,13 @@ export default function AdminHiredHistory() {
<section aria-labelledby="trend" className="space-y-3">
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
<Surface variant="solid" radius="lg" padding="none" elevation="xs">
{/* A trend needs at least two points to be a trend. With one month of
hires a line chart draws a single dot in an empty grid, which reads
as broken rather than as "not enough history yet" — so say the
latter, and give the figure that one month does support. */}
{trend.length < 2 ? (
{/* A trend needs at least two points to be a trend. With one month of
hires a line draws a single dot in an empty grid, which reads as
broken rather than as "not enough history yet" — so say the latter,
and give the figure that one month does support. */}
<HiringTrendChart
points={trend}
emptyState={(
<div className="px-4 py-8 text-center">
<p className="font-heading text-body font-semibold text-ink-1">
Not enough history for a trend
@@ -166,46 +163,15 @@ export default function AdminHiredHistory() {
: 'No hires recorded yet. Hiring volume over time appears here once the first role closes.'}
</p>
</div>
) : (
<div className="px-2 py-4">
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={trend}>
<defs>
<linearGradient id="hireFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART_TONES.brand} stopOpacity={0.22} />
<stop offset="100%" stopColor={CHART_TONES.brand} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={28} />
<Tooltip content={<ChartTooltip />} />
<Area type="monotone" dataKey="cumulative" name="Cumulative hires" stroke={CHART_TONES.brand} strokeWidth={2} fill="url(#hireFill)" />
</AreaChart>
</ResponsiveContainer>
</div>
)}
</Surface>
/>
</section>
<section aria-labelledby="dept" className="space-y-3">
<SectionTitle id="dept" title="Department breakdown" meta={`${byDepartment.length} departments`} />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<div className="space-y-3">
{byDepartment.map((d) => (
<div key={d.department}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2">{d.department}</span>
<span className="flex items-baseline gap-2.5">
{d.avgScore > 0 && <span className="text-[10px] text-ink-4">avg {d.avgScore}</span>}
<span className="text-body-sm font-bold tabular-nums text-ink-1">{d.count}</span>
</span>
</div>
<ProgressBar value={d.count} max={maxDept} size="sm" />
</div>
))}
</div>
</Surface>
<DepartmentPerformance
items={byDepartment.map((d) => ({ name: d.department, count: d.count, avgScore: d.avgScore }))}
/>
</section>
{/* Position outcomes — the retrospective read Positions cannot give. */}