Files
MathApp_frontend/src/screens/StreakScreen.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

130 lines
6.5 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 { Flame } from 'lucide-react';
import { api } from '@/api';
import { useAsync } from '@/lib/useAsync';
import { ErrorState, LoadingState, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { toFa } from '@/lib/format';
import { useAppData } from '@/state/AppData';
import { StreakFlame, flameState } from '@/components/StreakFlame';
const WEEKS = 6;
const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
/** YYYY-MM-DD به وقتِ محلی (سرور روزها را همین‌طور می‌دهد). */
const isoDay = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
/** شش هفته‌ی اخیر، هفته از شنبه شروع می‌شود. */
function recentWeeks(today = new Date()): Date[][] {
const end = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const offsetFromSaturday = (end.getDay() + 1) % 7;
const start = new Date(end);
start.setDate(end.getDate() - offsetFromSaturday - (WEEKS - 1) * 7);
return Array.from({ length: WEEKS }, (_, w) =>
Array.from({ length: 7 }, (_, d) => {
const day = new Date(start);
day.setDate(start.getDate() + w * 7 + d);
return day;
}),
);
}
const dayOfMonth = (d: Date) => new Intl.DateTimeFormat('fa-IR-u-ca-persian', { day: 'numeric' }).format(d);
export function StreakScreen() {
const calendar = useAsync(() => api.streakCalendar(), [], { key: 'streak-calendar' });
const { summary } = useAppData();
const flame = flameState(summary?.streak);
const active = new Set(calendar.data?.activeDays ?? []);
const today = isoDay(new Date());
const weeks = recentWeeks();
return (
<Screen>
<ScreenHeader title="زنجیره‌ی روزها" subtitle="هر روز حداقل یه درس، زنجیره‌ت رو زنده نگه می‌داره" color="#FF7A3D" back mascot={{ mood: 'excited', size: 56 }} />
<ScreenBody className="flex flex-col gap-4">
{calendar.loading ? (
<LoadingState />
) : calendar.error || !calendar.data ? (
<ErrorState error={calendar.error} onRetry={calendar.reload} />
) : (
<>
<div className="grid grid-cols-2 gap-3">
{[
{ label: 'زنجیره‌ی فعلی', value: calendar.data.currentStreak },
{ label: 'بیشترین زنجیره', value: calendar.data.longestStreak },
].map((s, i) => (
<div key={s.label} className="flex flex-col items-center rounded-3xl border border-line bg-white py-4">
{i === 0 ? <StreakFlame state={flame} size={26} /> : <Flame size={24} className="text-orange" fill="#FF7A3D" />}
<span className="mt-1 text-2xl font-black text-ink">{toFa(s.value)}</span>
<span className="text-xs text-muted">{s.label} (روز)</span>
</div>
))}
</div>
<StreakGoal current={calendar.data.currentStreak} doneToday={active.has(today)} lost={summary?.streak.lost ?? 0} />
<div className="rounded-3xl border border-line bg-white p-4">
<div className="mb-2 grid grid-cols-7 gap-1.5 text-center text-[10px] font-bold text-muted">
{WEEKDAYS.map((d) => <span key={d}>{d}</span>)}
</div>
<div className="flex flex-col gap-1.5">
{weeks.map((week, w) => (
<div key={w} className="grid grid-cols-7 gap-1.5">
{week.map((day) => {
const key = isoDay(day);
const on = active.has(key);
const future = key > today;
return (
<span
key={key}
title={key}
className={`flex aspect-square items-center justify-center rounded-xl text-[11px] font-bold ${
on ? 'bg-orange text-white' : future ? 'text-faint/50' : 'bg-soft text-muted'
} ${key === today ? 'ring-2 ring-orange ring-offset-1' : ''}`}
>
{on ? <Flame size={14} fill="white" /> : dayOfMonth(day)}
</span>
);
})}
</div>
))}
</div>
</div>
</>
)}
</ScreenBody>
</Screen>
);
}
/** نقطه‌عطف‌های زنجیره؛ فقط پیام و نوار، هیچ پاداشِ واقعی‌ای پشتش نیست (پاداش تصمیمِ محصولی است). */
const MILESTONES = [3, 7, 14, 30, 60, 100];
function StreakGoal({ current, doneToday, lost }: { current: number; doneToday: boolean; lost: number }) {
const next = MILESTONES.find((m) => m > current);
const reached = MILESTONES.filter((m) => m <= current).pop();
// زنجیره‌ی صفر: «۳ روز دیگه می‌شه زنجیره‌ی ۳ روزه» هدفی دور بود؛ قدمِ امروز کوچک و روشن گفته می‌شود.
// زنجیره‌ی روشن و امروزِ خالی: یادآوریِ این‌که امروز چیزی مانده تا زنجیره نسوزد.
const today = current === 0 && lost > 0
? `زنجیره‌ی ${toFa(lost)} روزه‌ت یخ زد! امروز یه درس بزن تا دوباره آتیشش روشن بشه.`
: current === 0
? 'امروز فقط یه درس یا یه جزیره تموم کن تا زنجیره‌ت روشن بشه.'
: !doneToday
? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیره‌ی ${toFa(current)} روزه‌ت نسوزه.`
: null;
return (
<div className="rounded-3xl border border-line bg-[#FFF3E8] px-4 py-3">
{today && <p className="mb-1 text-xs font-bold leading-6 text-[#B4501C]">{today}</p>}
<p className="text-sm font-black leading-7 text-ink">
{reached ? <><Flame size={16} fill="#FF7A3D" className="mb-0.5 ml-1 inline text-orange" />{toFa(current)} روزه‌ای! </> : null}
{next ? `${toFa(next - current)} روز دیگه می‌شه زنجیره‌ی ${toFa(next)} روزه.` : 'از همه‌ی نقطه‌عطف‌ها رد شدی، افسانه‌ای!'}
</p>
{next && (
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white">
<div className="h-full rounded-full bg-orange transition-[width]" style={{ width: `${Math.round((current / next) * 100)}%` }} />
</div>
)}
</div>
);
}