feat: add growth analyst UI foundation
This commit is contained in:
@@ -1,2 +1,4 @@
|
|||||||
# Same-origin by default. Vite proxies /api to http://127.0.0.1:8080 locally.
|
# Same-origin by default. Vite proxies /api to http://127.0.0.1:8080 locally.
|
||||||
PUBLIC_API_BASE_URL=/api/v1
|
PUBLIC_API_BASE_URL=/api/v1
|
||||||
|
# Keep disabled until the backend growth-report capability and budgets are configured.
|
||||||
|
PUBLIC_AI_GROWTH_ENABLED=false
|
||||||
|
|||||||
@@ -30,7 +30,9 @@
|
|||||||
retryOpportunity,
|
retryOpportunity,
|
||||||
} from '$lib/insights';
|
} from '$lib/insights';
|
||||||
import { formatPeerMetric, peerMetricLabel } from '$lib/peer-metrics';
|
import { formatPeerMetric, peerMetricLabel } from '$lib/peer-metrics';
|
||||||
|
import { growthFeatureEnabled } from '$lib/growth-feature';
|
||||||
import KpiCard from './KpiCard.svelte';
|
import KpiCard from './KpiCard.svelte';
|
||||||
|
import GrowthTeaser from './GrowthTeaser.svelte';
|
||||||
import FeeShareDonut from './FeeShareDonut.svelte';
|
import FeeShareDonut from './FeeShareDonut.svelte';
|
||||||
import PageHeader from './PageHeader.svelte';
|
import PageHeader from './PageHeader.svelte';
|
||||||
import PaymentHistoryChart from './PaymentHistoryChart.svelte';
|
import PaymentHistoryChart from './PaymentHistoryChart.svelte';
|
||||||
@@ -204,6 +206,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
{#if growthFeatureEnabled}<GrowthTeaser href={`/growth${page.url.search}`} />{/if}
|
||||||
{:else if kind === 'payments' && performance.state === 'READY'}
|
{:else if kind === 'payments' && performance.state === 'READY'}
|
||||||
{@const retryRate = retryEngagementRate(
|
{@const retryRate = retryEngagementRate(
|
||||||
performance.data.retriedSessions.value,
|
performance.data.retriedSessions.value,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||||
import PanelsTopLeftIcon from '@lucide/svelte/icons/panels-top-left';
|
import PanelsTopLeftIcon from '@lucide/svelte/icons/panels-top-left';
|
||||||
import SunIcon from '@lucide/svelte/icons/sun';
|
import SunIcon from '@lucide/svelte/icons/sun';
|
||||||
|
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
|
||||||
import UsersRoundIcon from '@lucide/svelte/icons/users-round';
|
import UsersRoundIcon from '@lucide/svelte/icons/users-round';
|
||||||
import WalletCardsIcon from '@lucide/svelte/icons/wallet-cards';
|
import WalletCardsIcon from '@lucide/svelte/icons/wallet-cards';
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
import * as Sidebar from '$lib/components/ui/sidebar';
|
import * as Sidebar from '$lib/components/ui/sidebar';
|
||||||
import { useDashboardContext } from '$lib/dashboard-context.svelte';
|
import { useDashboardContext } from '$lib/dashboard-context.svelte';
|
||||||
import { setDashboardQuery } from '$lib/dashboard-filters';
|
import { setDashboardQuery } from '$lib/dashboard-filters';
|
||||||
|
import { growthFeatureEnabled } from '$lib/growth-feature';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
const dashboard = useDashboardContext();
|
const dashboard = useDashboardContext();
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
{ href: '/payments', label: 'عملکرد پرداخت', icon: ActivityIcon },
|
{ href: '/payments', label: 'عملکرد پرداخت', icon: ActivityIcon },
|
||||||
{ href: '/fees', label: 'کارمزدها', icon: HandCoinsIcon },
|
{ href: '/fees', label: 'کارمزدها', icon: HandCoinsIcon },
|
||||||
{ href: '/peers', label: 'همگروهها', icon: UsersRoundIcon },
|
{ href: '/peers', label: 'همگروهها', icon: UsersRoundIcon },
|
||||||
|
...(growthFeatureEnabled ? [{ href: '/growth', label: 'تحلیلگر رشد', icon: TrendingUpIcon }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
function navUrl(path: string) {
|
function navUrl(path: string) {
|
||||||
|
|||||||
264
src/lib/components/GrowthForecastChart.svelte
Normal file
264
src/lib/components/GrowthForecastChart.svelte
Normal 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>
|
||||||
31
src/lib/components/GrowthForecastChart.test.ts
Normal file
31
src/lib/components/GrowthForecastChart.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
src/lib/components/GrowthReportSkeleton.svelte
Normal file
30
src/lib/components/GrowthReportSkeleton.svelte
Normal 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>
|
||||||
29
src/lib/components/GrowthTeaser.svelte
Normal file
29
src/lib/components/GrowthTeaser.svelte
Normal 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>
|
||||||
15
src/lib/components/GrowthTeaser.test.ts
Normal file
15
src/lib/components/GrowthTeaser.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
34
src/lib/growth-chart.test.ts
Normal file
34
src/lib/growth-chart.test.ts
Normal 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
47
src/lib/growth-chart.ts
Normal 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 'داده ناکافی';
|
||||||
|
}
|
||||||
1
src/lib/growth-feature.ts
Normal file
1
src/lib/growth-feature.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const growthFeatureEnabled = import.meta.env.PUBLIC_AI_GROWTH_ENABLED === 'true';
|
||||||
@@ -7,6 +7,9 @@ export default defineConfig(({ mode }) => {
|
|||||||
const env = loadEnv(mode, '.', '');
|
const env = loadEnv(mode, '.', '');
|
||||||
return {
|
return {
|
||||||
plugins: [tailwindcss(), sveltekit()],
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
resolve: {
|
||||||
|
conditions: mode === 'test' ? ['browser'] : undefined,
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
Reference in New Issue
Block a user