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.
|
||||
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 |
|
||||
| ------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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.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. |
|
||||
|
||||
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 ReportingPeriod = components['schemas']['ReportingPeriod'];
|
||||
@@ -11,6 +11,9 @@ export type SessionStatus = components['schemas']['SessionStatus'];
|
||||
export type DetailView = components['schemas']['DetailView'];
|
||||
export type FailureStage = components['schemas']['FailureStage'];
|
||||
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(/\/$/, '');
|
||||
|
||||
@@ -119,6 +122,23 @@ export const api = {
|
||||
sessionDetail(sessionId: string) {
|
||||
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(
|
||||
periodId: string,
|
||||
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,
|
||||
} 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) {
|
||||
|
||||
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),
|
||||
sideOffset = 4,
|
||||
align = 'start',
|
||||
collisionPadding = 8,
|
||||
avoidCollisions = true,
|
||||
sticky = 'always',
|
||||
hideWhenDetached = true,
|
||||
dir = 'rtl',
|
||||
portalProps,
|
||||
class: className,
|
||||
...restProps
|
||||
@@ -22,8 +27,13 @@
|
||||
data-slot="dropdown-menu-content"
|
||||
{sideOffset}
|
||||
{align}
|
||||
{collisionPadding}
|
||||
{avoidCollisions}
|
||||
{sticky}
|
||||
{hideWhenDetached}
|
||||
{dir}
|
||||
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,
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -5,15 +5,25 @@
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
collisionPadding = 8,
|
||||
avoidCollisions = true,
|
||||
sticky = 'always',
|
||||
hideWhenDetached = true,
|
||||
dir = 'rtl',
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.SubContentProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
bind:ref
|
||||
{collisionPadding}
|
||||
{avoidCollisions}
|
||||
{sticky}
|
||||
{hideWhenDetached}
|
||||
{dir}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
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,
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
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>
|
||||
|
||||
<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,
|
||||
sideOffset = 4,
|
||||
align = 'center',
|
||||
collisionPadding = 8,
|
||||
avoidCollisions = true,
|
||||
sticky = 'always',
|
||||
hideWhenDetached = true,
|
||||
dir = 'rtl',
|
||||
portalProps,
|
||||
...restProps
|
||||
}: PopoverPrimitive.ContentProps & {
|
||||
@@ -22,8 +27,13 @@
|
||||
data-slot="popover-content"
|
||||
{sideOffset}
|
||||
{align}
|
||||
{collisionPadding}
|
||||
{avoidCollisions}
|
||||
{sticky}
|
||||
{hideWhenDetached}
|
||||
{dir}
|
||||
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,
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
collisionPadding = 8,
|
||||
avoidCollisions = true,
|
||||
sticky = 'always',
|
||||
hideWhenDetached = true,
|
||||
dir = 'rtl',
|
||||
portalProps,
|
||||
children,
|
||||
preventScroll = true,
|
||||
@@ -24,17 +29,25 @@
|
||||
<SelectPrimitive.Content
|
||||
bind:ref
|
||||
{sideOffset}
|
||||
{collisionPadding}
|
||||
{avoidCollisions}
|
||||
{sticky}
|
||||
{hideWhenDetached}
|
||||
{dir}
|
||||
{preventScroll}
|
||||
data-slot="select-content"
|
||||
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,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<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?.()}
|
||||
</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, '.', '');
|
||||
return {
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
resolve: {
|
||||
conditions: mode === 'test' ? ['browser'] : undefined,
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user