Files
MathApp_frontend/src/screens/ReportScreen.tsx
AliReza 8a61cb25b8 The selected companion everywhere, each with a small trait; next-APK notes
- state/companion.ts: one store for the chosen companion (also in localStorage so splash/login show it).
  BrandMark (the orange «π» tile) now shows the companion's symbol (Σ Δ π ω) in its colour; screen-header
  mascots, loaders and empty/error states default to it; the assistant is «از {name} بپرس» with its face,
  colour and wording; the shop, lesson and quiz «ask» buttons name it; the active tab takes its tint.
- Traits, all client-side and light: Σ sigma keeps a running tally of correct answers in the quiz header;
  Δ delta shows a flame «×N» chip and an extra buzz for answers in a row; π pi gives a short solving tip
  after a wrong answer; ω omega throws gold confetti at the end of every island whatever the score.
  Each companion's «correct» sound is pitched differently; confetti uses its colours. Characters screen
  lists each trait.
- android/README.md: what MainActivity.kt has that the installed APK lacks (vibrate bridge, media gesture,
  in-app gateway + paysInApp), what to check on a phone after building, signing owner, Bazaar policy note.

Checked in headless Chrome with Sigma, Delta and Omega selected (home, assistant, shop, characters, quiz).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:51:19 +03:30

349 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';
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 (
<Screen>
<ScreenHeader
title="گزارشِ والدین"
subtitle={`پیشرفتِ ${profile.name || 'دانش‌آموز'} در ۷ روزِ اخیر`}
color={THIS_WEEK}
back
mascot={{ 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);
const companion = useCompanion().char;
return (
<div className="flex items-center gap-3 rounded-3xl border border-line bg-white p-4">
<SeyarMascot char={companion} 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>
);
}