feat: add growth analyst UI foundation
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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';
|
||||
Reference in New Issue
Block a user