feat: add growth analyst UI foundation

This commit is contained in:
2026-08-21 00:25:45 +03:30
parent 849e53e3dd
commit 7a495b04c2
12 changed files with 462 additions and 0 deletions

View File

@@ -30,7 +30,9 @@
retryOpportunity,
} from '$lib/insights';
import { formatPeerMetric, peerMetricLabel } from '$lib/peer-metrics';
import { growthFeatureEnabled } from '$lib/growth-feature';
import KpiCard from './KpiCard.svelte';
import GrowthTeaser from './GrowthTeaser.svelte';
import FeeShareDonut from './FeeShareDonut.svelte';
import PageHeader from './PageHeader.svelte';
import PaymentHistoryChart from './PaymentHistoryChart.svelte';
@@ -204,6 +206,7 @@
</div>
</CardContent>
</Card>
{#if growthFeatureEnabled}<GrowthTeaser href={`/growth${page.url.search}`} />{/if}
{:else if kind === 'payments' && performance.state === 'READY'}
{@const retryRate = retryEngagementRate(
performance.data.retriedSessions.value,

View File

@@ -9,6 +9,7 @@
import MoonIcon from '@lucide/svelte/icons/moon';
import PanelsTopLeftIcon from '@lucide/svelte/icons/panels-top-left';
import SunIcon from '@lucide/svelte/icons/sun';
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
import UsersRoundIcon from '@lucide/svelte/icons/users-round';
import WalletCardsIcon from '@lucide/svelte/icons/wallet-cards';
@@ -18,6 +19,7 @@
import * as Sidebar from '$lib/components/ui/sidebar';
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import { setDashboardQuery } from '$lib/dashboard-filters';
import { growthFeatureEnabled } from '$lib/growth-feature';
let { children } = $props();
const dashboard = useDashboardContext();
@@ -29,6 +31,7 @@
{ href: '/payments', label: 'عملکرد پرداخت', icon: ActivityIcon },
{ href: '/fees', label: 'کارمزدها', icon: HandCoinsIcon },
{ href: '/peers', label: 'هم‌گروه‌ها', icon: UsersRoundIcon },
...(growthFeatureEnabled ? [{ href: '/growth', label: 'تحلیل‌گر رشد', icon: TrendingUpIcon }] : []),
];
function navUrl(path: string) {

View File

@@ -0,0 +1,264 @@
<script lang="ts">
import ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { buildForecastChart, formatConfidence } from '$lib/growth-chart';
import { formatMoney, formatValue } from '$lib/format';
type Range = { p10: number; p50: number; p90: number };
type ForecastPoint = {
date: string;
paidSessions: Range;
paidAmountRial: Range;
};
let { points, confidence, startDate }: { points: ForecastPoint[]; confidence: string; startDate: string } =
$props();
let metric = $state<'count' | 'amount'>('count');
let active = $state<number | null>(null);
const width = 820;
const height = 320;
const chartData = $derived(
points.map((point) => ({
localDate: point.date,
...(metric === 'count' ? point.paidSessions : point.paidAmountRial),
})),
);
const chart = $derived(buildForecastChart(chartData, width, height));
const selected = $derived(active === null ? undefined : chart.points[active]);
const xTicks = $derived([
...new Set(
Array.from({ length: Math.min(7, points.length) }, (_, index) =>
Math.round((index * (points.length - 1)) / Math.max(1, Math.min(7, points.length) - 1)),
),
),
]);
function dateLabel(value: string) {
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
month: 'short',
day: 'numeric',
timeZone: 'Asia/Tehran',
}).format(new Date(value));
}
function valueLabel(value: number) {
return metric === 'count' ? `${formatValue(value)} پرداخت` : formatMoney(value);
}
function onChartKeydown(event: KeyboardEvent) {
const current = active ?? 0;
if (event.key === 'ArrowLeft') active = Math.min(chart.points.length - 1, current + 1);
else if (event.key === 'ArrowRight') active = Math.max(0, current - 1);
else if (event.key === 'Home') active = 0;
else if (event.key === 'End') active = chart.points.length - 1;
else return;
event.preventDefault();
}
</script>
<Card class="overflow-hidden rounded-lg shadow-xs">
<CardHeader class="border-b pb-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle class="flex items-center gap-2 text-base">
<ChartNoAxesCombinedIcon class="text-primary size-5" /> چشم‌انداز ۹۰ روز آینده
</CardTitle>
<p class="text-muted-foreground mt-1 text-xs">
خط میانی برآورد معمول و ناحیه کم‌رنگ بازه محتمل را نشان می‌دهد.
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary" class="rounded-md">{formatConfidence(confidence)}</Badge>
<div class="bg-muted flex rounded-md p-1" aria-label="شاخص نمودار">
<Button
variant={metric === 'count' ? 'default' : 'ghost'}
size="sm"
class="min-h-9 rounded-sm px-3 text-xs"
aria-pressed={metric === 'count'}
onclick={() => (metric = 'count')}>تعداد</Button
>
<Button
variant={metric === 'amount' ? 'default' : 'ghost'}
size="sm"
class="min-h-9 rounded-sm px-3 text-xs"
aria-pressed={metric === 'amount'}
onclick={() => (metric = 'amount')}>مبلغ</Button
>
</div>
</div>
</div>
</CardHeader>
<CardContent class="px-2 pt-5 sm:px-5">
{#if chart.points.length}
<div
class="relative overflow-hidden"
dir="ltr"
role="slider"
tabindex="0"
aria-label={`نمودار پیش‌بینی ${metric === 'count' ? 'تعداد پرداخت' : 'مبلغ پرداخت'}؛ برای جابه‌جایی میان روزها از کلیدهای جهت‌دار استفاده کنید.`}
aria-valuemin="1"
aria-valuemax={chart.points.length}
aria-valuenow={(active ?? 0) + 1}
aria-valuetext={active === null
? 'برای بررسی روزها کلید جهت‌دار را بزنید'
: dateLabel(chart.points[active].localDate)}
onkeydown={onChartKeydown}
onfocus={() => (active = active ?? 0)}
onblur={() => (active = null)}
>
<svg viewBox={`0 0 ${width} ${height}`} class="h-auto min-h-72 w-full" aria-hidden="true">
{#each [0, 1, 2, 3, 4] as row}
{@const y = chart.padding.top + (row * (height - chart.padding.top - chart.padding.bottom)) / 4}
<line
x1={chart.padding.left}
x2={width - chart.padding.right}
y1={y}
y2={y}
stroke="var(--border)"
stroke-dasharray="4 6"
/>
<text
x={chart.padding.left - 10}
y={y + 4}
fill="var(--muted-foreground)"
font-size="10"
text-anchor="end"
>
{formatValue(Math.round(chart.max * (1 - row / 4)))}
</text>
{/each}
<line
x1={chart.padding.left}
x2={chart.padding.left}
y1={chart.padding.top}
y2={height - chart.padding.bottom}
stroke="var(--border)"
/>
<line
x1={chart.padding.left}
x2={width - chart.padding.right}
y1={height - chart.padding.bottom}
y2={height - chart.padding.bottom}
stroke="var(--border)"
/>
{#each xTicks as index}
{@const point = chart.points[index]}
<text
x={point.x}
y={height - chart.padding.bottom + 19}
fill="var(--muted-foreground)"
font-size="9"
text-anchor="middle"
direction="rtl"
>
{dateLabel(point.localDate)}
</text>
{/each}
<text
x={(chart.padding.left + width - chart.padding.right) / 2}
y={height - 5}
fill="var(--muted-foreground)"
font-size="10"
text-anchor="middle"
direction="rtl">تاریخ</text
>
<text
x={-((chart.padding.top + height - chart.padding.bottom) / 2)}
y="14"
fill="var(--muted-foreground)"
font-size="10"
text-anchor="middle"
transform="rotate(-90)"
direction="rtl"
>
{metric === 'count' ? 'تعداد پرداخت' : 'مبلغ (ریال)'}
</text>
<path d={chart.bandPath} fill="var(--primary)" fill-opacity=".12" />
<path
d={chart.medianPath}
fill="none"
stroke="var(--primary)"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="forecast-line"
/>
{#each chart.points as point, index}
<circle
cx={point.x}
cy={point.medianY}
r={active === index ? 5 : 2.5}
fill="var(--card)"
stroke="var(--primary)"
stroke-width="2.5"
role="presentation"
onmouseenter={() => (active = index)}
onmouseleave={() => (active = null)}
/>
{/each}
</svg>
{#if selected}
<div
class="bg-popover text-popover-foreground pointer-events-none absolute bottom-14 left-1/2 z-10 w-48 -translate-x-1/2 rounded-md border p-3 text-right text-xs shadow-lg transition-opacity duration-150 motion-reduce:transition-none sm:top-4 sm:bottom-auto sm:left-24 sm:translate-x-0"
dir="rtl"
aria-live="polite"
>
<strong class="block">{dateLabel(selected.localDate)}</strong>
<span class="mt-1 block">برآورد معمول: {valueLabel(selected.p50)}</span>
<span class="text-muted-foreground block"
>بازه محتمل: {valueLabel(selected.p10)} تا {valueLabel(selected.p90)}</span
>
</div>
{/if}
</div>
<p class="text-muted-foreground px-3 pb-4 text-xs">
شروع پیش‌بینی: {dateLabel(startDate)}. این برآورد قطعی نیست و بر پایه الگوی داده‌های کامل واردشده است.
</p>
<details class="border-t px-3 py-3 text-xs">
<summary class="font-semibold">جدول داده‌های نمودار</summary>
<div class="mt-3 max-h-72 overflow-auto">
<table class="w-full min-w-[34rem] text-right">
<thead
><tr class="text-muted-foreground"
><th class="p-2">تاریخ</th><th class="p-2">کمینه محتمل</th><th class="p-2">برآورد معمول</th
><th class="p-2">بیشینه محتمل</th></tr
></thead
>
<tbody>
{#each chart.points as point}
<tr class="border-t"
><td class="p-2">{dateLabel(point.localDate)}</td><td class="p-2"
>{valueLabel(point.p10)}</td
><td class="p-2 font-bold">{valueLabel(point.p50)}</td><td class="p-2"
>{valueLabel(point.p90)}</td
></tr
>
{/each}
</tbody>
</table>
</div>
</details>
{:else}
<div class="text-muted-foreground grid min-h-72 place-items-center text-sm" role="status">
برای این بازه پیش‌بینی قابل اتکایی آماده نشده است.
</div>
{/if}
</CardContent>
</Card>
<style>
.forecast-line {
stroke-dasharray: 2200;
stroke-dashoffset: 2200;
animation: draw-forecast 800ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes draw-forecast {
to {
stroke-dashoffset: 0;
}
}
</style>

View File

@@ -0,0 +1,31 @@
import { fireEvent, render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import GrowthForecastChart from './GrowthForecastChart.svelte';
const points = [
{
date: '2026-07-01T00:00:00Z',
paidSessions: { p10: 80, p50: 100, p90: 130 },
paidAmountRial: { p10: 8_000_000, p50: 10_000_000, p90: 13_000_000 },
},
{
date: '2026-07-02T00:00:00Z',
paidSessions: { p10: 90, p50: 120, p90: 150 },
paidAmountRial: { p10: 9_000_000, p50: 12_000_000, p90: 15_000_000 },
},
];
describe('GrowthForecastChart', () => {
it('offers an accessible table alternative and amount mode', async () => {
render(GrowthForecastChart, {
points,
confidence: 'MEDIUM',
startDate: '2026-07-01T00:00:00Z',
});
expect(screen.getByText('جدول داده‌های نمودار')).not.toBeNull();
await fireEvent.click(screen.getByRole('button', { name: 'مبلغ' }));
expect(screen.getAllByText(/ریال/).length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { Skeleton } from '$lib/components/ui/skeleton';
let { refreshing = false }: { refreshing?: boolean } = $props();
</script>
<div
class={refreshing
? 'bg-background/65 pointer-events-none absolute inset-0 z-20 rounded-lg p-4 backdrop-blur-[1px]'
: 'space-y-4'}
role="status"
aria-label={refreshing ? 'در حال به‌روزرسانی تحلیل' : 'در حال آماده‌سازی تحلیل رشد'}
aria-live="polite"
>
<span class="sr-only"
>{refreshing
? 'تحلیل قبلی نمایش داده می‌شود و نسخه تازه در حال آماده‌شدن است.'
: 'اطلاعات تحلیل رشد در حال بارگذاری است.'}</span
>
{#if refreshing}
<div class="flex justify-end"><Skeleton class="h-10 w-44 rounded-md" /></div>
{:else}
<Skeleton class="h-24 w-full rounded-lg" />
<Skeleton class="h-96 w-full rounded-lg" />
<div class="grid gap-4 md:grid-cols-2">
<Skeleton class="h-64 rounded-lg" />
<Skeleton class="h-64 rounded-lg" />
</div>
{/if}
</div>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import { Card, CardContent } from '$lib/components/ui/card';
let { href = '/growth' }: { href?: string } = $props();
</script>
<Card class="border-primary/20 bg-primary/[.035] mt-4 rounded-lg shadow-xs">
<CardContent class="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-start gap-3">
<SparklesIcon class="text-primary mt-1 size-5 shrink-0" />
<div>
<h2 class="font-bold">تحلیل‌گر رشد</h2>
<p class="text-muted-foreground mt-1 text-sm leading-7">
فرصت‌های پیش رو و دوره‌های کم‌رونق احتمالی را با تکیه بر داده‌های خودتان ببینید.
</p>
</div>
</div>
<a
{href}
class="border-primary/25 text-primary hover:bg-accent inline-flex min-h-11 shrink-0 items-center justify-center gap-2 rounded-md border px-4 text-sm font-bold"
>
دیدن تحلیل ۹۰ روزه
<ArrowLeftIcon class="size-4" />
</a>
</CardContent>
</Card>

View File

@@ -0,0 +1,15 @@
import { render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import GrowthTeaser from './GrowthTeaser.svelte';
describe('GrowthTeaser', () => {
it('links to the growth analyst without starting a generation request', () => {
render(GrowthTeaser, { href: '/growth?terminal=terminal-1' });
expect(screen.getByRole('link', { name: /دیدن تحلیل ۹۰ روزه/ }).getAttribute('href')).toBe(
'/growth?terminal=terminal-1',
);
expect(screen.getByText('تحلیل‌گر رشد')).not.toBeNull();
});
});

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { buildForecastChart, formatConfidence } from './growth-chart';
describe('growth forecast chart presentation', () => {
it('maps the p10, p50 and p90 series into a shared bounded plot', () => {
const chart = buildForecastChart(
[
{ localDate: '2026-07-01', p10: 80, p50: 100, p90: 130 },
{ localDate: '2026-07-02', p10: 90, p50: 120, p90: 150 },
],
760,
300,
);
expect(chart.points).toHaveLength(2);
expect(chart.points[0].highY).toBeLessThan(chart.points[0].medianY);
expect(chart.points[0].medianY).toBeLessThan(chart.points[0].lowY);
expect(chart.points.every((point) => point.x >= chart.padding.left)).toBe(true);
});
it('keeps the chart useful when every forecast value is zero', () => {
const chart = buildForecastChart([{ localDate: '2026-07-01', p10: 0, p50: 0, p90: 0 }], 760, 300);
expect(chart.max).toBe(1);
expect(chart.medianPath).toContain('M');
});
it('translates forecast confidence without implying certainty', () => {
expect(formatConfidence('HIGH')).toBe('اطمینان بالا');
expect(formatConfidence('MEDIUM')).toBe('اطمینان متوسط');
expect(formatConfidence('INSUFFICIENT')).toBe('داده ناکافی');
});
});

47
src/lib/growth-chart.ts Normal file
View File

@@ -0,0 +1,47 @@
export type ForecastChartDatum = {
localDate: string;
p10: number;
p50: number;
p90: number;
};
export type ForecastChartPoint = ForecastChartDatum & {
x: number;
lowY: number;
medianY: number;
highY: number;
};
export function buildForecastChart(data: ForecastChartDatum[], width: number, height: number) {
const padding = { top: 24, right: 24, bottom: 52, left: 72 };
const innerWidth = width - padding.left - padding.right;
const innerHeight = height - padding.top - padding.bottom;
const max = Math.max(1, ...data.map((point) => point.p90));
const y = (value: number) => height - padding.bottom - (value / max) * innerHeight;
const points: ForecastChartPoint[] = data.map((point, index) => ({
...point,
x: padding.left + (index * innerWidth) / Math.max(1, data.length - 1),
lowY: y(point.p10),
medianY: y(point.p50),
highY: y(point.p90),
}));
const medianPath = points
.map((point, index) => `${index ? 'L' : 'M'} ${point.x} ${point.medianY}`)
.join(' ');
const bandPath = points.length
? `${points.map((point, index) => `${index ? 'L' : 'M'} ${point.x} ${point.highY}`).join(' ')} ${[
...points,
]
.reverse()
.map((point) => `L ${point.x} ${point.lowY}`)
.join(' ')} Z`
: '';
return { points, max, padding, medianPath, bandPath };
}
export function formatConfidence(value: string): string {
if (value === 'HIGH') return 'اطمینان بالا';
if (value === 'MEDIUM') return 'اطمینان متوسط';
return 'داده ناکافی';
}

View File

@@ -0,0 +1 @@
export const growthFeatureEnabled = import.meta.env.PUBLIC_AI_GROWTH_ENABLED === 'true';