diff --git a/.env.example b/.env.example index 1498847..e460a78 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ # Same-origin by default. Vite proxies /api to http://127.0.0.1:8080 locally. PUBLIC_API_BASE_URL=/api/v1 +# Keep disabled until the backend growth-report capability and budgets are configured. +PUBLIC_AI_GROWTH_ENABLED=false diff --git a/src/lib/components/AnalyticsPage.svelte b/src/lib/components/AnalyticsPage.svelte index ea28619..873b004 100644 --- a/src/lib/components/AnalyticsPage.svelte +++ b/src/lib/components/AnalyticsPage.svelte @@ -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 @@ + {#if growthFeatureEnabled}{/if} {:else if kind === 'payments' && performance.state === 'READY'} {@const retryRate = retryEngagementRate( performance.data.retriedSessions.value, diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index 9e06ab5..7f5f52b 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -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) { diff --git a/src/lib/components/GrowthForecastChart.svelte b/src/lib/components/GrowthForecastChart.svelte new file mode 100644 index 0000000..c25d461 --- /dev/null +++ b/src/lib/components/GrowthForecastChart.svelte @@ -0,0 +1,264 @@ + + + + +
+
+ + چشم‌انداز ۹۰ روز آینده + +

+ خط میانی برآورد معمول و ناحیه کم‌رنگ بازه محتمل را نشان می‌دهد. +

+
+
+ {formatConfidence(confidence)} +
+ + +
+
+
+
+ + {#if chart.points.length} +
(active = active ?? 0)} + onblur={() => (active = null)} + > + + {#if selected} +
+ {dateLabel(selected.localDate)} + برآورد معمول: {valueLabel(selected.p50)} + بازه محتمل: {valueLabel(selected.p10)} تا {valueLabel(selected.p90)} +
+ {/if} +
+

+ شروع پیش‌بینی: {dateLabel(startDate)}. این برآورد قطعی نیست و بر پایه الگوی داده‌های کامل واردشده است. +

+
+ جدول داده‌های نمودار +
+ + + + {#each chart.points as point} + + {/each} + +
تاریخکمینه محتملبرآورد معمولبیشینه محتمل
{dateLabel(point.localDate)}{valueLabel(point.p10)}{valueLabel(point.p50)}{valueLabel(point.p90)}
+
+
+ {:else} +
+ برای این بازه پیش‌بینی قابل اتکایی آماده نشده است. +
+ {/if} +
+
+ + diff --git a/src/lib/components/GrowthForecastChart.test.ts b/src/lib/components/GrowthForecastChart.test.ts new file mode 100644 index 0000000..4de7943 --- /dev/null +++ b/src/lib/components/GrowthForecastChart.test.ts @@ -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); + }); +}); diff --git a/src/lib/components/GrowthReportSkeleton.svelte b/src/lib/components/GrowthReportSkeleton.svelte new file mode 100644 index 0000000..99a60fb --- /dev/null +++ b/src/lib/components/GrowthReportSkeleton.svelte @@ -0,0 +1,30 @@ + + +
+ {refreshing + ? 'تحلیل قبلی نمایش داده می‌شود و نسخه تازه در حال آماده‌شدن است.' + : 'اطلاعات تحلیل رشد در حال بارگذاری است.'} + {#if refreshing} +
+ {:else} + + +
+ + +
+ {/if} +
diff --git a/src/lib/components/GrowthTeaser.svelte b/src/lib/components/GrowthTeaser.svelte new file mode 100644 index 0000000..025d115 --- /dev/null +++ b/src/lib/components/GrowthTeaser.svelte @@ -0,0 +1,29 @@ + + + + +
+ +
+

تحلیل‌گر رشد

+

+ فرصت‌های پیش رو و دوره‌های کم‌رونق احتمالی را با تکیه بر داده‌های خودتان ببینید. +

+
+
+ + دیدن تحلیل ۹۰ روزه + + +
+
diff --git a/src/lib/components/GrowthTeaser.test.ts b/src/lib/components/GrowthTeaser.test.ts new file mode 100644 index 0000000..5716982 --- /dev/null +++ b/src/lib/components/GrowthTeaser.test.ts @@ -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(); + }); +}); diff --git a/src/lib/growth-chart.test.ts b/src/lib/growth-chart.test.ts new file mode 100644 index 0000000..6f88fcb --- /dev/null +++ b/src/lib/growth-chart.test.ts @@ -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('داده ناکافی'); + }); +}); diff --git a/src/lib/growth-chart.ts b/src/lib/growth-chart.ts new file mode 100644 index 0000000..53b3b17 --- /dev/null +++ b/src/lib/growth-chart.ts @@ -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 'داده ناکافی'; +} diff --git a/src/lib/growth-feature.ts b/src/lib/growth-feature.ts new file mode 100644 index 0000000..03c7a1f --- /dev/null +++ b/src/lib/growth-feature.ts @@ -0,0 +1 @@ +export const growthFeatureEnabled = import.meta.env.PUBLIC_AI_GROWTH_ENABLED === 'true'; diff --git a/vite.config.ts b/vite.config.ts index fc1e193..0bf5c52 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,9 @@ export default defineConfig(({ mode }) => { const env = loadEnv(mode, '.', ''); return { plugins: [tailwindcss(), sveltekit()], + resolve: { + conditions: mode === 'test' ? ['browser'] : undefined, + }, server: { host: '127.0.0.1', port: 5173,