feat: integrate AI growth analyst experience

This commit is contained in:
2026-08-21 01:16:57 +03:30
parent 7a495b04c2
commit c79a1219bf
12 changed files with 2441 additions and 1421 deletions

View File

@@ -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,

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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();

View File

@@ -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 },
},

View 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>

View 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}

View 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');
});
});

View 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';
}

View File

@@ -0,0 +1,5 @@
<script>
import GrowthPage from '$lib/components/GrowthPage.svelte';
</script>
<GrowthPage />