feat: Puente Mayúscula-Minúscula + TTS SpeechSynthesis + E2E fixes
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
const errorLabels: Record<string, string> = {
|
||||
"discriminacion-auditiva": "Discriminación auditiva",
|
||||
"confusion-letra": "Confusión de letra",
|
||||
"silaba-dificil": "Sílaba difícil",
|
||||
"omision-letra": "Omisión de letra",
|
||||
"inversion-letra": "Inversión de letra",
|
||||
}
|
||||
|
||||
const errorColors: Record<string, string> = {
|
||||
"discriminacion-auditiva": "bg-blue-200 text-blue-800",
|
||||
"confusion-letra": "bg-red-200 text-red-800",
|
||||
"silaba-dificil": "bg-yellow-200 text-yellow-800",
|
||||
"omision-letra": "bg-purple-200 text-purple-800",
|
||||
"inversion-letra": "bg-orange-200 text-orange-800",
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: Record<string, number>
|
||||
total: number
|
||||
}
|
||||
|
||||
export default function ErrorPatternsChart({ data, total }: Props) {
|
||||
const entries = Object.entries(data)
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Sin errores registrados. ¡Bien ahí!
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{entries.map(([key, count]) => {
|
||||
const pct = Math.round((count / total) * 100)
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="font-medium">{errorLabels[key] || key}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{count} ({pct}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
key === "discriminacion-auditiva"
|
||||
? "bg-blue-400"
|
||||
: key === "confusion-letra"
|
||||
? "bg-red-400"
|
||||
: key === "silaba-dificil"
|
||||
? "bg-yellow-400"
|
||||
: key === "omision-letra"
|
||||
? "bg-purple-400"
|
||||
: "bg-orange-400"
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
subtitle?: string
|
||||
variant?: "default" | "primary" | "success" | "accent"
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variants = {
|
||||
default: "bg-white",
|
||||
primary: "bg-primary/10 border-primary/20",
|
||||
success: "bg-success/10 border-success/20",
|
||||
accent: "bg-accent/10 border-accent/20",
|
||||
}
|
||||
|
||||
export function MetricCard({ title, value, subtitle, variant = "default", className }: MetricCardProps) {
|
||||
return (
|
||||
<div className={cn("rounded-2xl p-4 shadow-sm border border-border", variants[variant], className)}>
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className="text-3xl font-display font-bold text-foreground mt-1">{value}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
interface MilestoneData {
|
||||
skillCode: string
|
||||
reachedAt: string
|
||||
}
|
||||
|
||||
const skillLabels: Record<string, string> = {
|
||||
"vocal-a": "Letra A",
|
||||
"vocal-e": "Letra E",
|
||||
"vocal-i": "Letra I",
|
||||
"vocal-o": "Letra O",
|
||||
"vocal-u": "Letra U",
|
||||
"numero-1": "Número 1",
|
||||
"numero-2": "Número 2",
|
||||
"numero-3": "Número 3",
|
||||
"numero-4": "Número 4",
|
||||
"numero-5": "Número 5",
|
||||
}
|
||||
|
||||
export function MilestonesList({ data }: { data: MilestoneData[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground italic py-4">
|
||||
Todavía no hay logros
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.map((m) => (
|
||||
<div
|
||||
key={m.skillCode}
|
||||
className="bg-primary/10 border border-primary/20 rounded-xl px-3 py-2 text-sm font-display font-medium"
|
||||
>
|
||||
{skillLabels[m.skillCode] || m.skillCode}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
RadarChart,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
PolarRadiusAxis,
|
||||
Radar,
|
||||
ResponsiveContainer,
|
||||
} from "recharts"
|
||||
|
||||
interface SkillData {
|
||||
skillCode: string
|
||||
stability: number
|
||||
reps: number
|
||||
}
|
||||
|
||||
export function SkillMasteryChart({ data }: { data: SkillData[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground italic py-8">
|
||||
Sin skills todavía
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const chartData = data
|
||||
.filter((s) => s.reps > 0)
|
||||
.slice(0, 8)
|
||||
.map((s) => ({
|
||||
skill: s.skillCode.replace("vocal-", "").replace("numero-", "num "),
|
||||
mastery: Math.min(Math.round((s.stability / 100) * 100), 100),
|
||||
}))
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground italic py-8">
|
||||
Sin skills todavía
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={chartData} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<PolarGrid stroke="#E8E0D8" />
|
||||
<PolarAngleAxis dataKey="skill" tick={{ fontSize: 11, fill: "#6B7280" }} />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} />
|
||||
<Radar
|
||||
name="Maestría"
|
||||
dataKey="mastery"
|
||||
stroke="#8CB8A0"
|
||||
fill="#8CB8A0"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
} from "recharts"
|
||||
|
||||
interface WeeklyData {
|
||||
date: string
|
||||
minutes: number
|
||||
}
|
||||
|
||||
export function WeeklyActivityChart({ data }: { data: WeeklyData[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground italic py-8">
|
||||
No hay actividad esta semana
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const daysOfWeek = ["dom", "lun", "mar", "mié", "jue", "vie", "sáb"]
|
||||
const chartData = data.map((d) => {
|
||||
const day = new Date(d.date).getDay()
|
||||
return {
|
||||
name: daysOfWeek[day],
|
||||
minutos: d.minutes,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="w-full h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E8E0D8" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12, fill: "#6B7280" }} />
|
||||
<YAxis tick={{ fontSize: 12, fill: "#6B7280" }} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: 12, border: "1px solid #E8E0D8" }}
|
||||
formatter={(value: number) => [`${value} min`, "Actividad"]}
|
||||
/>
|
||||
<Bar dataKey="minutos" fill="#8CB8A0" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user