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';
import { useCompanion } from '@/state/companion';
/*
* «گزارشِ والدین». تا ۱۴۰۵-۰۷ والد حسابِ جدا و کدِ دعوت داشت؛ به خواستِ سینیور
* حذف شد و والد همین صفحه را در حسابِ خودِ دانشآموز میبیند. همهی عددها از
* 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(), [], { key: 'weekly-report' });
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);
const companion = useCompanion().char;
return (
);
}
/* ─── کاشیهای عدد ─────────────────────────────────────────────────────────── */
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 (
setSelected(i)}
aria-label={`${longDay(day.date)}: ${series.map((s) => `${s.label} ${toFa(s.values[i])} ${unit}`).join('، ')}`}
aria-pressed={selected === i}
className={`flex flex-1 flex-col items-center justify-end rounded-xl ${selected === i ? 'bg-soft' : ''}`}
>
{mode === 'grouped' ? (
series.map((s) => (
))
) : (
// بخشها از پایین روی هم؛ فاصلهی سفیدِ ۲ پیکسلی جدایشان میکند.
{series.map((s, k) =>
s.values[i] ? (
!n.values[i]) ? 'rounded-t' : ''}
style={{ height: Math.max(height(s.values[i]) - 2, 3), backgroundColor: s.color }}
/>
) : null,
)}
)}
{isToday ? 'امروز' : WEEKDAY_SHORT[localDate(day.date).getDay()]}
);
})}
{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)}
))}
);
}