Compare commits
7 Commits
b983eb37e8
...
0fb044cd59
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fb044cd59 | |||
| 35472979d6 | |||
| 9722adc93e | |||
| 5445a8676b | |||
| c79a1219bf | |||
| 7a495b04c2 | |||
| 849e53e3dd |
@@ -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
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ The approved Persian-first baseline remains authoritative where the shipped arti
|
|||||||
|
|
||||||
| Version | Date | Decision |
|
| Version | Date | Decision |
|
||||||
| ------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 0.11.0 | 2026-08-21 | Added the feature-gated AI growth analyst: a cache-first 90-day forecast view with count/amount modes, P10–P90 uncertainty band, keyboard and tabular alternatives, evidence-linked Persian campaign guidance, preserved-content refresh states, and restrained semantic opportunity/risk styling. |
|
||||||
| 0.10.0 | 2026-08-20 | Reframed retry guidance around the share of failed payment paths retried, including an explicit zero state; made payment-flow bars proportional to counts; and removed single-day hover styling from whole-week selection. |
|
| 0.10.0 | 2026-08-20 | Reframed retry guidance around the share of failed payment paths retried, including an explicit zero state; made payment-flow bars proportional to counts; and removed single-day hover styling from whole-week selection. |
|
||||||
| 0.9.3 | 2026-08-20 | Replaced stacked field focus outlines with one narrow semantic-blue border across text fields, textareas, native selects, and custom select triggers; other keyboard targets retain a subtle 1px outline. |
|
| 0.9.3 | 2026-08-20 | Replaced stacked field focus outlines with one narrow semantic-blue border across text fields, textareas, native selects, and custom select triggers; other keyboard targets retain a subtle 1px outline. |
|
||||||
| 0.9.2 | 2026-08-20 | Pinned the initialization skeleton sidebar to the physical right and reserved matching right-side content space so loading and loaded shells share the same geometry. |
|
| 0.9.2 | 2026-08-20 | Pinned the initialization skeleton sidebar to the physical right and reserved matching right-side content space so loading and loaded shells share the same geometry. |
|
||||||
|
|||||||
31
docs/GROWTH-ANALYST-UX-SPEC.md
Normal file
31
docs/GROWTH-ANALYST-UX-SPEC.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# AI Growth Analyst UX Spec
|
||||||
|
|
||||||
|
- Status: Approved — implementation authorized 2026-08-21
|
||||||
|
- Route: `/growth`
|
||||||
|
- Language/direction: Persian, RTL
|
||||||
|
|
||||||
|
## Experience
|
||||||
|
|
||||||
|
- Add `تحلیلگر رشد` to the authenticated sidebar and a compact dashboard teaser linking to it.
|
||||||
|
- Reuse the global terminal selector. Merchant scope may show peers; terminal scope explains that peer comparison is unavailable.
|
||||||
|
- Keep the previous successful report visible during refresh. Initial and uncached states use visible light/dark skeletons; no state renders an empty page.
|
||||||
|
- Header states the 90-day horizon starts after the latest complete dataset date. A daily `بهروزرسانی تحلیل` action shows `nextRefreshAt`, loading, and typed rate-limit feedback.
|
||||||
|
- Render a responsive, keyboard-accessible forecast chart with count/amount mode, hover/focus points, P10–P90 band, axes/units, chart summary, and a non-visual data alternative.
|
||||||
|
- Show historical wins, up to five upcoming opportunities, up to three gentle low-period warnings, and a peer card when permitted.
|
||||||
|
- Each brief contains server-rendered timing/range/evidence plus validated AI headline, interpretation, offer/message suggestion, one preparation step, confidence, and limitation.
|
||||||
|
- `PARTIAL` keeps deterministic charts/cards and explains that the written AI interpretation was unavailable. `INSUFFICIENT_DATA` explains the minimum evidence without presenting a forecast. `FAILED` offers safe retry without hiding an older successful report.
|
||||||
|
- Motion is restrained, supports reduced motion, and does not block reading. All interactive targets are at least 44 px.
|
||||||
|
|
||||||
|
## Copy rules
|
||||||
|
|
||||||
|
- Use natural professional Persian, Persian `ی`/`ک`, appropriate نیمفاصله, Persian digits, and Rial.
|
||||||
|
- Keep tone constructive: describe opportunity, preparation, and uncertainty; do not shame the merchant or claim guaranteed outcomes.
|
||||||
|
- Every recommendation visibly cites its deterministic evidence card. AI is labeled advisory.
|
||||||
|
- No chat, notifications, campaign execution, user memory, provider/model names, token usage, or internal failure details appear in merchant UI.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Works at 360, 768, 1024, and wide desktop widths in light/dark RTL layouts.
|
||||||
|
- All job, cache, refresh, partial, insufficient, unauthorized, and recoverable error states are accessible and tested.
|
||||||
|
- Generated OpenAPI types are used; no handwritten duplicate growth DTOs or browser secrets exist.
|
||||||
|
- Cached navigation performs no generation request; manual refresh is explicit and limited once per merchant per Tehran day.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { components } from './schema';
|
import type { components, operations } from './schema';
|
||||||
|
|
||||||
export type AuthSession = components['schemas']['AuthSession'];
|
export type AuthSession = components['schemas']['AuthSession'];
|
||||||
export type ReportingPeriod = components['schemas']['ReportingPeriod'];
|
export type ReportingPeriod = components['schemas']['ReportingPeriod'];
|
||||||
@@ -11,6 +11,9 @@ export type SessionStatus = components['schemas']['SessionStatus'];
|
|||||||
export type DetailView = components['schemas']['DetailView'];
|
export type DetailView = components['schemas']['DetailView'];
|
||||||
export type FailureStage = components['schemas']['FailureStage'];
|
export type FailureStage = components['schemas']['FailureStage'];
|
||||||
export type Problem = components['schemas']['Problem'];
|
export type Problem = components['schemas']['Problem'];
|
||||||
|
export type GrowthReport = components['schemas']['GrowthReport'];
|
||||||
|
export type CreateGrowthReportRequest =
|
||||||
|
operations['createGrowthReport']['requestBody']['content']['application/json'];
|
||||||
|
|
||||||
const apiBase = (import.meta.env.PUBLIC_API_BASE_URL || '/api/v1').replace(/\/$/, '');
|
const apiBase = (import.meta.env.PUBLIC_API_BASE_URL || '/api/v1').replace(/\/$/, '');
|
||||||
|
|
||||||
@@ -119,6 +122,23 @@ export const api = {
|
|||||||
sessionDetail(sessionId: string) {
|
sessionDetail(sessionId: string) {
|
||||||
return request<SessionDetail>(`/analytics/sessions/${encodeURIComponent(sessionId)}`);
|
return request<SessionDetail>(`/analytics/sessions/${encodeURIComponent(sessionId)}`);
|
||||||
},
|
},
|
||||||
|
latestGrowthReport(terminalId?: string) {
|
||||||
|
const query = terminalId ? `?terminalId=${encodeURIComponent(terminalId)}` : '';
|
||||||
|
return request<GrowthReport>(`/analytics/growth-reports/latest${query}`);
|
||||||
|
},
|
||||||
|
createGrowthReport(csrfToken: string, options: CreateGrowthReportRequest = {}) {
|
||||||
|
return request<GrowthReport>('/analytics/growth-reports', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-Token': csrfToken,
|
||||||
|
'Idempotency-Key': crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(options),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
growthReport(reportId: string) {
|
||||||
|
return request<GrowthReport>(`/analytics/growth-reports/${encodeURIComponent(reportId)}`);
|
||||||
|
},
|
||||||
exportUrl(
|
exportUrl(
|
||||||
periodId: string,
|
periodId: string,
|
||||||
view: DetailView,
|
view: DetailView,
|
||||||
|
|||||||
71
src/lib/api/growth-client.test.ts
Normal file
71
src/lib/api/growth-client.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
reportId: 'report-1',
|
||||||
|
status: 'QUEUED',
|
||||||
|
scope: { terminalId: null },
|
||||||
|
horizonDays: 90,
|
||||||
|
deterministicEvidence: {},
|
||||||
|
cached: false,
|
||||||
|
stale: false,
|
||||||
|
nextRefreshAt: '2026-08-22T00:00:00Z',
|
||||||
|
createdAt: '2026-08-21T00:00:00Z',
|
||||||
|
updatedAt: '2026-08-21T00:00:00Z',
|
||||||
|
eventsUrl: '/api/v1/analytics/growth-reports/report-1/events',
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
describe('growth report API client', () => {
|
||||||
|
it('reads the cached terminal report without creating a new report', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(report), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await api.latestGrowthReport('terminal-1');
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/analytics/growth-reports/latest?terminalId=terminal-1');
|
||||||
|
expect(fetchMock.mock.calls[0][1].method).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks manual refresh explicitly and sends the authenticated write headers', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(report), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
vi.stubGlobal('crypto', { randomUUID: () => 'idempotency-1' });
|
||||||
|
|
||||||
|
await api.createGrowthReport('csrf-1', { terminalId: 'terminal-1', refresh: true });
|
||||||
|
|
||||||
|
const init = fetchMock.mock.calls[0][1];
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(init.headers['X-CSRF-Token']).toBe('csrf-1');
|
||||||
|
expect(init.headers['Idempotency-Key']).toBe('idempotency-1');
|
||||||
|
expect(JSON.parse(init.body)).toEqual({ terminalId: 'terminal-1', refresh: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a report with an empty request when optional fields are omitted', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(report), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
vi.stubGlobal('crypto', { randomUUID: () => 'idempotency-2' });
|
||||||
|
|
||||||
|
await api.createGrowthReport('csrf-2');
|
||||||
|
|
||||||
|
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
3236
src/lib/api/schema.d.ts
vendored
3236
src/lib/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||||
|
|||||||
260
src/lib/components/GrowthForecastChart.svelte
Normal file
260
src/lib/components/GrowthForecastChart.svelte
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
<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';
|
||||||
|
import type { components } from '$lib/api/schema';
|
||||||
|
|
||||||
|
type ForecastPoint = components['schemas']['GrowthForecastPoint'];
|
||||||
|
|
||||||
|
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>
|
||||||
35
src/lib/components/GrowthForecastChart.test.ts
Normal file
35
src/lib/components/GrowthForecastChart.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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',
|
||||||
|
baselineSessions: 95,
|
||||||
|
baselineAmountRial: 9_500_000,
|
||||||
|
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',
|
||||||
|
baselineSessions: 110,
|
||||||
|
baselineAmountRial: 11_000_000,
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
207
src/lib/components/GrowthInsights.svelte
Normal file
207
src/lib/components/GrowthInsights.svelte
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CalendarHeartIcon from '@lucide/svelte/icons/calendar-heart';
|
||||||
|
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
|
||||||
|
import LightbulbIcon from '@lucide/svelte/icons/lightbulb';
|
||||||
|
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||||
|
import TrophyIcon from '@lucide/svelte/icons/trophy';
|
||||||
|
import UsersRoundIcon from '@lucide/svelte/icons/users-round';
|
||||||
|
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
import { formatMoney, formatPercent, formatValue } from '$lib/format';
|
||||||
|
import type { components } from '$lib/api/schema';
|
||||||
|
|
||||||
|
type Win = components['schemas']['GrowthHistoricalWin'];
|
||||||
|
type Opportunity = components['schemas']['GrowthOpportunity'];
|
||||||
|
type Risk = components['schemas']['GrowthRisk'];
|
||||||
|
type Brief = components['schemas']['GrowthCampaignBrief'];
|
||||||
|
type Peer = components['schemas']['GrowthPeer'];
|
||||||
|
|
||||||
|
let {
|
||||||
|
wins = [],
|
||||||
|
opportunities = [],
|
||||||
|
risks = [],
|
||||||
|
briefs = [],
|
||||||
|
peer,
|
||||||
|
terminalScoped = false,
|
||||||
|
}: {
|
||||||
|
wins?: Win[];
|
||||||
|
opportunities?: Opportunity[];
|
||||||
|
risks?: Risk[];
|
||||||
|
briefs?: Brief[];
|
||||||
|
peer?: Peer | null;
|
||||||
|
terminalScoped?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function dateLabel(value: string) {
|
||||||
|
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeZone: 'Asia/Tehran',
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bps(value: number) {
|
||||||
|
return formatPercent(String(value / 100));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid gap-4 xl:grid-cols-[1.25fr_.75fr]">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Card class="rounded-lg shadow-xs">
|
||||||
|
<CardHeader
|
||||||
|
><CardTitle class="flex items-center gap-2 text-base"
|
||||||
|
><CalendarHeartIcon class="text-primary size-5" /> فرصتهای پیش رو</CardTitle
|
||||||
|
></CardHeader
|
||||||
|
>
|
||||||
|
<CardContent class="grid gap-3 md:grid-cols-2">
|
||||||
|
{#each opportunities.slice(0, 5) as opportunity, index}
|
||||||
|
<article id={`evidence-${opportunity.evidenceId}`} class="bg-muted/55 rounded-md border p-4">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted-foreground text-xs">فرصت {formatValue(index + 1)}</span>
|
||||||
|
<h3 class="mt-1 font-bold">{opportunity.eventTitleFa}</h3>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" class="shrink-0 rounded-md"
|
||||||
|
>شباهت {bps(opportunity.similarityBps)}</Badge
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground mt-3 text-xs">{dateLabel(opportunity.date)}</p>
|
||||||
|
<p class="mt-2 text-sm leading-7">
|
||||||
|
برآورد معمول <strong>{formatValue(opportunity.expectedSessions.p50)} پرداخت</strong> به ارزش
|
||||||
|
<strong>{formatMoney(opportunity.expectedAmountRial.p50)}</strong> است.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted-foreground md:col-span-2 text-sm">
|
||||||
|
در افق فعلی، فرصت رویدادی معناداری پیدا نشد.
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{#if briefs.length}
|
||||||
|
<section aria-labelledby="campaign-briefs-heading">
|
||||||
|
<h2 id="campaign-briefs-heading" class="mb-3 flex items-center gap-2 text-lg font-bold">
|
||||||
|
<SparklesIcon class="text-primary size-5" /> پیشنهادهای آمادهسازی
|
||||||
|
</h2>
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
{#each briefs as brief}
|
||||||
|
<Card class="rounded-lg shadow-xs">
|
||||||
|
<CardContent class="p-5">
|
||||||
|
<h3 class="font-bold">{brief.headline}</h3>
|
||||||
|
<p class="text-muted-foreground mt-2 text-sm leading-7">{brief.interpretation}</p>
|
||||||
|
<dl class="mt-4 grid gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt class="font-semibold">پیشنهاد فروش</dt>
|
||||||
|
<dd class="text-muted-foreground mt-1">{brief.offerSuggestion}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-semibold">پیام پیشنهادی</dt>
|
||||||
|
<dd class="text-muted-foreground mt-1">{brief.messageSuggestion}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-semibold">قدم آمادهسازی</dt>
|
||||||
|
<dd class="text-muted-foreground mt-1">{brief.preparationAction}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="text-muted-foreground mt-4 border-t pt-3 text-xs">{brief.limitation}</p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
{#each brief.evidenceIds as evidenceId}<a
|
||||||
|
class="text-primary text-xs font-semibold underline-offset-4 hover:underline"
|
||||||
|
href={`#evidence-${evidenceId}`}>مشاهده شاهد</a
|
||||||
|
>{/each}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground mt-3 text-xs">
|
||||||
|
این پیشنهادها نقش راهنما دارند؛ تصمیم نهایی را با شرایط کسبوکارتان هماهنگ کنید.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Card class="rounded-lg shadow-xs">
|
||||||
|
<CardHeader
|
||||||
|
><CardTitle class="flex items-center gap-2 text-base"
|
||||||
|
><TrophyIcon class="size-5 text-[var(--warning)]" /> تجربههای موفق گذشته</CardTitle
|
||||||
|
></CardHeader
|
||||||
|
>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
{#each wins as win}
|
||||||
|
<article id={`evidence-${win.evidenceId}`} class="border-b pb-3 last:border-0 last:pb-0">
|
||||||
|
<h3 class="font-semibold">{win.eventTitleFa}</h3>
|
||||||
|
<p class="text-muted-foreground mt-1 text-xs">{dateLabel(win.startDate)}</p>
|
||||||
|
<p class="mt-2 text-sm">
|
||||||
|
تعداد پرداخت <strong class="text-[var(--success)]"
|
||||||
|
>{bps(win.paidSessionUpliftBps ?? 0)} بیشتر</strong
|
||||||
|
>
|
||||||
|
و مبلغ
|
||||||
|
<strong>{bps(win.amountUpliftBps ?? 0)} بیشتر</strong> از روزهای مشابه بوده است.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
{:else}<p class="text-muted-foreground text-sm">
|
||||||
|
هنوز رویداد موفقی با شواهد کافی ثبت نشده است.
|
||||||
|
</p>{/each}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="rounded-lg border-amber-300/60 shadow-xs dark:border-amber-800/70">
|
||||||
|
<CardHeader
|
||||||
|
><CardTitle class="flex items-center gap-2 text-base"
|
||||||
|
><CircleAlertIcon class="size-5 text-[var(--warning)]" /> دورههای نیازمند آمادگی</CardTitle
|
||||||
|
></CardHeader
|
||||||
|
>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
{#each risks.slice(0, 3) as risk}
|
||||||
|
<article
|
||||||
|
id={`evidence-${risk.evidenceId}`}
|
||||||
|
class="rounded-md border bg-amber-50/50 p-4 dark:bg-amber-950/15"
|
||||||
|
>
|
||||||
|
<h3 class="font-semibold">{dateLabel(risk.date)}</h3>
|
||||||
|
<p class="text-muted-foreground mt-2 text-sm leading-7">
|
||||||
|
ممکن است تعداد پرداخت حدود {bps(risk.sessionDeclineBps)} کمتر از روند معمول باشد. برآورد میانی این
|
||||||
|
روز {formatValue(risk.expectedSessions.p50)} پرداخت است.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
{:else}<p class="text-muted-foreground text-sm">افت معناداری برای هشدار پیدا نشد.</p>{/each}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="rounded-lg shadow-xs">
|
||||||
|
<CardHeader
|
||||||
|
><CardTitle class="flex items-center gap-2 text-base"
|
||||||
|
><UsersRoundIcon class="text-primary size-5" /> مقایسه امن با همگروهها</CardTitle
|
||||||
|
></CardHeader
|
||||||
|
>
|
||||||
|
<CardContent>
|
||||||
|
{#if terminalScoped}
|
||||||
|
<p class="text-muted-foreground text-sm leading-7">
|
||||||
|
برای حفظ معنای مقایسه، جایگاه همگروهی فقط در سطح کل پذیرنده نمایش داده میشود و برای یک درگاه
|
||||||
|
محاسبه نمیشود.
|
||||||
|
</p>
|
||||||
|
{:else if peer?.state === 'READY'}
|
||||||
|
<p class="text-sm leading-7">
|
||||||
|
این مقایسه با {formatValue(peer.eligiblePeerCount ?? 0)} کسبوکار واجد شرایط و بدون نمایش اطلاعات هیچ
|
||||||
|
پذیرندهای ساخته شده است.
|
||||||
|
</p>
|
||||||
|
<div class="bg-muted mt-4 h-2 overflow-hidden rounded-sm" dir="ltr">
|
||||||
|
<div
|
||||||
|
class="bg-primary h-full rounded-sm"
|
||||||
|
style={`width:${Math.min(100, (peer.percentileBps ?? 0) / 100)}%`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground mt-2 text-xs">
|
||||||
|
جایگاه شما: صدک {formatValue(Math.round((peer.percentileBps ?? 0) / 100))}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted-foreground text-sm leading-7">
|
||||||
|
برای یک مقایسه محرمانه و قابل اتکا، تعداد همگروههای واجد شرایط هنوز کافی نیست.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
284
src/lib/components/GrowthPage.svelte
Normal file
284
src/lib/components/GrowthPage.svelte
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import BrainCircuitIcon from '@lucide/svelte/icons/brain-circuit';
|
||||||
|
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
|
||||||
|
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
import { ApiError, api, type GrowthReport } from '$lib/api/client';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Card, CardContent } from '$lib/components/ui/card';
|
||||||
|
import { useDashboardContext } from '$lib/dashboard-context.svelte';
|
||||||
|
import { growthFeatureEnabled } from '$lib/growth-feature';
|
||||||
|
import { reportDisplayMode } from '$lib/growth-report-state';
|
||||||
|
import { formatDate } from '$lib/format';
|
||||||
|
import GrowthForecastChart from './GrowthForecastChart.svelte';
|
||||||
|
import GrowthInsights from './GrowthInsights.svelte';
|
||||||
|
import GrowthReportSkeleton from './GrowthReportSkeleton.svelte';
|
||||||
|
import PageHeader from './PageHeader.svelte';
|
||||||
|
|
||||||
|
const dashboard = useDashboardContext();
|
||||||
|
const terminalId = $derived(page.url.searchParams.get('terminal') ?? 'all');
|
||||||
|
let report = $state<GrowthReport | null>(null);
|
||||||
|
let previousReport = $state<GrowthReport | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let refreshing = $state(false);
|
||||||
|
let message = $state('');
|
||||||
|
let loadSequence = 0;
|
||||||
|
let eventStream: EventSource | undefined;
|
||||||
|
let polling: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const isRunning = $derived(report ? ['QUEUED', 'ANALYZING', 'GENERATING'].includes(report.status) : false);
|
||||||
|
const visibleReport = $derived.by(() => {
|
||||||
|
if (report?.deterministicEvidence?.forecast?.points) return report;
|
||||||
|
return previousReport;
|
||||||
|
});
|
||||||
|
const displayMode = $derived(
|
||||||
|
reportDisplayMode(
|
||||||
|
report?.status,
|
||||||
|
Boolean(report?.deterministicEvidence?.forecast?.points),
|
||||||
|
Boolean(previousReport?.deterministicEvidence?.forecast?.points),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const evidence = $derived(visibleReport?.deterministicEvidence);
|
||||||
|
const briefs = $derived.by(() => {
|
||||||
|
if (!visibleReport?.deterministicEvidence) return [];
|
||||||
|
if (visibleReport.aiCopy?.briefs.length) return visibleReport.aiCopy.briefs;
|
||||||
|
return visibleReport.deterministicEvidence.fallbackBriefs.map((brief) => ({
|
||||||
|
headline: brief.headlineFa,
|
||||||
|
interpretation: brief.guidanceFa,
|
||||||
|
offerSuggestion: brief.offerFa,
|
||||||
|
messageSuggestion: brief.messageFa,
|
||||||
|
preparationAction: brief.preparationFa,
|
||||||
|
limitation: brief.limitationFa,
|
||||||
|
evidenceIds: brief.evidenceIds,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
function scopedTerminal() {
|
||||||
|
return terminalId === 'all' ? undefined : terminalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReport() {
|
||||||
|
const sequence = ++loadSequence;
|
||||||
|
report = null;
|
||||||
|
previousReport = null;
|
||||||
|
loading = true;
|
||||||
|
message = '';
|
||||||
|
closeRealtime();
|
||||||
|
try {
|
||||||
|
let next: GrowthReport;
|
||||||
|
try {
|
||||||
|
next = await api.latestGrowthReport(scopedTerminal());
|
||||||
|
} catch (cause) {
|
||||||
|
if (!(cause instanceof ApiError) || cause.status !== 404 || !dashboard.session) throw cause;
|
||||||
|
next = await api.createGrowthReport(dashboard.session.csrfToken, {
|
||||||
|
terminalId: scopedTerminal(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (sequence !== loadSequence) return;
|
||||||
|
report = next;
|
||||||
|
connectRealtime();
|
||||||
|
} catch (cause) {
|
||||||
|
if (sequence !== loadSequence) return;
|
||||||
|
message =
|
||||||
|
cause instanceof ApiError && cause.status === 403
|
||||||
|
? 'این درگاه در محدوده حساب شما نیست.'
|
||||||
|
: 'تحلیل رشد در حال حاضر در دسترس نیست. کمی بعد دوباره تلاش کنید.';
|
||||||
|
} finally {
|
||||||
|
if (sequence === loadSequence) loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (!dashboard.session || refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
|
message = '';
|
||||||
|
previousReport = visibleReport;
|
||||||
|
try {
|
||||||
|
report = await api.createGrowthReport(dashboard.session.csrfToken, {
|
||||||
|
terminalId: scopedTerminal(),
|
||||||
|
refresh: true,
|
||||||
|
});
|
||||||
|
connectRealtime();
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof ApiError && cause.status === 429) {
|
||||||
|
message = report?.nextRefreshAt
|
||||||
|
? `بهروزرسانی بعدی از ${formatDate(report.nextRefreshAt)} در دسترس است.`
|
||||||
|
: 'سهم بهروزرسانی امروز استفاده شده است؛ تحلیل ذخیرهشده همچنان در دسترس است.';
|
||||||
|
} else {
|
||||||
|
message = 'نسخه تازه آماده نشد؛ آخرین تحلیل معتبر همچنان نمایش داده میشود.';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRealtime() {
|
||||||
|
eventStream?.close();
|
||||||
|
eventStream = undefined;
|
||||||
|
if (polling) clearTimeout(polling);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectRealtime() {
|
||||||
|
closeRealtime();
|
||||||
|
if (!report || !['QUEUED', 'ANALYZING', 'GENERATING'].includes(report.status)) return;
|
||||||
|
const source = new EventSource(report.eventsUrl, { withCredentials: true });
|
||||||
|
eventStream = source;
|
||||||
|
const update = (event: MessageEvent<string>) => {
|
||||||
|
try {
|
||||||
|
const payload: unknown = JSON.parse(event.data);
|
||||||
|
const candidate =
|
||||||
|
typeof payload === 'object' && payload !== null && 'report' in payload
|
||||||
|
? (payload as { report?: unknown }).report
|
||||||
|
: payload;
|
||||||
|
if (!isGrowthReport(candidate)) return;
|
||||||
|
const next = candidate;
|
||||||
|
report = next;
|
||||||
|
if (['READY', 'PARTIAL', 'FAILED'].includes(next.status)) closeRealtime();
|
||||||
|
} catch {
|
||||||
|
schedulePoll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const name of ['report.snapshot', 'report.updated', 'report.completed', 'report.failed']) {
|
||||||
|
source.addEventListener(name, update as EventListener);
|
||||||
|
}
|
||||||
|
source.onerror = () => {
|
||||||
|
source.close();
|
||||||
|
schedulePoll();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGrowthReport(value: unknown): value is GrowthReport {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'reportId' in value &&
|
||||||
|
typeof (value as { reportId?: unknown }).reportId === 'string' &&
|
||||||
|
'status' in value &&
|
||||||
|
typeof (value as { status?: unknown }).status === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePoll() {
|
||||||
|
if (!report || !['QUEUED', 'ANALYZING', 'GENERATING'].includes(report.status)) return;
|
||||||
|
if (polling) clearTimeout(polling);
|
||||||
|
polling = setTimeout(async () => {
|
||||||
|
if (!report) return;
|
||||||
|
try {
|
||||||
|
report = await api.growthReport(report.reportId);
|
||||||
|
if (['QUEUED', 'ANALYZING', 'GENERATING'].includes(report.status)) schedulePoll();
|
||||||
|
} catch {
|
||||||
|
schedulePoll();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
terminalId;
|
||||||
|
if (growthFeatureEnabled) void loadReport();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(closeRealtime);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>تحلیلگر رشد | تحلیل زرینپال</title></svelte:head>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="تحلیلگر رشد"
|
||||||
|
description="چشمانداز ۹۰ روز آینده بر پایه روندهای واقعی کسبوکارتان"
|
||||||
|
icon={BrainCircuitIcon}
|
||||||
|
controls={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if !growthFeatureEnabled}
|
||||||
|
<Card class="rounded-lg"
|
||||||
|
><CardContent class="p-10 text-center"
|
||||||
|
><h2 class="font-bold">تحلیلگر رشد هنوز فعال نشده است</h2>
|
||||||
|
<p class="text-muted-foreground mt-2 text-sm">
|
||||||
|
پس از آمادهشدن سرویس تحلیل، این بخش برای حساب شما نمایش داده میشود.
|
||||||
|
</p></CardContent
|
||||||
|
></Card
|
||||||
|
>
|
||||||
|
{:else if (loading && !visibleReport) || displayMode === 'loading'}
|
||||||
|
<GrowthReportSkeleton />
|
||||||
|
{:else if displayMode === 'error' || !report || !visibleReport || !evidence}
|
||||||
|
<Card class="border-destructive/30 rounded-lg"
|
||||||
|
><CardContent class="space-y-4 p-10 text-center"
|
||||||
|
><CircleAlertIcon class="text-destructive mx-auto size-9" />
|
||||||
|
<p role="alert">{message}</p>
|
||||||
|
<Button variant="outline" onclick={loadReport}><RefreshCcwIcon /> تلاش دوباره</Button></CardContent
|
||||||
|
></Card
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<div class="relative space-y-4" aria-busy={refreshing || isRunning}>
|
||||||
|
<Card class="rounded-lg shadow-xs">
|
||||||
|
<CardContent class="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between sm:p-5">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-bold">افق گزارش: {visibleReport.horizonDays} روز پس از آخرین داده کامل</p>
|
||||||
|
<p class="text-muted-foreground mt-1 text-xs">
|
||||||
|
{visibleReport.cached
|
||||||
|
? 'این تحلیل از نسخه ذخیرهشده نمایش داده میشود.'
|
||||||
|
: 'نسخه تازه تحلیل در حال آمادهشدن است.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-start gap-1 sm:items-end">
|
||||||
|
<Button class="min-h-11 rounded-md" onclick={refresh} disabled={refreshing || isRunning}
|
||||||
|
><RefreshCcwIcon class={refreshing ? 'animate-spin' : ''} /> بهروزرسانی تحلیل</Button
|
||||||
|
>
|
||||||
|
<span class="text-muted-foreground text-[11px]">نوبت بعدی: {formatDate(report.nextRefreshAt)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{#if message}<div
|
||||||
|
class="rounded-md border border-amber-300/60 bg-amber-50/50 p-3 text-sm dark:border-amber-800/70 dark:bg-amber-950/15"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>{/if}
|
||||||
|
|
||||||
|
{#if report.status === 'PARTIAL'}
|
||||||
|
<div
|
||||||
|
class="rounded-md border border-amber-300/60 bg-amber-50/50 p-4 text-sm dark:border-amber-800/70 dark:bg-amber-950/15"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
اعداد و پیشبینیها آمادهاند، اما توضیح نوشتاری تازه در دسترس نبود. دادههای قابل اتکا همچنان نمایش
|
||||||
|
داده میشوند.
|
||||||
|
</div>
|
||||||
|
{:else if report.status === 'FAILED'}
|
||||||
|
<div class="border-destructive/30 bg-destructive/5 rounded-md border p-4 text-sm" role="alert">
|
||||||
|
آمادهسازی این تحلیل کامل نشد. اگر تحلیل معتبری از قبل وجود داشته باشد، همان نسخه حفظ میشود.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if evidence.forecast.confidence === 'INSUFFICIENT_DATA'}
|
||||||
|
<Card class="rounded-lg"
|
||||||
|
><CardContent class="p-10 text-center"
|
||||||
|
><h2 class="font-bold">برای پیشبینی قابل اتکا هنوز داده کافی نداریم</h2>
|
||||||
|
<p class="text-muted-foreground mx-auto mt-2 max-w-xl text-sm leading-7">
|
||||||
|
این بخش فقط زمانی پیشبینی نشان میدهد که سابقه کافی برای مقایسه روزهای مشابه و ارزیابی دقت مدل
|
||||||
|
وجود داشته باشد.
|
||||||
|
</p></CardContent
|
||||||
|
></Card
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<GrowthForecastChart
|
||||||
|
points={evidence.forecast.points}
|
||||||
|
confidence={evidence.forecast.confidence}
|
||||||
|
startDate={evidence.forecast.startDate}
|
||||||
|
/>
|
||||||
|
<GrowthInsights
|
||||||
|
wins={evidence.historicalWins}
|
||||||
|
opportunities={evidence.opportunities}
|
||||||
|
risks={evidence.risks}
|
||||||
|
{briefs}
|
||||||
|
peer={evidence.peer}
|
||||||
|
terminalScoped={visibleReport.scope.terminalId !== null &&
|
||||||
|
visibleReport.scope.terminalId !== undefined}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if refreshing || isRunning}<GrowthReportSkeleton refreshing />{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,11 @@
|
|||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
align = 'start',
|
align = 'start',
|
||||||
|
collisionPadding = 8,
|
||||||
|
avoidCollisions = true,
|
||||||
|
sticky = 'always',
|
||||||
|
hideWhenDetached = true,
|
||||||
|
dir = 'rtl',
|
||||||
portalProps,
|
portalProps,
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
@@ -22,8 +27,13 @@
|
|||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
{sideOffset}
|
{sideOffset}
|
||||||
{align}
|
{align}
|
||||||
|
{collisionPadding}
|
||||||
|
{avoidCollisions}
|
||||||
|
{sticky}
|
||||||
|
{hideWhenDetached}
|
||||||
|
{dir}
|
||||||
class={cn(
|
class={cn(
|
||||||
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 z-50 w-(--bits-dropdown-menu-anchor-width) min-w-32 overflow-x-hidden overflow-y-auto rounded-lg p-1 shadow-md ring-1 duration-100 outline-none data-closed:overflow-hidden',
|
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 z-50 max-h-[min(20rem,var(--bits-dropdown-menu-content-available-height))] w-(--bits-dropdown-menu-anchor-width) min-w-32 overflow-x-hidden overflow-y-auto overscroll-contain rounded-lg p-1 shadow-md ring-1 duration-100 outline-none data-closed:overflow-hidden',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -5,15 +5,25 @@
|
|||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
|
collisionPadding = 8,
|
||||||
|
avoidCollisions = true,
|
||||||
|
sticky = 'always',
|
||||||
|
hideWhenDetached = true,
|
||||||
|
dir = 'rtl',
|
||||||
...restProps
|
...restProps
|
||||||
}: DropdownMenuPrimitive.SubContentProps = $props();
|
}: DropdownMenuPrimitive.SubContentProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
bind:ref
|
bind:ref
|
||||||
|
{collisionPadding}
|
||||||
|
{avoidCollisions}
|
||||||
|
{sticky}
|
||||||
|
{hideWhenDetached}
|
||||||
|
{dir}
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
class={cn(
|
class={cn(
|
||||||
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 w-auto min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100',
|
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 max-h-[min(20rem,var(--bits-dropdown-menu-content-available-height))] w-auto min-w-[96px] overflow-y-auto overscroll-contain rounded-lg p-1 shadow-lg ring-1 duration-100',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.RootProps = $props();
|
let { open = $bindable(false), dir = 'rtl', ...restProps }: DropdownMenuPrimitive.RootProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DropdownMenuPrimitive.Root bind:open {...restProps} />
|
<DropdownMenuPrimitive.Root bind:open {dir} {...restProps} />
|
||||||
|
|||||||
26
src/lib/components/ui/floating-content.test.svelte
Normal file
26
src/lib/components/ui/floating-content.test.svelte
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as DropdownMenu from './dropdown-menu';
|
||||||
|
import * as Popover from './popover';
|
||||||
|
import * as Select from './select';
|
||||||
|
|
||||||
|
let { kind }: { kind: 'select' | 'popover' | 'menu' } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div dir="rtl">
|
||||||
|
{#if kind === 'select'}
|
||||||
|
<Select.Root type="single" value="" open items={[{ value: 'one', label: 'یک' }]}>
|
||||||
|
<Select.Trigger>انتخاب</Select.Trigger>
|
||||||
|
<Select.Content forceMount><Select.Item value="one" label="یک" /></Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
{:else if kind === 'popover'}
|
||||||
|
<Popover.Root open>
|
||||||
|
<Popover.Trigger>جزئیات</Popover.Trigger>
|
||||||
|
<Popover.Content forceMount>محتوای جزئیات</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
{:else}
|
||||||
|
<DropdownMenu.Root open>
|
||||||
|
<DropdownMenu.Trigger>عملیات</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content forceMount><DropdownMenu.Item>ویرایش</DropdownMenu.Item></DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
33
src/lib/components/ui/floating-content.test.ts
Normal file
33
src/lib/components/ui/floating-content.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { cleanup, render, screen } from '@testing-library/svelte';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import FloatingContentHarness from './floating-content.test.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('floating content primitives', () => {
|
||||||
|
it('bounds select menus to the available viewport with an internal scroll region', async () => {
|
||||||
|
render(FloatingContentHarness, { kind: 'select' });
|
||||||
|
|
||||||
|
const content = await screen.findByRole('listbox');
|
||||||
|
const viewport = content.querySelector('[data-select-viewport]');
|
||||||
|
expect(content.className).toContain('var(--bits-select-content-available-height)');
|
||||||
|
expect(viewport?.className).toContain('overscroll-contain');
|
||||||
|
expect(content.closest('[data-bits-floating-content-wrapper]')?.getAttribute('dir')).toBe('rtl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds popovers and menus to their collision-aware available height', async () => {
|
||||||
|
const popoverView = render(FloatingContentHarness, { kind: 'popover' });
|
||||||
|
const popover = screen.getByText('محتوای جزئیات');
|
||||||
|
expect(popover.className).toContain('var(--bits-popover-content-available-height)');
|
||||||
|
expect(popover.closest('[data-bits-floating-content-wrapper]')?.getAttribute('dir')).toBe('rtl');
|
||||||
|
|
||||||
|
popoverView.unmount();
|
||||||
|
|
||||||
|
render(FloatingContentHarness, { kind: 'menu' });
|
||||||
|
const menu = screen.getByRole('menu');
|
||||||
|
expect(menu.className).toContain('var(--bits-dropdown-menu-content-available-height)');
|
||||||
|
expect(menu.className).toContain('overscroll-contain');
|
||||||
|
expect(menu.closest('[data-bits-floating-content-wrapper]')?.getAttribute('dir')).toBe('rtl');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,11 @@
|
|||||||
class: className,
|
class: className,
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
align = 'center',
|
align = 'center',
|
||||||
|
collisionPadding = 8,
|
||||||
|
avoidCollisions = true,
|
||||||
|
sticky = 'always',
|
||||||
|
hideWhenDetached = true,
|
||||||
|
dir = 'rtl',
|
||||||
portalProps,
|
portalProps,
|
||||||
...restProps
|
...restProps
|
||||||
}: PopoverPrimitive.ContentProps & {
|
}: PopoverPrimitive.ContentProps & {
|
||||||
@@ -22,8 +27,13 @@
|
|||||||
data-slot="popover-content"
|
data-slot="popover-content"
|
||||||
{sideOffset}
|
{sideOffset}
|
||||||
{align}
|
{align}
|
||||||
|
{collisionPadding}
|
||||||
|
{avoidCollisions}
|
||||||
|
{sticky}
|
||||||
|
{hideWhenDetached}
|
||||||
|
{dir}
|
||||||
class={cn(
|
class={cn(
|
||||||
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg p-2.5 text-sm shadow-md ring-1 outline-hidden duration-100',
|
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 z-50 flex max-h-[min(32rem,var(--bits-popover-content-available-height))] w-72 origin-(--transform-origin) flex-col gap-2.5 overflow-y-auto overscroll-contain rounded-lg p-2.5 text-sm shadow-md ring-1 outline-hidden duration-100',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
|
collisionPadding = 8,
|
||||||
|
avoidCollisions = true,
|
||||||
|
sticky = 'always',
|
||||||
|
hideWhenDetached = true,
|
||||||
|
dir = 'rtl',
|
||||||
portalProps,
|
portalProps,
|
||||||
children,
|
children,
|
||||||
preventScroll = true,
|
preventScroll = true,
|
||||||
@@ -24,17 +29,25 @@
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
bind:ref
|
bind:ref
|
||||||
{sideOffset}
|
{sideOffset}
|
||||||
|
{collisionPadding}
|
||||||
|
{avoidCollisions}
|
||||||
|
{sticky}
|
||||||
|
{hideWhenDetached}
|
||||||
|
{dir}
|
||||||
{preventScroll}
|
{preventScroll}
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
class={cn(
|
class={cn(
|
||||||
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 relative isolate z-50 min-w-36 overflow-x-hidden overflow-y-auto rounded-lg shadow-md ring-1 duration-100',
|
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 relative isolate z-50 max-h-[min(20rem,var(--bits-select-content-available-height))] min-w-36 overflow-x-hidden rounded-lg shadow-md ring-1 duration-100',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
<SelectScrollUpButton />
|
<SelectScrollUpButton />
|
||||||
<SelectPrimitive.Viewport
|
<SelectPrimitive.Viewport
|
||||||
class={cn('h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1')}
|
dir="rtl"
|
||||||
|
class={cn(
|
||||||
|
'max-h-[min(20rem,var(--bits-select-content-available-height))] w-full min-w-(--bits-select-anchor-width) scroll-my-1 overscroll-contain',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</SelectPrimitive.Viewport>
|
</SelectPrimitive.Viewport>
|
||||||
|
|||||||
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 'داده ناکافی';
|
||||||
|
}
|
||||||
3
src/lib/growth-feature.ts
Normal file
3
src/lib/growth-feature.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { PUBLIC_AI_GROWTH_ENABLED } from '$env/static/public';
|
||||||
|
|
||||||
|
export const growthFeatureEnabled = PUBLIC_AI_GROWTH_ENABLED === 'true';
|
||||||
21
src/lib/growth-report-state.test.ts
Normal file
21
src/lib/growth-report-state.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { reportDisplayMode } from './growth-report-state';
|
||||||
|
|
||||||
|
describe('growth report display state', () => {
|
||||||
|
it('keeps an uncached queued report in a visible loading state', () => {
|
||||||
|
expect(reportDisplayMode('QUEUED', false, false)).toBe('loading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps prior deterministic evidence visible during refresh', () => {
|
||||||
|
expect(reportDisplayMode('GENERATING', false, true)).toBe('content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows deterministic content when AI copy is partial', () => {
|
||||||
|
expect(reportDisplayMode('PARTIAL', true, false)).toBe('content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers recovery only when a failed report has no prior evidence', () => {
|
||||||
|
expect(reportDisplayMode('FAILED', false, false)).toBe('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
11
src/lib/growth-report-state.ts
Normal file
11
src/lib/growth-report-state.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export type GrowthDisplayMode = 'loading' | 'content' | 'error';
|
||||||
|
|
||||||
|
export function reportDisplayMode(
|
||||||
|
status: string | undefined,
|
||||||
|
hasCurrentEvidence: boolean,
|
||||||
|
hasPreviousEvidence: boolean,
|
||||||
|
): GrowthDisplayMode {
|
||||||
|
if (hasCurrentEvidence || hasPreviousEvidence) return 'content';
|
||||||
|
if (status === 'QUEUED' || status === 'ANALYZING' || status === 'GENERATING') return 'loading';
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
5
src/routes/(app)/growth/+page.svelte
Normal file
5
src/routes/(app)/growth/+page.svelte
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<script>
|
||||||
|
import GrowthPage from '$lib/components/GrowthPage.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<GrowthPage />
|
||||||
7
tasks/ai-growth-analyst.md
Normal file
7
tasks/ai-growth-analyst.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# AI Growth Analyst Frontend Tasks
|
||||||
|
|
||||||
|
- [x] U1: Regenerate OpenAPI types and extend the typed client/report state model.
|
||||||
|
- [x] U2: Add `/growth`, sidebar navigation, dashboard teaser, and global-terminal scope integration.
|
||||||
|
- [x] U3: Add accessible 90-day forecast chart, confidence/summary states, event evidence, opportunity/risk, peer, and campaign brief components.
|
||||||
|
- [x] U4: Add initial/refresh skeletons, preserved previous report, SSE/poll recovery, daily refresh feedback, partial/insufficient/error states, and reduced-motion behavior.
|
||||||
|
- [x] U5: Add unit/component tests, format/check/build verification, responsive RTL review, and light/dark accessibility review without browser automation unless the user grants permission.
|
||||||
@@ -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