forked from Tadrisino/MathApp_frontend
- 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>
347 lines
17 KiB
TypeScript
347 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|