import type { ProfileView } from '@/types/views'; 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: any, profile: Partial = {}) { 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'] : []; } /* ── The same records, read as an administrator ─────────────────────────── */ /** * Everything below reads the Course entity from the managing side rather than * the doing side. The worker asks "what do I have to prove"; the admin asks * "what does this skill teach, how is it proven, what does Owliver check, and is * it live". Same fields, different question — which is why these are derivations * here rather than a second shape stored somewhere. */ /** * Publication state. * * The seed calls a live skill `active`; the admin vocabulary is Draft / * Published / Archived. Mapping rather than renaming keeps every existing record * and every existing `status === 'active'` filter in the app working. */ export const STATUS_LABEL = { active: 'Published', published: 'Published', draft: 'Draft', archived: 'Archived', inactive: 'Archived', }; export const STATUS_TONE = { Published: 'success', Draft: 'warning', Archived: 'neutral', }; export const statusOf = (course) => STATUS_LABEL[course?.status] || 'Draft'; /** The criteria Owliver scores a submission against. */ export const owliverCriteria = (course) => (course?.challenge?.rubric || []).map((r) => r.criterion).filter(Boolean); /** * What the workforce actually studies. * * `training_outline` is written by the Forge authoring flow. Skills that predate * it have none, and the detail view says so rather than inventing a curriculum * from the description. */ export const trainingOutline = (course) => (Array.isArray(course?.training_outline) ? course.training_outline : []).filter(Boolean); /** * The kinds of training material a skill carries, named only where the record * actually has it. Nothing is listed on the strength of the entity *supporting* * a field — only on it being filled in. */ export function trainingKinds(course) { const kinds = []; if (trainingOutline(course).length) kinds.push('Instructions'); if (course?.quiz?.length) kinds.push('Quiz'); if (course?.challenge?.prompt) kinds.push('Practice'); return kinds; } /** The one-word training type a card shows. */ export const trainingType = (course) => trainingKinds(course)[0] || 'Practice'; /** * How a skill is doing across the workforce, counted from the worker profiles * the platform already holds. * * Only completion and verification are counted, because those are the two states * a profile records. Assignment and "started" have no field behind them, so they * are absent here rather than estimated. */ export function workforceUsage(course, profiles = []) { if (!course) return { completed: 0, verified: 0 }; let completed = 0; let verified = 0; for (const p of profiles) { if ((p.completed_courses || []).some((c) => c.course_id === course.id)) completed += 1; if ((p.earned_badges || []).some((b) => b.course_id === course.id)) verified += 1; } return { completed, verified }; }