fix: QA report bugs BUG-01 through BUG-11 resolved - streak modal close btn, calculator crash guard, AI chat auto-scroll, leaderboard zero-streak fix, name annotation cleanup everywhere, CBT score display colors, PrepPilot difficulty badge layout

This commit is contained in:
2026-09-19 22:34:45 +05:30
parent afab06d33c
commit 45f2f82eaf
11 changed files with 88 additions and 26 deletions

Binary file not shown.

View File

@@ -8,6 +8,7 @@ from uuid import UUID
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
from pydantic import BaseModel from pydantic import BaseModel
import os import os
import re
from database import init_db, get_session from database import init_db, get_session
import models import models
@@ -838,14 +839,14 @@ def get_leaderboard(
# Calculate real user's stats # Calculate real user's stats
user_score = 0 user_score = 0
user_accuracy = "0%" user_accuracy = "0%"
user_streak = 1 user_streak = 0 # BUG-04 fix: real zero for new users (no artificial min floor)
if requesting_user: if requesting_user:
if str(requesting_user.id) in DEMO_USER_UUIDS: if str(requesting_user.id) in DEMO_USER_UUIDS:
user_score = 9250 user_score = 9250
user_accuracy = "88.5%" user_accuracy = "88.5%"
user_streak = requesting_user.streak_count or 25 user_streak = requesting_user.streak_count or 25
else: else:
user_streak = max(1, requesting_user.streak_count) user_streak = requesting_user.streak_count or 0 # BUG-04 fix: 0 is valid
user_attempts = session.exec( user_attempts = session.exec(
select(models.Attempt).where( select(models.Attempt).where(
models.Attempt.user_id == requesting_user.id, models.Attempt.user_id == requesting_user.id,
@@ -871,10 +872,11 @@ def get_leaderboard(
"isCurrentUser": False, "isCurrentUser": False,
}) })
# Add the requesting real user # Add the requesting real user — strip role annotations from display name
if requesting_user: if requesting_user:
clean_name = re.sub(r'\s*\([^)]*\)\s*$', '', requesting_user.name).strip()
entries.append({ entries.append({
"name": requesting_user.name, "name": clean_name,
"score": user_score, "score": user_score,
"accuracy": user_accuracy, "accuracy": user_accuracy,
"streak": user_streak, "streak": user_streak,

View File

@@ -185,10 +185,12 @@ export default function AIDailyPracticePage() {
</div> </div>
<h3 className="font-bold text-lg leading-tight group-hover:text-[#00F0FF] transition-colors"> <h3 className="font-bold text-lg leading-tight group-hover:text-[#00F0FF] transition-colors">
{cleanTitle(dpp.title)} {cleanTitle(dpp.title)}
<span className={`ml-2 text-[10px] uppercase px-2 py-0.5 rounded-md border font-bold tracking-wider ${getDifficultyColor(dpp.difficulty)}`}> </h3>
<div className="mt-1">
<span className={`text-[10px] uppercase px-2 py-0.5 rounded-md border font-bold tracking-wider ${getDifficultyColor(dpp.difficulty)}`}>
{dpp.difficulty} {dpp.difficulty}
</span> </span>
</h3> </div>
<div className="text-sm text-muted-foreground mt-2 font-medium"> <div className="text-sm text-muted-foreground mt-2 font-medium">
{count} Questions • {dpp.total_time} mins {count} Questions • {dpp.total_time} mins
{dpp.scheduled_date && ( {dpp.scheduled_date && (

View File

@@ -411,7 +411,14 @@ export default function CBTPage() {
</div> </div>
) : attempts.filter(a => a.source_type === "Test" && exams.some(e => e.id === a.source_id)).map((attempt, i) => { ) : attempts.filter(a => a.source_type === "Test" && exams.some(e => e.id === a.source_id)).map((attempt, i) => {
const exam = exams.find(e => e.id === attempt.source_id); const exam = exams.find(e => e.id === attempt.source_id);
const scoreCalc = (attempt.score * 4) - ((attempt.total_questions - attempt.score) * 1); // rough calc for display // BUG-07 fix: use actual final_score from backend if available, else derive correctly
// backend stores final_score = (correct*4) - (incorrect*1)
const correctCount = attempt.answers ? attempt.answers.filter((a: any) => a.is_correct).length : attempt.score;
const incorrectCount = attempt.answers ? (attempt.answers.filter((a: any) => a.selected_answer !== null).length - correctCount) : 0;
const scoreCalc = attempt.final_score ?? ((correctCount * 4) - (incorrectCount * 1));
const maxScore = attempt.total_questions * 4;
const scorePercent = maxScore > 0 ? (scoreCalc / maxScore) * 100 : 0;
const scoreColor = scorePercent >= 60 ? 'text-green-500' : scorePercent >= 35 ? 'text-amber-500' : 'text-red-500';
return ( return (
<Card key={i} className="bg-secondary/20"> <Card key={i} className="bg-secondary/20">
@@ -424,8 +431,8 @@ export default function CBTPage() {
<div className="text-right"> <div className="text-right">
<div className="text-xs md:text-sm text-muted-foreground font-bold uppercase tracking-wider">Score</div> <div className="text-xs md:text-sm text-muted-foreground font-bold uppercase tracking-wider">Score</div>
<div className="font-bold text-base md:text-lg"> <div className="font-bold text-base md:text-lg">
<span className="text-foreground">{scoreCalc}</span> <span className={scoreColor}>{scoreCalc}</span>
<span className="text-muted-foreground">/{attempt.total_questions * 4}</span> <span className="text-muted-foreground">/{maxScore}</span>
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">

View File

@@ -49,7 +49,11 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
return () => document.removeEventListener("mousedown", handleClickOutside) return () => document.removeEventListener("mousedown", handleClickOutside)
}, []) }, [])
const userInitials = user ? user.name.split(" ").filter(n => /^[a-zA-Z]/.test(n)).slice(0, 2).map(n => n[0]).join("").toUpperCase() : "DS" // BUG-05 fix: strip any parenthetical role annotations from display names
// e.g. "Rahul (AI Coach)" → "Rahul", "Priya (NEET Aspirant)" → "Priya"
const cleanDisplayName = (name: string) => name.replace(/\s*\([^)]*\)\s*$/, '').trim();
const userInitials = user ? cleanDisplayName(user.name).split(" ").filter(n => /^[a-zA-Z]/.test(n)).slice(0, 2).map(n => n[0]).join("").toUpperCase() : "DS"
const targetExamLabel = user?.target_exam === "JEE_Main" ? "JEE" : user?.target_exam === "JEE_Advanced" ? "JEE Advanced" : user?.target_exam === "NEET" ? "NEET" : "NEET/JEE" const targetExamLabel = user?.target_exam === "JEE_Main" ? "JEE" : user?.target_exam === "JEE_Advanced" ? "JEE Advanced" : user?.target_exam === "NEET" ? "NEET" : "NEET/JEE"
// Trial logic removed as requested // Trial logic removed as requested
@@ -251,7 +255,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
{dropdownOpen && user && ( {dropdownOpen && user && (
<div className="absolute bottom-full left-0 w-full mb-2 bg-background border border-border rounded-xl shadow-xl shadow-black/20 overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200 z-50"> <div className="absolute bottom-full left-0 w-full mb-2 bg-background border border-border rounded-xl shadow-xl shadow-black/20 overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200 z-50">
<div className="px-4 py-3 border-b border-border bg-secondary/30"> <div className="px-4 py-3 border-b border-border bg-secondary/30">
<p className="text-sm font-bold text-foreground truncate">{user.name}</p> <p className="text-sm font-bold text-foreground truncate">{cleanDisplayName(user.name)}</p>
<p className="text-xs text-muted-foreground truncate">{user.email || user.phone}</p> <p className="text-xs text-muted-foreground truncate">{user.email || user.phone}</p>
{/* Trial info in dropdown */} {/* Trial info in dropdown */}
{trialInfo.isTrialActive && ( {trialInfo.isTrialActive && (
@@ -295,7 +299,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
{userInitials} {userInitials}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-bold leading-none truncate">{user ? user.name.split(" ")[0] : "Loading..."}</p> <p className="text-sm font-bold leading-none truncate">{user ? cleanDisplayName(user.name).split(" ")[0] : "Loading..."}</p>
<div className="mt-1"> <div className="mt-1">
{trialInfo.isTrialActive ? ( {trialInfo.isTrialActive ? (
<span className="inline-flex items-center text-[10px] font-bold text-primary"> <span className="inline-flex items-center text-[10px] font-bold text-primary">
@@ -319,7 +323,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
<header className="h-16 border-b border-border bg-background/95 backdrop-blur-md flex items-center justify-between px-8 shrink-0 z-40"> <header className="h-16 border-b border-border bg-background/95 backdrop-blur-md flex items-center justify-between px-8 shrink-0 z-40">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<h2 className="font-bold text-lg hidden sm:block"> <h2 className="font-bold text-lg hidden sm:block">
{user ? `Welcome back, ${user.name.split(" ")[0]}!` : "Welcome back!"} {user ? `Welcome back, ${cleanDisplayName(user.name).split(" ")[0]}!` : "Welcome back!"}
</h2> </h2>
{user && ( {user && (
<Badge variant="outline" className="bg-cyan-500/10 text-cyan-700 dark:text-cyan-400 border-cyan-500/20 font-bold tracking-wide uppercase px-2.5 py-0.5 animate-pulse shadow-none dark:shadow-[0_0_10px_-2px_#00F0FF]"> <Badge variant="outline" className="bg-cyan-500/10 text-cyan-700 dark:text-cyan-400 border-cyan-500/20 font-bold tracking-wide uppercase px-2.5 py-0.5 animate-pulse shadow-none dark:shadow-[0_0_10px_-2px_#00F0FF]">

View File

@@ -14,6 +14,9 @@ export default function LeaderboardPage() {
const [rankings, setRankings] = useState<any[]>([]) const [rankings, setRankings] = useState<any[]>([])
const [loadingRankings, setLoadingRankings] = useState(true) const [loadingRankings, setLoadingRankings] = useState(true)
// Strip parenthetical role annotations from user names (e.g. "Rahul (AI Coach)" → "Rahul")
const cleanDisplayName = (name: string) => name.replace(/\s*\([^)]*\)\s*$/, '').trim();
useEffect(() => { useEffect(() => {
async function fetchLeaderboard() { async function fetchLeaderboard() {
setLoadingRankings(true) setLoadingRankings(true)
@@ -39,7 +42,7 @@ export default function LeaderboardPage() {
rank: rankings.length + 1, rank: rankings.length + 1,
score: 0, score: 0,
streak: user?.streak_count || 1, streak: user?.streak_count || 1,
name: user?.name || "You" name: user?.name ? cleanDisplayName(user.name) : "You"
} }
return ( return (
@@ -121,7 +124,7 @@ export default function LeaderboardPage() {
</div> </div>
<div> <div>
<p className={`font-bold ${student.isCurrentUser ? 'text-[#00F0FF]' : 'text-foreground'}`}> <p className={`font-bold ${student.isCurrentUser ? 'text-[#00F0FF]' : 'text-foreground'}`}>
{student.isCurrentUser ? (isAnonymous ? "Anonymous User" : `${student.name} (You)`) : student.name} {student.isCurrentUser ? (isAnonymous ? "Anonymous User" : `${cleanDisplayName(student.name)} (You)`) : cleanDisplayName(student.name)}
</p> </p>
<p className="text-xs text-muted-foreground flex items-center gap-1"> <p className="text-xs text-muted-foreground flex items-center gap-1">
<Shield size={12} /> Accuracy: {student.accuracy} <Shield size={12} /> Accuracy: {student.accuracy}

View File

@@ -85,7 +85,7 @@ export default function RankPredictorPage() {
</p> </p>
{user && ( {user && (
<div className="inline-flex items-center gap-2 px-3 py-1 bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 rounded-xl text-xs font-bold mt-1"> <div className="inline-flex items-center gap-2 px-3 py-1 bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 rounded-xl text-xs font-bold mt-1">
<UserCheck size={14} /> Profile Connected: {user.name} ({user.target_exam?.replace("_", " ")}) <UserCheck size={14} /> Profile Connected: {user.name.replace(/\s*\([^)]*\)\s*$/, '').trim()} ({user.target_exam?.replace("_", " ")})
</div> </div>
)} )}

View File

@@ -32,7 +32,9 @@ export default function SettingsPage() {
setAvatarUrl(localStorage.getItem(`user_avatar_${user.id}`) || null); setAvatarUrl(localStorage.getItem(`user_avatar_${user.id}`) || null);
if (user.name) { if (user.name) {
const parts = user.name.split(" "); // BUG-06 fix: strip parenthetical role annotations before splitting into first/last
const cleanName = user.name.replace(/\s*\([^)]*\)\s*$/, '').trim();
const parts = cleanName.split(" ");
setFirstName(parts[0] || ""); setFirstName(parts[0] || "");
setLastName(parts.slice(1).join(" ") || ""); setLastName(parts.slice(1).join(" ") || "");
} }

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { useState } from "react" import { useState, useRef, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -39,6 +39,15 @@ export function AITutorChat() {
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
// BUG-03 fix: ref to anchor bottom of chat for auto-scroll
const bottomRef = useRef<HTMLDivElement>(null);
// Auto-scroll to latest message whenever messages change
useEffect(() => {
if (bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [messages]);
const handleSend = () => { const handleSend = () => {
if (!input.trim()) return; if (!input.trim()) return;
@@ -118,6 +127,8 @@ export function AITutorChat() {
</div> </div>
</div> </div>
))} ))}
{/* BUG-03 fix: invisible scroll anchor */}
<div ref={bottomRef} />
</CardContent> </CardContent>
<CardFooter className="p-4 border-t border-border bg-background shrink-0"> <CardFooter className="p-4 border-t border-border bg-background shrink-0">

View File

@@ -20,8 +20,17 @@ export function Calculator({ isOpen, onClose }: CalculatorProps) {
const handleCalculate = () => { const handleCalculate = () => {
try { try {
if (!display.trim()) return;
// BUG-02 fix: strip trailing operator(s) so "5*", "9+", "/2" (leading slash) don't crash
const sanitized = display
.replace(/[+\-*/^]+$/, "") // remove trailing operators
.replace(/^[*/^]+/, ""); // remove leading * / ^ (but allow leading minus for negatives)
if (!sanitized.trim()) return;
// Map functions to JS Math // Map functions to JS Math
let mappedExp = display let mappedExp = sanitized
.replace(/sin\(/g, 'Math.sin(') .replace(/sin\(/g, 'Math.sin(')
.replace(/cos\(/g, 'Math.cos(') .replace(/cos\(/g, 'Math.cos(')
.replace(/tan\(/g, 'Math.tan(') .replace(/tan\(/g, 'Math.tan(')
@@ -30,8 +39,15 @@ export function Calculator({ isOpen, onClose }: CalculatorProps) {
.replace(/sqrt\(/g, 'Math.sqrt(') .replace(/sqrt\(/g, 'Math.sqrt(')
.replace(/\^/g, '**'); .replace(/\^/g, '**');
// eslint-disable-next-line no-new-func
const result = new Function('return ' + mappedExp)(); const result = new Function('return ' + mappedExp)();
// Guard against NaN / Infinity
if (result === null || result === undefined || !isFinite(result) || isNaN(result)) {
setDisplay("Error");
return;
}
// Handle floating point precision issues simply // Handle floating point precision issues simply
if (typeof result === 'number' && !Number.isInteger(result)) { if (typeof result === 'number' && !Number.isInteger(result)) {
setDisplay(String(parseFloat(result.toFixed(6)))); setDisplay(String(parseFloat(result.toFixed(6))));
@@ -43,6 +59,7 @@ export function Calculator({ isOpen, onClose }: CalculatorProps) {
} }
}; };
const handleClear = () => setDisplay(""); const handleClear = () => setDisplay("");
const handleBackspace = () => setDisplay((prev) => prev.slice(0, -1)); const handleBackspace = () => setDisplay((prev) => prev.slice(0, -1));

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Flame, Sparkles } from "lucide-react"; import { Flame, Sparkles, X } from "lucide-react";
import { useActiveUser } from "@/hooks/useActiveUser"; import { useActiveUser } from "@/hooks/useActiveUser";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
@@ -42,7 +42,7 @@ export function StreakOnboardingModal() {
wishMessage = `Incredible! A ${streak}-day streak shows true championship discipline. You are outperforming the competition. Let's make today count!`; wishMessage = `Incredible! A ${streak}-day streak shows true championship discipline. You are outperforming the competition. Let's make today count!`;
} }
const handleEnter = () => { const handleDismiss = () => {
sessionStorage.setItem(`hasSeenStreakPopup_${user.id}`, "true"); sessionStorage.setItem(`hasSeenStreakPopup_${user.id}`, "true");
setIsOpen(false); setIsOpen(false);
}; };
@@ -57,10 +57,24 @@ export function StreakOnboardingModal() {
} }
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/85 backdrop-blur-md animate-in fade-in duration-300"> // BUG-01 fix: backdrop click dismisses modal
<div
{/* Centered Modal Card Container covering 80% screen size (w-[80vw] h-[80vh]) */} className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md animate-in fade-in duration-300"
<div className="bg-background border-none shadow-[0_0_60px_-10px_rgba(0,240,255,0.25)] rounded-3xl w-[80vw] max-w-5xl h-[80vh] max-h-[800px] relative overflow-hidden animate-in zoom-in-95 slide-in-from-bottom-4 duration-300 z-20 pointer-events-auto mx-4 flex flex-col justify-start"> onClick={handleDismiss}
>
{/* Centered Modal Card — stop propagation so inner clicks don't close */}
<div
className="bg-background border border-border shadow-[0_0_60px_-10px_rgba(0,240,255,0.25)] rounded-3xl w-[90vw] max-w-5xl h-[85vh] max-h-[800px] relative overflow-hidden animate-in zoom-in-95 slide-in-from-bottom-4 duration-300 z-20 pointer-events-auto mx-4 flex flex-col justify-start"
onClick={(e) => e.stopPropagation()}
>
{/* BUG-01 fix: X close button */}
<button
onClick={handleDismiss}
className="absolute top-4 right-4 z-30 size-9 flex items-center justify-center rounded-full bg-background/80 border border-border text-muted-foreground hover:text-foreground hover:bg-secondary transition-all duration-200 shadow-sm"
aria-label="Close streak modal"
>
<X size={18} />
</button>
{/* Subtle background glow */} {/* Subtle background glow */}
<div className="absolute -right-20 -top-20 size-48 bg-[#00F0FF]/5 rounded-full blur-3xl pointer-events-none" /> <div className="absolute -right-20 -top-20 size-48 bg-[#00F0FF]/5 rounded-full blur-3xl pointer-events-none" />
@@ -112,7 +126,7 @@ export function StreakOnboardingModal() {
{/* Action Button */} {/* Action Button */}
<div className="pt-2 max-w-sm w-full mx-auto"> <div className="pt-2 max-w-sm w-full mx-auto">
<Button <Button
onClick={handleEnter} onClick={handleDismiss}
className="w-full h-12 rounded-xl text-base font-black bg-[#00F0FF] text-black hover:bg-[#00F0FF]/90 shadow-[0_0_15px_-3px_#00F0FF] hover:shadow-[0_0_25px_#00F0FF] hover:-translate-y-0.5 transition-all duration-300" className="w-full h-12 rounded-xl text-base font-black bg-[#00F0FF] text-black hover:bg-[#00F0FF]/90 shadow-[0_0_15px_-3px_#00F0FF] hover:shadow-[0_0_25px_#00F0FF] hover:-translate-y-0.5 transition-all duration-300"
> >
Enter Workspace Enter Workspace