feat: integrate AI growth analyst experience
This commit is contained in:
@@ -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. |
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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'];
|
||||||
|
|
||||||
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 +120,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: { terminalId?: string; refresh?: boolean } = {}) {
|
||||||
|
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,
|
||||||
|
|||||||
56
src/lib/api/growth-client.test.ts
Normal file
56
src/lib/api/growth-client.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
1138
src/lib/api/schema.d.ts
vendored
1138
src/lib/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -6,13 +6,9 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { buildForecastChart, formatConfidence } from '$lib/growth-chart';
|
import { buildForecastChart, formatConfidence } from '$lib/growth-chart';
|
||||||
import { formatMoney, formatValue } from '$lib/format';
|
import { formatMoney, formatValue } from '$lib/format';
|
||||||
|
import type { components } from '$lib/api/schema';
|
||||||
|
|
||||||
type Range = { p10: number; p50: number; p90: number };
|
type ForecastPoint = components['schemas']['GrowthForecastPoint'];
|
||||||
type ForecastPoint = {
|
|
||||||
date: string;
|
|
||||||
paidSessions: Range;
|
|
||||||
paidAmountRial: Range;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { points, confidence, startDate }: { points: ForecastPoint[]; confidence: string; startDate: string } =
|
let { points, confidence, startDate }: { points: ForecastPoint[]; confidence: string; startDate: string } =
|
||||||
$props();
|
$props();
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import GrowthForecastChart from './GrowthForecastChart.svelte';
|
|||||||
const points = [
|
const points = [
|
||||||
{
|
{
|
||||||
date: '2026-07-01T00:00:00Z',
|
date: '2026-07-01T00:00:00Z',
|
||||||
|
baselineSessions: 95,
|
||||||
|
baselineAmountRial: 9_500_000,
|
||||||
paidSessions: { p10: 80, p50: 100, p90: 130 },
|
paidSessions: { p10: 80, p50: 100, p90: 130 },
|
||||||
paidAmountRial: { p10: 8_000_000, p50: 10_000_000, p90: 13_000_000 },
|
paidAmountRial: { p10: 8_000_000, p50: 10_000_000, p90: 13_000_000 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2026-07-02T00:00:00Z',
|
date: '2026-07-02T00:00:00Z',
|
||||||
|
baselineSessions: 110,
|
||||||
|
baselineAmountRial: 11_000_000,
|
||||||
paidSessions: { p10: 90, p50: 120, p90: 150 },
|
paidSessions: { p10: 90, p50: 120, p90: 150 },
|
||||||
paidAmountRial: { p10: 9_000_000, p50: 12_000_000, p90: 15_000_000 },
|
paidAmountRial: { p10: 9_000_000, p50: 12_000_000, p90: 15_000_000 },
|
||||||
},
|
},
|
||||||
|
|||||||
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}
|
||||||
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 />
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
# AI Growth Analyst Frontend Tasks
|
# AI Growth Analyst Frontend Tasks
|
||||||
|
|
||||||
- [ ] U1: Regenerate OpenAPI types and extend the typed client/report state model.
|
- [x] U1: Regenerate OpenAPI types and extend the typed client/report state model.
|
||||||
- [ ] U2: Add `/growth`, sidebar navigation, dashboard teaser, and global-terminal scope integration.
|
- [x] U2: Add `/growth`, sidebar navigation, dashboard teaser, and global-terminal scope integration.
|
||||||
- [ ] U3: Add accessible 90-day forecast chart, confidence/summary states, event evidence, opportunity/risk, peer, and campaign brief components.
|
- [x] U3: Add accessible 90-day forecast chart, confidence/summary states, event evidence, opportunity/risk, peer, and campaign brief components.
|
||||||
- [ ] U4: Add initial/refresh skeletons, preserved previous report, SSE/poll recovery, daily refresh feedback, partial/insufficient/error states, and reduced-motion behavior.
|
- [x] U4: Add initial/refresh skeletons, preserved previous report, SSE/poll recovery, daily refresh feedback, partial/insufficient/error states, and reduced-motion behavior.
|
||||||
- [ ] 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.
|
- [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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user