import { useState, type ReactNode } from 'react'; import { BookOpen, CalendarCheck, CheckCircle2, Clock, Flame, MessageCircle, Target, type LucideIcon } from 'lucide-react'; import { api, type ReportDay, type ReportTotals, type WeeklyReport } from '@/api'; import { useAppData } from '@/state/AppData'; import { useAsync } from '@/lib/useAsync'; import { SeyarMascot } from '@/mascots/SeyarMascot'; import type { SeyarMood } from '@/mascots/seyar'; import { ErrorState, LoadingState, Screen, ScreenBody, ScreenHeader } from '@/components/ui'; import { toFa } from '@/lib/format'; /* * «گزارشِ والدین». تا ۱۴۰۵-۰۷ والد حسابِ جدا و کدِ دعوت داشت؛ به خواستِ سینیور * حذف شد و والد همین صفحه را در حسابِ خودِ دانش‌آموز می‌بیند. همه‌ی عددها از * GET /user/weekly-report می‌آیند: ۷ روزِ اخیر در کنارِ ۷ روزِ قبلش. * * رنگِ نمودارها با validate_palette (مهارتِ dataviz) سنجیده شده: هر جفت برای * کوررنگیِ قرمز-سبز هم جداست. نارنجی و طلایی کنتراستِ کمتر از ۳:۱ با سفید دارند، * برای همین عددِ هر روز با زدن روی ستون و در جدولِ پایین هم نوشته می‌شود. */ const THIS_WEEK = '#4C5FD5'; const LAST_WEEK = '#FF7A3D'; const CORRECT = '#2E9E73'; const WRONG = '#E8A400'; const WEEKDAY_SHORT = ['ی', 'د', 'س', 'چ', 'پ', 'ج', 'ش']; /** «2026-10-01» به تاریخِ محلی؛ new Date(iso) آن را UTC می‌خواند و روز جابه‌جا می‌شد. */ function localDate(iso: string): Date { const [y, m, d] = iso.split('-').map(Number); return new Date(y, m - 1, d); } const longDay = (iso: string) => new Intl.DateTimeFormat('fa-IR-u-ca-persian', { weekday: 'long', day: 'numeric', month: 'long' }).format(localDate(iso)); const percent = (part: number, whole: number) => (whole ? Math.round((part / whole) * 100) : 0); export function ReportScreen() { const { profile } = useAppData(); const report = useAsync(() => api.weeklyReport(), []); return ( {report.loading ? ( ) : report.error || !report.data ? ( ) : ( )} ); } function Report({ data, name }: { data: WeeklyReport; name: string }) { const { thisWeek: now, lastWeek: before } = data; return ( <>
{/* آزمونِ جزیره و آزمونِ تک‌درس هر دو «تمام کردن» اند؛ جدا شمردنشان برای والد فرقی نمی‌کند. */}
{data.streak.longest > 0 && (
زنجیره‌ی فعلی {toFa(data.streak.current)} روز؛ بیشترین {toFa(data.streak.longest)} روز
)} d.minutes) }, { label: 'هفته‌ی پیش', color: LAST_WEEK, values: data.previousDays.map((d) => d.minutes) }, ]} note="زمان تقریبی است: از فاصله‌ی جواب‌ها و پرسش‌ها حساب می‌شود، پس خواندنِ درس بدونِ جواب دادن در آن نیست." /> d.correct) }, { label: 'نادرست', color: WRONG, values: data.days.map((d) => d.questions - d.correct) }, ]} /> ); } /* ─── جمله‌ی بالای صفحه ─────────────────────────────────────────────────────── */ /** مقایسه با زمانِ تمرین؛ تغییرِ کمتر از ۱۰٪ یا ۳ دقیقه «مثلِ هفته‌ی پیش» است، نه بالا و پایینِ اتفاقی. */ function verdict(now: ReportTotals, before: ReportTotals, name: string): { mood: SeyarMood; text: string } { const who = name ? `${name} ` : ''; const diff = now.minutes - before.minutes; const meaningful = Math.abs(diff) >= 3 && Math.abs(diff) >= before.minutes * 0.1; if (!now.minutes && !before.minutes) { return { mood: 'idle', text: 'این هفته هنوز تمرینی ثبت نشده. یه جزیره‌ی کوتاه شروعِ خوبیه!' }; } if (!before.minutes) { return { mood: 'excited', text: `آفرین! ${who}این هفته شروع کرده و ${toFa(now.activeDays)} روز تمرین کرده.` }; } if (meaningful && diff > 0) { return { mood: 'excited', text: `آفرین! ${who}این هفته حدودِ ${toFa(diff)} دقیقه بیشتر از هفته‌ی پیش تمرین کرده.` }; } if (meaningful && diff < 0) { return { mood: 'idle', text: `این هفته حدودِ ${toFa(-diff)} دقیقه کمتر از هفته‌ی پیش تمرین شده. یه تشویقِ کوچیک کمک می‌کنه!` }; } return { mood: 'happy', text: `${who}مثلِ هفته‌ی پیش منظم تمرین کرده؛ همین‌طور ادامه بده!` }; } function Verdict({ now, before, name }: { now: ReportTotals; before: ReportTotals; name: string }) { const { mood, text } = verdict(now, before, name); return (

{text}

); } /* ─── کاشی‌های عدد ─────────────────────────────────────────────────────────── */ type Delta = { text: string; up: boolean | null } | null; function change(now: number, before: number, unit: string): Delta { const diff = now - before; const u = unit ? ` ${unit}` : ''; // صفر در برابرِ صفر خبری ندارد؛ زیرِ همه‌ی کاشی‌های حسابِ تازه «مثلِ هفته‌ی پیش» می‌نشست. if (!now && !before) return null; if (diff === 0) return { text: 'مثلِ هفته‌ی پیش', up: null }; return { text: `${toFa(Math.abs(diff))}${u} ${diff > 0 ? 'بیشتر' : 'کمتر'} از هفته‌ی پیش`, up: diff > 0 }; } function Stat({ icon: Icon, label, value, delta }: { icon: LucideIcon; label: string; value: string; delta: Delta }) { return (
{label} {value} {delta && ( {delta.up === null ? '' : delta.up ? '▲ ' : '▼ '} {delta.text} )}
); } /* ─── نمودارِ ستونی ─────────────────────────────────────────────────────────── */ interface Series { label: string; color: string; values: number[]; } /** سقفِ گرد برای محورِ عمودی: ۱، ۲، ۵ × ۱۰ⁿ. */ function niceMax(value: number): number { if (value <= 0) return 1; const pow = 10 ** Math.floor(Math.log10(value)); return [1, 2, 5, 10].map((m) => m * pow).find((m) => m >= value) ?? value; } const PLOT_HEIGHT = 128; function BarChart({ title, description, unit, days, series, mode, note, }: { title: string; /** یک جمله برای والد: این نمودار چه می‌گوید. */ description: string; unit: string; days: ReportDay[]; series: Series[]; /** grouped: ستون‌های کنارِ هم (مقایسه)؛ stacked: روی هم (بخش‌های یک کل). */ mode: 'grouped' | 'stacked'; note?: ReactNode; }) { const [selected, setSelected] = useState(days.length - 1); const totals = days.map((_, i) => series.reduce((sum, s) => sum + s.values[i], 0)); const peak = mode === 'stacked' ? Math.max(...totals) : Math.max(...series.flatMap((s) => s.values)); const max = niceMax(peak); const empty = peak === 0; const height = (v: number) => (v / max) * PLOT_HEIGHT; return (

{title}

{description}

{series.map((s) => ( {s.label} ))}
{/* خواندنِ روزِ انتخاب‌شده؛ همان کارِ tooltip را روی گوشی می‌کند. */}

{longDay(days[selected].date)}:{' '} {series.map((s) => `${s.label} ${toFa(s.values[selected])} ${unit}`).join('، ')}

{/* خطِ راهنمای سقف و نیمه؛ کم‌رنگ تا با ستون‌ها رقابت نکند. */} {[1, 0.5].map((f) => (
{/* نیمه‌ی سقفِ ۱ یا ۵ اعشاری است («۰.۵»)؛ فقط عددِ صحیح نوشته می‌شود. */} {!empty && Number.isInteger(max * f) && ( {toFa(max * f)} )}
))}
{empty && (

هنوز چیزی ثبت نشده

)}
{days.map((day, i) => { const isToday = i === days.length - 1; return ( ); })}
{note &&

{note}

}
); } /* ─── جدولِ روزها ───────────────────────────────────────────────────────────── */ /** همان عددهای نمودارها، برای کسی که رنگ‌ها را از هم جدا نمی‌بیند یا عددِ دقیق می‌خواهد. */ function DayTable({ days, previous }: { days: ReportDay[]; previous: ReportDay[] }) { return (
عددهای هر روز {days.map((d, i) => ( ))}
روز دقیقه هفته‌ی پیش سوال درست
{longDay(d.date)} {toFa(d.minutes)} {toFa(previous[i]?.minutes ?? 0)} {toFa(d.questions)} {toFa(d.correct)}
); }