Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins: - F1: Evaluation progress indicator with touch tracking in sticky status bar - F2: Export filtering results as CSV with dynamic AI column flattening - F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure) Batch 2 - Jury Experience: - F4: Countdown timer component with urgency colors + email reminder service with cron endpoint - F5: Conflict of interest declaration system (dialog, admin management, review workflow) Batch 3 - Admin & AI Enhancements: - F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording - F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns - F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking) Batch 4 - Form Flexibility & Applicant Portal: - F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility) - F10: Applicant portal (status timeline, per-round documents, mentor messaging) Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useTransition } from 'react'
|
||||
import { useState, useEffect, useCallback, useTransition, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -14,6 +14,7 @@ import { Textarea } from '@/components/ui/textarea'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -38,15 +39,36 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||
|
||||
// Define criterion type from the evaluation form JSON
|
||||
type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header'
|
||||
|
||||
interface CriterionCondition {
|
||||
criterionId: string
|
||||
operator: 'equals' | 'greaterThan' | 'lessThan'
|
||||
value: number | string | boolean
|
||||
}
|
||||
|
||||
interface Criterion {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
scale: number // max value (e.g., 5 or 10)
|
||||
type?: CriterionType // defaults to 'numeric'
|
||||
// Numeric
|
||||
scale?: number // max value (e.g., 5 or 10)
|
||||
weight?: number
|
||||
required?: boolean
|
||||
// Text
|
||||
maxLength?: number
|
||||
placeholder?: string
|
||||
// Boolean
|
||||
trueLabel?: string
|
||||
falseLabel?: string
|
||||
// Conditional visibility
|
||||
condition?: CriterionCondition
|
||||
// Section grouping
|
||||
sectionId?: string
|
||||
}
|
||||
|
||||
interface EvaluationFormProps {
|
||||
@@ -55,7 +77,7 @@ interface EvaluationFormProps {
|
||||
projectTitle: string
|
||||
criteria: Criterion[]
|
||||
initialData?: {
|
||||
criterionScoresJson: Record<string, number> | null
|
||||
criterionScoresJson: Record<string, number | string | boolean> | null
|
||||
globalScore: number | null
|
||||
binaryDecision: boolean | null
|
||||
feedbackText: string | null
|
||||
@@ -65,15 +87,52 @@ interface EvaluationFormProps {
|
||||
deadline?: Date | null
|
||||
}
|
||||
|
||||
const createEvaluationSchema = (criteria: Criterion[]) =>
|
||||
z.object({
|
||||
criterionScores: z.record(z.number()),
|
||||
const createEvaluationSchema = (criteria: Criterion[]) => {
|
||||
const criterionFields: Record<string, z.ZodTypeAny> = {}
|
||||
for (const c of criteria) {
|
||||
const type = c.type || 'numeric'
|
||||
if (type === 'section_header') continue
|
||||
if (type === 'numeric') {
|
||||
criterionFields[c.id] = z.number()
|
||||
} else if (type === 'text') {
|
||||
criterionFields[c.id] = z.string()
|
||||
} else if (type === 'boolean') {
|
||||
criterionFields[c.id] = z.boolean()
|
||||
}
|
||||
}
|
||||
return z.object({
|
||||
criterionScores: z.object(criterionFields).passthrough(),
|
||||
globalScore: z.number().int().min(1).max(10),
|
||||
binaryDecision: z.boolean(),
|
||||
feedbackText: z.string().min(10, 'Please provide at least 10 characters of feedback'),
|
||||
})
|
||||
}
|
||||
|
||||
type EvaluationFormData = z.infer<ReturnType<typeof createEvaluationSchema>>
|
||||
type EvaluationFormData = {
|
||||
criterionScores: Record<string, number | string | boolean>
|
||||
globalScore: number
|
||||
binaryDecision: boolean
|
||||
feedbackText: string
|
||||
}
|
||||
|
||||
/** Evaluate whether a condition is met based on current form values */
|
||||
function evaluateCondition(
|
||||
condition: CriterionCondition,
|
||||
scores: Record<string, number | string | boolean>
|
||||
): boolean {
|
||||
const val = scores[condition.criterionId]
|
||||
if (val === undefined) return false
|
||||
switch (condition.operator) {
|
||||
case 'equals':
|
||||
return val === condition.value
|
||||
case 'greaterThan':
|
||||
return typeof val === 'number' && typeof condition.value === 'number' && val > condition.value
|
||||
case 'lessThan':
|
||||
return typeof val === 'number' && typeof condition.value === 'number' && val < condition.value
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function EvaluationForm({
|
||||
assignmentId,
|
||||
@@ -89,10 +148,41 @@ export function EvaluationForm({
|
||||
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null)
|
||||
|
||||
// Progress tracking state
|
||||
const [touchedCriteria, setTouchedCriteria] = useState<Set<string>>(() => {
|
||||
const initial = new Set<string>()
|
||||
if (initialData?.criterionScoresJson) {
|
||||
for (const key of Object.keys(initialData.criterionScoresJson)) {
|
||||
initial.add(key)
|
||||
}
|
||||
}
|
||||
return initial
|
||||
})
|
||||
const [globalScoreTouched, setGlobalScoreTouched] = useState(
|
||||
() => initialData?.globalScore != null
|
||||
)
|
||||
const [decisionTouched, setDecisionTouched] = useState(
|
||||
() => initialData?.binaryDecision != null
|
||||
)
|
||||
|
||||
// Compute which criteria are scorable (not section headers)
|
||||
const scorableCriteria = useMemo(
|
||||
() => criteria.filter((c) => (c.type || 'numeric') !== 'section_header'),
|
||||
[criteria]
|
||||
)
|
||||
|
||||
// Initialize criterion scores with existing data or defaults
|
||||
const defaultCriterionScores: Record<string, number> = {}
|
||||
criteria.forEach((c) => {
|
||||
defaultCriterionScores[c.id] = initialData?.criterionScoresJson?.[c.id] ?? Math.ceil(c.scale / 2)
|
||||
const defaultCriterionScores: Record<string, number | string | boolean> = {}
|
||||
scorableCriteria.forEach((c) => {
|
||||
const type = c.type || 'numeric'
|
||||
const existing = initialData?.criterionScoresJson?.[c.id]
|
||||
if (type === 'numeric') {
|
||||
defaultCriterionScores[c.id] = typeof existing === 'number' ? existing : Math.ceil((c.scale ?? 5) / 2)
|
||||
} else if (type === 'text') {
|
||||
defaultCriterionScores[c.id] = typeof existing === 'string' ? existing : ''
|
||||
} else if (type === 'boolean') {
|
||||
defaultCriterionScores[c.id] = typeof existing === 'boolean' ? existing : false
|
||||
}
|
||||
})
|
||||
|
||||
const form = useForm<EvaluationFormData>({
|
||||
@@ -109,6 +199,46 @@ export function EvaluationForm({
|
||||
const { watch, handleSubmit, control, formState } = form
|
||||
const { errors, isValid, isDirty } = formState
|
||||
|
||||
// Progress tracking callbacks
|
||||
const onCriterionTouch = useCallback((criterionId: string) => {
|
||||
setTouchedCriteria((prev) => {
|
||||
if (prev.has(criterionId)) return prev
|
||||
const next = new Set(prev)
|
||||
next.add(criterionId)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Compute progress - section_headers count as always complete (skip them)
|
||||
const feedbackValue = watch('feedbackText')
|
||||
const watchedScores = watch('criterionScores')
|
||||
const progress = useMemo(() => {
|
||||
// Only count scorable criteria (not section headers)
|
||||
const totalItems = scorableCriteria.length + 3
|
||||
|
||||
// Count completed criteria
|
||||
let criteriaDone = 0
|
||||
for (const c of scorableCriteria) {
|
||||
const type = c.type || 'numeric'
|
||||
if (type === 'numeric') {
|
||||
if (touchedCriteria.has(c.id)) criteriaDone++
|
||||
} else if (type === 'text') {
|
||||
const val = watchedScores?.[c.id]
|
||||
if (typeof val === 'string' && val.length > 0) criteriaDone++
|
||||
} else if (type === 'boolean') {
|
||||
if (touchedCriteria.has(c.id)) criteriaDone++
|
||||
}
|
||||
}
|
||||
|
||||
const completedItems =
|
||||
criteriaDone +
|
||||
(globalScoreTouched ? 1 : 0) +
|
||||
(decisionTouched ? 1 : 0) +
|
||||
(feedbackValue.length >= 10 ? 1 : 0)
|
||||
const percentage = Math.round((completedItems / totalItems) * 100)
|
||||
return { totalItems, completedItems, percentage, criteriaDone, criteriaTotal: scorableCriteria.length }
|
||||
}, [scorableCriteria, touchedCriteria, watchedScores, globalScoreTouched, decisionTouched, feedbackValue])
|
||||
|
||||
// tRPC mutations
|
||||
const startEvaluation = trpc.evaluation.start.useMutation()
|
||||
const autosave = trpc.evaluation.autosave.useMutation()
|
||||
@@ -206,10 +336,20 @@ export function EvaluationForm({
|
||||
{/* Status bar */}
|
||||
<div className="sticky top-0 z-10 -mx-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4 py-3 border-b">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<h2 className="font-semibold truncate max-w-[200px] sm:max-w-none">
|
||||
{projectTitle}
|
||||
</h2>
|
||||
{!isReadOnly && deadline && (
|
||||
<CountdownTimer deadline={new Date(deadline)} />
|
||||
)}
|
||||
{!isReadOnly && (
|
||||
<ProgressIndicator
|
||||
percentage={progress.percentage}
|
||||
criteriaDone={progress.criteriaDone}
|
||||
criteriaTotal={progress.criteriaTotal}
|
||||
/>
|
||||
)}
|
||||
<AutosaveIndicator status={autosaveStatus} lastSaved={lastSaved} />
|
||||
</div>
|
||||
|
||||
@@ -273,14 +413,61 @@ export function EvaluationForm({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{criteria.map((criterion) => (
|
||||
<CriterionField
|
||||
key={criterion.id}
|
||||
criterion={criterion}
|
||||
control={control}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
{criteria.map((criterion) => {
|
||||
const type = criterion.type || 'numeric'
|
||||
|
||||
// Evaluate conditional visibility
|
||||
if (criterion.condition) {
|
||||
const conditionMet = evaluateCondition(criterion.condition, watchedScores ?? {})
|
||||
if (!conditionMet) return null
|
||||
}
|
||||
|
||||
// Section header
|
||||
if (type === 'section_header') {
|
||||
return <SectionHeaderField key={criterion.id} criterion={criterion} />
|
||||
}
|
||||
|
||||
// Numeric (default)
|
||||
if (type === 'numeric') {
|
||||
return (
|
||||
<NumericCriterionField
|
||||
key={criterion.id}
|
||||
criterion={criterion}
|
||||
control={control}
|
||||
disabled={isReadOnly}
|
||||
onTouch={onCriterionTouch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Text
|
||||
if (type === 'text') {
|
||||
return (
|
||||
<TextCriterionField
|
||||
key={criterion.id}
|
||||
criterion={criterion}
|
||||
control={control}
|
||||
disabled={isReadOnly}
|
||||
onTouch={onCriterionTouch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Boolean
|
||||
if (type === 'boolean') {
|
||||
return (
|
||||
<BooleanCriterionField
|
||||
key={criterion.id}
|
||||
criterion={criterion}
|
||||
control={control}
|
||||
disabled={isReadOnly}
|
||||
onTouch={onCriterionTouch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -309,7 +496,10 @@ export function EvaluationForm({
|
||||
max={10}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
onValueChange={(v) => field.onChange(v[0])}
|
||||
onValueChange={(v) => {
|
||||
field.onChange(v[0])
|
||||
setGlobalScoreTouched(true)
|
||||
}}
|
||||
disabled={isReadOnly}
|
||||
className="py-4"
|
||||
/>
|
||||
@@ -318,7 +508,12 @@ export function EvaluationForm({
|
||||
<button
|
||||
key={num}
|
||||
type="button"
|
||||
onClick={() => !isReadOnly && field.onChange(num)}
|
||||
onClick={() => {
|
||||
if (!isReadOnly) {
|
||||
field.onChange(num)
|
||||
setGlobalScoreTouched(true)
|
||||
}
|
||||
}}
|
||||
disabled={isReadOnly}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full text-sm font-medium transition-colors',
|
||||
@@ -359,7 +554,12 @@ export function EvaluationForm({
|
||||
'flex-1 h-20',
|
||||
field.value && 'bg-green-600 hover:bg-green-700'
|
||||
)}
|
||||
onClick={() => !isReadOnly && field.onChange(true)}
|
||||
onClick={() => {
|
||||
if (!isReadOnly) {
|
||||
field.onChange(true)
|
||||
setDecisionTouched(true)
|
||||
}
|
||||
}}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-6 w-6" />
|
||||
@@ -372,7 +572,12 @@ export function EvaluationForm({
|
||||
'flex-1 h-20',
|
||||
!field.value && 'bg-red-600 hover:bg-red-700'
|
||||
)}
|
||||
onClick={() => !isReadOnly && field.onChange(false)}
|
||||
onClick={() => {
|
||||
if (!isReadOnly) {
|
||||
field.onChange(false)
|
||||
setDecisionTouched(true)
|
||||
}
|
||||
}}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<ThumbsDown className="mr-2 h-6 w-6" />
|
||||
@@ -484,16 +689,31 @@ export function EvaluationForm({
|
||||
)
|
||||
}
|
||||
|
||||
// Criterion field component
|
||||
function CriterionField({
|
||||
// Section header component (no input)
|
||||
function SectionHeaderField({ criterion }: { criterion: Criterion }) {
|
||||
return (
|
||||
<div className="border-b pb-2 pt-4 first:pt-0">
|
||||
<h3 className="font-semibold text-lg">{criterion.label}</h3>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{criterion.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Numeric criterion field component (original behavior)
|
||||
function NumericCriterionField({
|
||||
criterion,
|
||||
control,
|
||||
disabled,
|
||||
onTouch,
|
||||
}: {
|
||||
criterion: Criterion
|
||||
control: any
|
||||
disabled: boolean
|
||||
onTouch: (criterionId: string) => void
|
||||
}) {
|
||||
const scale = criterion.scale ?? 5
|
||||
return (
|
||||
<Controller
|
||||
name={`criterionScores.${criterion.id}`}
|
||||
@@ -510,7 +730,7 @@ function CriterionField({
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{field.value}/{criterion.scale}
|
||||
{field.value}/{scale}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -518,23 +738,31 @@ function CriterionField({
|
||||
<span className="text-xs text-muted-foreground w-4">1</span>
|
||||
<Slider
|
||||
min={1}
|
||||
max={criterion.scale}
|
||||
max={scale}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
onValueChange={(v) => field.onChange(v[0])}
|
||||
value={[typeof field.value === 'number' ? field.value : Math.ceil(scale / 2)]}
|
||||
onValueChange={(v) => {
|
||||
field.onChange(v[0])
|
||||
onTouch(criterion.id)
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-4">{criterion.scale}</span>
|
||||
<span className="text-xs text-muted-foreground w-4">{scale}</span>
|
||||
</div>
|
||||
|
||||
{/* Visual rating buttons */}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{Array.from({ length: criterion.scale }, (_, i) => i + 1).map((num) => (
|
||||
{Array.from({ length: scale }, (_, i) => i + 1).map((num) => (
|
||||
<button
|
||||
key={num}
|
||||
type="button"
|
||||
onClick={() => !disabled && field.onChange(num)}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
field.onChange(num)
|
||||
onTouch(criterion.id)
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
||||
@@ -556,6 +784,159 @@ function CriterionField({
|
||||
)
|
||||
}
|
||||
|
||||
// Text criterion field component
|
||||
function TextCriterionField({
|
||||
criterion,
|
||||
control,
|
||||
disabled,
|
||||
onTouch,
|
||||
}: {
|
||||
criterion: Criterion
|
||||
control: any
|
||||
disabled: boolean
|
||||
onTouch: (criterionId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Controller
|
||||
name={`criterionScores.${criterion.id}`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base font-medium">{criterion.label}</Label>
|
||||
{criterion.required && (
|
||||
<Badge variant="destructive" className="text-xs">Required</Badge>
|
||||
)}
|
||||
</div>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{criterion.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
value={typeof field.value === 'string' ? field.value : ''}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value)
|
||||
onTouch(criterion.id)
|
||||
}}
|
||||
placeholder={criterion.placeholder || 'Enter your response...'}
|
||||
rows={3}
|
||||
maxLength={criterion.maxLength ?? 1000}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{typeof field.value === 'string' ? field.value.length : 0}
|
||||
{criterion.maxLength ? ` / ${criterion.maxLength}` : ''} characters
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Boolean criterion field component
|
||||
function BooleanCriterionField({
|
||||
criterion,
|
||||
control,
|
||||
disabled,
|
||||
onTouch,
|
||||
}: {
|
||||
criterion: Criterion
|
||||
control: any
|
||||
disabled: boolean
|
||||
onTouch: (criterionId: string) => void
|
||||
}) {
|
||||
const trueLabel = criterion.trueLabel || 'Yes'
|
||||
const falseLabel = criterion.falseLabel || 'No'
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={`criterionScores.${criterion.id}`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base font-medium">{criterion.label}</Label>
|
||||
{criterion.required && (
|
||||
<Badge variant="destructive" className="text-xs">Required</Badge>
|
||||
)}
|
||||
</div>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{criterion.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={field.value === true ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'flex-1 h-12',
|
||||
field.value === true && 'bg-green-600 hover:bg-green-700'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
field.onChange(true)
|
||||
onTouch(criterion.id)
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-4 w-4" />
|
||||
{trueLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={field.value === false ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'flex-1 h-12',
|
||||
field.value === false && 'bg-red-600 hover:bg-red-700'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
field.onChange(false)
|
||||
onTouch(criterion.id)
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ThumbsDown className="mr-2 h-4 w-4" />
|
||||
{falseLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Progress indicator component
|
||||
function ProgressIndicator({
|
||||
percentage,
|
||||
criteriaDone,
|
||||
criteriaTotal,
|
||||
}: {
|
||||
percentage: number
|
||||
criteriaDone: number
|
||||
criteriaTotal: number
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Progress value={percentage} className="w-16 sm:w-24 h-2" />
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
<span className="sm:hidden">{percentage}%</span>
|
||||
<span className="hidden sm:inline">
|
||||
{criteriaDone} of {criteriaTotal} criteria scored · {percentage}%
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Autosave indicator component
|
||||
function AutosaveIndicator({
|
||||
status,
|
||||
|
||||
Reference in New Issue
Block a user