feat: integrate AI growth analyst experience
This commit is contained in:
@@ -11,6 +11,7 @@ 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'];
|
||||
|
||||
const apiBase = (import.meta.env.PUBLIC_API_BASE_URL || '/api/v1').replace(/\/$/, '');
|
||||
|
||||
@@ -119,6 +120,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: { 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(
|
||||
periodId: string,
|
||||
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 });
|
||||
});
|
||||
});
|
||||
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
@@ -6,13 +6,9 @@
|
||||
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 Range = { p10: number; p50: number; p90: number };
|
||||
type ForecastPoint = {
|
||||
date: string;
|
||||
paidSessions: Range;
|
||||
paidAmountRial: Range;
|
||||
};
|
||||
type ForecastPoint = components['schemas']['GrowthForecastPoint'];
|
||||
|
||||
let { points, confidence, startDate }: { points: ForecastPoint[]; confidence: string; startDate: string } =
|
||||
$props();
|
||||
|
||||
@@ -6,11 +6,15 @@ 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 },
|
||||
},
|
||||
|
||||
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';
|
||||
}
|
||||
Reference in New Issue
Block a user