Files
MathApp_frontend/src/screens/ReportScreen.tsx
AliReza de7cd0bfa8 parent report page with two charts, Jalali birth date picker, user ID and invoices in profile
- the parents section is gone: no parent login, invite codes or "I'm a parent" button;
  "گزارشِ والدین" in the profile shows the week against the previous one (praise line,
  six tiles, minutes-per-day and right/wrong-per-day charts, a table of the numbers)
- birth date as day / month / year selects (Jalali, leap Esfand handled) instead of age;
  adults (19+) are asked which grade's math they want to learn
- user ID under the name, copyable
- "پرداخت‌ها و فاکتورها": every order with invoice number, discount, amount and reference code
- tutor: when the free daily quota runs out, a button to the subscription page instead of "retry"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:38:43 +03:30

347 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<Screen>
<ScreenHeader
title="گزارشِ والدین"
subtitle={`پیشرفتِ ${profile.name || 'دانش‌آموز'} در ۷ روزِ اخیر`}
color={THIS_WEEK}
back
mascot={{ char: 'sigma', mood: 'happy', size: 56 }}
/>
<ScreenBody className="flex flex-col gap-4">
{report.loading ? (
<LoadingState />
) : report.error || !report.data ? (
<ErrorState error={report.error} onRetry={report.reload} />
) : (
<Report data={report.data} name={profile.name} />
)}
</ScreenBody>
</Screen>
);
}
function Report({ data, name }: { data: WeeklyReport; name: string }) {
const { thisWeek: now, lastWeek: before } = data;
return (
<>
<Verdict now={now} before={before} name={name} />
<div className="grid grid-cols-2 gap-2.5">
<Stat icon={Clock} label="زمانِ تمرین" value={`حدود ${toFa(now.minutes)} دقیقه`} delta={change(now.minutes, before.minutes, 'دقیقه')} />
<Stat icon={CalendarCheck} label="روزهای فعال" value={`${toFa(now.activeDays)} از ۷ روز`} delta={change(now.activeDays, before.activeDays, 'روز')} />
<Stat icon={Target} label="سوالِ حل‌شده" value={toFa(now.questions)} delta={change(now.questions, before.questions, '')} />
<Stat
icon={CheckCircle2}
label="درصدِ جوابِ درست"
value={now.questions ? `${toFa(percent(now.correct, now.questions))}٪` : '—'}
delta={now.questions && before.questions ? change(percent(now.correct, now.questions), percent(before.correct, before.questions), '') : null}
/>
{/* آزمونِ جزیره و آزمونِ تک‌درس هر دو «تمام کردن» اند؛ جدا شمردنشان برای والد فرقی نمی‌کند. */}
<Stat icon={BookOpen} label="جزیره و درسِ تمام‌شده" value={toFa(now.islands + now.lessons)} delta={change(now.islands + now.lessons, before.islands + before.lessons, '')} />
<Stat icon={MessageCircle} label="سوال از دستیار" value={toFa(now.aiQuestions)} delta={change(now.aiQuestions, before.aiQuestions, '')} />
</div>
{data.streak.longest > 0 && (
<div className="flex items-center gap-2 rounded-2xl bg-[#FFF1E6] px-4 py-3 text-xs text-body">
<Flame size={16} className="text-orange" fill="#FF7A3D" />
زنجیره‌ی فعلی <b className="text-ink">{toFa(data.streak.current)}</b> روز؛ بیشترین <b className="text-ink">{toFa(data.streak.longest)}</b> روز
</div>
)}
<BarChart
title="زمانِ تمرین در هر روز"
description="ستونِ آبی هر روزِ این هفته است و ستونِ نارنجیِ کنارش همان روز در هفته‌ی پیش؛ آبیِ بلندتر یعنی بیشتر از هفته‌ی پیش."
unit="دقیقه"
days={data.days}
mode="grouped"
series={[
{ label: 'این هفته', color: THIS_WEEK, values: data.days.map((d) => d.minutes) },
{ label: 'هفته‌ی پیش', color: LAST_WEEK, values: data.previousDays.map((d) => d.minutes) },
]}
note="زمان تقریبی است: از فاصله‌ی جواب‌ها و پرسش‌ها حساب می‌شود، پس خواندنِ درس بدونِ جواب دادن در آن نیست."
/>
<BarChart
title="سوال‌های حل‌شده در هر روز"
description="کلِ ستون تعدادِ سوال‌هایی است که آن روز جواب داده؛ بخشِ سبز جواب‌های درست است و بخشِ طلایی نادرست."
unit="سوال"
days={data.days}
mode="stacked"
series={[
{ label: 'درست', color: CORRECT, values: data.days.map((d) => d.correct) },
{ label: 'نادرست', color: WRONG, values: data.days.map((d) => d.questions - d.correct) },
]}
/>
<DayTable days={data.days} previous={data.previousDays} />
</>
);
}
/* ─── جمله‌ی بالای صفحه ─────────────────────────────────────────────────────── */
/** مقایسه با زمانِ تمرین؛ تغییرِ کمتر از ۱۰٪ یا ۳ دقیقه «مثلِ هفته‌ی پیش» است، نه بالا و پایینِ اتفاقی. */
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 (
<div className="flex items-center gap-3 rounded-3xl border border-line bg-white p-4">
<SeyarMascot char="sigma" mood={mood} size={52} />
<p className="flex-1 text-sm font-bold leading-7 text-ink">{text}</p>
</div>
);
}
/* ─── کاشی‌های عدد ─────────────────────────────────────────────────────────── */
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 (
<div className="flex flex-col gap-1 rounded-2xl border border-line bg-white px-3 py-3">
<span className="flex items-center gap-1.5 text-[11px] font-bold text-muted">
<Icon size={13} /> {label}
</span>
<span className="text-lg font-black text-ink">{value}</span>
{delta && (
<span className={`text-[11px] font-bold ${delta.up ? 'text-[#2E7D5B]' : 'text-muted'}`}>
{delta.up === null ? '' : delta.up ? '▲ ' : '▼ '}
{delta.text}
</span>
)}
</div>
);
}
/* ─── نمودارِ ستونی ─────────────────────────────────────────────────────────── */
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 (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="text-sm font-black text-ink">{title}</h2>
<p className="mt-1 text-[11px] leading-5 text-muted">{description}</p>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-body">
{series.map((s) => (
<span key={s.label} className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: s.color }} />
{s.label}
</span>
))}
</div>
{/* خواندنِ روزِ انتخاب‌شده؛ همان کارِ tooltip را روی گوشی می‌کند. */}
<p className="mt-2 min-h-5 text-xs text-body" aria-live="polite">
<b className="text-ink">{longDay(days[selected].date)}:</b>{' '}
{series.map((s) => `${s.label} ${toFa(s.values[selected])} ${unit}`).join('، ')}
</p>
<div className="relative mt-2" style={{ height: PLOT_HEIGHT + 22 }}>
{/* خطِ راهنمای سقف و نیمه؛ کم‌رنگ تا با ستون‌ها رقابت نکند. */}
{[1, 0.5].map((f) => (
<div key={f} className="pointer-events-none absolute inset-x-0 border-t border-dashed border-line" style={{ bottom: 22 + PLOT_HEIGHT * f }}>
{/* نیمه‌ی سقفِ ۱ یا ۵ اعشاری است («۰.۵»)؛ فقط عددِ صحیح نوشته می‌شود. */}
{!empty && Number.isInteger(max * f) && (
<span className="absolute -top-2 left-0 bg-white pr-1 text-[10px] text-muted">{toFa(max * f)}</span>
)}
</div>
))}
<div className="absolute inset-x-0 border-t border-faint" style={{ bottom: 22 }} />
{empty && (
<p className="absolute inset-x-0 text-center text-xs text-muted" style={{ bottom: 22 + PLOT_HEIGHT / 2 - 8 }}>
هنوز چیزی ثبت نشده
</p>
)}
<div className="absolute inset-0 flex gap-1 pl-6">
{days.map((day, i) => {
const isToday = i === days.length - 1;
return (
<button
key={day.date}
type="button"
onClick={() => 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' : ''}`}
>
<div className="flex items-end justify-center gap-0.5" style={{ height: PLOT_HEIGHT }}>
{mode === 'grouped' ? (
series.map((s) => (
<span
key={s.label}
className="w-2.5 rounded-t"
style={{ height: Math.max(height(s.values[i]), s.values[i] ? 3 : 0), backgroundColor: s.color }}
/>
))
) : (
// بخش‌ها از پایین روی هم؛ فاصله‌ی سفیدِ ۲ پیکسلی جدایشان می‌کند.
<span className="flex w-4 flex-col-reverse gap-0.5">
{series.map((s, k) =>
s.values[i] ? (
<span
key={s.label}
className={k === series.length - 1 || series.slice(k + 1).every((n) => !n.values[i]) ? 'rounded-t' : ''}
style={{ height: Math.max(height(s.values[i]) - 2, 3), backgroundColor: s.color }}
/>
) : null,
)}
</span>
)}
</div>
<span className={`mt-1 h-4 text-[10px] ${isToday ? 'font-black text-ink' : 'text-muted'}`}>
{isToday ? 'امروز' : WEEKDAY_SHORT[localDate(day.date).getDay()]}
</span>
</button>
);
})}
</div>
</div>
{note && <p className="mt-3 text-[11px] leading-5 text-muted">{note}</p>}
</section>
);
}
/* ─── جدولِ روزها ───────────────────────────────────────────────────────────── */
/** همان عددهای نمودارها، برای کسی که رنگ‌ها را از هم جدا نمی‌بیند یا عددِ دقیق می‌خواهد. */
function DayTable({ days, previous }: { days: ReportDay[]; previous: ReportDay[] }) {
return (
<details className="rounded-3xl border border-line bg-white p-4 text-xs">
<summary className="cursor-pointer font-black text-ink">عددهای هر روز</summary>
<table className="mt-3 w-full text-center">
<thead className="text-[11px] text-muted">
<tr>
<th className="py-1 text-right font-bold">روز</th>
<th className="font-bold">دقیقه</th>
<th className="font-bold">هفته‌ی پیش</th>
<th className="font-bold">سوال</th>
<th className="font-bold">درست</th>
</tr>
</thead>
<tbody className="text-body">
{days.map((d, i) => (
<tr key={d.date} className="border-t border-line">
<td className="py-1.5 text-right">{longDay(d.date)}</td>
<td>{toFa(d.minutes)}</td>
<td>{toFa(previous[i]?.minutes ?? 0)}</td>
<td>{toFa(d.questions)}</td>
<td>{toFa(d.correct)}</td>
</tr>
))}
</tbody>
</table>
</details>
);
}